Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ NODE_HOST = "0.0.0.0"
# XRAY_EXECUTABLE_PATH = /usr/local/bin/xray
# XRAY_ASSETS_PATH = /usr/local/share/xray

# MTProto (telemt) backend binary
# TELEMT_EXECUTABLE_PATH = /usr/local/bin/telemt

SSL_CERT_FILE = /var/lib/pg-node/certs/ssl_cert.pem
SSL_KEY_FILE = /var/lib/pg-node/certs/ssl_key.pem

Expand Down
37 changes: 37 additions & 0 deletions Dockerfile.mtproto
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder

ARG TARGETOS
ARG TARGETARCH
ARG TELEMT_REPOSITORY=telemt/telemt
ARG TELEMT_VERSION=3.4.25

RUN apk update && apk add --no-cache make curl tar

WORKDIR /src

COPY go* .
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} make NAME=main build
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
TELEMT_REPOSITORY=${TELEMT_REPOSITORY} TELEMT_VERSION=${TELEMT_VERSION} \
make install_telemt

FROM alpine:latest

LABEL org.opencontainers.image.source="https://github.com/PasarGuard/node"

RUN apk update && apk add --no-cache ca-certificates \
&& addgroup -S pgnode \
&& adduser -S -G pgnode pgnode \
&& mkdir -p /var/lib/pg-node/generated /var/lib/pg-node/certs \
&& chown -R pgnode:pgnode /var/lib/pg-node

WORKDIR /app
COPY --from=builder /src/main /app/main
COPY --from=builder /usr/local/bin/telemt /usr/local/bin/telemt
RUN chown pgnode:pgnode /app/main /usr/local/bin/telemt

USER pgnode
ENTRYPOINT ["./main"]
Comment on lines +21 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Drop root in the runtime stage.

The final image never switches away from root, so a compromise in either main or telemt gets full root privileges inside the container. Add a dedicated runtime user here; if you need privileged ports, grant only CAP_NET_BIND_SERVICE at deploy time instead of keeping the whole container root.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile.mtproto` around lines 21 - 31, The runtime stage in the Dockerfile
currently leaves the container running as root, so update the final stage to
create and switch to a dedicated non-root user before ENTRYPOINT. Keep the
existing COPY steps for main and telemt, then add ownership/permission setup as
needed and switch user in the runtime image so the container no longer executes
as root; reference the final stage around FROM alpine:latest, COPY, and
ENTRYPOINT.

Source: Linters/SAST tools

51 changes: 50 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ XRAY_OS_EFFECTIVE := $(if $(XRAY_OS),$(XRAY_OS),$(GOOS))
XRAY_ARCH_EFFECTIVE := $(if $(XRAY_ARCH),$(XRAY_ARCH),$(XRAY_ARCH_MAP_$(GOARCH)))
XRAY_INSTALL_ARGS := $(strip $(if $(XRAY_OS_EFFECTIVE),--os $(XRAY_OS_EFFECTIVE)) $(if $(XRAY_ARCH_EFFECTIVE),--arch $(XRAY_ARCH_EFFECTIVE)))

# telemt (MTProto backend) — pinned release; static musl tarball + sha256 verify
TELEMT_REPOSITORY ?= telemt/telemt
TELEMT_VERSION ?= 3.4.25
TELEMT_OS ?=
TELEMT_ARCH ?=
TELEMT_ARCH_MAP_amd64 = x86_64
TELEMT_ARCH_MAP_arm64 = aarch64
TELEMT_GOOS := $(if $(GOOS),$(GOOS),$(shell go env GOOS))
TELEMT_GOARCH := $(if $(GOARCH),$(GOARCH),$(shell go env GOARCH))
TELEMT_OS_EFFECTIVE := $(if $(TELEMT_OS),$(TELEMT_OS),$(TELEMT_GOOS))
TELEMT_ARCH_EFFECTIVE := $(if $(TELEMT_ARCH),$(TELEMT_ARCH),$(TELEMT_ARCH_MAP_$(TELEMT_GOARCH)))
TELEMT_GOARCH_EFFECTIVE := $(TELEMT_GOARCH)

ifeq ($(GOOS),windows)
OUTPUT = $(NAME).exe
ADDITION = go build -o w$(NAME).exe -trimpath -ldflags "-H windowsgui $(LDFLAGS)" -v $(MAIN)
Expand All @@ -38,7 +51,7 @@ ifeq ($(shell echo "$(GOARCH)" | grep -Eq "(mips|mipsle)" && echo true),true)
ADDITION = GOMIPS=softfloat go build -o $(NAME)_softfloat -trimpath -ldflags "$(LDFLAGS)" -v $(MAIN)
endif

.PHONY: clean build test test-race-wireguard test-integration test-integration-full test-integration-wireguard
.PHONY: clean build test test-race-wireguard test-integration test-integration-full test-integration-wireguard install_telemt

build:
CGO_ENABLED=0 go build -o $(OUTPUT) $(PARAMS) $(MAIN)
Expand Down Expand Up @@ -161,6 +174,42 @@ else
@exit 1
endif

install_telemt: update_os
ifeq ($(UNAME_S),Linux)
@if [ "$(TELEMT_OS_EFFECTIVE)" != "linux" ]; then \
echo "Unsupported TELEMT target OS: $(TELEMT_OS_EFFECTIVE)"; \
exit 1; \
fi
@if [ -z "$(TELEMT_ARCH_EFFECTIVE)" ]; then \
echo "Unsupported TELEMT target architecture: $(TELEMT_GOARCH_EFFECTIVE)"; \
exit 1; \
fi
@set -eu; \
telemt_asset="telemt-$(TELEMT_ARCH_EFFECTIVE)-linux-musl.tar.gz"; \
telemt_release="$(TELEMT_VERSION)"; \
telemt_release="$${telemt_release#refs/tags/}"; \
if [ -z "$${telemt_release}" ] || [ "$${telemt_release}" = "latest" ]; then \
telemt_base_url="https://github.com/$(TELEMT_REPOSITORY)/releases/latest/download"; \
else \
telemt_base_url="https://github.com/$(TELEMT_REPOSITORY)/releases/download/$${telemt_release}"; \
fi; \
tmp_dir="$$(mktemp -d)"; \
trap 'rm -rf "$$tmp_dir"' EXIT; \
curl -fL --retry 5 --retry-delay 3 --connect-timeout 10 --max-time 120 \
-o "$$tmp_dir/$$telemt_asset" "$$telemt_base_url/$$telemt_asset"; \
curl -fL --retry 5 --retry-delay 3 --connect-timeout 10 --max-time 120 \
-o "$$tmp_dir/$$telemt_asset.sha256" "$$telemt_base_url/$$telemt_asset.sha256"; \
( cd "$$tmp_dir" && sha256sum -c "$$telemt_asset.sha256" ); \
tar -xzf "$$tmp_dir/$$telemt_asset" -C "$$tmp_dir"; \
test -f "$$tmp_dir/telemt"; \
if [ "$$(id -u)" -eq 0 ]; then install_cmd="install"; else install_cmd="sudo install"; fi; \
$$install_cmd -m 0755 "$$tmp_dir/telemt" /usr/local/bin/telemt
@telemt --version || true
else
@echo "Unsupported operating system: $(UNAME_S)"
@exit 1
endif

test-integration:
$(MAKE) test-integration-full

Expand Down
275 changes: 275 additions & 0 deletions backend/mtproto/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
package mtproto

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

type apiClient struct {
baseURL string
authHeader string
httpClient *http.Client
}

type apiSuccessResponse[T any] struct {
OK bool `json:"ok"`
Data T `json:"data"`
Revision string `json:"revision"`
Error *apiErrorBody `json:"error,omitempty"`
}

type apiErrorResponse struct {
OK bool `json:"ok"`
Error *apiErrorBody `json:"error"`
Request uint64 `json:"request_id"`
Revision string `json:"revision"`
}

type apiErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}

type telemtUserInfo struct {
Username string `json:"username"`
InRuntime bool `json:"in_runtime"`
CurrentConnections uint64 `json:"current_connections"`
ActiveUniqueIPsList []string `json:"active_unique_ips_list"`
RecentUniqueIPsList []string `json:"recent_unique_ips_list"`
ActiveUniqueIPs int `json:"active_unique_ips"`
RecentUniqueIPs int `json:"recent_unique_ips"`
UserAdTag string `json:"user_ad_tag"`
MaxTCPConns int `json:"max_tcp_conns"`
MaxUniqueIPs int `json:"max_unique_ips"`
TotalOctets uint64 `json:"total_octets"`
}

type telemtHealth struct {
Status string `json:"status"`
ReadOnly bool `json:"read_only"`
}

type telemtCreateUserRequest struct {
Username string `json:"username"`
Secret string `json:"secret,omitempty"`
UserAdTag string `json:"user_ad_tag,omitempty"`
MaxTCPConns *int `json:"max_tcp_conns,omitempty"`
MaxUniqueIPs *int `json:"max_unique_ips,omitempty"`
}

// telemtPatchUserRequest uses JSON Merge Patch: omitted fields are unchanged,
// JSON null removes the per-user override, and a value sets it. omitempty is
// therefore not used on the clearable fields.
type telemtPatchUserRequest struct {
Secret string `json:"secret,omitempty"`
UserAdTag *string `json:"user_ad_tag"`
MaxTCPConns *int `json:"max_tcp_conns"`
MaxUniqueIPs *int `json:"max_unique_ips"`
}
Comment on lines +71 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== client.go around telemtPatchUserRequest ==\n'
sed -n '60,110p' backend/mtproto/client.go

printf '\n== buildPatchUserRequest ==\n'
sed -n '160,210p' backend/mtproto/client.go

printf '\n== mtproto.go around upsertUser ==\n'
sed -n '1,220p' backend/mtproto/mtproto.go

printf '\n== search for PatchUser / telemtPatchUserRequest usage ==\n'
rg -n "PatchUser|telemtPatchUserRequest|buildPatchUserRequest|UpdateUsers|upsertUser" backend/mtproto

Repository: PasarGuard/node

Length of output: 9240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== runtimeUser definition and transforms ==\n'
rg -n "type runtimeUser|func .*runtimeUser|optionalPositive|ExpirationRFC3339|UserAdTag|MaxTCPConns|MaxUniqueIPs|DataQuotaBytes" backend/mtproto/client.go backend/mtproto/mtproto.go backend/mtproto/*.go

printf '\n== buildNextDesiredLocked / applyFullDesiredLocked / upsertUser ==\n'
sed -n '300,430p' backend/mtproto/mtproto.go

printf '\n== any clear/reset/delete calls in apiClient ==\n'
rg -n "CreateUser|PatchUser|DeleteUser|Reset|Clear|UpdateUser" backend/mtproto/*.go

Repository: PasarGuard/node

Length of output: 16123


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '209,285p' backend/mtproto/config.go

Repository: PasarGuard/node

Length of output: 2549


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== search for telemt API docs / handlers ==\n'
rg -n "PatchUser|patch user|user_ad_tag|max_tcp_conns|expiration_rfc3339|data_quota_bytes|max_unique_ips|omitempty" -g '!**/*_test.go' .

printf '\n== list likely doc files ==\n'
git ls-files | rg '(^|/)(README|docs|openapi|swagger|api|telemt|mtproto).*'

Repository: PasarGuard/node

Length of output: 17128


PATCH drops explicit clears for runtime limits
telemtPatchUserRequest omits zero/empty values, so a partial update can’t clear an existing user_ad_tag, expiration_rfc3339, or limit/quota field on an already-created user. UpdateUsers() will PATCH the old telemt values back into place instead of removing them. Fix by sending explicit zero/empty values on patch, or by using pointer fields / a separate clear path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mtproto/client.go` around lines 72 - 79, `telemtPatchUserRequest` is
omitting zero/empty fields, so `UpdateUsers()` cannot clear existing telemt
settings during a PATCH. Update the request model and patch flow in
`UpdateUsers()` to preserve explicit clears for `UserAdTag`,
`ExpirationRFC3339`, and the limit/quota fields, either by switching those
fields to pointers/nullable values or by adding a separate clear-path so omitted
values are not re-sent as old state.


type apiStatusError struct {
StatusCode int
Code string
Message string
}

func (e *apiStatusError) Error() string {
message := strings.TrimSpace(e.Message)
if message != "" {
return message
}
if e.Code != "" {
return e.Code
}
return fmt.Sprintf("telemt API request failed with status %d", e.StatusCode)
}

func (e *apiStatusError) NotFound() bool {
return e.StatusCode == http.StatusNotFound || e.Code == "not_found"
}

func newAPIClient(baseURL, authHeader string) *apiClient {
return &apiClient{
baseURL: strings.TrimRight(baseURL, "/"),
authHeader: authHeader,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}

func (c *apiClient) Health(ctx context.Context) error {
_, err := doAPIRequest[telemtHealth](ctx, c, http.MethodGet, "/v1/health", nil)
return err
}

func (c *apiClient) GetUser(ctx context.Context, username string) (*telemtUserInfo, error) {
escapedUsername := url.PathEscape(username)
user, err := doAPIRequest[telemtUserInfo](ctx, c, http.MethodGet, "/v1/users/"+escapedUsername, nil)
if err != nil {
return nil, err
}
return user, nil
}

func (c *apiClient) CreateUser(ctx context.Context, user *runtimeUser) error {
request, err := buildCreateUserRequest(user)
if err != nil {
return err
}
_, err = doAPIRequest[telemtUserInfo](ctx, c, http.MethodPost, "/v1/users", request)
return err
}

func (c *apiClient) PatchUser(ctx context.Context, username string, user *runtimeUser) error {
request, err := buildPatchUserRequest(user)
if err != nil {
return err
}
_, err = doAPIRequest[telemtUserInfo](ctx, c, http.MethodPatch, "/v1/users/"+url.PathEscape(username), request)
return err
}

func (c *apiClient) DeleteUser(ctx context.Context, username string) error {
_, err := doAPIRequest[map[string]any](ctx, c, http.MethodDelete, "/v1/users/"+url.PathEscape(username), nil)
return err
}

func buildCreateUserRequest(user *runtimeUser) (*telemtCreateUserRequest, error) {
maxTCPConns, err := optionalPositiveInt(user.MaxTCPConns, "max_tcp_conns")
if err != nil {
return nil, err
}
maxUniqueIPs, err := optionalPositiveInt(user.MaxUniqueIPs, "max_unique_ips")
if err != nil {
return nil, err
}

request := &telemtCreateUserRequest{
Username: user.Username,
Secret: user.Secret,
UserAdTag: user.UserAdTag,
MaxTCPConns: maxTCPConns,
MaxUniqueIPs: maxUniqueIPs,
}
return request, nil
}

func buildPatchUserRequest(user *runtimeUser) (*telemtPatchUserRequest, error) {
if user == nil {
return nil, errors.New("mtproto user is nil")
}

maxTCPConns, err := optionalPositiveInt(user.MaxTCPConns, "max_tcp_conns")
if err != nil {
return nil, err
}
maxUniqueIPs, err := optionalPositiveInt(user.MaxUniqueIPs, "max_unique_ips")
if err != nil {
return nil, err
}

request := &telemtPatchUserRequest{
Secret: user.Secret,
MaxTCPConns: maxTCPConns,
MaxUniqueIPs: maxUniqueIPs,
}
if user.UserAdTag != "" {
tag := user.UserAdTag
request.UserAdTag = &tag
}
return request, nil
}

func optionalPositiveInt(value uint32, field string) (*int, error) {
if value == 0 {
return nil, nil
}
maxInt := int(^uint(0) >> 1)
if uint64(value) > uint64(maxInt) {
return nil, fmt.Errorf("mtproto field %s exceeds native int range", field)
}
converted := int(value)
return &converted, nil
}

func doAPIRequest[T any](ctx context.Context, client *apiClient, method, path string, body any) (*T, error) {
if client == nil {
return nil, errors.New("mtproto api client is nil")
}

var requestBody io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to encode mtproto api request: %w", err)
}
requestBody = bytes.NewReader(encoded)
}

request, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, requestBody)
if err != nil {
return nil, err
}

if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if client.authHeader != "" {
request.Header.Set("Authorization", client.authHeader)
}

response, err := client.httpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()

payload, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}

if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
var failure apiErrorResponse
if json.Unmarshal(payload, &failure) == nil && failure.Error != nil {
return nil, &apiStatusError{
StatusCode: response.StatusCode,
Code: failure.Error.Code,
Message: failure.Error.Message,
}
}
return nil, &apiStatusError{
StatusCode: response.StatusCode,
Message: strings.TrimSpace(string(payload)),
}
}

var success apiSuccessResponse[T]
if err := json.Unmarshal(payload, &success); err != nil {
return nil, fmt.Errorf("failed to decode mtproto api response: %w", err)
}
if !success.OK {
if success.Error != nil {
return nil, &apiStatusError{
StatusCode: response.StatusCode,
Code: success.Error.Code,
Message: success.Error.Message,
}
}
return nil, &apiStatusError{
StatusCode: response.StatusCode,
Message: "telemt api returned ok=false",
}
}

return &success.Data, nil
}
Loading