Compare commits

..

14 Commits

Author SHA1 Message Date
Marc Schäfer
a45dc3254a Update .goreleaser.yaml to build package directory instead of single file
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>

Former-commit-id: cdd8a132f2
2026-02-22 23:46:07 +01:00
Marc Schäfer
c1c8e4a5b9 Remove binary archives configuration from .goreleaser.yaml
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>

Former-commit-id: 6618bb4483
2026-02-22 23:34:24 +01:00
Marc Schäfer
6f49a2214b Refactor CI/CD pipeline: remove version update step in main.go, add git status check for GoReleaser, and update GoReleaser version to 2.14.0
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>

Former-commit-id: 08952c20c5
2026-02-22 23:28:26 +01:00
Marc Schäfer
f8ba7fd0f0 Refactor CI/CD workflows: streamline inputs in mirror.yaml, update publish-apt.yml for tag descriptions, and adjust test.yml to comment out docker-build target
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>

Former-commit-id: 5e60da37d1
2026-02-22 23:05:17 +01:00
Marc Schäfer
2278ac5135 Refactor .goreleaser.yaml for improved release management and update README with installation instructions
Add script to append release notes and enhance publish-apt.sh for asset downloading

Signed-off-by: Marc Schäfer <git@marcschaeferger.de>

Former-commit-id: 53d79aea5a
2026-02-22 23:05:06 +01:00
Marc Schäfer
e8a7d1b516 Disable Build binaries step in cicd.yml
Comment out the Build binaries step in the CI/CD workflow.

Former-commit-id: 0f6852b681
2026-02-22 22:17:27 +01:00
Marc Schäfer
1bf9e58814 Update .goreleaser.yaml for release configuration
Former-commit-id: 2b8e280f2e
2026-02-22 22:16:31 +01:00
Marc Schäfer
9732660b89 Add .goreleaser.yaml for project configuration
Former-commit-id: 3a377d43de
2026-02-22 22:12:48 +01:00
Marc Schäfer
fea0d84cce Merge pull request #30 from marcschaeferger/repo
Repo

Former-commit-id: 792057cf6c
2026-02-22 22:03:57 +01:00
Marc Schäfer
f13895f187 Create nfpm.yaml.tmpl for Newt packaging
Added nfpm.yaml template for packaging configuration.

Former-commit-id: 57afe91e85
2026-02-22 22:02:04 +01:00
Marc Schäfer
eb16361e3f Add script to publish APT packages to S3
This script publishes APT packages to an S3 bucket, handling GPG signing and CloudFront invalidation.

Former-commit-id: 3389088c43
2026-02-22 22:01:24 +01:00
Marc Schäfer
d05fe2bf33 Update APT publishing workflow configuration
Refactor APT publishing workflow with improved variable handling and script execution.

Former-commit-id: e73150c187
2026-02-22 22:00:46 +01:00
Marc Schäfer
5d5492b8ff Refactor package build process in publish-apt.yml
Refactor nfpm.yaml generation to use Python script and update package naming conventions.

Former-commit-id: 18556f34b2
2026-02-22 21:58:56 +01:00
Marc Schäfer
100a96a453 Add workflow to publish APT repo to S3/CloudFront
This workflow automates the process of publishing an APT repository to S3/CloudFront upon release events. It includes steps for configuring AWS credentials, installing necessary tools, processing tags, building packages, and uploading the repository.

Former-commit-id: 66c235624a
2026-02-22 21:56:11 +01:00
90 changed files with 1320 additions and 9841 deletions

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @oschwartz10612 @miloschwartz

View File

@@ -14,13 +14,12 @@ body:
label: Environment
description: Please fill out the relevant details below for your environment.
value: |
- OS Type & Version:
- OS Type & Version: (e.g., Ubuntu 22.04)
- Pangolin Version:
- Edition (Community or Enterprise):
- Gerbil Version:
- Traefik Version:
- Newt Version:
- Client Version:
- Olm Version: (if applicable)
validations:
required: true

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,28 @@
name: Mirror & Sign (Docker Hub to GHCR)
on:
workflow_dispatch: {}
workflow_dispatch:
inputs:
source_image:
description: "Source image (e.g., docker.io/owner/newt)"
required: true
type: string
dest_image:
description: "Destination image (e.g., ghcr.io/owner/newt)"
required: true
type: string
permissions:
contents: read
packages: write
id-token: write # for keyless OIDC
env:
SOURCE_IMAGE: docker.io/fosrl/newt
DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
jobs:
mirror-and-dual-sign:
runs-on: amd64-runner
runs-on: ubuntu-24.04
env:
SOURCE_IMAGE: ${{ inputs.source_image }}
DEST_IMAGE: ${{ inputs.dest_image }}
steps:
- name: Install skopeo + jq
run: |
@@ -23,7 +31,7 @@ jobs:
skopeo --version
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Input check
run: |

64
.github/workflows/publish-apt.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Publish APT repo to S3/CloudFront
on:
release:
types: [published]
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
workflow_dispatch:
inputs:
tag:
description: "Tag to publish (e.g. 1.9.0). Leave empty to use latest release."
required: false
type: string
backfill_all:
description: "Build/publish repo for ALL releases."
required: false
default: false
type: boolean
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-24.04
env:
PKG_NAME: newt
SUITE: stable
COMPONENT: main
REPO_BASE_URL: https://repo.dev.fosrl.io/apt
AWS_REGION: ${{ vars.AWS_REGION }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_PREFIX: ${{ vars.S3_PREFIX }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
INPUT_TAG: ${{ inputs.tag }}
BACKFILL_ALL: ${{ inputs.backfill_all }}
EVENT_TAG: ${{ github.event.release.tag_name }}
PUSH_TAG: ${{ github.ref_name }}
GH_REPO: ${{ github.repository }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y dpkg-dev apt-utils gnupg curl jq gh
- name: Publish APT repo
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APT_GPG_PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }}
APT_GPG_PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }}
run: ./scripts/publish-apt.sh

View File

@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
days-before-stale: 14
days-before-close: 14

View File

@@ -10,27 +10,13 @@ on:
- dev
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Run Go tests
run: make test
build:
runs-on: ubuntu-latest
strategy:
matrix:
target:
- local
- docker-build
#- docker-build
- go-build-release-darwin-amd64
- go-build-release-darwin-arm64
- go-build-release-freebsd-amd64
@@ -45,9 +31,9 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with:
go-version-file: go.mod
go-version: 1.25
- name: Build targets via `make`
run: make ${{ matrix.target }}

44
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,44 @@
version: 2
project_name: newt
release:
draft: true
prerelease: "{{ contains .Tag \"-rc.\" }}"
name_template: "{{ .Tag }}"
builds:
- id: newt
# build the package directory (include all .go files) instead of a single file
main: .
binary: newt
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
flags:
- -trimpath
ldflags:
- -s -w -X main.newtVersion={{ .Tag }}
checksum:
name_template: "checksums.txt"
nfpms:
- id: packages
package_name: newt
vendor: fosrl
maintainer: fosrl <repo@fosrl.io>
description: Newt - userspace tunnel client and TCP/UDP proxy
license: AGPL-3.0-or-later
formats:
- deb
- rpm
- apk
bindir: /usr/bin
file_name_template: "newt_{{ .Version }}_{{ .Arch }}"
contents:
- src: LICENSE
dst: /usr/share/doc/newt/LICENSE

View File

@@ -1,5 +1,4 @@
# FROM golang:1.25-alpine AS builder
FROM public.ecr.aws/docker/library/golang:1.25-alpine AS builder
FROM golang:1.25-alpine AS builder
# Install git and ca-certificates
RUN apk --no-cache add ca-certificates git tzdata
@@ -17,20 +16,15 @@ RUN go mod download
COPY . .
# Build the application
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.newtVersion=${VERSION}" -o /newt
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /newt
FROM public.ecr.aws/docker/library/alpine:3.23 AS runner
FROM alpine:3.23 AS runner
RUN apk --no-cache add ca-certificates tzdata iputils
COPY --from=builder /newt /usr/local/bin/
COPY entrypoint.sh /
# Marks this as an official Fossorial container image.
# Auto-update is disabled in official images — update by pulling a new image tag.
ENV NEWT_SYSTEM_SUBSTRATE="CONTAINER"
# Admin/metrics endpoint (Prometheus scrape)
EXPOSE 2112

View File

@@ -1,15 +1,9 @@
.PHONY: all local test docker-build docker-build-release
.PHONY: all local docker-build docker-build-release
all: local
VERSION ?= dev
LDFLAGS = -X main.newtVersion=$(VERSION)
local:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=$(shell go env GOOS)_$(shell go env GOARCH)" -o ./bin/newt
test:
go test ./...
CGO_ENABLED=0 go build -o ./bin/newt
docker-build:
docker build -t fosrl/newt:latest .
@@ -46,31 +40,31 @@ go-build-release: \
go-build-release-freebsd-arm64
go-build-release-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm64" -o bin/newt_linux_arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/newt_linux_arm64
go-build-release-linux-arm32-v7:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32" -o bin/newt_linux_arm32
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/newt_linux_arm32
go-build-release-linux-arm32-v6:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32v6" -o bin/newt_linux_arm32v6
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/newt_linux_arm32v6
go-build-release-linux-amd64:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_amd64" -o bin/newt_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/newt_linux_amd64
go-build-release-linux-riscv64:
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_riscv64" -o bin/newt_linux_riscv64
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/newt_linux_riscv64
go-build-release-darwin-arm64:
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_arm64" -o bin/newt_darwin_arm64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/newt_darwin_arm64
go-build-release-darwin-amd64:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_amd64" -o bin/newt_darwin_amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/newt_darwin_amd64
go-build-release-windows-amd64:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=windows_amd64" -o bin/newt_windows_amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/newt_windows_amd64.exe
go-build-release-freebsd-amd64:
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_amd64" -o bin/newt_freebsd_amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -o bin/newt_freebsd_amd64
go-build-release-freebsd-arm64:
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_arm64" -o bin/newt_freebsd_arm64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -o bin/newt_freebsd_arm64

View File

@@ -1,15 +1,24 @@
# Newt
[![PkgGoDev](https://pkg.go.dev/badge/github.com/fosrl/newt)](https://pkg.go.dev/github.com/fosrl/newt)
[![GitHub License](https://img.shields.io/github/license/fosrl/newt)](https://github.com/fosrl/newt/blob/main/LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/fosrl/newt)](https://goreportcard.com/report/github.com/fosrl/newt)
Newt is a fully user space [WireGuard](https://www.wireguard.com/) tunnel client and TCP/UDP proxy, designed to securely expose private resources controlled by Pangolin. By using Newt, you don't need to manage complex WireGuard tunnels and NATing.
### Installation and Documentation
## Installation and Documentation
Newt is used with Pangolin and Gerbil as part of the larger system. See documentation below:
- [Full Documentation](https://docs.pangolin.net/manage/sites/understanding-sites)
- [Full Documentation](https://docs.pangolin.net/manage/sites/understanding-sites)
### Install via APT (Debian/Ubuntu)
```bash
curl -fsSL https://repo.dev.fosrl.io/apt/public.key | sudo gpg --dearmor -o /usr/share/keyrings/newt-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/newt-archive-keyring.gpg] https://repo.dev.fosrl.io/apt stable main" | sudo tee /etc/apt/sources.list.d/newt.list
sudo apt update && sudo apt install newt
```
## Key Functions

View File

@@ -46,12 +46,11 @@ func startAuthDaemon(ctx context.Context) error {
// Create auth daemon server
cfg := authdaemon.Config{
DisableHTTPS: true, // We run without HTTP server in newt
PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation
PrincipalsFilePath: principalsFile,
CACertPath: caCertPath,
Force: true,
GenerateRandomPassword: authDaemonGenerateRandomPassword,
DisableHTTPS: true, // We run without HTTP server in newt
PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation
PrincipalsFilePath: principalsFile,
CACertPath: caCertPath,
Force: true,
}
srv, err := authdaemon.NewServer(cfg)
@@ -63,7 +62,7 @@ func startAuthDaemon(ctx context.Context) error {
// Start the auth daemon in a goroutine so it runs alongside newt
go func() {
logger.Debug("Auth daemon starting (native mode, no HTTP server)")
logger.Info("Auth daemon starting (native mode, no HTTP server)")
if err := srv.Run(ctx); err != nil {
logger.Error("Auth daemon error: %v", err)
}
@@ -73,6 +72,8 @@ func startAuthDaemon(ctx context.Context) error {
return nil
}
// runPrincipalsCmd executes the principals subcommand logic
func runPrincipalsCmd(args []string) {
opts := struct {
@@ -147,4 +148,4 @@ Example:
newt principals --username alice
`, defaultPrincipalsPath)
}
}

View File

@@ -7,8 +7,8 @@ import (
// ProcessConnection runs the same logic as POST /connection: CA cert, user create/reconcile, principals.
// Use this when DisableHTTPS is true (e.g. embedded in Newt) instead of calling the API.
func (s *Server) ProcessConnection(req ConnectionRequest) {
logger.Info("connection: niceId=%q username=%q metadata.sudoMode=%q metadata.sudoCommands=%v metadata.homedir=%v metadata.groups=%v",
req.NiceId, req.Username, req.Metadata.SudoMode, req.Metadata.SudoCommands, req.Metadata.Homedir, req.Metadata.Groups)
logger.Info("connection: niceId=%q username=%q metadata.sudo=%v metadata.homedir=%v",
req.NiceId, req.Username, req.Metadata.Sudo, req.Metadata.Homedir)
cfg := &s.cfg
if cfg.CACertPath != "" {
@@ -16,10 +16,10 @@ func (s *Server) ProcessConnection(req ConnectionRequest) {
logger.Warn("auth-daemon: write CA cert: %v", err)
}
}
if err := ensureUser(req.Username, req.Metadata, s.cfg.GenerateRandomPassword); err != nil {
if err := ensureUser(req.Username, req.Metadata); err != nil {
logger.Warn("auth-daemon: ensure user: %v", err)
}
if cfg.PrincipalsFilePath != "" && req.NiceId != "" {
if cfg.PrincipalsFilePath != "" {
if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil {
logger.Warn("auth-daemon: write principals: %v", err)
}

View File

@@ -4,8 +4,6 @@ package authdaemon
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
@@ -124,73 +122,8 @@ func sudoGroup() string {
return "sudo"
}
// setRandomPassword generates a random password and sets it for username via chpasswd.
// Used when GenerateRandomPassword is true so SSH with PermitEmptyPasswords no can accept the user.
func setRandomPassword(username string) error {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Errorf("generate password: %w", err)
}
password := hex.EncodeToString(b)
cmd := exec.Command("chpasswd")
cmd.Stdin = strings.NewReader(username + ":" + password)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("chpasswd: %w (output: %s)", err, string(out))
}
return nil
}
const skelDir = "/etc/skel"
// copySkelInto copies files from srcDir (e.g. /etc/skel) into dstDir (e.g. user's home).
// Only creates files that don't already exist. All created paths are chowned to uid:gid.
func copySkelInto(srcDir, dstDir string, uid, gid int) {
entries, err := os.ReadDir(srcDir)
if err != nil {
if !os.IsNotExist(err) {
logger.Warn("auth-daemon: read %s: %v", srcDir, err)
}
return
}
for _, e := range entries {
name := e.Name()
src := filepath.Join(srcDir, name)
dst := filepath.Join(dstDir, name)
if e.IsDir() {
if st, err := os.Stat(dst); err == nil && st.IsDir() {
copySkelInto(src, dst, uid, gid)
continue
}
if err := os.MkdirAll(dst, 0755); err != nil {
logger.Warn("auth-daemon: mkdir %s: %v", dst, err)
continue
}
if err := os.Chown(dst, uid, gid); err != nil {
logger.Warn("auth-daemon: chown %s: %v", dst, err)
}
copySkelInto(src, dst, uid, gid)
continue
}
if _, err := os.Stat(dst); err == nil {
continue
}
data, err := os.ReadFile(src)
if err != nil {
logger.Warn("auth-daemon: read %s: %v", src, err)
continue
}
if err := os.WriteFile(dst, data, 0644); err != nil {
logger.Warn("auth-daemon: write %s: %v", dst, err)
continue
}
if err := os.Chown(dst, uid, gid); err != nil {
logger.Warn("auth-daemon: chown %s: %v", dst, err)
}
}
}
// ensureUser creates the system user if missing, or reconciles sudo and homedir to match meta.
func ensureUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error {
func ensureUser(username string, meta ConnectionMetadata) error {
if username == "" {
return nil
}
@@ -199,49 +132,12 @@ func ensureUser(username string, meta ConnectionMetadata, generateRandomPassword
if _, ok := err.(user.UnknownUserError); !ok {
return fmt.Errorf("lookup user %s: %w", username, err)
}
return createUser(username, meta, generateRandomPassword)
return createUser(username, meta)
}
return reconcileUser(u, meta)
}
// desiredGroups returns the exact list of supplementary groups the user should have:
// meta.Groups plus the sudo group when meta.SudoMode is "full" (deduped).
func desiredGroups(meta ConnectionMetadata) []string {
seen := make(map[string]struct{})
var out []string
for _, g := range meta.Groups {
g = strings.TrimSpace(g)
if g == "" {
continue
}
if _, ok := seen[g]; ok {
continue
}
seen[g] = struct{}{}
out = append(out, g)
}
if meta.SudoMode == "full" {
sg := sudoGroup()
if _, ok := seen[sg]; !ok {
out = append(out, sg)
}
}
return out
}
// setUserGroups sets the user's supplementary groups to exactly groups (local mirrors metadata).
// When groups is empty, clears all supplementary groups (usermod -G "").
func setUserGroups(username string, groups []string) {
list := strings.Join(groups, ",")
cmd := exec.Command("usermod", "-G", list, username)
if out, err := cmd.CombinedOutput(); err != nil {
logger.Warn("auth-daemon: usermod -G %s: %v (output: %s)", list, err, string(out))
} else {
logger.Info("auth-daemon: set %s supplementary groups to %s", username, list)
}
}
func createUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error {
func createUser(username string, meta ConnectionMetadata) error {
args := []string{"-s", "/bin/bash"}
if meta.Homedir {
args = append(args, "-m")
@@ -254,143 +150,75 @@ func createUser(username string, meta ConnectionMetadata, generateRandomPassword
return fmt.Errorf("useradd %s: %w (output: %s)", username, err, string(out))
}
logger.Info("auth-daemon: created user %s (homedir=%v)", username, meta.Homedir)
if generateRandomPassword {
if err := setRandomPassword(username); err != nil {
logger.Warn("auth-daemon: set random password for %s: %v", username, err)
if meta.Sudo {
group := sudoGroup()
cmd := exec.Command("usermod", "-aG", group, username)
if out, err := cmd.CombinedOutput(); err != nil {
logger.Warn("auth-daemon: usermod -aG %s %s: %v (output: %s)", group, username, err, string(out))
} else {
logger.Info("auth-daemon: set random password for %s (PermitEmptyPasswords no)", username)
logger.Info("auth-daemon: added %s to %s", username, group)
}
}
if meta.Homedir {
if u, err := user.Lookup(username); err == nil && u.HomeDir != "" {
uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid)
copySkelInto(skelDir, u.HomeDir, uid, gid)
}
}
setUserGroups(username, desiredGroups(meta))
switch meta.SudoMode {
case "full":
if err := configurePasswordlessSudo(username); err != nil {
logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", username, err)
}
case "commands":
if len(meta.SudoCommands) > 0 {
if err := configureSudoCommands(username, meta.SudoCommands); err != nil {
logger.Warn("auth-daemon: configure sudo commands for %s: %v", username, err)
}
}
default:
removeSudoers(username)
}
return nil
}
const sudoersFilePrefix = "90-pangolin-"
func sudoersPath(username string) string {
return filepath.Join("/etc/sudoers.d", sudoersFilePrefix+username)
}
// writeSudoersFile writes content to the user's sudoers.d file and validates with visudo.
func writeSudoersFile(username, content string) error {
sudoersFile := sudoersPath(username)
tmpFile := sudoersFile + ".tmp"
if err := os.WriteFile(tmpFile, []byte(content), 0440); err != nil {
return fmt.Errorf("write temp sudoers file: %w", err)
}
cmd := exec.Command("visudo", "-c", "-f", tmpFile)
if out, err := cmd.CombinedOutput(); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("visudo validation failed: %w (output: %s)", err, string(out))
}
if err := os.Rename(tmpFile, sudoersFile); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("move sudoers file: %w", err)
}
return nil
}
// configurePasswordlessSudo creates a sudoers.d file to allow passwordless sudo for the user.
func configurePasswordlessSudo(username string) error {
content := fmt.Sprintf("# Created by Pangolin auth-daemon\n%s ALL=(ALL) NOPASSWD:ALL\n", username)
if err := writeSudoersFile(username, content); err != nil {
return err
}
logger.Info("auth-daemon: configured passwordless sudo for %s", username)
return nil
}
// configureSudoCommands creates a sudoers.d file allowing only the listed commands (NOPASSWD).
// Each command should be a full path (e.g. /usr/bin/systemctl).
func configureSudoCommands(username string, commands []string) error {
var b strings.Builder
b.WriteString("# Created by Pangolin auth-daemon (restricted commands)\n")
n := 0
for _, c := range commands {
c = strings.TrimSpace(c)
if c == "" {
continue
}
fmt.Fprintf(&b, "%s ALL=(ALL) NOPASSWD: %s\n", username, c)
n++
}
if n == 0 {
return fmt.Errorf("no valid sudo commands")
}
if err := writeSudoersFile(username, b.String()); err != nil {
return err
}
logger.Info("auth-daemon: configured restricted sudo for %s (%d commands)", username, len(commands))
return nil
}
// removeSudoers removes the sudoers.d file for the user.
func removeSudoers(username string) {
sudoersFile := sudoersPath(username)
if err := os.Remove(sudoersFile); err != nil && !os.IsNotExist(err) {
logger.Warn("auth-daemon: remove sudoers for %s: %v", username, err)
} else if err == nil {
logger.Info("auth-daemon: removed sudoers for %s", username)
}
}
func mustAtoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
func reconcileUser(u *user.User, meta ConnectionMetadata) error {
setUserGroups(u.Username, desiredGroups(meta))
switch meta.SudoMode {
case "full":
if err := configurePasswordlessSudo(u.Username); err != nil {
logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", u.Username, err)
}
case "commands":
if len(meta.SudoCommands) > 0 {
if err := configureSudoCommands(u.Username, meta.SudoCommands); err != nil {
logger.Warn("auth-daemon: configure sudo commands for %s: %v", u.Username, err)
}
group := sudoGroup()
inGroup, err := userInGroup(u.Username, group)
if err != nil {
logger.Warn("auth-daemon: check group %s: %v", group, err)
inGroup = false
}
if meta.Sudo && !inGroup {
cmd := exec.Command("usermod", "-aG", group, u.Username)
if out, err := cmd.CombinedOutput(); err != nil {
logger.Warn("auth-daemon: usermod -aG %s %s: %v (output: %s)", group, u.Username, err, string(out))
} else {
removeSudoers(u.Username)
logger.Info("auth-daemon: added %s to %s", u.Username, group)
}
} else if !meta.Sudo && inGroup {
cmd := exec.Command("gpasswd", "-d", u.Username, group)
if out, err := cmd.CombinedOutput(); err != nil {
logger.Warn("auth-daemon: gpasswd -d %s %s: %v (output: %s)", u.Username, group, err, string(out))
} else {
logger.Info("auth-daemon: removed %s from %s", u.Username, group)
}
default:
removeSudoers(u.Username)
}
if meta.Homedir && u.HomeDir != "" {
uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid)
if st, err := os.Stat(u.HomeDir); err != nil || !st.IsDir() {
if err := os.MkdirAll(u.HomeDir, 0755); err != nil {
logger.Warn("auth-daemon: mkdir %s: %v", u.HomeDir, err)
} else {
uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid)
_ = os.Chown(u.HomeDir, uid, gid)
copySkelInto(skelDir, u.HomeDir, uid, gid)
logger.Info("auth-daemon: created home %s for %s", u.HomeDir, u.Username)
}
} else {
// Ensure .bashrc etc. exist (e.g. home existed but was empty or skel was minimal)
copySkelInto(skelDir, u.HomeDir, uid, gid)
}
}
return nil
}
func userInGroup(username, groupName string) (bool, error) {
// getent group wheel returns "wheel:x:10:user1,user2"
cmd := exec.Command("getent", "group", groupName)
out, err := cmd.Output()
if err != nil {
return false, err
}
parts := strings.SplitN(strings.TrimSpace(string(out)), ":", 4)
if len(parts) < 4 {
return false, nil
}
members := strings.Split(parts[3], ",")
for _, m := range members {
if strings.TrimSpace(m) == username {
return true, nil
}
}
return false, nil
}

View File

@@ -12,7 +12,7 @@ func writeCACertIfNotExists(path, contents string, force bool) error {
}
// ensureUser returns an error on non-Linux.
func ensureUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error {
func ensureUser(username string, meta ConnectionMetadata) error {
return errLinuxOnly
}

View File

@@ -13,10 +13,8 @@ func (s *Server) registerRoutes() {
// ConnectionMetadata is the metadata object in POST /connection.
type ConnectionMetadata struct {
SudoMode string `json:"sudoMode"` // "none" | "full" | "commands"
SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands"
Homedir bool `json:"homedir"`
Groups []string `json:"groups"` // system groups to add the user to
Sudo bool `json:"sudo"`
Homedir bool `json:"homedir"`
}
// ConnectionRequest is the JSON body for POST /connection.

View File

@@ -23,13 +23,12 @@ import (
type Config struct {
// DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events.
DisableHTTPS bool
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
DisableHTTPS bool
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
}
type Server struct {
@@ -136,7 +135,7 @@ func NewServer(cfg Config) (*Server, error) {
// When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events.
func (s *Server) Run(ctx context.Context) error {
if s.cfg.DisableHTTPS {
logger.Debug("auth-daemon running (HTTPS disabled)")
logger.Info("auth-daemon running (HTTPS disabled)")
<-ctx.Done()
s.cleanupPrincipalsFile()
return nil

37
blueprint.yaml Normal file
View File

@@ -0,0 +1,37 @@
resources:
resource-nice-id:
name: this is my resource
protocol: http
full-domain: level1.test3.example.com
host-header: example.com
tls-server-name: example.com
auth:
pincode: 123456
password: sadfasdfadsf
sso-enabled: true
sso-roles:
- Member
sso-users:
- owen@pangolin.net
whitelist-users:
- owen@pangolin.net
targets:
# - site: glossy-plains-viscacha-rat
- hostname: localhost
method: http
port: 8000
healthcheck:
port: 8000
hostname: localhost
# - site: glossy-plains-viscacha-rat
- hostname: localhost
method: http
port: 8001
resource-nice-id2:
name: this is other resource
protocol: tcp
proxy-port: 3000
targets:
# - site: glossy-plains-viscacha-rat
- hostname: localhost
port: 3000

View File

@@ -1,144 +0,0 @@
package browsergateway
import (
"crypto/subtle"
"errors"
"net"
"net/http"
"strings"
"sync"
"github.com/fosrl/newt/nativessh"
)
// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap
// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame
// without wasting memory per session.
const forwardBufSize = 64 * 1024
// ListenPort is the port the browser gateway HTTP server listens on inside the
// WireGuard netstack. This is a fixed value shared between newt and pangolin.
// Targets do not overlap with this port because they start at 40000.
const ListenPort = 39999
// Target represents an allowed proxy destination for the browser gateway.
// Only connections whose (Type, Destination, DestinationPort, AuthToken) match a
// registered Target will be forwarded; all others are rejected.
type Target struct {
ID int
Type string // "rdp" | "ssh" | "vnc"
Destination string
DestinationPort int
AuthToken string // per-target secret; must match the token supplied by the client
}
// Config holds the configuration for a Gateway.
type Config struct {
// AuthToken is used only for NativeSSH mode (which has no external target
// to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are
// stored per-Target and validated by isAllowed.
AuthToken string
// SSHCredentials are used by native SSH browser sessions for certificate
// validation against the in-memory CA/principal store.
SSHCredentials *nativessh.CredentialStore
}
// Gateway is a browser-based RDP/SSH/VNC WebSocket proxy.
// Create one with New and mount it via RegisterHandlers or the individual
// HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods.
type Gateway struct {
authToken string
sshCreds *nativessh.CredentialStore
mu sync.RWMutex
targets map[int]Target // keyed by Target.ID
server *http.Server
}
// New creates a new Gateway from the provided Config.
func New(cfg Config) *Gateway {
return &Gateway{
authToken: cfg.AuthToken,
sshCreds: cfg.SSHCredentials,
targets: make(map[int]Target),
}
}
// SetTargets replaces the entire allowed-destination list atomically.
func (g *Gateway) SetTargets(targets []Target) {
g.mu.Lock()
defer g.mu.Unlock()
g.targets = make(map[int]Target, len(targets))
for _, t := range targets {
g.targets[t.ID] = t
}
}
// AddTarget adds or updates a single allowed destination.
func (g *Gateway) AddTarget(t Target) {
g.mu.Lock()
defer g.mu.Unlock()
g.targets[t.ID] = t
}
// RemoveTarget removes an allowed destination by its ID.
func (g *Gateway) RemoveTarget(id int) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.targets, id)
}
// isAllowed reports whether a connection to (targetType, host, port) with the
// given authToken is permitted. The token is compared against the per-target
// AuthToken using constant-time comparison to prevent timing attacks.
func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) bool {
g.mu.RLock()
defer g.mu.RUnlock()
for _, t := range g.targets {
if t.Type == targetType && t.Destination == host && t.DestinationPort == port {
return subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1
}
}
return false
}
// isTokenValid reports whether the given authToken matches any registered
// target of the specified type. Used for native SSH mode where there is no
// external destination to match against.
func (g *Gateway) isTokenValid(targetType, authToken string) bool {
g.mu.RLock()
defer g.mu.RUnlock()
for _, t := range g.targets {
if t.Type == targetType {
if subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 {
return true
}
}
}
return false
}
// Start serves the browser gateway HTTP server on the provided listener.
// It returns nil when the listener is closed (normal shutdown).
func (g *Gateway) Start(ln net.Listener) error {
mux := http.NewServeMux()
g.RegisterHandlers(mux)
g.server = &http.Server{Handler: mux}
err := g.server.Serve(ln)
if err == nil ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, http.ErrServerClosed) ||
strings.Contains(err.Error(), "use of closed") ||
strings.Contains(err.Error(), "invalid state") {
return nil
}
return err
}
// RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux.
func (g *Gateway) RegisterHandlers(mux *http.ServeMux) {
mux.HandleFunc("/gateway/rdp", g.HandleRDP)
mux.HandleFunc("/gateway/ssh", g.HandleSSH)
mux.HandleFunc("/gateway/vnc", g.handleVNC)
}

View File

@@ -1,88 +0,0 @@
package browsergateway
import (
"encoding/asn1"
"fmt"
)
// RDCleanPath PDU version (BASE_VERSION + 1 = 3389 + 1).
const rdCleanPathVersion = int64(3390)
// rdCleanPathPdu is a Go translation of the ASN.1 SEQUENCE defined in the
// ironrdp-rdcleanpath crate. All optional fields use EXPLICIT context-specific
// tagging, matching the Rust `der::Sequence` derivation with
// `tag_mode = "EXPLICIT"`.
//
// We only need a subset of fields for the basic proxy flow, but the struct
// declares every tag we may encounter so that decoding does not fail on an
// unexpected element.
type rdCleanPathPdu struct {
Version int64 `asn1:"explicit,tag:0"`
Destination string `asn1:"explicit,tag:2,optional,utf8"`
ProxyAuth string `asn1:"explicit,tag:3,optional,utf8"`
ServerAuth string `asn1:"explicit,tag:4,optional,utf8"`
PreconnectionBlob string `asn1:"explicit,tag:5,optional,utf8"`
X224 []byte `asn1:"explicit,tag:6,optional"`
ServerCertChain [][]byte `asn1:"explicit,tag:7,optional"`
ServerAddr string `asn1:"explicit,tag:9,optional,utf8"`
}
// decodeRDCleanPathRequest parses a client-to-proxy RDCleanPath PDU.
func decodeRDCleanPathRequest(buf []byte) (*rdCleanPathPdu, error) {
var pdu rdCleanPathPdu
rest, err := asn1.Unmarshal(buf, &pdu)
if err != nil {
return nil, fmt.Errorf("asn1 unmarshal: %w", err)
}
if len(rest) != 0 {
return nil, fmt.Errorf("trailing data after RDCleanPath PDU: %d bytes", len(rest))
}
if pdu.Version != rdCleanPathVersion {
return nil, fmt.Errorf("unexpected RDCleanPath version: %d", pdu.Version)
}
return &pdu, nil
}
// encodeRDCleanPathResponse builds a proxy-to-client RDCleanPath response PDU
// containing the server address, X.224 connection confirm and server TLS chain.
func encodeRDCleanPathResponse(serverAddr string, x224Rsp []byte, certChain [][]byte) ([]byte, error) {
pdu := rdCleanPathPdu{
Version: rdCleanPathVersion,
X224: x224Rsp,
ServerCertChain: certChain,
ServerAddr: serverAddr,
}
return asn1.Marshal(pdu)
}
// detectRDCleanPathLength returns the total DER length of an RDCleanPath PDU
// if enough bytes are available, otherwise -1.
//
// The PDU is a DER SEQUENCE, which begins with the universal SEQUENCE tag
// (0x30) followed by a length octet/octets. We parse just enough to know the
// total length so we can buffer accordingly.
func detectRDCleanPathLength(buf []byte) int {
if len(buf) < 2 {
return -1
}
if buf[0] != 0x30 {
// Not a SEQUENCE: cannot be RDCleanPath.
return -2
}
l := buf[1]
if l < 0x80 {
return 2 + int(l)
}
n := int(l & 0x7f)
if n == 0 || n > 4 {
return -2
}
if len(buf) < 2+n {
return -1
}
total := 0
for i := 0; i < n; i++ {
total = (total << 8) | int(buf[2+i])
}
return 2 + n + total
}

View File

@@ -1,271 +0,0 @@
package browsergateway
import (
"context"
"crypto/tls"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"time"
"github.com/coder/websocket"
"github.com/fosrl/newt/logger"
)
// HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections.
func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true, // any-origin: minimal dev proxy with no auth
Subprotocols: []string{"binary"},
})
if err != nil {
logger.Debug("websocket upgrade failed: %v", err)
return
}
// Disable per-message read size cap (default is 32 KiB which would break
// large RDP graphics messages).
ws.SetReadLimit(-1)
defer ws.CloseNow() //nolint:errcheck
if err := g.serveSession(ctx, ws); err != nil {
logger.Debug("session error: %v", err)
}
}
func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
// Expose the WebSocket as a streaming net.Conn. Binary messages are
// concatenated into a byte stream and writes become single binary frames.
// This is a thin wrapper with no per-message goroutine, unlike Gorilla.
stream := websocket.NetConn(ctx, ws, websocket.MessageBinary)
defer stream.Close() //nolint:errcheck
// -- Read the initial RDCleanPath request from the client --
pdu, err := readCleanPath(stream)
if err != nil {
return fmt.Errorf("read RDCleanPath: %w", err)
}
if pdu.Destination == "" {
return errors.New("RDCleanPath missing destination")
}
if len(pdu.X224) == 0 {
return errors.New("RDCleanPath missing X224 connection PDU")
}
target := pdu.Destination
// Default port for RDP if not specified.
if _, _, splitErr := net.SplitHostPort(target); splitErr != nil {
target = net.JoinHostPort(target, "3389")
}
// Validate destination against the registered target allowlist,
// including per-target auth token.
rdpHost, rdpPortStr, _ := net.SplitHostPort(target)
rdpPort, _ := strconv.Atoi(rdpPortStr)
if !g.isAllowed("rdp", rdpHost, rdpPort, pdu.ProxyAuth) {
return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target)
}
logger.Debug("Connecting to RDP server %s", target)
// -- Open TCP connection to the destination RDP server --
serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second)
if err != nil {
return fmt.Errorf("dial %s: %w", target, err)
}
defer serverTCP.Close()
if tcp, ok := serverTCP.(*net.TCPConn); ok {
// NoDelay is Go's default; set explicitly. RDP wants low latency for
// input echo, and the bulk path is naturally chunked by TLS records.
_ = tcp.SetNoDelay(true)
_ = tcp.SetKeepAlive(true)
_ = tcp.SetKeepAlivePeriod(30 * time.Second)
_ = tcp.SetReadBuffer(forwardBufSize)
_ = tcp.SetWriteBuffer(forwardBufSize)
}
serverAddr := serverTCP.RemoteAddr().String()
// Forward the optional pre-connection blob, then the X.224 connection request.
if pdu.PreconnectionBlob != "" {
if _, err := serverTCP.Write([]byte(pdu.PreconnectionBlob)); err != nil {
return fmt.Errorf("send PCB: %w", err)
}
}
if _, err := serverTCP.Write(pdu.X224); err != nil {
return fmt.Errorf("send X224: %w", err)
}
// -- Read the X.224 connection confirm from the server --
x224Rsp, err := readX224(serverTCP)
if err != nil {
return fmt.Errorf("read X224 response: %w", err)
}
logX224Negotiation(x224Rsp)
// -- Upgrade the server connection to TLS (skip verification) --
//
// Windows RDP hosts are picky: only set SNI when the target is a hostname
// (Go would skip SNI for IP literals anyway, but be explicit), and accept
// the full range of TLS versions / ciphers since some servers only
// negotiate TLS 1.0 or legacy suites.
host, _, _ := net.SplitHostPort(target)
tlsCfg := &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // proxy intentionally skips verification
MinVersion: tls.VersionTLS10,
// Cap at TLS 1.2: Windows RDP servers commonly send a TLS "internal_error"
// alert when CredSSP/NLA is layered on top of a TLS 1.3 session.
MaxVersion: tls.VersionTLS12,
}
if net.ParseIP(host) == nil {
tlsCfg.ServerName = host
}
tlsConn := tls.Client(serverTCP, tlsCfg)
if err := tlsConn.Handshake(); err != nil {
return fmt.Errorf("TLS handshake with server: %w", err)
}
logger.Debug("Server TLS handshake OK (version=0x%04x cipher=0x%04x)",
tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite)
// Collect the raw DER server certificate chain to return to the client.
state := tlsConn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return errors.New("server did not present any certificates")
}
chain := make([][]byte, 0, len(state.PeerCertificates))
for _, c := range state.PeerCertificates {
chain = append(chain, c.Raw)
}
// -- Send the RDCleanPath response back to the client --
rsp, err := encodeRDCleanPathResponse(serverAddr, x224Rsp, chain)
if err != nil {
return fmt.Errorf("encode RDCleanPath response: %w", err)
}
if _, err := stream.Write(rsp); err != nil {
return fmt.Errorf("write RDCleanPath response: %w", err)
}
logger.Debug("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr)
// -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream --
return forward(stream, tlsConn)
}
// readCleanPath buffers bytes from the stream until a full RDCleanPath PDU has
// been received, then decodes it.
func readCleanPath(r io.Reader) (*rdCleanPathPdu, error) {
buf := make([]byte, 0, 1024)
tmp := make([]byte, 1024)
for {
total := detectRDCleanPathLength(buf)
switch {
case total == -2:
return nil, errors.New("invalid RDCleanPath PDU")
case total > 0 && len(buf) >= total:
return decodeRDCleanPathRequest(buf[:total])
}
n, err := r.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if err != nil {
return nil, err
}
}
}
// readX224 reads exactly one TPKT-framed X.224 PDU from the server.
//
// The TPKT header is 4 bytes: version (0x03), reserved (0x00), and a u16
// big-endian total length that includes the header itself.
func readX224(r io.Reader) ([]byte, error) {
hdr := make([]byte, 4)
if _, err := io.ReadFull(r, hdr); err != nil {
return nil, err
}
if hdr[0] != 0x03 {
return nil, fmt.Errorf("unexpected TPKT version 0x%02x", hdr[0])
}
total := int(binary.BigEndian.Uint16(hdr[2:4]))
if total < 4 || total > 4096 {
return nil, fmt.Errorf("unreasonable TPKT length %d", total)
}
out := make([]byte, total)
copy(out, hdr)
if _, err := io.ReadFull(r, out[4:]); err != nil {
return nil, err
}
return out, nil
}
// logX224Negotiation prints which RDP security protocol the server selected
// (or the failure code), to help diagnose handshake issues such as the server
// requiring NLA/CredSSP.
//
// X.224 Connection Confirm layout (RFC 1006 / [MS-RDPBCGR]):
//
// bytes 0..3 TPKT header (03 00 LL LL)
// byte 4 X.224 length indicator
// byte 5 X.224 code (0xD0 = CC)
// bytes 6..10 DST-REF, SRC-REF, class
// byte 11 optional RDP Negotiation type (0x02 = response, 0x03 = failure)
// byte 12 flags
// bytes 13..14 length (little-endian, =8)
// bytes 15..18 selected protocol / failure code (u32 little-endian)
func logX224Negotiation(pdu []byte) {
if len(pdu) < 19 {
logger.Debug("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu))
return
}
switch pdu[11] {
case 0x02:
proto := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24
name := "unknown"
switch proto {
case 0:
name = "RDP (standard)"
case 1:
name = "SSL/TLS"
case 2:
name = "HYBRID (CredSSP/NLA)"
case 8:
name = "HYBRID_EX"
}
logger.Debug("Server selected RDP protocol 0x%x (%s)", proto, name)
case 0x03:
code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24
logger.Debug("Server returned RDP negotiation failure code 0x%x", code)
default:
logger.Debug("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11])
}
}
// forward shuttles bytes between the two streams until either side closes.
func forward(a, b io.ReadWriteCloser) error {
errc := make(chan error, 2)
go func() {
buf := make([]byte, forwardBufSize)
_, err := io.CopyBuffer(a, b, buf)
_ = a.Close()
_ = b.Close()
errc <- err
}()
go func() {
buf := make([]byte, forwardBufSize)
_, err := io.CopyBuffer(b, a, buf)
_ = a.Close()
_ = b.Close()
errc <- err
}()
// Wait for one side to finish, then return.
err := <-errc
if errors.Is(err, io.EOF) || err == nil {
return nil
}
return err
}

View File

@@ -1,282 +0,0 @@
package browsergateway
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"time"
"github.com/coder/websocket"
"github.com/fosrl/newt/logger"
"golang.org/x/crypto/ssh"
)
// sshClientMsg is a JSON message sent from the browser to the proxy.
type sshClientMsg struct {
// type: "auth" | "data" | "resize"
Type string `json:"type"`
Password string `json:"password,omitempty"` // used when type="auth"
PrivateKey string `json:"privateKey,omitempty"` // used when type="auth"
Certificate string `json:"certificate,omitempty"` // used when type="auth"
Data string `json:"data,omitempty"` // used when type="data"
Cols uint32 `json:"cols,omitempty"` // used when type="resize"
Rows uint32 `json:"rows,omitempty"` // used when type="resize"
}
// sshServerMsg is a JSON message sent from the proxy back to the browser.
type sshServerMsg struct {
// type: "data" | "error"
Type string `json:"type"`
Data string `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections.
func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) {
logger.Debug("SSH connection request from %s", r.RemoteAddr)
ctx := r.Context()
token := r.URL.Query().Get("authToken")
// "mode=native" (default) connects to the local SSH daemon on this host.
// "mode=proxy" connects to an arbitrary host+port supplied in query params.
nativeSSH := r.URL.Query().Get("mode") != "proxy"
// In proxy mode we also need host + username from query params.
var target, username string
if !nativeSSH {
host := r.URL.Query().Get("host")
port := r.URL.Query().Get("port")
username = r.URL.Query().Get("username")
if host == "" || username == "" {
http.Error(w, "missing host or username", http.StatusBadRequest)
return
}
if port == "" {
port = "22"
}
sshPort, _ := strconv.Atoi(port)
if !g.isAllowed("ssh", host, sshPort, token) {
http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden)
return
}
target = net.JoinHostPort(host, port)
} else {
// Native SSH mode: validate the token against any registered ssh target.
if !g.isTokenValid("ssh", token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
username = r.URL.Query().Get("username")
if username == "" {
http.Error(w, "missing username", http.StatusBadRequest)
return
}
}
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
Subprotocols: []string{"ssh"},
})
if err != nil {
logger.Debug("SSH websocket upgrade failed: %v", err)
return
}
ws.SetReadLimit(-1)
defer ws.CloseNow() //nolint:errcheck
if nativeSSH {
if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil {
logger.Debug("SSH native session error: %v", err)
}
} else {
if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil {
logger.Debug("SSH session error: %v", err)
}
}
}
func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, _ string) error {
// -- Wait for the auth message from the client to get the password --
_, authBytes, err := ws.Read(ctx)
if err != nil {
return fmt.Errorf("read auth message: %w", err)
}
var authMsg sshClientMsg
if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" {
return fmt.Errorf("expected auth message, got: %s", authBytes)
}
password := authMsg.Password
privateKey := authMsg.PrivateKey
certificate := authMsg.Certificate
// Build the list of auth methods. Certificate+private key takes priority
// when both are provided, then private key, then password.
var authMethods []ssh.AuthMethod
if privateKey != "" {
signer, err := ssh.ParsePrivateKey([]byte(privateKey))
if err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse private key: %v", err))
return fmt.Errorf("parse private key: %w", err)
}
if certificate != "" {
certPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
if err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse certificate: %v", err))
return fmt.Errorf("parse certificate: %w", err)
}
cert, ok := certPubKey.(*ssh.Certificate)
if !ok {
sendSSHError(ctx, ws, "Provided certificate is not a valid SSH certificate")
return fmt.Errorf("provided certificate is not ssh certificate")
}
certSigner, err := ssh.NewCertSigner(cert, signer)
if err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to combine private key and certificate: %v", err))
return fmt.Errorf("new cert signer: %w", err)
}
authMethods = append(authMethods, ssh.PublicKeys(certSigner))
} else {
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
}
if password != "" {
authMethods = append(authMethods, ssh.Password(password))
}
if len(authMethods) == 0 {
sendSSHError(ctx, ws, "No authentication credentials provided")
return fmt.Errorf("no auth credentials")
}
logger.Debug("SSH: connecting to %s as %s", target, username)
sshCfg := &ssh.ClientConfig{
User: username,
Auth: authMethods,
// HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev
// proxy. In production, verify against a known-hosts store.
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
Timeout: 15 * time.Second,
}
sshClient, err := ssh.Dial("tcp", target, sshCfg)
if err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("SSH dial failed: %v", err))
return fmt.Errorf("ssh dial %s: %w", target, err)
}
defer sshClient.Close()
// -- Open an interactive session --
sess, err := sshClient.NewSession()
if err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to open SSH session: %v", err))
return fmt.Errorf("ssh new session: %w", err)
}
defer sess.Close()
// Request a PTY.
if err := sess.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 38400,
ssh.TTY_OP_OSPEED: 38400,
}); err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to request PTY: %v", err))
return fmt.Errorf("ssh request pty: %w", err)
}
stdinPipe, err := sess.StdinPipe()
if err != nil {
return fmt.Errorf("ssh stdin pipe: %w", err)
}
stdoutPipe, err := sess.StdoutPipe()
if err != nil {
return fmt.Errorf("ssh stdout pipe: %w", err)
}
stderrPipe, err := sess.StderrPipe()
if err != nil {
return fmt.Errorf("ssh stderr pipe: %w", err)
}
if err := sess.Shell(); err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to start shell: %v", err))
return fmt.Errorf("ssh shell: %w", err)
}
logger.Debug("SSH: session established with %s", target)
// -- Pump SSH stdout/stderr → WebSocket --
sessCtx, cancelSess := context.WithCancel(ctx)
defer cancelSess()
go func() {
buf := make([]byte, 4096)
for {
n, readErr := stdoutPipe.Read(buf)
if n > 0 {
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
b, _ := json.Marshal(msg)
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
return
}
}
if readErr != nil {
cancelSess()
return
}
}
}()
go func() {
buf := make([]byte, 4096)
for {
n, readErr := stderrPipe.Read(buf)
if n > 0 {
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
b, _ := json.Marshal(msg)
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
return
}
}
if readErr != nil {
return
}
}
}()
// -- Pump WebSocket input → SSH stdin / resize --
for {
_, msgBytes, readErr := ws.Read(sessCtx)
if readErr != nil {
break
}
var msg sshClientMsg
if err := json.Unmarshal(msgBytes, &msg); err != nil {
continue
}
switch msg.Type {
case "data":
if _, err := stdinPipe.Write([]byte(msg.Data)); err != nil {
return fmt.Errorf("write ssh stdin: %w", err)
}
case "resize":
if msg.Cols > 0 && msg.Rows > 0 {
_ = sess.WindowChange(int(msg.Rows), int(msg.Cols))
}
}
}
return nil
}
// sendSSHError sends an error message to the browser and logs it.
func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) {
logger.Debug("SSH error: %s", msg)
b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg})
_ = ws.Write(ctx, websocket.MessageText, b)
}

View File

@@ -1,94 +0,0 @@
//go:build !windows
package browsergateway
import (
"context"
"encoding/json"
"fmt"
"github.com/coder/websocket"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/nativessh"
)
// serveNativeSSHSession handles a WebSocket SSH session by authenticating the
// user against the host OS (authorized_keys then PAM password) and then
// spawning a PTY+shell running as that user.
//
// The auth frame from the browser must be a JSON sshClientMsg with type="auth"
// carrying the same password/privateKey fields used by the proxy SSH path.
// The target username is passed in from the HTTP layer (query param).
func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string, creds *nativessh.CredentialStore) error {
// Read the auth frame.
_, authBytes, err := ws.Read(ctx)
if err != nil {
return fmt.Errorf("read auth message: %w", err)
}
var authMsg sshClientMsg
if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" {
return fmt.Errorf("expected auth message, got: %s", authBytes)
}
// Authenticate using host authorized_keys or PAM password.
if err := nativessh.AuthenticateWithCertificate(creds, username, authMsg.Password, authMsg.PrivateKey, authMsg.Certificate); err != nil {
sendSSHError(ctx, ws, "Authentication failed")
return fmt.Errorf("auth for user %q: %w", username, err)
}
logger.Debug("SSH native: spawning shell as user %q", username)
sess, err := nativessh.NewPTYSessionAs(username)
if err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err))
return fmt.Errorf("pty session as %q: %w", username, err)
}
defer sess.Close()
// Cancel context to unblock the WebSocket read loop when the shell exits.
sessCtx, cancelSess := context.WithCancel(ctx)
defer cancelSess()
// Pump PTY output → WebSocket.
go func() {
defer cancelSess()
buf := make([]byte, 4096)
for {
n, readErr := sess.Read(buf)
if n > 0 {
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
b, _ := json.Marshal(msg)
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
return
}
}
if readErr != nil {
return
}
}
}()
// Pump WebSocket input → PTY stdin / resize.
for {
_, msgBytes, readErr := ws.Read(sessCtx)
if readErr != nil {
break
}
var msg sshClientMsg
if err := json.Unmarshal(msgBytes, &msg); err != nil {
continue
}
switch msg.Type {
case "data":
if _, writeErr := sess.Write([]byte(msg.Data)); writeErr != nil {
return fmt.Errorf("write pty: %w", writeErr)
}
case "resize":
if msg.Cols > 0 && msg.Rows > 0 {
_ = sess.Resize(uint16(msg.Cols), uint16(msg.Rows))
}
}
}
return nil
}

View File

@@ -1,16 +0,0 @@
//go:build windows
package browsergateway
import (
"context"
"errors"
"github.com/coder/websocket"
"github.com/fosrl/newt/nativessh"
)
// serveNativeSSHSession is not supported on Windows.
func serveNativeSSHSession(_ context.Context, _ *websocket.Conn, _ string, _ *nativessh.CredentialStore) error {
return errors.New("native SSH is not supported on Windows")
}

View File

@@ -1,125 +0,0 @@
package browsergateway
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strconv"
"time"
"github.com/coder/websocket"
"github.com/fosrl/newt/logger"
)
const (
vncDialTimeout = 10 * time.Second
vncKeepAlive = 30 * time.Second
vncForwardBufSize = 32 * 1024
)
// handleVNC proxies a noVNC WebSocket connection to a raw TCP VNC backend.
// It follows the same auth-token-in-query-param pattern as handleSSH.
//
// Query parameters:
//
// authToken shared secret matching the -auth-token flag
// host VNC backend hostname or IP
// port VNC backend port (default: 5900)
func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
port := r.URL.Query().Get("port")
if host == "" {
http.Error(w, "missing host", http.StatusBadRequest)
return
}
if port == "" {
port = "5900"
}
vncPort, _ := strconv.Atoi(port)
authToken := r.URL.Query().Get("authToken")
if !g.isAllowed("vnc", host, vncPort, authToken) {
http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden)
return
}
target := net.JoinHostPort(host, port)
// Optional HTTP probe used by the web UI to surface backend reachability
// failures before attempting a WebSocket session.
if r.URL.Query().Get("checkOnly") == "1" {
if err := dialVNCBackend(r.Context(), target); err != nil {
logger.Debug("vnc: preflight failed (%s): %v", target, err)
http.Error(w, fmt.Sprintf("failed to connect to VNC backend: %v", err), http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
// Accept the WebSocket. noVNC negotiates the "binary" subprotocol;
// fall back gracefully when the client sends "base64" as well.
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
Subprotocols: []string{"binary", "base64"},
})
if err != nil {
logger.Debug("vnc: websocket upgrade failed: %v", err)
return
}
ws.SetReadLimit(-1)
defer ws.CloseNow() //nolint:errcheck
ctx := r.Context()
if err := serveVNC(ctx, ws, target); err != nil {
logger.Debug("vnc: session error (%s): %v", target, err)
}
}
func dialVNCBackend(ctx context.Context, target string) error {
dialer := &net.Dialer{
Timeout: vncDialTimeout,
KeepAlive: vncKeepAlive,
}
conn, err := dialer.DialContext(ctx, "tcp", target)
if err != nil {
return err
}
defer conn.Close() //nolint:errcheck
return nil
}
func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error {
// Dial the VNC backend TCP server.
dialer := &net.Dialer{
Timeout: vncDialTimeout,
KeepAlive: vncKeepAlive,
}
conn, err := dialer.DialContext(ctx, "tcp", target)
if err != nil {
return err
}
defer conn.Close() //nolint:errcheck
// Expose the WebSocket as a plain net.Conn byte stream (binary frames).
stream := websocket.NetConn(ctx, ws, websocket.MessageBinary)
defer stream.Close() //nolint:errcheck
// Proxy bidirectionally: VNC backend <-> browser.
errc := make(chan error, 2)
go func() {
buf := make([]byte, vncForwardBufSize)
_, err := io.CopyBuffer(conn, stream, buf)
errc <- err
}()
go func() {
buf := make([]byte, vncForwardBufSize)
_, err := io.CopyBuffer(stream, conn, buf)
errc <- err
}()
// Return when either direction closes.
err = <-errc
return err
}

View File

@@ -7,7 +7,6 @@ import (
wgnetstack "github.com/fosrl/newt/clients"
"github.com/fosrl/newt/clients/permissions"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/nativessh"
"github.com/fosrl/newt/websocket"
"golang.zx2c4.com/wireguard/tun/netstack"
)
@@ -15,7 +14,7 @@ import (
var wgService *clients.WireGuardService
var ready bool
func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) {
func setupClients(client *websocket.Client) {
var host = endpoint
if strings.HasPrefix(host, "http://") {
host = strings.TrimPrefix(host, "http://")
@@ -43,8 +42,6 @@ func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore
logger.Fatal("Failed to create WireGuard service: %v", err)
}
wgService.SetCredentialStore(credStore)
client.OnTokenUpdate(func(token string) {
wgService.SetToken(token)
})
@@ -66,13 +63,6 @@ func closeClients() {
}
}
// setClientsBlocked enables or disables connection blocking on the WireGuard service.
func setClientsBlocked(v bool) {
if wgService != nil {
wgService.SetBlocked(v)
}
}
func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
if !ready {
return

View File

@@ -2,8 +2,6 @@ package clients
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
@@ -13,14 +11,12 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/fosrl/newt/bind"
newtDevice "github.com/fosrl/newt/device"
"github.com/fosrl/newt/holepunch"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/nativessh"
"github.com/fosrl/newt/netstack2"
"github.com/fosrl/newt/network"
"github.com/fosrl/newt/util"
@@ -38,21 +34,14 @@ type WgConfig struct {
IpAddress string `json:"ipAddress"`
Peers []Peer `json:"peers"`
Targets []Target `json:"targets"`
ChainId string `json:"chainId"`
}
type Target struct {
SourcePrefix string `json:"sourcePrefix"`
SourcePrefixes []string `json:"sourcePrefixes"`
DestPrefix string `json:"destPrefix"`
RewriteTo string `json:"rewriteTo,omitempty"`
DisableIcmp bool `json:"disableIcmp,omitempty"`
PortRange []PortRange `json:"portRange,omitempty"`
ResourceId int `json:"resourceId,omitempty"`
Protocol string `json:"protocol,omitempty"` // for now practicably either http or https
HTTPTargets []netstack2.HTTPTarget `json:"httpTargets,omitempty"` // for http protocol, list of downstream services to load balance across
TLSCert string `json:"tlsCert,omitempty"` // PEM-encoded certificate for incoming HTTPS termination
TLSKey string `json:"tlsKey,omitempty"` // PEM-encoded private key for incoming HTTPS termination
SourcePrefix string `json:"sourcePrefix"`
DestPrefix string `json:"destPrefix"`
RewriteTo string `json:"rewriteTo,omitempty"`
DisableIcmp bool `json:"disableIcmp,omitempty"`
PortRange []PortRange `json:"portRange,omitempty"`
}
type PortRange struct {
@@ -80,20 +69,19 @@ type PeerReading struct {
}
type WireGuardService struct {
interfaceName string
mtu int
client *websocket.Client
config WgConfig
key wgtypes.Key
newtId string
lastReadings map[string]PeerReading
mu sync.Mutex
Port uint16
host string
serverPubKey string
token string
stopGetConfig func()
pendingConfigChainId string
interfaceName string
mtu int
client *websocket.Client
config WgConfig
key wgtypes.Key
newtId string
lastReadings map[string]PeerReading
mu sync.Mutex
Port uint16
host string
serverPubKey string
token string
stopGetConfig func()
// Netstack fields
tun tun.Device
tnet *netstack2.Net
@@ -110,25 +98,12 @@ type WireGuardService struct {
sharedBind *bind.SharedBind
holePunchManager *holepunch.Manager
useNativeInterface bool
// SSH server running on the clients' netstack
sshServer *sshServerHandle
credStore *nativessh.CredentialStore
// Direct UDP relay from main tunnel to clients' WireGuard
directRelayStop chan struct{}
directRelayWg sync.WaitGroup
netstackListener net.PacketConn
netstackListenerMu sync.Mutex
wgTesterServer *wgtester.Server
// connection blocking: when true, all new incoming connections are dropped
blocked atomic.Bool
}
// generateChainId generates a random chain ID for deduplicating round-trip messages.
func generateChainId() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func NewWireGuardService(interfaceName string, port uint16, mtu int, host string, newtId string, wsClient *websocket.Client, dns string, useNativeInterface bool) (*WireGuardService, error) {
@@ -185,8 +160,9 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
useNativeInterface: useNativeInterface,
}
// Create the holepunch manager
service.holePunchManager = holepunch.NewManager(sharedBind, newtId, "newt", key.PublicKey().String(), nil)
// Create the holepunch manager with ResolveDomain function
// We'll need to pass a domain resolver function
service.holePunchManager = holepunch.NewManager(sharedBind, newtId, "newt", key.PublicKey().String())
// Register websocket handlers
wsClient.RegisterHandler("newt/wg/receive-config", service.handleConfig)
@@ -196,18 +172,10 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
wsClient.RegisterHandler("newt/wg/targets/add", service.handleAddTarget)
wsClient.RegisterHandler("newt/wg/targets/remove", service.handleRemoveTarget)
wsClient.RegisterHandler("newt/wg/targets/update", service.handleUpdateTarget)
wsClient.RegisterHandler("newt/wg/sync", service.handleSyncConfig)
return service, nil
}
// SetCredentialStore sets the in-memory SSH credential store used by the
// native SSH server. It must be called before the netstack is configured
// (i.e. before the first newt/wg/receive-config message is processed).
func (s *WireGuardService) SetCredentialStore(store *nativessh.CredentialStore) {
s.credStore = store
}
// ReportRTT allows reporting native RTTs to telemetry, rate-limited externally.
func (s *WireGuardService) ReportRTT(seconds float64) {
if s.serverPubKey == "" {
@@ -226,21 +194,6 @@ func (s *WireGuardService) Close() {
s.stopGetConfig = nil
}
// Stop SSH server before tearing down the netstack
if s.sshServer != nil {
s.sshServer.stop()
s.sshServer = nil
}
// Flush access logs before tearing down the tunnel
if s.tnet != nil {
if ph := s.tnet.GetProxyHandler(); ph != nil {
if al := ph.GetAccessLogger(); al != nil {
al.Close()
}
}
}
// Stop the direct UDP relay first
s.StopDirectUDPRelay()
@@ -307,21 +260,6 @@ func (s *WireGuardService) GetPublicKey() wgtypes.Key {
return s.key.PublicKey()
}
// SetBlocked enables or disables connection blocking for this WireGuard service.
// The state is persisted and applied immediately to any active proxy handler.
func (s *WireGuardService) SetBlocked(v bool) {
s.blocked.Store(v)
s.mu.Lock()
tnet := s.tnet
s.mu.Unlock()
if tnet == nil {
return
}
if ph := tnet.GetProxyHandler(); ph != nil {
ph.SetBlocked(v)
}
}
// SetOnNetstackReady sets a callback function to be called when the netstack interface is ready
func (s *WireGuardService) SetOnNetstackReady(callback func(*netstack2.Net)) {
s.onNetstackReady = callback
@@ -339,7 +277,7 @@ func (s *WireGuardService) StartHolepunch(publicKey string, endpoint string, rel
}
if relayPort == 0 {
relayPort = 21820
relayPort = 21820
}
// Convert websocket.ExitNode to holepunch.ExitNode
@@ -502,12 +440,9 @@ func (s *WireGuardService) LoadRemoteConfig() error {
s.stopGetConfig()
s.stopGetConfig = nil
}
chainId := generateChainId()
s.pendingConfigChainId = chainId
s.stopGetConfig = s.client.SendMessageInterval("newt/wg/get-config", map[string]interface{}{
"publicKey": s.key.PublicKey().String(),
"port": s.Port,
"chainId": chainId,
}, 2*time.Second)
logger.Debug("Requesting WireGuard configuration from remote server")
@@ -532,17 +467,6 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
logger.Info("Error unmarshaling target data: %v", err)
return
}
// Deduplicate using chainId: discard responses that don't match the
// pending request, or that we have already processed.
if config.ChainId != "" {
if config.ChainId != s.pendingConfigChainId {
logger.Debug("Discarding duplicate/stale newt/wg/get-config response (chainId=%s, expected=%s)", config.ChainId, s.pendingConfigChainId)
return
}
s.pendingConfigChainId = "" // consume further duplicates are rejected
}
s.config = config
if s.stopGetConfig != nil {
@@ -568,194 +492,6 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
logger.Info("Client connectivity setup. Ready to accept connections from clients!")
}
// SyncConfig represents the configuration sent from server for syncing
type SyncConfig struct {
Targets []Target `json:"targets"`
Peers []Peer `json:"peers"`
}
func (s *WireGuardService) handleSyncConfig(msg websocket.WSMessage) {
var syncConfig SyncConfig
logger.Debug("Received sync message: %v", msg)
logger.Info("Received sync configuration from remote server")
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling sync data: %v", err)
return
}
if err := json.Unmarshal(jsonData, &syncConfig); err != nil {
logger.Error("Error unmarshaling sync data: %v", err)
return
}
// Sync peers
if err := s.syncPeers(syncConfig.Peers); err != nil {
logger.Error("Failed to sync peers: %v", err)
}
// Sync targets
if err := s.syncTargets(syncConfig.Targets); err != nil {
logger.Error("Failed to sync targets: %v", err)
}
}
// syncPeers synchronizes the current peers with the desired state
// It removes peers not in the desired list and adds missing ones
func (s *WireGuardService) syncPeers(desiredPeers []Peer) error {
if s.device == nil {
return fmt.Errorf("WireGuard device is not initialized")
}
// Get current peers from the device
currentConfig, err := s.device.IpcGet()
if err != nil {
return fmt.Errorf("failed to get current device config: %v", err)
}
// Parse current peer public keys
lines := strings.Split(currentConfig, "\n")
currentPeerKeys := make(map[string]bool)
for _, line := range lines {
if strings.HasPrefix(line, "public_key=") {
pubKey := strings.TrimPrefix(line, "public_key=")
currentPeerKeys[pubKey] = true
}
}
// Build a map of desired peers by their public key (normalized)
desiredPeerMap := make(map[string]Peer)
for _, peer := range desiredPeers {
// Normalize the public key for comparison
pubKey, err := wgtypes.ParseKey(peer.PublicKey)
if err != nil {
logger.Warn("Invalid public key in desired peers: %s", peer.PublicKey)
continue
}
normalizedKey := util.FixKey(pubKey.String())
desiredPeerMap[normalizedKey] = peer
}
// Remove peers that are not in the desired list
for currentKey := range currentPeerKeys {
if _, exists := desiredPeerMap[currentKey]; !exists {
// Parse the key back to get the original format for removal
removeConfig := fmt.Sprintf("public_key=%s\nremove=true", currentKey)
if err := s.device.IpcSet(removeConfig); err != nil {
logger.Warn("Failed to remove peer %s during sync: %v", currentKey, err)
} else {
logger.Info("Removed peer %s during sync", currentKey)
}
}
}
// Add peers that are missing
for normalizedKey, peer := range desiredPeerMap {
if _, exists := currentPeerKeys[normalizedKey]; !exists {
if err := s.addPeerToDevice(peer); err != nil {
logger.Warn("Failed to add peer %s during sync: %v", peer.PublicKey, err)
} else {
logger.Info("Added peer %s during sync", peer.PublicKey)
}
}
}
return nil
}
// syncTargets synchronizes the current targets with the desired state
// It removes targets not in the desired list and adds missing ones
func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
if s.tnet == nil {
// Native interface mode - proxy features not available, skip silently
logger.Debug("Skipping target sync - using native interface (no proxy support)")
return nil
}
// Get current rules from the proxy handler
currentRules := s.tnet.GetProxySubnetRules()
// Build a map of current rules by source+dest prefix
type ruleKey struct {
sourcePrefix string
destPrefix string
}
currentRuleMap := make(map[ruleKey]bool)
for _, rule := range currentRules {
key := ruleKey{
sourcePrefix: rule.SourcePrefix.String(),
destPrefix: rule.DestPrefix.String(),
}
currentRuleMap[key] = true
}
// Build a map of desired targets
desiredTargetMap := make(map[ruleKey]Target)
for _, target := range desiredTargets {
key := ruleKey{
sourcePrefix: target.SourcePrefix,
destPrefix: target.DestPrefix,
}
desiredTargetMap[key] = target
}
// Remove targets that are not in the desired list
for _, rule := range currentRules {
key := ruleKey{
sourcePrefix: rule.SourcePrefix.String(),
destPrefix: rule.DestPrefix.String(),
}
if _, exists := desiredTargetMap[key]; !exists {
s.tnet.RemoveProxySubnetRule(rule.SourcePrefix, rule.DestPrefix)
logger.Info("Removed target %s -> %s during sync", rule.SourcePrefix.String(), rule.DestPrefix.String())
}
}
// Add targets that are missing
for key, target := range desiredTargetMap {
if _, exists := currentRuleMap[key]; !exists {
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
if err != nil {
logger.Warn("Invalid source prefix %s during sync: %v", target.SourcePrefix, err)
continue
}
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
if err != nil {
logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err)
continue
}
var portRanges []netstack2.PortRange
for _, pr := range target.PortRange {
portRanges = append(portRanges, netstack2.PortRange{
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
})
}
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
PortRanges: portRanges,
DisableIcmp: target.DisableIcmp,
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix)
}
}
return nil
}
func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
s.mu.Lock()
@@ -879,20 +615,6 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
s.TunnelIP = tunnelIP.String()
// Configure the access log sender to ship compressed session logs via websocket
s.tnet.SetAccessLogSender(func(data string) error {
return s.client.SendMessageNoLog("newt/access-log", map[string]interface{}{
"compressed": data,
})
})
// Configure the HTTP request log sender to ship compressed request logs via websocket
s.tnet.SetHTTPRequestLogSender(func(data string) error {
return s.client.SendMessageNoLog("newt/request-log", map[string]interface{}{
"compressed": data,
})
})
// Create WireGuard device using the shared bind
s.device = device.NewDevice(s.tun, s.sharedBind, device.NewLogger(
device.LogLevelSilent, // Use silent logging by default - could be made configurable
@@ -926,25 +648,7 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
logger.Error("Failed to start WireGuard tester server: %v", err)
}
// Start the SSH server on the clients' netstack (port 22).
// A nil credStore means SSH is disabled (--disable-ssh), so skip starting the server.
if s.credStore != nil {
if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil {
logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr)
} else {
s.sshServer = h
}
}
// Note: we already unlocked above, so don't use defer unlock
// Apply any pending blocked state to the newly created proxy handler
if s.blocked.Load() {
if ph := s.tnet.GetProxyHandler(); ph != nil {
ph.SetBlocked(true)
}
}
return nil
}
@@ -991,19 +695,6 @@ func (s *WireGuardService) ensureWireguardPeers(peers []Peer) error {
return nil
}
// resolveSourcePrefixes returns the effective list of source prefixes for a target,
// supporting both the legacy single SourcePrefix field and the new SourcePrefixes array.
// If SourcePrefixes is non-empty it takes precedence; otherwise SourcePrefix is used.
func resolveSourcePrefixes(target Target) []string {
if len(target.SourcePrefixes) > 0 {
return target.SourcePrefixes
}
if target.SourcePrefix != "" {
return []string{target.SourcePrefix}
}
return nil
}
func (s *WireGuardService) ensureTargets(targets []Target) error {
if s.tnet == nil {
// Native interface mode - proxy features not available, skip silently
@@ -1012,6 +703,11 @@ func (s *WireGuardService) ensureTargets(targets []Target) error {
}
for _, target := range targets {
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
if err != nil {
return fmt.Errorf("invalid CIDR %s: %v", target.SourcePrefix, err)
}
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
if err != nil {
return fmt.Errorf("invalid CIDR %s: %v", target.DestPrefix, err)
@@ -1026,25 +722,9 @@ func (s *WireGuardService) ensureTargets(targets []Target) error {
})
}
for _, sp := range resolveSourcePrefixes(target) {
sourcePrefix, err := netip.ParsePrefix(sp)
if err != nil {
return fmt.Errorf("invalid CIDR %s: %v", sp, err)
}
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
PortRanges: portRanges,
DisableIcmp: target.DisableIcmp,
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
}
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange)
}
return nil
@@ -1363,7 +1043,7 @@ func (s *WireGuardService) processPeerBandwidth(publicKey string, rxBytes, txByt
BytesOut: bytesOutMB,
}
}
return nil
}
}
@@ -1414,6 +1094,12 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) {
// Process all targets
for _, target := range targets {
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
if err != nil {
logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err)
continue
}
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
if err != nil {
logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err)
@@ -1423,32 +1109,15 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) {
var portRanges []netstack2.PortRange
for _, pr := range target.PortRange {
portRanges = append(portRanges, netstack2.PortRange{
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
})
}
for _, sp := range resolveSourcePrefixes(target) {
sourcePrefix, err := netip.ParsePrefix(sp)
if err != nil {
logger.Info("Invalid CIDR %s: %v", sp, err)
continue
}
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
PortRanges: portRanges,
DisableIcmp: target.DisableIcmp,
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
}
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange)
}
}
@@ -1477,21 +1146,21 @@ func (s *WireGuardService) handleRemoveTarget(msg websocket.WSMessage) {
// Process all targets
for _, target := range targets {
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
if err != nil {
logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err)
continue
}
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
if err != nil {
logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err)
continue
}
for _, sp := range resolveSourcePrefixes(target) {
sourcePrefix, err := netip.ParsePrefix(sp)
if err != nil {
logger.Info("Invalid CIDR %s: %v", sp, err)
continue
}
s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix)
logger.Info("Removed target subnet %s with destination %s", sp, target.DestPrefix)
}
s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix)
logger.Info("Removed target subnet %s with destination %s", target.SourcePrefix, target.DestPrefix)
}
}
@@ -1525,24 +1194,30 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) {
// Process all update requests
for _, target := range requests.OldTargets {
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
if err != nil {
logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err)
continue
}
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
if err != nil {
logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err)
continue
}
for _, sp := range resolveSourcePrefixes(target) {
sourcePrefix, err := netip.ParsePrefix(sp)
if err != nil {
logger.Info("Invalid CIDR %s: %v", sp, err)
continue
}
s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix)
logger.Info("Removed target subnet %s with destination %s", sp, target.DestPrefix)
}
s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix)
logger.Info("Removed target subnet %s with destination %s", target.SourcePrefix, target.DestPrefix)
}
for _, target := range requests.NewTargets {
// Now add the new target
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
if err != nil {
logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err)
continue
}
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
if err != nil {
logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err)
@@ -1552,32 +1227,14 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) {
var portRanges []netstack2.PortRange
for _, pr := range target.PortRange {
portRanges = append(portRanges, netstack2.PortRange{
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
})
}
for _, sp := range resolveSourcePrefixes(target) {
sourcePrefix, err := netip.ParsePrefix(sp)
if err != nil {
logger.Info("Invalid CIDR %s: %v", sp, err)
continue
}
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
PortRanges: portRanges,
DisableIcmp: target.DisableIcmp,
ResourceId: target.ResourceId,
Protocol: target.Protocol,
HTTPTargets: target.HTTPTargets,
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
}
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange)
}
}
@@ -1617,33 +1274,3 @@ func (s *WireGuardService) filterReadOnlyFields(config string) string {
return strings.Join(filteredLines, "\n")
}
// sshServerHandle holds the listener so the SSH server can be stopped by
// closing it.
type sshServerHandle struct {
ln net.Listener
}
func (h *sshServerHandle) stop() {
_ = h.ln.Close()
}
// startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack
// and starts serving SSH connections on it in the background. The returned
// handle can be used to stop the server by closing the listener.
func startSSHOnNetstack(tnet *netstack2.Net, creds *nativessh.CredentialStore) (*sshServerHandle, error) {
srv := nativessh.NewServer(nativessh.ServerConfig{
Credentials: creds,
})
ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22})
if err != nil {
return nil, fmt.Errorf("listen on netstack port 22: %w", err)
}
h := &sshServerHandle{ln: ln}
go func() {
if err := srv.Serve(ln); err != nil {
logger.Debug("nativessh: clients netstack server stopped: %v", err)
}
}()
return h, nil
}

247
common.go
View File

@@ -5,10 +5,8 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"regexp"
"strings"
"time"
@@ -18,7 +16,6 @@ import (
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/proxy"
"github.com/fosrl/newt/websocket"
"github.com/fsnotify/fsnotify"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.zx2c4.com/wireguard/tun/netstack"
@@ -122,11 +119,19 @@ func reliablePing(tnet *netstack.Net, dst string, baseTimeout time.Duration, max
totalLatency += latency
successCount++
// Return on first success
return totalLatency / time.Duration(successCount), nil
// If we get at least one success, we can return early for health checks
if successCount > 0 {
avgLatency := totalLatency / time.Duration(successCount)
// logger.Debug("Reliable ping succeeded after %d attempts, avg latency: %v", attempt, avgLatency)
return avgLatency, nil
}
}
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
if successCount == 0 {
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
}
return totalLatency / time.Duration(successCount), nil
}
func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
@@ -201,7 +206,6 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC
logger.Warn(msgHealthFileWriteFailed, err)
}
}
return
}
case <-pingStopChan:
// Stop the goroutine when signaled
@@ -214,25 +218,6 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
}
// shouldFireRecovery decides whether the data-plane recovery flow in
// startPingCheck should run on this tick. Recovery fires once when the
// consecutive-failure counter first crosses the threshold; the connectionLost
// flag prevents re-firing until a successful ping resets the state.
//
// This condition was previously inlined into startPingCheck and AND-ed with
// `currentInterval < maxInterval`, which silently broke recovery once
// pingInterval's default was bumped to 15s while maxInterval stayed at 6s
// (commit 8161fa6, March 2026): the gate became permanently false on default
// settings, so the recovery code never executed and ping failures climbed
// forever — the proximate cause of fosrl/newt#284, #310 and pangolin#1004.
//
// Recovery and backoff are independent concerns; the backoff ramp is now
// computed separately in the caller. Do not re-introduce currentInterval
// here.
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
return consecutiveFailures >= failureThreshold && !connectionLost
}
func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} {
maxInterval := 6 * time.Second
currentInterval := pingInterval
@@ -292,44 +277,35 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
// More lenient threshold for declaring connection lost under load
failureThreshold := 4
if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) {
connectionLost = true
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
if tunnelID != "" {
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
}
pingChainId := generateChainId()
pendingPingChainId = pingChainId
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{
"chainId": pingChainId,
}, 3*time.Second)
// Send registration message to the server for backward compatibility
bcChainId := generateChainId()
pendingRegisterChainId = bcChainId
err := client.SendMessage("newt/wg/register", map[string]interface{}{
"publicKey": publicKey.String(),
"backwardsCompatible": true,
"chainId": bcChainId,
})
if err != nil {
logger.Error("Failed to send registration message: %v", err)
}
if healthFile != "" {
err = os.Remove(healthFile)
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
if !connectionLost {
connectionLost = true
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
if tunnelID != "" {
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
}
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{}, 3*time.Second)
// Send registration message to the server for backward compatibility
err := client.SendMessage("newt/wg/register", map[string]interface{}{
"publicKey": publicKey.String(),
"backwardsCompatible": true,
})
if err != nil {
logger.Error("Failed to remove health file: %v", err)
logger.Error("Failed to send registration message: %v", err)
}
if healthFile != "" {
err = os.Remove(healthFile)
if err != nil {
logger.Error("Failed to remove health file: %v", err)
}
}
}
}
// Backoff: ramp the periodic-ping interval up while we are
// past the failure threshold, capped at maxInterval. Kept
// independent of the recovery trigger above so the trigger
// fires on every outage regardless of pingInterval.
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
currentInterval = time.Duration(float64(currentInterval) * 1.3)
currentInterval = time.Duration(float64(currentInterval) * 1.3) // Slower increase
if currentInterval > maxInterval {
currentInterval = maxInterval
}
ticker.Reset(currentInterval)
logger.Debug("Increased ping check interval to %v due to consecutive failures", currentInterval)
}
} else {
// Track recent latencies
@@ -387,62 +363,27 @@ func parseTargetData(data interface{}) (TargetData, error) {
return targetData, nil
}
// parseTargetString parses a target string in the format "listenPort:host:targetPort"
// It properly handles IPv6 addresses which must be in brackets: "listenPort:[ipv6]:targetPort"
// Examples:
// - IPv4: "3001:192.168.1.1:80"
// - IPv6: "3001:[::1]:8080" or "3001:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:80"
//
// Returns listenPort, targetAddress (in host:port format suitable for net.Dial), and error
func parseTargetString(target string) (int, string, error) {
// Find the first colon to extract the listen port
firstColon := strings.Index(target, ":")
if firstColon == -1 {
return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target)
}
listenPortStr := target[:firstColon]
var listenPort int
_, err := fmt.Sscanf(listenPortStr, "%d", &listenPort)
if err != nil {
return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr)
}
if listenPort <= 0 || listenPort > 65535 {
return 0, "", fmt.Errorf("listen port out of range: %d", listenPort)
}
// The remainder is host:targetPort - use net.SplitHostPort which handles IPv6 brackets
remainder := target[firstColon+1:]
host, targetPort, err := net.SplitHostPort(remainder)
if err != nil {
return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err)
}
// Reject empty host or target port
if host == "" {
return 0, "", fmt.Errorf("empty host in target: %s", target)
}
if targetPort == "" {
return 0, "", fmt.Errorf("empty target port in target: %s", target)
}
// Reconstruct the target address using JoinHostPort (handles IPv6 properly)
targetAddr := net.JoinHostPort(host, targetPort)
return listenPort, targetAddr, nil
}
func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
for _, t := range targetData.Targets {
// Parse the target string, handling both IPv4 and IPv6 addresses
port, target, err := parseTargetString(t)
// Split the first number off of the target with : separator and use as the port
parts := strings.Split(t, ":")
if len(parts) != 3 {
logger.Info("Invalid target format: %s", t)
continue
}
// Get the port as an int
port := 0
_, err := fmt.Sscanf(parts[0], "%d", &port)
if err != nil {
logger.Info("Invalid target format: %s (%v)", t, err)
logger.Info("Invalid port: %s", parts[0])
continue
}
switch action {
case "add":
target := parts[1] + ":" + parts[2]
// Call updown script if provided
processedTarget := target
if updownScript != "" {
@@ -469,6 +410,8 @@ func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto
case "remove":
logger.Info("Removing target with port %d", port)
target := parts[1] + ":" + parts[2]
// Call updown script if provided
if updownScript != "" {
_, err := executeUpdownScript(action, proto, target)
@@ -477,7 +420,7 @@ func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto
}
}
err = pm.RemoveTarget(proto, tunnelIP, port)
err := pm.RemoveTarget(proto, tunnelIP, port)
if err != nil {
logger.Error("Failed to remove target: %v", err)
return err
@@ -532,41 +475,15 @@ func executeUpdownScript(action, proto, target string) (string, error) {
return target, nil
}
// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and
// replaces recognised schemes with their resolved values. Currently supported:
//
// - env.<VAR> replaced with the value of the named environment variable
//
// Any token that does not match a supported scheme is left as-is so that
// future schemes (e.g. tag., api.) are preserved rather than silently dropped.
func interpolateBlueprint(data []byte) []byte {
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
return re.ReplaceAllFunc(data, func(match []byte) []byte {
// strip the surrounding {{ }}
inner := strings.TrimSpace(string(match[2 : len(match)-2]))
if strings.HasPrefix(inner, "env.") {
varName := strings.TrimPrefix(inner, "env.")
return []byte(os.Getenv(varName))
}
// unrecognised scheme leave the token untouched
return match
})
}
func sendBlueprint(client *websocket.Client, file string) error {
if file == "" {
func sendBlueprint(client *websocket.Client) error {
if blueprintFile == "" {
return nil
}
// try to read the blueprint file
blueprintData, err := os.ReadFile(file)
blueprintData, err := os.ReadFile(blueprintFile)
if err != nil {
logger.Error("Failed to read blueprint file: %v", err)
} else {
// interpolate {{env.VAR}} (and any future schemes) before parsing
blueprintData = interpolateBlueprint(blueprintData)
// first we should convert the yaml to json and error if the yaml is bad
var yamlObj interface{}
var blueprintJsonData string
@@ -601,61 +518,3 @@ func sendBlueprint(client *websocket.Client, file string) error {
return nil
}
func watchBlueprintFile(ctx context.Context, filePath string, send func() error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
logger.Error("blueprint watcher: failed to create: %v", err)
return
}
defer watcher.Close()
if err := watcher.Add(filePath); err != nil {
logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err)
return
}
logger.Info("Watching blueprint file for changes: %s", filePath)
var debounce *time.Timer
scheduleSend := func() {
if debounce != nil {
debounce.Stop()
}
debounce = time.AfterFunc(500*time.Millisecond, func() {
logger.Info("Blueprint file changed, resending...")
if err := send(); err != nil {
logger.Error("blueprint watcher: resend failed: %v", err)
}
})
}
for {
select {
case <-ctx.Done():
if debounce != nil {
debounce.Stop()
}
return
case event, ok := <-watcher.Events:
if !ok {
return
}
switch {
case event.Has(fsnotify.Write) || event.Has(fsnotify.Create):
if event.Has(fsnotify.Create) {
_ = watcher.Add(filePath)
}
scheduleSend()
case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename):
_ = watcher.Add(filePath)
scheduleSend()
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
logger.Error("blueprint watcher: %v", err)
}
}
}

View File

@@ -1,383 +0,0 @@
package main
import (
"context"
"net"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatchBlueprintFile_WriteTriggersSend(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
go watchBlueprintFile(ctx, f.Name(), func() error {
calls.Add(1)
return nil
})
time.Sleep(50 * time.Millisecond)
if err := os.WriteFile(f.Name(), []byte("content"), 0644); err != nil {
t.Fatal(err)
}
time.Sleep(700 * time.Millisecond)
if calls.Load() != 1 {
t.Errorf("expected 1 send call, got %d", calls.Load())
}
}
func TestWatchBlueprintFile_DebounceCoalescesEvents(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
go watchBlueprintFile(ctx, f.Name(), func() error {
calls.Add(1)
return nil
})
time.Sleep(50 * time.Millisecond)
for i := 0; i < 5; i++ {
if err := os.WriteFile(f.Name(), []byte("change"), 0644); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
}
time.Sleep(700 * time.Millisecond)
if calls.Load() != 1 {
t.Errorf("expected 1 send call after debounce, got %d", calls.Load())
}
}
func TestWatchBlueprintFile_ContextCancellationStops(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
watchBlueprintFile(ctx, f.Name(), func() error { return nil })
close(done)
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("watchBlueprintFile did not exit after context cancellation")
}
}
func TestWatchBlueprintFile_AtomicWriteTriggersSend(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "blueprint.yaml")
if err := os.WriteFile(target, []byte("initial"), 0644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
go watchBlueprintFile(ctx, target, func() error {
calls.Add(1)
return nil
})
time.Sleep(50 * time.Millisecond)
tmp := filepath.Join(dir, "blueprint.yaml.tmp")
if err := os.WriteFile(tmp, []byte("updated"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, target); err != nil {
t.Fatal(err)
}
time.Sleep(700 * time.Millisecond)
if calls.Load() < 1 {
t.Errorf("expected at least 1 send call after atomic write, got %d", calls.Load())
}
}
func TestWatchBlueprintFile_MissingFileReturnsGracefully(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
watchBlueprintFile(ctx, "/nonexistent/path/blueprint.yaml", func() error { return nil })
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("watchBlueprintFile did not return for missing file")
}
}
func TestParseTargetString(t *testing.T) {
tests := []struct {
name string
input string
wantListenPort int
wantTargetAddr string
wantErr bool
}{
{
name: "valid IPv4 basic",
input: "3001:192.168.1.1:80",
wantListenPort: 3001,
wantTargetAddr: "192.168.1.1:80",
wantErr: false,
},
{
name: "valid IPv4 localhost",
input: "8080:127.0.0.1:3000",
wantListenPort: 8080,
wantTargetAddr: "127.0.0.1:3000",
wantErr: false,
},
{
name: "valid IPv4 same ports",
input: "443:10.0.0.1:443",
wantListenPort: 443,
wantTargetAddr: "10.0.0.1:443",
wantErr: false,
},
{
name: "valid IPv6 loopback",
input: "3001:[::1]:8080",
wantListenPort: 3001,
wantTargetAddr: "[::1]:8080",
wantErr: false,
},
{
name: "valid IPv6 full address",
input: "80:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080",
wantListenPort: 80,
wantTargetAddr: "[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080",
wantErr: false,
},
{
name: "valid IPv6 link-local",
input: "443:[fe80::1]:443",
wantListenPort: 443,
wantTargetAddr: "[fe80::1]:443",
wantErr: false,
},
{
name: "valid IPv6 all zeros compressed",
input: "8000:[::]:9000",
wantListenPort: 8000,
wantTargetAddr: "[::]:9000",
wantErr: false,
},
{
name: "valid IPv6 mixed notation",
input: "5000:[::ffff:192.168.1.1]:6000",
wantListenPort: 5000,
wantTargetAddr: "[::ffff:192.168.1.1]:6000",
wantErr: false,
},
{
name: "valid hostname",
input: "8080:example.com:80",
wantListenPort: 8080,
wantTargetAddr: "example.com:80",
wantErr: false,
},
{
name: "valid hostname with subdomain",
input: "443:api.example.com:8443",
wantListenPort: 443,
wantTargetAddr: "api.example.com:8443",
wantErr: false,
},
{
name: "valid localhost hostname",
input: "3000:localhost:3000",
wantListenPort: 3000,
wantTargetAddr: "localhost:3000",
wantErr: false,
},
{
name: "invalid - no colons",
input: "invalid",
wantErr: true,
},
{
name: "invalid - empty string",
input: "",
wantErr: true,
},
{
name: "invalid - non-numeric listen port",
input: "abc:192.168.1.1:80",
wantErr: true,
},
{
name: "invalid - missing target port",
input: "3001:192.168.1.1",
wantErr: true,
},
{
name: "invalid - IPv6 without brackets",
input: "3001:fd70:1452:b736:4dd5:caca:7db9:c588:f5b3:80",
wantErr: true,
},
{
name: "invalid - only listen port",
input: "3001:",
wantErr: true,
},
{
name: "invalid - missing host",
input: "3001::80",
wantErr: true,
},
{
name: "invalid - IPv6 unclosed bracket",
input: "3001:[::1:80",
wantErr: true,
},
{
name: "invalid - listen port zero",
input: "0:192.168.1.1:80",
wantErr: true,
},
{
name: "invalid - listen port negative",
input: "-1:192.168.1.1:80",
wantErr: true,
},
{
name: "invalid - listen port out of range",
input: "70000:192.168.1.1:80",
wantErr: true,
},
{
name: "invalid - empty target port",
input: "3001:192.168.1.1:",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
listenPort, targetAddr, err := parseTargetString(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseTargetString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if listenPort != tt.wantListenPort {
t.Errorf("parseTargetString(%q) listenPort = %d, want %d", tt.input, listenPort, tt.wantListenPort)
}
if targetAddr != tt.wantTargetAddr {
t.Errorf("parseTargetString(%q) targetAddr = %q, want %q", tt.input, targetAddr, tt.wantTargetAddr)
}
})
}
}
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial.
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
tests := []struct {
name string
input string
}{
{"IPv4", "8080:127.0.0.1:80"},
{"IPv6 loopback", "8080:[::1]:80"},
{"IPv6 full", "8080:[2001:db8::1]:80"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, targetAddr, err := parseTargetString(tt.input)
if err != nil {
t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err)
}
_, _, err = net.SplitHostPort(targetAddr)
if err != nil {
t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err)
}
})
}
}
// TestShouldFireRecovery is the regression guard for the broken trigger gate
// that prevented data-plane recovery from ever firing under default settings
// (fosrl/newt#284, #310, pangolin#1004). The pre-fix condition was
//
// consecutiveFailures >= failureThreshold && currentInterval < maxInterval
//
// which became permanently false once pingInterval's default was bumped from
// 3s to 15s in commit 8161fa6 — currentInterval starts at pingInterval=15s,
// maxInterval stayed at 6s, so 15<6 is false and the recovery branch never
// executed.
//
// The fix is to drop currentInterval from the trigger condition entirely;
// backoff is a separate concern computed in the caller. The cases below
// exercise the documented contract.
func TestShouldFireRecovery(t *testing.T) {
const threshold = 4
cases := []struct {
name string
failures int
connectionLost bool
want bool
}{
{"below threshold, fresh", 3, false, false},
{"below threshold, already lost", 3, true, false},
{"at threshold, fresh — recovery must fire", threshold, false, true},
{"at threshold, already lost — gate prevents re-fire", threshold, true, false},
{"far above threshold, fresh", 100, false, true},
{"far above threshold, already lost", 100, true, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := shouldFireRecovery(c.failures, threshold, c.connectionLost); got != c.want {
t.Errorf("shouldFireRecovery(failures=%d, threshold=%d, lost=%v) = %v, want %v",
c.failures, threshold, c.connectionLost, got, c.want)
}
})
}
}

View File

@@ -9,9 +9,10 @@ import (
"strings"
"time"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/events"
"github.com/moby/moby/client"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/fosrl/newt/logger"
)
@@ -169,7 +170,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
}
// Used to filter down containers returned to Pangolin
containerFilters := make(client.Filters)
containerFilters := filters.NewArgs()
// Used to determine if we will send IP addresses or hostnames to Pangolin
useContainerIpAddresses := true
@@ -214,28 +215,21 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
}
// List containers
containerListResult, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: containerFilters})
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: containerFilters})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %v", err)
}
addrToString := func(addr interface{ IsValid() bool; String() string }) string {
if addr.IsValid() {
return addr.String()
}
return ""
}
var dockerContainers []Container
for _, c := range containerListResult.Items {
for _, c := range containers {
// Short ID like docker ps
shortId := c.ID[:12]
// Inspect container to get hostname
hostname := ""
containerInfo, err := cli.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{})
if err == nil && containerInfo.Container.Config != nil {
hostname = containerInfo.Container.Config.Hostname
containerInfo, err := cli.ContainerInspect(ctx, c.ID)
if err == nil && containerInfo.Config != nil {
hostname = containerInfo.Config.Hostname
}
// Skip host container if set
@@ -259,8 +253,8 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
if port.PublicPort != 0 {
dockerPort.PublicPort = int(port.PublicPort)
}
if port.IP.IsValid() {
dockerPort.IP = port.IP.String()
if port.IP != "" {
dockerPort.IP = port.IP
}
ports = append(ports, dockerPort)
}
@@ -274,19 +268,19 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
dockerNetwork := Network{
NetworkID: endpoint.NetworkID,
EndpointID: endpoint.EndpointID,
Gateway: addrToString(endpoint.Gateway),
Gateway: endpoint.Gateway,
IPPrefixLen: endpoint.IPPrefixLen,
IPv6Gateway: addrToString(endpoint.IPv6Gateway),
GlobalIPv6Address: addrToString(endpoint.GlobalIPv6Address),
IPv6Gateway: endpoint.IPv6Gateway,
GlobalIPv6Address: endpoint.GlobalIPv6Address,
GlobalIPv6PrefixLen: endpoint.GlobalIPv6PrefixLen,
MacAddress: endpoint.MacAddress.String(),
MacAddress: endpoint.MacAddress,
Aliases: endpoint.Aliases,
DNSNames: endpoint.DNSNames,
}
// Use IPs over hostnames/containers as we're on the bridge network
if useContainerIpAddresses {
dockerNetwork.IPAddress = addrToString(endpoint.IPAddress)
dockerNetwork.IPAddress = endpoint.IPAddress
}
networks[networkName] = dockerNetwork
@@ -297,7 +291,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
ID: shortId,
Name: name,
Image: c.Image,
State: string(c.State),
State: c.State,
Status: c.Status,
Ports: ports,
Labels: c.Labels,
@@ -321,12 +315,12 @@ func getHostContainer(dockerContext context.Context, dockerClient *client.Client
}
// Get host container from the docker socket
hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName, client.ContainerInspectOptions{})
hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName)
if err != nil {
return nil, fmt.Errorf("failed to find host container")
}
return &hostContainer.Container, nil
return &hostContainer, nil
}
// EventCallback defines the function signature for handling Docker events
@@ -377,7 +371,7 @@ func (em *EventMonitor) Start() error {
logger.Debug("Starting Docker event monitoring")
// Filter for container events we care about
eventFilters := make(client.Filters)
eventFilters := filters.NewArgs()
eventFilters.Add("type", "container")
// eventFilters.Add("event", "create")
eventFilters.Add("event", "start")
@@ -388,10 +382,9 @@ func (em *EventMonitor) Start() error {
// eventFilters.Add("event", "unpause")
// Start listening for events
eventsResult := em.client.Events(em.ctx, client.EventsListOptions{
eventCh, errCh := em.client.Events(em.ctx, events.ListOptions{
Filters: eventFilters,
})
eventCh, errCh := eventsResult.Messages, eventsResult.Err
go func() {
defer func() {
@@ -415,10 +408,9 @@ func (em *EventMonitor) Start() error {
time.Sleep(5 * time.Second)
if em.ctx.Err() == nil {
logger.Info("Attempting to reconnect to Docker event stream")
eventsResult = em.client.Events(em.ctx, client.EventsListOptions{
eventCh, errCh = em.client.Events(em.ctx, events.ListOptions{
Filters: eventFilters,
})
eventCh, errCh = eventsResult.Messages, eventsResult.Err
}
}
return

View File

@@ -25,7 +25,7 @@
inherit (pkgs) lib;
# Update version when releasing
version = "1.12.5";
version = "1.8.0";
in
{
default = self.packages.${system}.pangolin-newt;
@@ -35,7 +35,7 @@
inherit version;
src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
vendorHash = "sha256-X70emc3uPN2YpsbudtoeEZDHilaTvFMqfRaDmIgRVZE=";
vendorHash = "sha256-Sib6AUCpMgxlMpTc2Esvs+UU0yduVOxWUgT44FHAI+k=";
nativeInstallCheckInputs = [ pkgs.versionCheckHook ];

View File

@@ -1,7 +1,7 @@
#!/bin/sh
#!/bin/bash
# Get Newt - Cross-platform installation script
# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | sh
# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | bash
set -e
@@ -17,51 +17,54 @@ GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest"
# Function to print colored output
print_status() {
printf '%b[INFO]%b %s\n' "${GREEN}" "${NC}" "$1"
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
printf '%b[WARN]%b %s\n' "${YELLOW}" "${NC}" "$1"
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
printf '%b[ERROR]%b %s\n' "${RED}" "${NC}" "$1"
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to get latest version from GitHub API
get_latest_version() {
latest_info=""
local latest_info
if command -v curl >/dev/null 2>&1; then
latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null)
elif command -v wget >/dev/null 2>&1; then
latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null)
else
print_error "Neither curl nor wget is available."
print_error "Neither curl nor wget is available. Please install one of them." >&2
exit 1
fi
if [ -z "$latest_info" ]; then
print_error "Failed to fetch latest version info"
print_error "Failed to fetch latest version information" >&2
exit 1
fi
version=$(printf '%s' "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
# Extract version from JSON response (works without jq)
local version=$(echo "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
if [ -z "$version" ]; then
print_error "Could not parse version from GitHub API response"
print_error "Could not parse version from GitHub API response" >&2
exit 1
fi
version=$(printf '%s' "$version" | sed 's/^v//')
printf '%s' "$version"
# Remove 'v' prefix if present
version=$(echo "$version" | sed 's/^v//')
echo "$version"
}
# Detect OS and architecture
detect_platform() {
os=""
arch=""
local os arch
# Detect OS
case "$(uname -s)" in
Linux*) os="linux" ;;
Darwin*) os="darwin" ;;
@@ -72,11 +75,12 @@ detect_platform() {
exit 1
;;
esac
# Detect architecture
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
arm64|aarch64) arch="arm64" ;;
armv7l|armv6l)
armv7l|armv6l)
if [ "$os" = "linux" ]; then
if [ "$(uname -m)" = "armv6l" ]; then
arch="arm32v6"
@@ -84,10 +88,10 @@ detect_platform() {
arch="arm32"
fi
else
arch="arm64"
arch="arm64" # Default for non-Linux ARM
fi
;;
riscv64)
riscv64)
if [ "$os" = "linux" ]; then
arch="riscv64"
else
@@ -100,166 +104,90 @@ detect_platform() {
exit 1
;;
esac
printf '%s_%s' "$os" "$arch"
echo "${os}_${arch}"
}
# Determine installation directory (default fallback)
# Get installation directory
get_install_dir() {
case "$PLATFORM" in
*windows*)
echo "$HOME/bin"
;;
*)
echo "/usr/local/bin"
;;
esac
}
# Parse --path argument from args
# Returns the value after --path, or empty string if not provided
parse_path_arg() {
while [ $# -gt 0 ]; do
case "$1" in
--path)
if [ -n "$2" ]; then
printf '%s' "$2"
return
fi
;;
--path=*)
printf '%s' "${1#--path=}"
return
;;
esac
shift
done
}
# Detect an existing newt binary location.
# Tries unprivileged which first, then sudo which (for binaries only visible to root).
# Returns the full path of the binary, or empty string if not found.
detect_existing_binary() {
existing=""
# Try unprivileged which first
existing=$(command -v newt 2>/dev/null || true)
if [ -n "$existing" ]; then
printf '%s' "$existing"
return
fi
# Try sudo which — some installations land in paths only root can see in $PATH
if command -v sudo >/dev/null 2>&1; then
existing=$(sudo which newt 2>/dev/null || true)
if [ -n "$existing" ]; then
printf '%s' "$existing"
return
fi
fi
}
# Check if we need sudo for installation
needs_sudo() {
install_dir="$1"
if [ -w "$install_dir" ] 2>/dev/null; then
return 1 # No sudo needed
if [ "$OS" = "windows" ]; then
echo "$HOME/bin"
else
return 0 # Sudo needed
fi
}
# Get the appropriate command prefix (sudo or empty)
get_sudo_cmd() {
install_dir="$1"
if needs_sudo "$install_dir"; then
if command -v sudo >/dev/null 2>&1; then
echo "sudo"
# Try to use a directory in PATH, fallback to ~/.local/bin
if echo "$PATH" | grep -q "/usr/local/bin"; then
if [ -w "/usr/local/bin" ] 2>/dev/null; then
echo "/usr/local/bin"
else
echo "$HOME/.local/bin"
fi
else
print_error "Cannot write to ${install_dir} and sudo is not available."
print_error "Please run this script as root or install sudo."
exit 1
echo "$HOME/.local/bin"
fi
else
echo ""
fi
}
# Download and install newt
install_newt() {
platform="$1"
install_dir="$2"
sudo_cmd="$3"
custom_path="$4"
binary_name="newt_${platform}"
final_name="newt"
case "$platform" in
*windows*)
binary_name="${binary_name}.exe"
final_name="newt.exe"
;;
esac
download_url="${BASE_URL}/${binary_name}"
temp_file="/tmp/${final_name}"
# If a custom path is provided, use it directly; otherwise use install_dir/final_name
if [ -n "$custom_path" ]; then
final_path="$custom_path"
install_dir=$(dirname "$final_path")
else
final_path="${install_dir}/${final_name}"
local platform="$1"
local install_dir="$2"
local binary_name="newt_${platform}"
local exe_suffix=""
# Add .exe suffix for Windows
if [[ "$platform" == *"windows"* ]]; then
binary_name="${binary_name}.exe"
exe_suffix=".exe"
fi
local download_url="${BASE_URL}/${binary_name}"
local temp_file="/tmp/newt${exe_suffix}"
local final_path="${install_dir}/newt${exe_suffix}"
print_status "Downloading newt from ${download_url}"
# Download the binary
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$download_url" -o "$temp_file"
elif command -v wget >/dev/null 2>&1; then
wget -q "$download_url" -O "$temp_file"
else
print_error "Neither curl nor wget is available."
print_error "Neither curl nor wget is available. Please install one of them."
exit 1
fi
# Make executable before moving
chmod +x "$temp_file"
# Create install directory if it doesn't exist and move binary
if [ -n "$sudo_cmd" ]; then
$sudo_cmd mkdir -p "$install_dir"
print_status "Using sudo to install to ${install_dir}"
$sudo_cmd mv "$temp_file" "$final_path"
else
mkdir -p "$install_dir"
mv "$temp_file" "$final_path"
fi
# Create install directory if it doesn't exist
mkdir -p "$install_dir"
# Move binary to install directory
mv "$temp_file" "$final_path"
# Make executable (not needed on Windows, but doesn't hurt)
chmod +x "$final_path"
print_status "newt installed to ${final_path}"
# Check if install directory is in PATH
if ! echo "$PATH" | grep -q "$install_dir"; then
print_warning "Install directory ${install_dir} is not in your PATH."
print_warning "Add it with:"
print_warning "Add it to your PATH by adding this line to your shell profile:"
print_warning " export PATH=\"${install_dir}:\$PATH\""
fi
}
# Verify installation
verify_installation() {
install_dir="$1"
exe_suffix=""
case "$PLATFORM" in
*windows*) exe_suffix=".exe" ;;
esac
newt_path="${install_dir}/newt${exe_suffix}"
if [ -x "$newt_path" ]; then
local install_dir="$1"
local exe_suffix=""
if [[ "$PLATFORM" == *"windows"* ]]; then
exe_suffix=".exe"
fi
local newt_path="${install_dir}/newt${exe_suffix}"
if [ -f "$newt_path" ] && [ -x "$newt_path" ]; then
print_status "Installation successful!"
print_status "newt version: $("$newt_path" --version 2>/dev/null || printf 'unknown')"
print_status "newt version: $("$newt_path" --version 2>/dev/null || echo "unknown")"
return 0
else
print_error "Installation failed. Binary not found or not executable."
@@ -269,66 +197,39 @@ verify_installation() {
# Main installation process
main() {
# --path explicitly overrides everything
CUSTOM_PATH=$(parse_path_arg "$@")
if [ -n "$CUSTOM_PATH" ]; then
print_status "Installing latest version of newt to ${CUSTOM_PATH}..."
else
print_status "Installing latest version of newt..."
fi
print_status "Fetching latest version..."
print_status "Installing latest version of newt..."
# Get latest version
print_status "Fetching latest version from GitHub..."
VERSION=$(get_latest_version)
print_status "Latest version: v${VERSION}"
# Set base URL with the fetched version
BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
# Detect platform
PLATFORM=$(detect_platform)
print_status "Detected platform: ${PLATFORM}"
if [ -n "$CUSTOM_PATH" ]; then
# --path wins; derive INSTALL_DIR from it
INSTALL_DIR=$(dirname "$CUSTOM_PATH")
else
# Try to find an existing installation so we update the right place
EXISTING_BINARY=$(detect_existing_binary)
if [ -n "$EXISTING_BINARY" ]; then
print_status "Found existing newt binary at ${EXISTING_BINARY}"
CUSTOM_PATH="$EXISTING_BINARY"
INSTALL_DIR=$(dirname "$EXISTING_BINARY")
print_status "Will update existing installation at ${INSTALL_DIR}"
else
INSTALL_DIR=$(get_install_dir)
fi
fi
# Get install directory
INSTALL_DIR=$(get_install_dir)
print_status "Install directory: ${INSTALL_DIR}"
# Check if we need sudo
SUDO_CMD=$(get_sudo_cmd "$INSTALL_DIR")
if [ -n "$SUDO_CMD" ]; then
print_status "Root privileges required for installation to ${INSTALL_DIR}"
fi
install_newt "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" "$CUSTOM_PATH"
if [ -n "$CUSTOM_PATH" ]; then
if [ -x "$CUSTOM_PATH" ]; then
print_status "Installation successful!"
print_status "newt version: $("$CUSTOM_PATH" --version 2>/dev/null || printf 'unknown')"
print_status "newt is ready to use!"
else
print_error "Installation failed. Binary not found or not executable at ${CUSTOM_PATH}."
exit 1
fi
elif verify_installation "$INSTALL_DIR"; then
# Install newt
install_newt "$PLATFORM" "$INSTALL_DIR"
# Verify installation
if verify_installation "$INSTALL_DIR"; then
print_status "newt is ready to use!"
print_status "Run 'newt --help' to get started."
if [[ "$PLATFORM" == *"windows"* ]]; then
print_status "Run 'newt --help' to get started"
else
print_status "Run 'newt --help' to get started"
fi
else
exit 1
fi
}
# Run main function
main "$@"
main "$@"

81
go.mod
View File

@@ -1,75 +1,76 @@
module github.com/fosrl/newt
go 1.25.0
go 1.25
require (
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/gaissmai/bart v0.26.1
github.com/go-crypt/crypt v0.14.15
github.com/go-crypt/x v0.4.16
github.com/docker/docker v28.5.2+incompatible
github.com/gorilla/websocket v1.5.3
github.com/moby/moby/api v1.54.2
github.com/moby/moby/client v0.4.1
github.com/prometheus/client_golang v1.23.2
github.com/vishvananda/netlink v1.3.1
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
golang.org/x/crypto v0.52.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.64.0
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.opentelemetry.io/otel/exporters/prometheus v0.61.0
go.opentelemetry.io/otel/metric v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/sdk/metric v1.39.0
golang.org/x/crypto v0.46.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
golang.org/x/net v0.48.0
golang.org/x/sys v0.39.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v1.0.1
google.golang.org/grpc v1.81.1
golang.zx2c4.com/wireguard/windows v0.5.3
google.golang.org/grpc v1.77.0
gopkg.in/yaml.v3 v3.0.1
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
software.sslmate.com/src/go-pkcs12 v0.7.1
software.sslmate.com/src/go-pkcs12 v0.7.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs v0.3.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/text v0.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

178
go.sum
View File

@@ -1,37 +1,31 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=
github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/go-crypt/crypt v0.14.15 h1:q1i5OMpL05r935IxWmXgpDAVF0nvi4SMoHhGXLBQUEQ=
github.com/go-crypt/crypt v0.14.15/go.mod h1:0n/to1VqIZPENj2yEUa/sLLYYnmupma6cp+QMX4zfF0=
github.com/go-crypt/x v0.4.16 h1:WXdY28H/0MsXnH+gwerxuCcvBTJPkBG90u6oS4gIPZI=
github.com/go-crypt/x v0.4.16/go.mod h1:vmVFA/d/oLrEaCbqsLcjBMlTqF8u8pvH/c4+EJ/ped8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -47,8 +41,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -59,30 +53,38 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
@@ -91,78 +93,82 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU=
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/contrib/instrumentation/runtime v0.64.0 h1:/+/+UjlXjFcdDlXxKL1PouzX8Z2Vl0OxolRKeBEgYDw=
go.opentelemetry.io/contrib/instrumentation/runtime v0.64.0/go.mod h1:Ldm/PDuzY2DP7IypudopCR3OCOW42NJlN9+mNEroevo=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/exporters/prometheus v0.61.0 h1:cCyZS4dr67d30uDyh8etKM2QyDsQ4zC9ds3bdbrVoD0=
go.opentelemetry.io/otel/exporters/prometheus v0.61.0/go.mod h1:iivMuj3xpR2DkUrUya3TPS/Z9h3dz7h01GxU+fQBRNg=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8=
software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

View File

@@ -5,9 +5,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -37,38 +35,33 @@ func (s Health) String() string {
// Config holds the health check configuration for a target
type Config struct {
ID int `json:"id"`
Enabled bool `json:"hcEnabled"`
Path string `json:"hcPath"`
Scheme string `json:"hcScheme"`
Mode string `json:"hcMode"`
Hostname string `json:"hcHostname"`
Port int `json:"hcPort"`
Interval int `json:"hcInterval"` // in seconds
UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds
Timeout int `json:"hcTimeout"` // in seconds
FollowRedirects *bool `json:"hcFollowRedirects"`
Headers map[string]string `json:"hcHeaders"`
Method string `json:"hcMethod"`
Status int `json:"hcStatus"` // HTTP status code
TLSServerName string `json:"hcTlsServerName"`
HealthyThreshold int `json:"hcHealthyThreshold"` // consecutive successes required to become healthy
UnhealthyThreshold int `json:"hcUnhealthyThreshold"` // consecutive failures required to become unhealthy
ID int `json:"id"`
Enabled bool `json:"hcEnabled"`
Path string `json:"hcPath"`
Scheme string `json:"hcScheme"`
Mode string `json:"hcMode"`
Hostname string `json:"hcHostname"`
Port int `json:"hcPort"`
Interval int `json:"hcInterval"` // in seconds
UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds
Timeout int `json:"hcTimeout"` // in seconds
Headers map[string]string `json:"hcHeaders"`
Method string `json:"hcMethod"`
Status int `json:"hcStatus"` // HTTP status code
TLSServerName string `json:"hcTlsServerName"`
}
// Target represents a health check target with its current status
type Target struct {
Config Config `json:"config"`
Status Health `json:"status"`
LastCheck time.Time `json:"lastCheck"`
LastError string `json:"lastError,omitempty"`
CheckCount int `json:"checkCount"`
timer *time.Timer
ctx context.Context
cancel context.CancelFunc
client *http.Client
consecutiveSuccesses int
consecutiveFailures int
Config Config `json:"config"`
Status Health `json:"status"`
LastCheck time.Time `json:"lastCheck"`
LastError string `json:"lastError,omitempty"`
CheckCount int `json:"checkCount"`
timer *time.Timer
ctx context.Context
cancel context.CancelFunc
client *http.Client
}
// StatusChangeCallback is called when any target's status changes
@@ -170,16 +163,9 @@ func (m *Monitor) addTargetUnsafe(config Config) error {
if config.Timeout == 0 {
config.Timeout = 5
}
if config.HealthyThreshold == 0 {
config.HealthyThreshold = 1
}
if config.UnhealthyThreshold == 0 {
config.UnhealthyThreshold = 1
}
logger.Debug("Target %d configuration: mode=%s, scheme=%s, method=%s, interval=%ds, timeout=%ds, healthyThreshold=%d, unhealthyThreshold=%d",
config.ID, config.Mode, config.Scheme, config.Method, config.Interval, config.Timeout,
config.HealthyThreshold, config.UnhealthyThreshold)
logger.Debug("Target %d configuration: scheme=%s, method=%s, interval=%ds, timeout=%ds",
config.ID, config.Scheme, config.Method, config.Interval, config.Timeout)
// Parse headers if provided as string
if len(config.Headers) == 0 && config.Path != "" {
@@ -201,21 +187,7 @@ func (m *Monitor) addTargetUnsafe(config Config) error {
ctx: ctx,
cancel: cancel,
client: &http.Client{
CheckRedirect: func() func(*http.Request, []*http.Request) error {
// Default to following redirects if not explicitly configured
followRedirects := config.FollowRedirects == nil || *config.FollowRedirects
if !followRedirects {
return func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
}
return nil
}(),
Transport: &http.Transport{
// Disable keep-alives so each health check uses a fresh connection.
// Reusing idle connections causes spurious timeouts when the remote
// server closes the connection between long check intervals.
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{
// Configure TLS settings based on certificate enforcement
InsecureSkipVerify: !m.enforceCert,
@@ -256,7 +228,7 @@ func (m *Monitor) RemoveTarget(id int) error {
// Notify callback of status change
if m.callback != nil {
go m.callback(m.getAllTargetsUnsafe())
go m.callback(m.GetTargets())
}
logger.Info("Successfully removed target %d", id)
@@ -289,7 +261,7 @@ func (m *Monitor) RemoveTargets(ids []int) error {
// Notify callback of status change if any targets were removed
if len(notFound) != len(ids) && m.callback != nil {
go m.callback(m.getAllTargetsUnsafe())
go m.callback(m.GetTargets())
}
if len(notFound) > 0 {
@@ -387,77 +359,17 @@ func (m *Monitor) monitorTarget(target *Target) {
}
}
// performHealthCheck performs a health check on a target and applies threshold logic
// performHealthCheck performs a health check on a target
func (m *Monitor) performHealthCheck(target *Target) {
target.CheckCount++
target.LastCheck = time.Now()
target.LastError = ""
var passed bool
var checkErr string
switch strings.ToLower(target.Config.Mode) {
case "tcp":
passed, checkErr = m.performTCPCheck(target)
default:
// "http", "https", or anything else falls through to HTTP
passed, checkErr = m.performHTTPCheck(target)
}
if passed {
target.consecutiveFailures = 0
target.consecutiveSuccesses++
logger.Debug("Target %d: check passed (consecutive successes: %d / threshold: %d)",
target.Config.ID, target.consecutiveSuccesses, target.Config.HealthyThreshold)
if target.consecutiveSuccesses >= target.Config.HealthyThreshold {
target.Status = StatusHealthy
target.LastError = ""
}
} else {
target.consecutiveSuccesses = 0
target.consecutiveFailures++
target.LastError = checkErr
logger.Debug("Target %d: check failed (consecutive failures: %d / threshold: %d): %s",
target.Config.ID, target.consecutiveFailures, target.Config.UnhealthyThreshold, checkErr)
if target.consecutiveFailures >= target.Config.UnhealthyThreshold {
target.Status = StatusUnhealthy
}
}
}
// performTCPCheck dials the target's host:port over TCP and returns whether it succeeded
func (m *Monitor) performTCPCheck(target *Target) (bool, string) {
address := net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port))
timeout := time.Duration(target.Config.Timeout) * time.Second
logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)",
target.Config.ID, address, timeout)
ctx, cancel := context.WithTimeout(target.ctx, timeout)
defer cancel()
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
if err != nil {
msg := fmt.Sprintf("TCP dial failed: %v", err)
logger.Warn("Target %d: %s", target.Config.ID, msg)
return false, msg
}
conn.Close()
logger.Debug("Target %d: TCP health check passed", target.Config.ID)
return true, ""
}
// performHTTPCheck performs an HTTP/HTTPS health check and returns whether it succeeded
func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
// Build URL (use net.JoinHostPort to properly handle IPv6 addresses with ports)
host := target.Config.Hostname
// Build URL
url := fmt.Sprintf("%s://%s", target.Config.Scheme, target.Config.Hostname)
if target.Config.Port > 0 {
host = net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port))
url = fmt.Sprintf("%s:%d", url, target.Config.Port)
}
url := fmt.Sprintf("%s://%s", target.Config.Scheme, host)
if target.Config.Path != "" {
if !strings.HasPrefix(target.Config.Path, "/") {
url += "/"
@@ -465,7 +377,7 @@ func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
url += target.Config.Path
}
logger.Debug("Target %d: performing HTTP health check %d to %s",
logger.Debug("Target %d: performing health check %d to %s",
target.Config.ID, target.CheckCount, url)
if target.Config.Scheme == "https" {
@@ -473,16 +385,16 @@ func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
target.Config.ID, m.enforceCert)
}
// Create request with timeout context, derived from the target's context so
// that in-flight requests are cancelled immediately when the target is replaced.
ctx, cancel := context.WithTimeout(target.ctx, time.Duration(target.Config.Timeout)*time.Second)
// Create request
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)
if err != nil {
msg := fmt.Sprintf("failed to create request: %v", err)
logger.Warn("Target %d: %s", target.Config.ID, msg)
return false, msg
target.Status = StatusUnhealthy
target.LastError = fmt.Sprintf("failed to create request: %v", err)
logger.Warn("Target %d: failed to create request: %v", target.Config.ID, err)
return
}
// Add headers
@@ -498,34 +410,43 @@ func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
// Perform request
resp, err := target.client.Do(req)
if err != nil {
msg := fmt.Sprintf("request failed: %v", err)
target.Status = StatusUnhealthy
target.LastError = fmt.Sprintf("request failed: %v", err)
logger.Warn("Target %d: health check failed: %v", target.Config.ID, err)
return false, msg
return
}
defer resp.Body.Close()
// Check response status
var expectedStatus int
if target.Config.Status > 0 {
expectedStatus = target.Config.Status
} else {
expectedStatus = 0 // Use range check for 200-299
}
if expectedStatus > 0 {
logger.Debug("Target %d: checking health status against expected code %d", target.Config.ID, expectedStatus)
// Check for specific status code
logger.Debug("Target %d: checking status against expected code %d", target.Config.ID, target.Config.Status)
if resp.StatusCode == target.Config.Status {
logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode)
return true, ""
if resp.StatusCode == expectedStatus {
target.Status = StatusHealthy
logger.Debug("Target %d: health check passed (status: %d, expected: %d)", target.Config.ID, resp.StatusCode, expectedStatus)
} else {
target.Status = StatusUnhealthy
target.LastError = fmt.Sprintf("unexpected status code: %d (expected: %d)", resp.StatusCode, expectedStatus)
logger.Warn("Target %d: health check failed with status code %d (expected: %d)", target.Config.ID, resp.StatusCode, expectedStatus)
}
} else {
// Check for 2xx range
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
target.Status = StatusHealthy
logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode)
} else {
target.Status = StatusUnhealthy
target.LastError = fmt.Sprintf("unhealthy status code: %d", resp.StatusCode)
logger.Warn("Target %d: health check failed with status code %d", target.Config.ID, resp.StatusCode)
}
msg := fmt.Sprintf("unexpected status code: %d (expected: %d)", resp.StatusCode, target.Config.Status)
logger.Warn("Target %d: %s", target.Config.ID, msg)
return false, msg
}
// Default: check for 2xx range
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode)
return true, ""
}
msg := fmt.Sprintf("unhealthy status code: %d", resp.StatusCode)
logger.Warn("Target %d: health check failed with status code %d", target.Config.ID, resp.StatusCode)
return false, msg
}
// Stop stops monitoring all targets
@@ -592,7 +513,7 @@ func (m *Monitor) DisableTarget(id int) error {
// Notify callback of status change
if m.callback != nil {
go m.callback(m.getAllTargetsUnsafe())
go m.callback(m.GetTargets())
}
} else {
logger.Debug("Target %d is already disabled", id)
@@ -600,82 +521,3 @@ func (m *Monitor) DisableTarget(id int) error {
return nil
}
// GetTargetIDs returns a slice of all current target IDs
func (m *Monitor) GetTargetIDs() []int {
m.mutex.RLock()
defer m.mutex.RUnlock()
ids := make([]int, 0, len(m.targets))
for id := range m.targets {
ids = append(ids, id)
}
return ids
}
// SyncTargets synchronizes the current targets to match the desired set.
// It removes targets not in the desired set and adds targets that are missing.
func (m *Monitor) SyncTargets(desiredConfigs []Config) error {
m.mutex.Lock()
defer m.mutex.Unlock()
logger.Info("Syncing health check targets: %d desired targets", len(desiredConfigs))
// Build a set of desired target IDs
desiredIDs := make(map[int]Config)
for _, config := range desiredConfigs {
desiredIDs[config.ID] = config
}
// Find targets to remove (exist but not in desired set)
var toRemove []int
for id := range m.targets {
if _, exists := desiredIDs[id]; !exists {
toRemove = append(toRemove, id)
}
}
// Remove targets that are not in the desired set
for _, id := range toRemove {
logger.Info("Sync: removing health check target %d", id)
if target, exists := m.targets[id]; exists {
target.cancel()
delete(m.targets, id)
}
}
// Add or update targets from the desired set
var addedCount, updatedCount int
for id, config := range desiredIDs {
if existing, exists := m.targets[id]; exists {
// Target exists - check if config changed and update if needed
// For now, we'll replace it to ensure config is up to date
logger.Debug("Sync: updating health check target %d", id)
existing.cancel()
delete(m.targets, id)
if err := m.addTargetUnsafe(config); err != nil {
logger.Error("Sync: failed to update target %d: %v", id, err)
return fmt.Errorf("failed to update target %d: %v", id, err)
}
updatedCount++
} else {
// Target doesn't exist - add it
logger.Debug("Sync: adding health check target %d", id)
if err := m.addTargetUnsafe(config); err != nil {
logger.Error("Sync: failed to add target %d: %v", id, err)
return fmt.Errorf("failed to add target %d: %v", id, err)
}
addedCount++
}
}
logger.Info("Sync complete: removed %d, added %d, updated %d targets",
len(toRemove), addedCount, updatedCount)
// Notify callback if any changes were made
if (len(toRemove) > 0 || addedCount > 0 || updatedCount > 0) && m.callback != nil {
go m.callback(m.getAllTargetsUnsafe())
}
return nil
}

View File

@@ -27,17 +27,16 @@ type ExitNode struct {
// Manager handles UDP hole punching operations
type Manager struct {
mu sync.Mutex
running bool
stopChan chan struct{}
sharedBind *bind.SharedBind
ID string
token string
publicKey string
clientType string
exitNodes map[string]ExitNode // key is endpoint
updateChan chan struct{} // signals the goroutine to refresh exit nodes
publicDNS []string
mu sync.Mutex
running bool
stopChan chan struct{}
sharedBind *bind.SharedBind
ID string
token string
publicKey string
clientType string
exitNodes map[string]ExitNode // key is endpoint
updateChan chan struct{} // signals the goroutine to refresh exit nodes
sendHolepunchInterval time.Duration
sendHolepunchIntervalMin time.Duration
@@ -50,13 +49,12 @@ const defaultSendHolepunchIntervalMax = 60 * time.Second
const defaultSendHolepunchIntervalMin = 1 * time.Second
// NewManager creates a new hole punch manager
func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publicKey string, publicDNS []string) *Manager {
func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publicKey string) *Manager {
return &Manager{
sharedBind: sharedBind,
ID: ID,
clientType: clientType,
publicKey: publicKey,
publicDNS: publicDNS,
exitNodes: make(map[string]ExitNode),
sendHolepunchInterval: defaultSendHolepunchIntervalMin,
sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin,
@@ -283,13 +281,7 @@ func (m *Manager) TriggerHolePunch() error {
// Send hole punch to all exit nodes
successCount := 0
for _, exitNode := range currentExitNodes {
var host string
var err error
if len(m.publicDNS) > 0 {
host, err = util.ResolveDomainUpstream(exitNode.Endpoint, m.publicDNS)
} else {
host, err = util.ResolveDomain(exitNode.Endpoint)
}
host, err := util.ResolveDomain(exitNode.Endpoint)
if err != nil {
logger.Warn("Failed to resolve endpoint %s: %v", exitNode.Endpoint, err)
continue
@@ -400,13 +392,7 @@ func (m *Manager) runMultipleExitNodes() {
var resolvedNodes []resolvedExitNode
for _, exitNode := range currentExitNodes {
var host string
var err error
if len(m.publicDNS) > 0 {
host, err = util.ResolveDomainUpstream(exitNode.Endpoint, m.publicDNS)
} else {
host, err = util.ResolveDomain(exitNode.Endpoint)
}
host, err := util.ResolveDomain(exitNode.Endpoint)
if err != nil {
logger.Warn("Failed to resolve endpoint %s: %v", exitNode.Endpoint, err)
continue

View File

@@ -49,11 +49,10 @@ type cachedAddr struct {
// HolepunchTester monitors holepunch connectivity using magic packets
type HolepunchTester struct {
sharedBind *bind.SharedBind
publicDNS []string
mu sync.RWMutex
running bool
stopChan chan struct{}
sharedBind *bind.SharedBind
mu sync.RWMutex
running bool
stopChan chan struct{}
// Pending requests waiting for responses (key: echo data as string)
pendingRequests sync.Map // map[string]*pendingRequest
@@ -85,10 +84,9 @@ type pendingRequest struct {
}
// NewHolepunchTester creates a new holepunch tester using the given SharedBind
func NewHolepunchTester(sharedBind *bind.SharedBind, publicDNS []string) *HolepunchTester {
func NewHolepunchTester(sharedBind *bind.SharedBind) *HolepunchTester {
return &HolepunchTester{
sharedBind: sharedBind,
publicDNS: publicDNS,
addrCache: make(map[string]*cachedAddr),
addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes
}
@@ -171,13 +169,7 @@ func (t *HolepunchTester) resolveEndpoint(endpoint string) (*net.UDPAddr, error)
}
// Resolve the endpoint
var host string
var err error
if len(t.publicDNS) > 0 {
host, err = util.ResolveDomainUpstream(endpoint, t.publicDNS)
} else {
host, err = util.ResolveDomain(endpoint)
}
host, err := util.ResolveDomain(endpoint)
if err != nil {
host = endpoint
}

810
main.go

File diff suppressed because it is too large Load Diff

View File

@@ -1,158 +0,0 @@
package nativessh
import (
"bufio"
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/fosrl/newt/logger"
"golang.org/x/crypto/ssh"
)
type staticConnMeta struct {
user string
}
func (m staticConnMeta) User() string { return m.user }
func (m staticConnMeta) SessionID() []byte { return nil }
func (m staticConnMeta) ClientVersion() []byte { return nil }
func (m staticConnMeta) ServerVersion() []byte { return nil }
func (m staticConnMeta) RemoteAddr() net.Addr { return nil }
func (m staticConnMeta) LocalAddr() net.Addr { return nil }
// CheckAuthorizedKeys reports whether key matches any entry in the system
// user's ~/.ssh/authorized_keys file. Returns false (not an error) when the
// user or file does not exist.
func CheckAuthorizedKeys(username string, key ssh.PublicKey) bool {
u, err := user.Lookup(username)
if err != nil {
return false
}
f, err := os.Open(filepath.Join(u.HomeDir, ".ssh", "authorized_keys"))
if err != nil {
return false
}
defer f.Close()
want := ssh.FingerprintSHA256(key)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
if err != nil {
continue
}
if ssh.FingerprintSHA256(parsed) == want {
return true
}
}
return false
}
// SystemUserExists reports whether a user account with the given name exists
// on the host OS.
func SystemUserExists(username string) bool {
_, err := user.Lookup(username)
return err == nil
}
// Authenticate authenticates a user for a browser-based native SSH session.
// It tries, in order:
// 1. Private key — parses privateKeyPEM and checks it against the user's
// ~/.ssh/authorized_keys.
// 2. Password — verifies password via the host OS PAM stack (Linux only).
//
// Returns nil on the first method that succeeds, or an error if all fail.
func Authenticate(username, password, privateKeyPEM string) error {
return AuthenticateWithCertificate(nil, username, password, privateKeyPEM, "")
}
// AuthenticateWithCertificate authenticates a user for a browser-based native
// SSH session using the same method ordering as the native SSH server:
// 1. Private key in host ~/.ssh/authorized_keys.
// 2. SSH certificate signed by the configured CA (when provided).
// 3. Password via host PAM stack.
func AuthenticateWithCertificate(store *CredentialStore, username, password, privateKeyPEM, certificate string) error {
logger.Debug("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "")
if !SystemUserExists(username) {
logger.Debug("nativessh: user %q not found on system", username)
return fmt.Errorf("user %q does not exist", username)
}
var signer ssh.Signer
if privateKeyPEM != "" {
parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM))
if err != nil {
logger.Debug("nativessh: failed to parse private key for %q: %v", username, err)
} else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) {
logger.Debug("nativessh: private key auth succeeded for %q", username)
return nil
} else {
signer = parsedSigner
logger.Debug("nativessh: private key not in authorized_keys for %q", username)
}
}
if store != nil && certificate != "" {
if signer == nil {
logger.Debug("nativessh: certificate provided for %q but no matching private key was provided", username)
} else {
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
if err != nil {
logger.Debug("nativessh: failed to parse certificate for %q: %v", username, err)
} else {
cert, ok := pub.(*ssh.Certificate)
if !ok {
logger.Debug("nativessh: provided cert data for %q is not an SSH certificate", username)
} else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) {
logger.Debug("nativessh: certificate key mismatch for %q", username)
} else {
caKey, userPrincipals := store.get(username)
if caKey == nil {
logger.Debug("nativessh: CA key is not set for certificate auth user %q", username)
} else if len(userPrincipals) == 0 {
logger.Debug("nativessh: no allowed principals found for certificate auth user %q", username)
} else {
checker := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey)
},
}
var lastErr error
for principal := range userPrincipals {
_, authErr := checker.Authenticate(staticConnMeta{user: principal}, cert)
if authErr == nil {
logger.Debug("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal)
return nil
}
lastErr = authErr
}
if lastErr != nil {
logger.Debug("nativessh: certificate auth failed for %q: %v", username, lastErr)
}
}
}
}
}
}
if password != "" {
if err := VerifySystemPassword(username, password); err != nil {
logger.Debug("nativessh: password auth failed for %q: %v", username, err)
} else {
logger.Debug("nativessh: password auth succeeded for %q", username)
return nil
}
} else {
logger.Debug("nativessh: no password provided for %q", username)
}
return fmt.Errorf("authentication failed for user %q", username)
}

View File

@@ -1,99 +0,0 @@
//go:build linux
package nativessh
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"strings"
"github.com/fosrl/newt/logger"
"github.com/go-crypt/crypt"
"github.com/go-crypt/x/yescrypt"
)
// VerifySystemPassword authenticates username/password by reading /etc/shadow.
// Supports yescrypt ($y$), bcrypt ($2b$/$2a$/$2y$), SHA-512 ($6$), SHA-256
// ($5$), argon2, scrypt, and other schemes handled by go-crypt/crypt.
func VerifySystemPassword(username, password string) error {
hash, err := readShadowHash(username)
if err != nil {
logger.Debug("nativessh/pam: readShadowHash for %q failed: %v", username, err)
return fmt.Errorf("shadow: %w", err)
}
// Log the scheme prefix only (never the full hash).
scheme := "unknown"
for _, prefix := range []string{"$y$", "$2a$", "$2b$", "$2y$", "$6$", "$5$", "$1$"} {
if strings.HasPrefix(hash, prefix) {
scheme = prefix
break
}
}
logger.Debug("nativessh/pam: verifying password for %q using scheme %s", username, scheme)
// Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly.
if strings.HasPrefix(hash, "$y$") {
computed, err := yescrypt.Hash([]byte(password), []byte(hash))
if err != nil {
logger.Debug("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err)
return fmt.Errorf("yescrypt: %w", err)
}
if !bytes.Equal(computed, []byte(hash)) {
logger.Debug("nativessh/pam: yescrypt mismatch for %q", username)
return errors.New("authentication failed")
}
return nil
}
decoder, err := crypt.NewDefaultDecoder()
if err != nil {
return fmt.Errorf("crypt decoder: %w", err)
}
digest, err := decoder.Decode(hash)
if err != nil {
logger.Debug("nativessh/pam: failed to decode hash for %q: %v", username, err)
return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err)
}
match, err := digest.MatchAdvanced(password)
if err != nil {
logger.Debug("nativessh/pam: MatchAdvanced for %q failed: %v", username, err)
return err
}
if !match {
logger.Debug("nativessh/pam: password mismatch for %q", username)
return errors.New("authentication failed")
}
return nil
}
// readShadowHash reads /etc/shadow and returns the password hash for username.
func readShadowHash(username string) (string, error) {
f, err := os.Open("/etc/shadow")
if err != nil {
return "", err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.SplitN(scanner.Text(), ":", 3)
if len(fields) < 2 || fields[0] != username {
continue
}
h := fields[1]
if h == "" || h == "*" || strings.HasPrefix(h, "!") || h == "x" {
return "", errors.New("account locked or has no password")
}
return h, nil
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", errors.New("user not found in shadow database")
}

View File

@@ -1,11 +0,0 @@
//go:build !linux
package nativessh
import "errors"
// VerifySystemPassword is not supported on non-Linux platforms; it always
// returns an error so that password authentication is never accepted.
func VerifySystemPassword(username, password string) error {
return errors.New("password authentication not supported on this platform")
}

View File

@@ -1,130 +0,0 @@
//go:build !windows
package nativessh
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"strings"
"sync"
"github.com/creack/pty"
)
// PTYSession is a running shell process attached to a PTY.
// It implements io.ReadWriteCloser so it can be bridged to any transport.
type PTYSession struct {
ptmx *os.File
cmd *exec.Cmd
waitOnce sync.Once
exitCode int
}
// findShell returns the path to the best available interactive shell by
// checking preferred shells in order, falling back to /bin/sh.
func findShell() string {
preferred := []string{"zsh", "bash", "fish", "ksh", "sh"}
for _, name := range preferred {
if path, err := exec.LookPath(name); err == nil {
return path
}
}
return "/bin/sh"
}
// userShell returns the login shell configured for u in /etc/passwd.
// If the field is empty or the binary does not exist, it falls back to
// findShell so there is always a usable shell.
func userShell(u *user.User) string {
if shell := passwdShell(u.Username); shell != "" {
if _, err := exec.LookPath(shell); err == nil {
return shell
}
}
return findShell()
}
// passwdShell reads /etc/passwd and returns the login shell for the named user.
// Returns "" if the user is not found or the file cannot be read.
func passwdShell(username string) string {
f, err := os.Open("/etc/passwd")
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" || line[0] == '#' {
continue
}
// Fields: username:password:uid:gid:gecos:home:shell
fields := strings.SplitN(line, ":", 7)
if len(fields) == 7 && fields[0] == username {
return fields[6]
}
}
_ = scanner.Err()
return ""
}
// NewPTYSession spawns the best available shell in a PTY.
func NewPTYSession() (*PTYSession, error) {
shell := findShell()
cmd := exec.Command(shell)
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, fmt.Errorf("pty start: %w", err)
}
return &PTYSession{ptmx: ptmx, cmd: cmd}, nil
}
// Read reads output from the PTY.
func (p *PTYSession) Read(b []byte) (int, error) {
return p.ptmx.Read(b)
}
// Write writes input to the PTY.
func (p *PTYSession) Write(b []byte) (int, error) {
return p.ptmx.Write(b)
}
// Resize changes the PTY window size.
func (p *PTYSession) Resize(cols, rows uint16) error {
return pty.Setsize(p.ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// wait waits for the child process to exit exactly once and records its exit
// code. Safe to call concurrently or multiple times.
func (p *PTYSession) wait() {
p.waitOnce.Do(func() {
err := p.cmd.Wait()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
p.exitCode = exitErr.ExitCode()
return
}
p.exitCode = 1
}
})
}
// ExitCode waits for the shell process to exit and returns its exit code.
// It is safe to call before or after Close.
func (p *PTYSession) ExitCode() int {
p.wait()
return p.exitCode
}
// Close closes the PTY and waits for the child process to exit.
func (p *PTYSession) Close() error {
err := p.ptmx.Close()
p.wait()
return err
}

View File

@@ -1,78 +0,0 @@
//go:build !windows
package nativessh
import (
"fmt"
"os"
"os/exec"
"os/user"
"strconv"
"syscall"
"github.com/creack/pty"
)
// NewPTYSessionAs spawns an interactive shell in a PTY running as the given
// system user. The calling process must have sufficient privileges (typically
// root / CAP_SETUID) to switch to a different UID/GID.
func NewPTYSessionAs(username string) (*PTYSession, error) {
u, err := user.Lookup(username)
if err != nil {
return nil, fmt.Errorf("user lookup %q: %w", username, err)
}
uid, err := strconv.ParseUint(u.Uid, 10, 32)
if err != nil {
return nil, fmt.Errorf("parse uid: %w", err)
}
gid, err := strconv.ParseUint(u.Gid, 10, 32)
if err != nil {
return nil, fmt.Errorf("parse gid: %w", err)
}
// Collect supplementary group IDs.
groupIDs, err := u.GroupIds()
if err != nil {
groupIDs = []string{}
}
var groups []uint32
for _, g := range groupIDs {
gval, err := strconv.ParseUint(g, 10, 32)
if err == nil {
groups = append(groups, uint32(gval))
}
}
shell := userShell(u)
// Prefer the user's home directory as the working directory, but fall back
// to / if it does not exist (e.g. useradd was run without -m).
homeDir := u.HomeDir
if _, err := os.Stat(homeDir); err != nil {
homeDir = "/"
}
cmd := exec.Command(shell, "--login")
cmd.Env = []string{
"TERM=xterm-256color",
"HOME=" + u.HomeDir,
"USER=" + username,
"LOGNAME=" + username,
"SHELL=" + shell,
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
cmd.Dir = homeDir
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
Groups: groups,
},
}
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, fmt.Errorf("pty start: %w", err)
}
return &PTYSession{ptmx: ptmx, cmd: cmd}, nil
}

View File

@@ -1,467 +0,0 @@
//go:build !windows
package nativessh
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/user"
"strconv"
"strings"
"sync"
"syscall"
"github.com/fosrl/newt/logger"
"golang.org/x/crypto/ssh"
)
// CredentialStore holds in-memory SSH credentials that can be updated at runtime.
// It is safe for concurrent use.
type CredentialStore struct {
mu sync.RWMutex
caKey ssh.PublicKey
principals map[string]map[string]struct{} // username -> set of allowed principals
}
// connMetaWithUser wraps ConnMetadata while overriding User() for cert checks.
type connMetaWithUser struct {
ssh.ConnMetadata
user string
}
func (m connMetaWithUser) User() string { return m.user }
// NewCredentialStore returns an empty, ready-to-use CredentialStore.
func NewCredentialStore() *CredentialStore {
return &CredentialStore{
principals: make(map[string]map[string]struct{}),
}
}
// SetCAKey parses and stores the CA public key from authorized_keys-format data.
func (s *CredentialStore) SetCAKey(authorizedKeyData string) error {
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyData))
if err != nil {
return fmt.Errorf("parse CA key: %w", err)
}
s.mu.Lock()
s.caKey = key
s.mu.Unlock()
return nil
}
// AddPrincipals records username and niceId as allowed principals for username.
// Both values are stored; either can appear in the certificate's ValidPrincipals
// field to satisfy the standard cert-auth principal check.
func (s *CredentialStore) AddPrincipals(username, niceId string) {
username = strings.TrimSpace(username)
niceId = strings.TrimSpace(niceId)
if username == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.principals[username] == nil {
s.principals[username] = make(map[string]struct{})
}
s.principals[username][username] = struct{}{}
if niceId != "" {
s.principals[username][niceId] = struct{}{}
}
}
// get returns the CA key and the principal set for username under a read lock.
func (s *CredentialStore) get(username string) (ssh.PublicKey, map[string]struct{}) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.caKey, s.principals[username]
}
// ServerConfig holds configuration for the native SSH server.
type ServerConfig struct {
// ListenAddr is the TCP address to listen on. Defaults to ":2222".
ListenAddr string
// Credentials provides in-memory CA key and per-user principals.
// Updates to the store are reflected immediately for new connections.
// If nil or the store has no CA key set, all connections are rejected.
Credentials *CredentialStore
}
// Server is a simple SSH server that authenticates clients via SSH certificate
// auth only. Certificates must be signed by the configured CA and the
// connecting username must appear in both the certificate's principal list and
// the local principals file.
type Server struct {
cfg ServerConfig
}
// NewServer creates a new Server. The ListenAddr defaults to ":2222" when empty.
func NewServer(cfg ServerConfig) *Server {
if cfg.ListenAddr == "" {
cfg.ListenAddr = ":2222"
}
return &Server{cfg: cfg}
}
// buildSSHConfig builds the ssh.ServerConfig with multi-method authentication:
// 1. Public key: host ~/.ssh/authorized_keys, then CA certificate.
// 2. Password: system PAM stack (Linux only).
func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) {
hostSigner, err := generateHostKey()
if err != nil {
return nil, fmt.Errorf("host key: %w", err)
}
cfg := &ssh.ServerConfig{
PublicKeyCallback: makePublicKeyCallback(s.cfg.Credentials),
PasswordCallback: makePasswordCallback(),
}
cfg.AddHostKey(hostSigner)
return cfg, nil
}
// Serve accepts connections on ln and handles them. It returns when ln is
// closed or a non-temporary Accept error occurs.
func (s *Server) Serve(ln net.Listener) error {
sshCfg, err := s.buildSSHConfig()
if err != nil {
return err
}
logger.Debug("nativessh: server listening on %s", ln.Addr())
for {
conn, err := ln.Accept()
if err != nil {
return err
}
go s.handleConn(conn, sshCfg)
}
}
// ListenAndServe starts the SSH server on the host network and blocks until
// the listener is closed.
func (s *Server) ListenAndServe() error {
ln, err := net.Listen("tcp", s.cfg.ListenAddr)
if err != nil {
return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err)
}
defer ln.Close()
return s.Serve(ln)
}
func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) {
defer conn.Close()
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
if err != nil {
logger.Debug("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err)
return
}
defer sshConn.Close()
logger.Debug("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User())
go ssh.DiscardRequests(reqs)
for newChan := range chans {
if newChan.ChannelType() != "session" {
_ = newChan.Reject(ssh.UnknownChannelType, "unknown channel type")
continue
}
ch, requests, err := newChan.Accept()
if err != nil {
logger.Debug("nativessh: channel accept error: %v", err)
return
}
go s.handleSession(ch, requests, sshConn.User())
}
}
// handleSession drives a single SSH session channel. It waits for a pty-req
// followed by a shell request and then bridges the PTY to the channel.
func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, username string) {
defer ch.Close()
var (
sess *PTYSession
started bool
)
for req := range requests {
switch req.Type {
case "pty-req":
var err error
if sess == nil {
sess, err = NewPTYSessionAs(username)
if err != nil {
logger.Debug("nativessh: PTY start error: %v", err)
if req.WantReply {
_ = req.Reply(false, nil)
}
return
}
}
cols, rows := parsePTYReq(req.Payload)
_ = sess.Resize(cols, rows)
if req.WantReply {
_ = req.Reply(true, nil)
}
case "shell":
if req.WantReply {
_ = req.Reply(true, nil)
}
if started || sess == nil {
continue
}
started = true
// PTY output → SSH channel.
go func() {
_, _ = io.Copy(ch, sess)
// Notify the client of the shell's exit status so it can
// disconnect cleanly instead of requiring a manual disconnect.
exitCode := sess.ExitCode()
exitStatusPayload := ssh.Marshal(struct{ Status uint32 }{uint32(exitCode)})
_, _ = ch.SendRequest("exit-status", false, exitStatusPayload)
_ = ch.CloseWrite()
sess.Close() //nolint:errcheck
// Close the channel so the ssh library closes the requests
// channel, which unblocks the for-range loop in handleSession
// and allows the deferred ch.Close() to run. Without this,
// handleSession blocks forever waiting for requests to drain.
_ = ch.Close()
}()
// SSH channel input → PTY stdin.
go func() {
_, _ = io.Copy(sess, ch)
}()
case "exec":
if req.WantReply {
_ = req.Reply(true, nil)
}
cmd := parseExecPayload(req.Payload)
go s.runExecAs(ch, username, cmd)
// Drain remaining requests; the goroutine owns the channel now.
go ssh.DiscardRequests(requests)
return
case "window-change":
if sess != nil {
cols, rows := parseWindowChange(req.Payload)
_ = sess.Resize(cols, rows)
}
if req.WantReply {
_ = req.Reply(true, nil)
}
default:
if req.WantReply {
_ = req.Reply(false, nil)
}
}
}
if sess != nil && !started {
sess.Close() //nolint:errcheck
}
}
// runExecAs runs command as username, wiring stdin/stdout/stderr directly to
// the SSH channel (no PTY). This supports scp, rsync, and plain exec requests.
func (s *Server) runExecAs(ch ssh.Channel, username, command string) {
defer ch.Close()
u, err := user.Lookup(username)
if err != nil {
logger.Debug("nativessh: exec user lookup %q: %v", username, err)
sendExitStatus(ch, 1)
return
}
uid, err := strconv.ParseUint(u.Uid, 10, 32)
if err != nil {
logger.Debug("nativessh: exec parse uid for %q: %v", username, err)
sendExitStatus(ch, 1)
return
}
gid, err := strconv.ParseUint(u.Gid, 10, 32)
if err != nil {
logger.Debug("nativessh: exec parse gid for %q: %v", username, err)
sendExitStatus(ch, 1)
return
}
groupIDs, _ := u.GroupIds()
var groups []uint32
for _, g := range groupIDs {
gval, err := strconv.ParseUint(g, 10, 32)
if err == nil {
groups = append(groups, uint32(gval))
}
}
homeDir := u.HomeDir
if _, err := os.Stat(homeDir); err != nil {
homeDir = "/"
}
cmd := exec.Command("/bin/sh", "-c", command)
cmd.Env = []string{
"HOME=" + u.HomeDir,
"USER=" + username,
"LOGNAME=" + username,
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
cmd.Dir = homeDir
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
Groups: groups,
},
}
cmd.Stdin = ch
cmd.Stdout = ch
cmd.Stderr = ch.Stderr()
exitCode := 0
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
exitCode = 1
}
logger.Debug("nativessh: exec %q as %q exited: %v", command, username, err)
}
sendExitStatus(ch, exitCode)
_ = ch.CloseWrite()
}
// sendExitStatus sends an SSH exit-status request on ch.
func sendExitStatus(ch ssh.Channel, code int) {
payload := ssh.Marshal(struct{ Status uint32 }{uint32(code)})
_, _ = ch.SendRequest("exit-status", false, payload)
}
// parseExecPayload decodes the command string from an SSH exec request payload.
func parseExecPayload(payload []byte) string {
var msg struct{ Command string }
if err := ssh.Unmarshal(payload, &msg); err != nil {
return ""
}
return msg.Command
}
// makePublicKeyCallback returns a PublicKeyCallback that tries, in order:
// 1. Host authorized_keys matches any key in the OS user's
// ~/.ssh/authorized_keys file.
// 2. CA certificate validates an SSH certificate signed by the
// configured CA and checks that the user appears in the principals map.
//
// store may be nil or empty; those paths are simply skipped.
func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
// 1. Host authorized_keys.
if CheckAuthorizedKeys(meta.User(), key) {
logger.Debug("nativessh: authorized_keys auth for user %q", meta.User())
return &ssh.Permissions{}, nil
}
// 2. CA certificate.
if store != nil {
caKey, userPrincipals := store.get(meta.User())
if caKey != nil {
checker := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey)
},
}
if len(userPrincipals) == 0 {
return nil, fmt.Errorf("user %q not in allowed principals list", meta.User())
}
var lastErr error
for principal := range userPrincipals {
perms, err := checker.Authenticate(connMetaWithUser{ConnMetadata: meta, user: principal}, key)
if err == nil {
logger.Debug("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal)
return perms, nil
}
lastErr = err
}
if lastErr != nil {
logger.Debug("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr)
}
}
}
return nil, fmt.Errorf("public key not authorized for user %q", meta.User())
}
}
// makePasswordCallback returns a PasswordCallback that validates the supplied
// password via the host OS PAM stack. On non-Linux platforms this always
// fails (see pam_other.go).
func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) {
return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
if err := VerifySystemPassword(meta.User(), string(password)); err != nil {
// Return a generic message to the client; log the real reason.
logger.Debug("nativessh: password auth failed for user %q: %v", meta.User(), err)
return nil, fmt.Errorf("permission denied")
}
logger.Debug("nativessh: password auth for user %q", meta.User())
return &ssh.Permissions{}, nil
}
}
// generateHostKey generates a fresh ephemeral Ed25519 host key in memory.
// A new key is created on every server start; nothing is written to disk.
func generateHostKey() (ssh.Signer, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate host key: %w", err)
}
logger.Debug("nativessh: generated ephemeral Ed25519 host key")
return ssh.NewSignerFromKey(priv)
}
// ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 §6.2).
type ptyRequestMsg struct {
Term string
Columns uint32
Rows uint32
Width uint32
Height uint32
Modelist string
}
func parsePTYReq(payload []byte) (cols, rows uint16) {
var req ptyRequestMsg
if err := ssh.Unmarshal(payload, &req); err != nil {
return 80, 24
}
return uint16(req.Columns), uint16(req.Rows)
}
// windowChangeMsg mirrors the SSH wire format for window-change (RFC 4254 §6.7).
type windowChangeMsg struct {
Columns uint32
Rows uint32
Width uint32
Height uint32
}
func parseWindowChange(payload []byte) (cols, rows uint16) {
var msg windowChangeMsg
if err := ssh.Unmarshal(payload, &msg); err != nil {
return 80, 24
}
return uint16(msg.Columns), uint16(msg.Rows)
}

View File

@@ -1,64 +0,0 @@
//go:build windows
package nativessh
import (
"errors"
"log"
"net"
"sync"
"golang.org/x/crypto/ssh"
)
// CredentialStore is a stub on Windows. Native SSH is not supported on Windows.
type CredentialStore struct {
mu sync.RWMutex
principals map[string]map[string]struct{}
}
// NewCredentialStore returns an empty CredentialStore stub.
// Native SSH is not supported on Windows; a warning is logged.
func NewCredentialStore() *CredentialStore {
log.Println("WARNING: native SSH is not supported on Windows and will be disabled")
return &CredentialStore{
principals: make(map[string]map[string]struct{}),
}
}
// SetCAKey is a no-op stub on Windows.
func (s *CredentialStore) SetCAKey(_ string) error {
return errors.New("native SSH not supported on Windows")
}
// AddPrincipals is a no-op stub on Windows.
func (s *CredentialStore) AddPrincipals(_, _ string) {}
// get returns nil CA key and empty principals on Windows.
func (s *CredentialStore) get(_ string) (ssh.PublicKey, map[string]struct{}) {
return nil, nil
}
// ServerConfig holds configuration for the native SSH server (stub on Windows).
type ServerConfig struct {
ListenAddr string
Credentials *CredentialStore
}
// Server is a stub on Windows.
type Server struct{}
// NewServer returns a stub Server and logs a warning.
func NewServer(cfg ServerConfig) *Server {
return &Server{}
}
// ListenAndServe always returns an error on Windows.
func (s *Server) ListenAndServe() error {
return errors.New("native SSH not supported on Windows")
}
// Serve always returns an error on Windows.
func (s *Server) Serve(_ net.Listener) error {
return errors.New("native SSH not supported on Windows")
}

View File

@@ -1,514 +0,0 @@
package netstack2
import (
"bytes"
"compress/zlib"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net"
"sort"
"sync"
"time"
"github.com/fosrl/newt/logger"
)
const (
// flushInterval is how often the access logger flushes completed sessions to the server
flushInterval = 60 * time.Second
// maxBufferedSessions is the max number of completed sessions to buffer before forcing a flush
maxBufferedSessions = 100
// sessionGapThreshold is the maximum gap between the end of one connection
// and the start of the next for them to be considered part of the same session.
// If the gap exceeds this, a new consolidated session is created.
sessionGapThreshold = 5 * time.Second
// minConnectionsToConsolidate is the minimum number of connections in a group
// before we bother consolidating. Groups smaller than this are sent as-is.
minConnectionsToConsolidate = 2
)
// SendFunc is a callback that sends compressed access log data to the server.
// The data is a base64-encoded zlib-compressed JSON array of AccessSession objects.
type SendFunc func(data string) error
// AccessSession represents a tracked access session through the proxy
type AccessSession struct {
SessionID string `json:"sessionId"`
ResourceID int `json:"resourceId"`
SourceAddr string `json:"sourceAddr"`
DestAddr string `json:"destAddr"`
Protocol string `json:"protocol"`
StartedAt time.Time `json:"startedAt"`
EndedAt time.Time `json:"endedAt,omitempty"`
BytesTx int64 `json:"bytesTx"`
BytesRx int64 `json:"bytesRx"`
ConnectionCount int `json:"connectionCount,omitempty"` // number of raw connections merged into this session (0 or 1 = single)
}
// udpSessionKey identifies a unique UDP "session" by src -> dst
type udpSessionKey struct {
srcAddr string
dstAddr string
protocol string
}
// consolidationKey groups connections that may be part of the same logical session.
// Source port is intentionally excluded so that many ephemeral-port connections
// from the same source IP to the same destination are grouped together.
type consolidationKey struct {
sourceIP string // IP only, no port
destAddr string // full host:port of the destination
protocol string
resourceID int
}
// AccessLogger tracks access sessions for resources and periodically
// flushes completed sessions to the server via a configurable SendFunc.
type AccessLogger struct {
mu sync.Mutex
sessions map[string]*AccessSession // active sessions: sessionID -> session
udpSessions map[udpSessionKey]*AccessSession // active UDP sessions for dedup
completedSessions []*AccessSession // completed sessions waiting to be flushed
udpTimeout time.Duration
sendFn SendFunc
stopCh chan struct{}
flushDone chan struct{} // closed after the flush goroutine exits
}
// NewAccessLogger creates a new access logger.
// udpTimeout controls how long a UDP session is kept alive without traffic before being ended.
func NewAccessLogger(udpTimeout time.Duration) *AccessLogger {
al := &AccessLogger{
sessions: make(map[string]*AccessSession),
udpSessions: make(map[udpSessionKey]*AccessSession),
completedSessions: make([]*AccessSession, 0),
udpTimeout: udpTimeout,
stopCh: make(chan struct{}),
flushDone: make(chan struct{}),
}
go al.backgroundLoop()
return al
}
// SetSendFunc sets the callback used to send compressed access log batches
// to the server. This can be called after construction once the websocket
// client is available.
func (al *AccessLogger) SetSendFunc(fn SendFunc) {
al.mu.Lock()
defer al.mu.Unlock()
al.sendFn = fn
}
// generateSessionID creates a random session identifier
func generateSessionID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
// StartTCPSession logs the start of a TCP session and returns a session ID.
func (al *AccessLogger) StartTCPSession(resourceID int, srcAddr, dstAddr string) string {
sessionID := generateSessionID()
now := time.Now()
session := &AccessSession{
SessionID: sessionID,
ResourceID: resourceID,
SourceAddr: srcAddr,
DestAddr: dstAddr,
Protocol: "tcp",
StartedAt: now,
}
al.mu.Lock()
al.sessions[sessionID] = session
al.mu.Unlock()
logger.Info("ACCESS START session=%s resource=%d proto=tcp src=%s dst=%s time=%s",
sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339))
return sessionID
}
// EndTCPSession logs the end of a TCP session and queues it for sending.
func (al *AccessLogger) EndTCPSession(sessionID string) {
now := time.Now()
al.mu.Lock()
session, ok := al.sessions[sessionID]
if ok {
session.EndedAt = now
delete(al.sessions, sessionID)
al.completedSessions = append(al.completedSessions, session)
}
shouldFlush := len(al.completedSessions) >= maxBufferedSessions
al.mu.Unlock()
if ok {
duration := now.Sub(session.StartedAt)
logger.Info("ACCESS END session=%s resource=%d proto=tcp src=%s dst=%s started=%s ended=%s duration=%s",
sessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
}
if shouldFlush {
al.flush()
}
}
// TrackUDPSession starts or returns an existing UDP session. Returns the session ID.
func (al *AccessLogger) TrackUDPSession(resourceID int, srcAddr, dstAddr string) string {
key := udpSessionKey{
srcAddr: srcAddr,
dstAddr: dstAddr,
protocol: "udp",
}
al.mu.Lock()
defer al.mu.Unlock()
if existing, ok := al.udpSessions[key]; ok {
return existing.SessionID
}
sessionID := generateSessionID()
now := time.Now()
session := &AccessSession{
SessionID: sessionID,
ResourceID: resourceID,
SourceAddr: srcAddr,
DestAddr: dstAddr,
Protocol: "udp",
StartedAt: now,
}
al.sessions[sessionID] = session
al.udpSessions[key] = session
logger.Info("ACCESS START session=%s resource=%d proto=udp src=%s dst=%s time=%s",
sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339))
return sessionID
}
// EndUDPSession ends a UDP session and queues it for sending.
func (al *AccessLogger) EndUDPSession(sessionID string) {
now := time.Now()
al.mu.Lock()
session, ok := al.sessions[sessionID]
if ok {
session.EndedAt = now
delete(al.sessions, sessionID)
key := udpSessionKey{
srcAddr: session.SourceAddr,
dstAddr: session.DestAddr,
protocol: "udp",
}
delete(al.udpSessions, key)
al.completedSessions = append(al.completedSessions, session)
}
shouldFlush := len(al.completedSessions) >= maxBufferedSessions
al.mu.Unlock()
if ok {
duration := now.Sub(session.StartedAt)
logger.Info("ACCESS END session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s",
sessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
}
if shouldFlush {
al.flush()
}
}
// backgroundLoop handles periodic flushing and stale session reaping.
func (al *AccessLogger) backgroundLoop() {
defer close(al.flushDone)
flushTicker := time.NewTicker(flushInterval)
defer flushTicker.Stop()
reapTicker := time.NewTicker(30 * time.Second)
defer reapTicker.Stop()
for {
select {
case <-al.stopCh:
return
case <-flushTicker.C:
al.flush()
case <-reapTicker.C:
al.reapStaleSessions()
}
}
}
// reapStaleSessions cleans up UDP sessions that were not properly ended.
func (al *AccessLogger) reapStaleSessions() {
al.mu.Lock()
defer al.mu.Unlock()
staleThreshold := time.Now().Add(-5 * time.Minute)
for key, session := range al.udpSessions {
if session.StartedAt.Before(staleThreshold) && session.EndedAt.IsZero() {
now := time.Now()
session.EndedAt = now
duration := now.Sub(session.StartedAt)
logger.Info("ACCESS END (reaped) session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s",
session.SessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
al.completedSessions = append(al.completedSessions, session)
delete(al.sessions, session.SessionID)
delete(al.udpSessions, key)
}
}
}
// extractIP strips the port from an address string and returns just the IP.
// If the address has no port component it is returned as-is.
func extractIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
// Might already be a bare IP
return addr
}
return host
}
// consolidateSessions takes a slice of completed sessions and merges bursts of
// short-lived connections from the same source IP to the same destination into
// single higher-level session entries.
//
// The algorithm:
// 1. Group sessions by (sourceIP, destAddr, protocol, resourceID).
// 2. Within each group, sort by StartedAt.
// 3. Walk through the sorted list and merge consecutive sessions whose gap
// (previous EndedAt → next StartedAt) is ≤ sessionGapThreshold.
// 4. For merged sessions the earliest StartedAt and latest EndedAt are kept,
// bytes are summed, and ConnectionCount records how many raw connections
// were folded in. If the merged connections used more than one source port,
// SourceAddr is set to just the IP (port omitted).
// 5. Groups with fewer than minConnectionsToConsolidate members are passed
// through unmodified.
func consolidateSessions(sessions []*AccessSession) []*AccessSession {
if len(sessions) <= 1 {
return sessions
}
// Group sessions by consolidation key
groups := make(map[consolidationKey][]*AccessSession)
for _, s := range sessions {
key := consolidationKey{
sourceIP: extractIP(s.SourceAddr),
destAddr: s.DestAddr,
protocol: s.Protocol,
resourceID: s.ResourceID,
}
groups[key] = append(groups[key], s)
}
result := make([]*AccessSession, 0, len(sessions))
for key, group := range groups {
// Small groups don't need consolidation
if len(group) < minConnectionsToConsolidate {
result = append(result, group...)
continue
}
// Sort the group by start time so we can detect gaps
sort.Slice(group, func(i, j int) bool {
return group[i].StartedAt.Before(group[j].StartedAt)
})
// Walk through and merge runs that are within the gap threshold
var merged []*AccessSession
cur := cloneSession(group[0])
cur.ConnectionCount = 1
sourcePorts := make(map[string]struct{})
sourcePorts[cur.SourceAddr] = struct{}{}
for i := 1; i < len(group); i++ {
s := group[i]
// Determine the gap: from the latest end time we've seen so far to the
// start of the next connection.
gapRef := cur.EndedAt
if gapRef.IsZero() {
gapRef = cur.StartedAt
}
gap := s.StartedAt.Sub(gapRef)
if gap <= sessionGapThreshold {
// Merge into the current consolidated session
cur.ConnectionCount++
cur.BytesTx += s.BytesTx
cur.BytesRx += s.BytesRx
sourcePorts[s.SourceAddr] = struct{}{}
// Extend EndedAt to the latest time
endTime := s.EndedAt
if endTime.IsZero() {
endTime = s.StartedAt
}
if endTime.After(cur.EndedAt) {
cur.EndedAt = endTime
}
} else {
// Gap exceeded — finalize the current session and start a new one
finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts)
merged = append(merged, cur)
cur = cloneSession(s)
cur.ConnectionCount = 1
sourcePorts = make(map[string]struct{})
sourcePorts[s.SourceAddr] = struct{}{}
}
}
// Finalize the last accumulated session
finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts)
merged = append(merged, cur)
result = append(result, merged...)
}
return result
}
// cloneSession creates a shallow copy of an AccessSession.
func cloneSession(s *AccessSession) *AccessSession {
cp := *s
return &cp
}
// finalizeMergedSourceAddr sets the SourceAddr on a consolidated session.
// If multiple distinct source addresses (ports) were seen, the port is
// stripped and only the IP is kept so the log isn't misleading.
func finalizeMergedSourceAddr(s *AccessSession, sourceIP string, ports map[string]struct{}) {
if len(ports) > 1 {
// Multiple source ports — just report the IP
s.SourceAddr = sourceIP
}
// Otherwise keep the original SourceAddr which already has ip:port
}
// flush drains the completed sessions buffer, consolidates bursts of
// short-lived connections, compresses with zlib, and sends via the SendFunc.
func (al *AccessLogger) flush() {
al.mu.Lock()
if len(al.completedSessions) == 0 {
al.mu.Unlock()
return
}
batch := al.completedSessions
al.completedSessions = make([]*AccessSession, 0)
sendFn := al.sendFn
al.mu.Unlock()
if sendFn == nil {
logger.Debug("Access logger: no send function configured, discarding %d sessions", len(batch))
return
}
// Consolidate bursts of short-lived connections into higher-level sessions
originalCount := len(batch)
batch = consolidateSessions(batch)
if len(batch) != originalCount {
logger.Info("Access logger: consolidated %d raw connections into %d sessions", originalCount, len(batch))
}
compressed, err := compressSessions(batch)
if err != nil {
logger.Error("Access logger: failed to compress %d sessions: %v", len(batch), err)
return
}
if err := sendFn(compressed); err != nil {
logger.Error("Access logger: failed to send %d sessions: %v", len(batch), err)
// Re-queue the batch so we don't lose data
al.mu.Lock()
al.completedSessions = append(batch, al.completedSessions...)
// Cap re-queued data to prevent unbounded growth if server is unreachable
if len(al.completedSessions) > maxBufferedSessions*5 {
dropped := len(al.completedSessions) - maxBufferedSessions*5
al.completedSessions = al.completedSessions[:maxBufferedSessions*5]
logger.Warn("Access logger: buffer overflow, dropped %d oldest sessions", dropped)
}
al.mu.Unlock()
return
}
logger.Info("Access logger: sent %d sessions to server", len(batch))
}
// compressSessions JSON-encodes the sessions, compresses with zlib, and returns
// a base64-encoded string suitable for embedding in a JSON message.
func compressSessions(sessions []*AccessSession) (string, error) {
jsonData, err := json.Marshal(sessions)
if err != nil {
return "", err
}
var buf bytes.Buffer
w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression)
if err != nil {
return "", err
}
if _, err := w.Write(jsonData); err != nil {
w.Close()
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// Close shuts down the background loop, ends all active sessions,
// and performs one final flush to send everything to the server.
func (al *AccessLogger) Close() {
// Signal the background loop to stop
select {
case <-al.stopCh:
// Already closed
return
default:
close(al.stopCh)
}
// Wait for the background loop to exit so we don't race on flush
<-al.flushDone
al.mu.Lock()
now := time.Now()
// End all active sessions and move them to the completed buffer
for _, session := range al.sessions {
if session.EndedAt.IsZero() {
session.EndedAt = now
duration := now.Sub(session.StartedAt)
logger.Info("ACCESS END (shutdown) session=%s resource=%d proto=%s src=%s dst=%s started=%s ended=%s duration=%s",
session.SessionID, session.ResourceID, session.Protocol, session.SourceAddr, session.DestAddr,
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
al.completedSessions = append(al.completedSessions, session)
}
}
al.sessions = make(map[string]*AccessSession)
al.udpSessions = make(map[udpSessionKey]*AccessSession)
al.mu.Unlock()
// Final flush to send all remaining sessions to the server
al.flush()
}

View File

@@ -1,811 +0,0 @@
package netstack2
import (
"testing"
"time"
)
func TestExtractIP(t *testing.T) {
tests := []struct {
name string
addr string
expected string
}{
{"ipv4 with port", "192.168.1.1:12345", "192.168.1.1"},
{"ipv4 without port", "192.168.1.1", "192.168.1.1"},
{"ipv6 with port", "[::1]:12345", "::1"},
{"ipv6 without port", "::1", "::1"},
{"empty string", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractIP(tt.addr)
if result != tt.expected {
t.Errorf("extractIP(%q) = %q, want %q", tt.addr, result, tt.expected)
}
})
}
}
func TestConsolidateSessions_Empty(t *testing.T) {
result := consolidateSessions(nil)
if result != nil {
t.Errorf("expected nil, got %v", result)
}
result = consolidateSessions([]*AccessSession{})
if len(result) != 0 {
t.Errorf("expected empty slice, got %d items", len(result))
}
}
func TestConsolidateSessions_SingleSession(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "abc123",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(1 * time.Second),
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 session, got %d", len(result))
}
if result[0].SourceAddr != "10.0.0.1:5000" {
t.Errorf("expected source addr preserved, got %q", result[0].SourceAddr)
}
}
func TestConsolidateSessions_MergesBurstFromSameSourceIP(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
BytesTx: 100,
BytesRx: 200,
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
BytesTx: 150,
BytesRx: 250,
},
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(400 * time.Millisecond),
EndedAt: now.Add(500 * time.Millisecond),
BytesTx: 50,
BytesRx: 75,
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 consolidated session, got %d", len(result))
}
s := result[0]
if s.ConnectionCount != 3 {
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
}
if s.SourceAddr != "10.0.0.1" {
t.Errorf("expected source addr to be IP only (multiple ports), got %q", s.SourceAddr)
}
if s.DestAddr != "192.168.1.100:443" {
t.Errorf("expected dest addr preserved, got %q", s.DestAddr)
}
if s.StartedAt != now {
t.Errorf("expected StartedAt to be earliest time")
}
if s.EndedAt != now.Add(500*time.Millisecond) {
t.Errorf("expected EndedAt to be latest time")
}
expectedTx := int64(300)
expectedRx := int64(525)
if s.BytesTx != expectedTx {
t.Errorf("expected BytesTx=%d, got %d", expectedTx, s.BytesTx)
}
if s.BytesRx != expectedRx {
t.Errorf("expected BytesRx=%d, got %d", expectedRx, s.BytesRx)
}
}
func TestConsolidateSessions_SameSourcePortPreserved(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 session, got %d", len(result))
}
if result[0].SourceAddr != "10.0.0.1:5000" {
t.Errorf("expected source addr with port preserved when all ports are the same, got %q", result[0].SourceAddr)
}
if result[0].ConnectionCount != 2 {
t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount)
}
}
func TestConsolidateSessions_GapSplitsSessions(t *testing.T) {
now := time.Now()
// First burst
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
// Big gap here (10 seconds)
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(10 * time.Second),
EndedAt: now.Add(10*time.Second + 100*time.Millisecond),
},
{
SessionID: "s4",
ResourceID: 1,
SourceAddr: "10.0.0.1:5003",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(10*time.Second + 200*time.Millisecond),
EndedAt: now.Add(10*time.Second + 300*time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 consolidated sessions (gap split), got %d", len(result))
}
// Find the sessions by their start time
var first, second *AccessSession
for _, s := range result {
if s.StartedAt.Equal(now) {
first = s
} else {
second = s
}
}
if first == nil || second == nil {
t.Fatal("could not find both consolidated sessions")
}
if first.ConnectionCount != 2 {
t.Errorf("first burst: expected ConnectionCount=2, got %d", first.ConnectionCount)
}
if second.ConnectionCount != 2 {
t.Errorf("second burst: expected ConnectionCount=2, got %d", second.ConnectionCount)
}
}
func TestConsolidateSessions_DifferentDestinationsNotMerged(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:8080",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
// Each goes to a different dest port so they should not be merged
if len(result) != 2 {
t.Fatalf("expected 2 sessions (different destinations), got %d", len(result))
}
}
func TestConsolidateSessions_DifferentProtocolsNotMerged(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "udp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions (different protocols), got %d", len(result))
}
}
func TestConsolidateSessions_DifferentResourceIDsNotMerged(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 2,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions (different resource IDs), got %d", len(result))
}
}
func TestConsolidateSessions_DifferentSourceIPsNotMerged(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.2:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions (different source IPs), got %d", len(result))
}
}
func TestConsolidateSessions_OutOfOrderInput(t *testing.T) {
now := time.Now()
// Provide sessions out of chronological order to verify sorting
sessions := []*AccessSession{
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(400 * time.Millisecond),
EndedAt: now.Add(500 * time.Millisecond),
BytesTx: 30,
},
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
BytesTx: 10,
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
BytesTx: 20,
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 consolidated session, got %d", len(result))
}
s := result[0]
if s.ConnectionCount != 3 {
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
}
if s.StartedAt != now {
t.Errorf("expected StartedAt to be earliest time")
}
if s.EndedAt != now.Add(500*time.Millisecond) {
t.Errorf("expected EndedAt to be latest time")
}
if s.BytesTx != 60 {
t.Errorf("expected BytesTx=60, got %d", s.BytesTx)
}
}
func TestConsolidateSessions_ExactlyAtGapThreshold(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
// Starts exactly sessionGapThreshold after s1 ends — should still merge
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold),
EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 session (gap exactly at threshold merges), got %d", len(result))
}
if result[0].ConnectionCount != 2 {
t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount)
}
}
func TestConsolidateSessions_JustOverGapThreshold(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
// Starts 1ms over the gap threshold after s1 ends — should split
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 1*time.Millisecond),
EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions (gap just over threshold splits), got %d", len(result))
}
}
func TestConsolidateSessions_UDPSessions(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "u1",
ResourceID: 5,
SourceAddr: "10.0.0.1:6000",
DestAddr: "192.168.1.100:53",
Protocol: "udp",
StartedAt: now,
EndedAt: now.Add(50 * time.Millisecond),
BytesTx: 64,
BytesRx: 512,
},
{
SessionID: "u2",
ResourceID: 5,
SourceAddr: "10.0.0.1:6001",
DestAddr: "192.168.1.100:53",
Protocol: "udp",
StartedAt: now.Add(100 * time.Millisecond),
EndedAt: now.Add(150 * time.Millisecond),
BytesTx: 64,
BytesRx: 256,
},
{
SessionID: "u3",
ResourceID: 5,
SourceAddr: "10.0.0.1:6002",
DestAddr: "192.168.1.100:53",
Protocol: "udp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(250 * time.Millisecond),
BytesTx: 64,
BytesRx: 128,
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 consolidated UDP session, got %d", len(result))
}
s := result[0]
if s.Protocol != "udp" {
t.Errorf("expected protocol=udp, got %q", s.Protocol)
}
if s.ConnectionCount != 3 {
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
}
if s.SourceAddr != "10.0.0.1" {
t.Errorf("expected source addr to be IP only, got %q", s.SourceAddr)
}
if s.BytesTx != 192 {
t.Errorf("expected BytesTx=192, got %d", s.BytesTx)
}
if s.BytesRx != 896 {
t.Errorf("expected BytesRx=896, got %d", s.BytesRx)
}
}
func TestConsolidateSessions_MixedGroupsSomeConsolidatedSomeNot(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
// Group 1: 3 connections to :443 from same IP — should consolidate
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(400 * time.Millisecond),
EndedAt: now.Add(500 * time.Millisecond),
},
// Group 2: 1 connection to :8080 from different IP — should pass through
{
SessionID: "s4",
ResourceID: 2,
SourceAddr: "10.0.0.2:6000",
DestAddr: "192.168.1.200:8080",
Protocol: "tcp",
StartedAt: now.Add(1 * time.Second),
EndedAt: now.Add(2 * time.Second),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions total, got %d", len(result))
}
var consolidated, passthrough *AccessSession
for _, s := range result {
if s.ConnectionCount > 1 {
consolidated = s
} else {
passthrough = s
}
}
if consolidated == nil {
t.Fatal("expected a consolidated session")
}
if consolidated.ConnectionCount != 3 {
t.Errorf("consolidated: expected ConnectionCount=3, got %d", consolidated.ConnectionCount)
}
if passthrough == nil {
t.Fatal("expected a passthrough session")
}
if passthrough.SessionID != "s4" {
t.Errorf("passthrough: expected session s4, got %s", passthrough.SessionID)
}
}
func TestConsolidateSessions_OverlappingConnections(t *testing.T) {
now := time.Now()
// Connections that overlap in time (not sequential)
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(5 * time.Second),
BytesTx: 100,
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(1 * time.Second),
EndedAt: now.Add(3 * time.Second),
BytesTx: 200,
},
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(2 * time.Second),
EndedAt: now.Add(6 * time.Second),
BytesTx: 300,
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 consolidated session, got %d", len(result))
}
s := result[0]
if s.ConnectionCount != 3 {
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
}
if s.StartedAt != now {
t.Error("expected StartedAt to be earliest")
}
if s.EndedAt != now.Add(6*time.Second) {
t.Error("expected EndedAt to be the latest end time")
}
if s.BytesTx != 600 {
t.Errorf("expected BytesTx=600, got %d", s.BytesTx)
}
}
func TestConsolidateSessions_DoesNotMutateOriginals(t *testing.T) {
now := time.Now()
s1 := &AccessSession{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
BytesTx: 100,
}
s2 := &AccessSession{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
BytesTx: 200,
}
// Save original values
origS1Addr := s1.SourceAddr
origS1Bytes := s1.BytesTx
origS2Addr := s2.SourceAddr
origS2Bytes := s2.BytesTx
_ = consolidateSessions([]*AccessSession{s1, s2})
if s1.SourceAddr != origS1Addr {
t.Errorf("s1.SourceAddr was mutated: %q -> %q", origS1Addr, s1.SourceAddr)
}
if s1.BytesTx != origS1Bytes {
t.Errorf("s1.BytesTx was mutated: %d -> %d", origS1Bytes, s1.BytesTx)
}
if s2.SourceAddr != origS2Addr {
t.Errorf("s2.SourceAddr was mutated: %q -> %q", origS2Addr, s2.SourceAddr)
}
if s2.BytesTx != origS2Bytes {
t.Errorf("s2.BytesTx was mutated: %d -> %d", origS2Bytes, s2.BytesTx)
}
}
func TestConsolidateSessions_ThreeBurstsWithGaps(t *testing.T) {
now := time.Now()
sessions := make([]*AccessSession, 0, 9)
// Burst 1: 3 connections at t=0
for i := 0; i < 3; i++ {
sessions = append(sessions, &AccessSession{
SessionID: generateSessionID(),
ResourceID: 1,
SourceAddr: "10.0.0.1:" + string(rune('A'+i)),
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(time.Duration(i*100) * time.Millisecond),
EndedAt: now.Add(time.Duration(i*100+50) * time.Millisecond),
})
}
// Burst 2: 3 connections at t=20s (well past the 5s gap)
for i := 0; i < 3; i++ {
sessions = append(sessions, &AccessSession{
SessionID: generateSessionID(),
ResourceID: 1,
SourceAddr: "10.0.0.1:" + string(rune('D'+i)),
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(20*time.Second + time.Duration(i*100)*time.Millisecond),
EndedAt: now.Add(20*time.Second + time.Duration(i*100+50)*time.Millisecond),
})
}
// Burst 3: 3 connections at t=40s
for i := 0; i < 3; i++ {
sessions = append(sessions, &AccessSession{
SessionID: generateSessionID(),
ResourceID: 1,
SourceAddr: "10.0.0.1:" + string(rune('G'+i)),
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(40*time.Second + time.Duration(i*100)*time.Millisecond),
EndedAt: now.Add(40*time.Second + time.Duration(i*100+50)*time.Millisecond),
})
}
result := consolidateSessions(sessions)
if len(result) != 3 {
t.Fatalf("expected 3 consolidated sessions (3 bursts), got %d", len(result))
}
for _, s := range result {
if s.ConnectionCount != 3 {
t.Errorf("expected each burst to have ConnectionCount=3, got %d (started=%v)", s.ConnectionCount, s.StartedAt)
}
}
}
func TestFinalizeMergedSourceAddr(t *testing.T) {
s := &AccessSession{SourceAddr: "10.0.0.1:5000"}
ports := map[string]struct{}{"10.0.0.1:5000": {}}
finalizeMergedSourceAddr(s, "10.0.0.1", ports)
if s.SourceAddr != "10.0.0.1:5000" {
t.Errorf("single port: expected addr preserved, got %q", s.SourceAddr)
}
s2 := &AccessSession{SourceAddr: "10.0.0.1:5000"}
ports2 := map[string]struct{}{"10.0.0.1:5000": {}, "10.0.0.1:5001": {}}
finalizeMergedSourceAddr(s2, "10.0.0.1", ports2)
if s2.SourceAddr != "10.0.0.1" {
t.Errorf("multiple ports: expected IP only, got %q", s2.SourceAddr)
}
}
func TestCloneSession(t *testing.T) {
original := &AccessSession{
SessionID: "test",
ResourceID: 42,
SourceAddr: "1.2.3.4:100",
DestAddr: "5.6.7.8:443",
Protocol: "tcp",
BytesTx: 999,
}
clone := cloneSession(original)
if clone == original {
t.Error("clone should be a different pointer")
}
if clone.SessionID != original.SessionID {
t.Error("clone should have same SessionID")
}
// Mutating clone should not affect original
clone.BytesTx = 0
clone.SourceAddr = "changed"
if original.BytesTx != 999 {
t.Error("mutating clone affected original BytesTx")
}
if original.SourceAddr != "1.2.3.4:100" {
t.Error("mutating clone affected original SourceAddr")
}
}

View File

@@ -1,3 +1,8 @@
/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
*/
package netstack2
import (
@@ -132,40 +137,14 @@ func (h *TCPHandler) InstallTCPHandler() error {
// handleTCPConn handles a TCP connection by proxying it to the actual target
func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.TransportEndpointID) {
// Extract source and target address from the connection ID first so they
// are available for HTTP routing before any defer is set up.
defer netstackConn.Close()
// Extract source and target address from the connection ID
srcIP := id.RemoteAddress.String()
srcPort := id.RemotePort
dstIP := id.LocalAddress.String()
dstPort := id.LocalPort
// Drop connection if blocking is enabled
if h.proxyHandler != nil && h.proxyHandler.IsBlocked() {
logger.Debug("TCP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
netstackConn.Close()
return
}
// For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has
// Protocol configured, hand the connection off to the HTTP handler which
// takes full ownership of the lifecycle (the defer close must not be
// installed before this point).
if (dstPort == 80 || dstPort == 443) && h.proxyHandler != nil && h.proxyHandler.httpHandler != nil {
srcAddr, _ := netip.ParseAddr(srcIP)
dstAddr, _ := netip.ParseAddr(dstIP)
rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber)
if rule != nil && rule.Protocol != "" && len(rule.HTTPTargets) > 0 {
logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)",
srcIP, srcPort, dstIP, dstPort, rule.Protocol)
h.proxyHandler.httpHandler.HandleConn(netstackConn, rule)
return
}
// Otherwise fall through to raw TCP forwarding (e.g. CIDR resources
// that happen to use port 80/443 without HTTP configuration).
}
defer netstackConn.Close()
logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
@@ -179,18 +158,6 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort)
// Look up resource ID and start access session if applicable
var accessSessionID string
if h.proxyHandler != nil {
resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(tcp.ProtocolNumber))
if resourceId != 0 {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort)
accessSessionID = al.StartTCPSession(resourceId, srcAddr, targetAddr)
}
}
}
// Create context with timeout for connection establishment
ctx, cancel := context.WithTimeout(context.Background(), tcpConnectTimeout)
defer cancel()
@@ -200,26 +167,11 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
targetConn, err := d.DialContext(ctx, "tcp", targetAddr)
if err != nil {
logger.Info("TCP Forwarder: Failed to connect to %s: %v", targetAddr, err)
// End access session on connection failure
if accessSessionID != "" {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
al.EndTCPSession(accessSessionID)
}
}
// Connection failed, netstack will handle RST
return
}
defer targetConn.Close()
// End access session when connection closes
if accessSessionID != "" {
defer func() {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
al.EndTCPSession(accessSessionID)
}
}()
}
logger.Info("TCP Forwarder: Successfully connected to %s, starting bidirectional copy", targetAddr)
// Bidirectional copy between netstack and target
@@ -317,12 +269,6 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo
logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
// Drop connection if blocking is enabled
if h.proxyHandler != nil && h.proxyHandler.IsBlocked() {
logger.Debug("UDP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
return
}
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
actualDstIP := dstIP
if h.proxyHandler != nil {
@@ -334,27 +280,6 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo
targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort)
// Look up resource ID and start access session if applicable
var accessSessionID string
if h.proxyHandler != nil {
resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(udp.ProtocolNumber))
if resourceId != 0 {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort)
accessSessionID = al.TrackUDPSession(resourceId, srcAddr, targetAddr)
}
}
}
// End access session when UDP handler returns (timeout or error)
if accessSessionID != "" {
defer func() {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
al.EndUDPSession(accessSessionID)
}
}()
}
// Resolve target address
remoteUDPAddr, err := net.ResolveUDPAddr("udp", targetAddr)
if err != nil {

View File

@@ -1,420 +0,0 @@
package netstack2
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
"time"
"github.com/fosrl/newt/logger"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
// ---------------------------------------------------------------------------
// HTTPTarget
// ---------------------------------------------------------------------------
// HTTPTarget describes a single downstream HTTP or HTTPS service that the
// proxy should forward requests to.
type HTTPTarget struct {
DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service
DestPort uint16 `json:"destPort"` // TCP port of the downstream service
Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS
}
// ---------------------------------------------------------------------------
// HTTPHandler
// ---------------------------------------------------------------------------
// HTTPHandler intercepts TCP connections from the netstack forwarder on ports
// 80 and 443 and services them as HTTP or HTTPS, reverse-proxying each request
// to downstream targets specified by the matching SubnetRule.
//
// HTTP and raw TCP are fully separate: a connection is only routed here when
// its SubnetRule has Protocol set ("http" or "https"). All other connections
// on those ports fall through to the normal raw-TCP path.
//
// Incoming TLS termination (Protocol == "https") is performed per-connection
// using the certificate and key stored in the rule, so different subnet rules
// can present different certificates without sharing any state.
//
// Outbound connections to downstream targets honour HTTPTarget.UseHTTPS
// independently of the incoming protocol.
type HTTPHandler struct {
stack *stack.Stack
proxyHandler *ProxyHandler
requestLogger *HTTPRequestLogger
listener *chanListener
server *http.Server
// proxyCache holds pre-built *httputil.ReverseProxy values keyed by the
// canonical target URL string ("scheme://host:port"). Building a proxy is
// cheap, but reusing one preserves the underlying http.Transport connection
// pool, which matters for throughput.
proxyCache sync.Map // map[string]*httputil.ReverseProxy
// tlsCache holds pre-parsed *tls.Config values keyed by the concatenation
// of the PEM certificate and key. Parsing a keypair is relatively expensive
// and the same cert is likely reused across many connections.
tlsCache sync.Map // map[string]*tls.Config
}
// ---------------------------------------------------------------------------
// chanListener net.Listener backed by a channel
// ---------------------------------------------------------------------------
// chanListener implements net.Listener by receiving net.Conn values over a
// buffered channel. This lets the netstack TCP forwarder hand off connections
// directly to a running http.Server without any real OS socket.
type chanListener struct {
connCh chan net.Conn
closed chan struct{}
once sync.Once
}
func newChanListener() *chanListener {
return &chanListener{
connCh: make(chan net.Conn, 128),
closed: make(chan struct{}),
}
}
// Accept blocks until a connection is available or the listener is closed.
func (l *chanListener) Accept() (net.Conn, error) {
select {
case conn, ok := <-l.connCh:
if !ok {
return nil, net.ErrClosed
}
return conn, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
// Close shuts down the listener; subsequent Accept calls return net.ErrClosed.
func (l *chanListener) Close() error {
l.once.Do(func() { close(l.closed) })
return nil
}
// Addr returns a placeholder address (the listener has no real OS socket).
func (l *chanListener) Addr() net.Addr {
return &net.TCPAddr{}
}
// send delivers conn to the listener. Returns false if the listener is already
// closed, in which case the caller is responsible for closing conn.
func (l *chanListener) send(conn net.Conn) bool {
select {
case l.connCh <- conn:
return true
case <-l.closed:
return false
}
}
// ---------------------------------------------------------------------------
// httpConnCtx conn wrapper that carries a SubnetRule through the listener
// ---------------------------------------------------------------------------
// httpConnCtx wraps a net.Conn so the matching SubnetRule and TLS state can
// be passed through the chanListener into the http.Server's ConnContext
// callback, making them available to request handlers via the request context.
type httpConnCtx struct {
net.Conn
rule *SubnetRule
isTLS bool // true when the conn was wrapped with tls.Server
}
// connCtxKey is the unexported context key used to store a *SubnetRule on the
// per-connection context created by http.Server.ConnContext.
type connCtxKey struct{}
// connTLSKey is the unexported context key used to store the isTLS flag on
// the per-connection context created by http.Server.ConnContext.
type connTLSKey struct{}
// ---------------------------------------------------------------------------
// Constructor and lifecycle
// ---------------------------------------------------------------------------
// NewHTTPHandler creates an HTTPHandler attached to the given stack and
// ProxyHandler. Call Start to begin serving connections.
func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler {
return &HTTPHandler{
stack: s,
proxyHandler: ph,
}
}
// SetRequestLogger attaches an HTTPRequestLogger so that every proxied request
// is recorded and periodically shipped to the server.
func (h *HTTPHandler) SetRequestLogger(rl *HTTPRequestLogger) {
h.requestLogger = rl
}
// Start launches the internal http.Server that services connections delivered
// via HandleConn. The server runs for the lifetime of the HTTPHandler; call
// Close to stop it.
func (h *HTTPHandler) Start() error {
h.listener = newChanListener()
h.server = &http.Server{
Handler: http.HandlerFunc(h.handleRequest),
// ConnContext runs once per accepted connection and attaches the
// SubnetRule carried by httpConnCtx to the connection's context so
// that handleRequest can retrieve it without any global state.
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
if cc, ok := c.(*httpConnCtx); ok {
ctx = context.WithValue(ctx, connCtxKey{}, cc.rule)
ctx = context.WithValue(ctx, connTLSKey{}, cc.isTLS)
}
return ctx
},
}
go func() {
if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed {
logger.Error("HTTP handler: server exited unexpectedly: %v", err)
}
}()
logger.Debug("HTTP handler: ready — routing determined per SubnetRule on ports 80/443")
return nil
}
// HandleConn accepts a TCP connection from the netstack forwarder together
// with the SubnetRule that matched it. The HTTP handler takes full ownership
// of the connection's lifecycle; the caller must NOT close conn after this call.
//
// When rule.Protocol is "https", TLS termination is performed on conn using
// the certificate and key stored in rule.TLSCert and rule.TLSKey before the
// connection is passed to the HTTP server. The HTTP server itself is always
// plain-HTTP; TLS is fully unwrapped at this layer.
func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) {
var effectiveConn net.Conn = conn
if rule.Protocol == "https" {
// Only perform TLS termination for connections arriving on port 443.
// Connections on port 80 are passed through as plain HTTP so that
// handleRequest can issue the HTTP→HTTPS redirect.
doTLS := false
if tcpAddr, ok := conn.LocalAddr().(*net.TCPAddr); ok {
doTLS = tcpAddr.Port == 443
}
if doTLS {
tlsCfg, err := h.getTLSConfig(rule)
if err != nil {
logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v",
conn.RemoteAddr(), err)
conn.Close()
return
}
// tls.Server wraps the raw conn; the TLS handshake is deferred until
// the first Read, which the http.Server will trigger naturally.
effectiveConn = tls.Server(conn, tlsCfg)
}
}
wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule, isTLS: effectiveConn != conn}
if !h.listener.send(wrapped) {
// Listener is already closed — clean up the orphaned connection.
effectiveConn.Close()
}
}
// Close gracefully shuts down the HTTP server and the underlying channel
// listener, causing the goroutine started in Start to exit.
func (h *HTTPHandler) Close() error {
if h.server != nil {
if err := h.server.Close(); err != nil {
return err
}
}
if h.listener != nil {
h.listener.Close()
}
return nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// getTLSConfig returns a *tls.Config for the cert/key pair in rule, using a
// cache to avoid re-parsing the same keypair on every connection.
// The cache key is the concatenation of the PEM cert and key strings, so
// different rules that happen to share the same material hit the same entry.
func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) {
cacheKey := rule.TLSCert + "|" + rule.TLSKey
if v, ok := h.tlsCache.Load(cacheKey); ok {
return v.(*tls.Config), nil
}
cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey))
if err != nil {
return nil, fmt.Errorf("failed to parse TLS keypair: %w", err)
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
}
// LoadOrStore is safe under concurrent calls: if two goroutines race here
// both will produce a valid config; the loser's work is discarded.
actual, _ := h.tlsCache.LoadOrStore(cacheKey, cfg)
return actual.(*tls.Config), nil
}
// getProxy returns a cached *httputil.ReverseProxy for the given target,
// creating one on first use. Reusing the proxy preserves its http.Transport
// connection pool, avoiding repeated TCP/TLS handshakes to the downstream.
func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy {
scheme := target.Scheme
cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort)
if v, ok := h.proxyCache.Load(cacheKey); ok {
return v.(*httputil.ReverseProxy)
}
targetURL := &url.URL{
Scheme: scheme,
Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort),
}
var transport http.RoundTripper = http.DefaultTransport
if target.Scheme == "https" {
// Allow self-signed certificates on downstream HTTPS targets.
transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration
},
}
}
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(targetURL)
if host := pr.In.Host; host != "" {
pr.Out.Host = host
}
// SetXForwarded sets X-Forwarded-For from the inbound request's
// RemoteAddr (the WireGuard/netstack client address), along with
// X-Forwarded-Host and X-Forwarded-Proto. Using Rewrite instead of
// Director means the proxy does not append its own automatic
// X-Forwarded-For entry, so the header is set exactly once.
pr.SetXForwarded()
// SetXForwarded derives X-Forwarded-Proto from pr.In.TLS,
// which is nil because httpConnCtx wraps *tls.Conn behind
// net.Conn. Override using the context flag set by ConnContext.
if isTLS, _ := pr.In.Context().Value(connTLSKey{}).(bool); isTLS {
pr.Out.Header.Set("X-Forwarded-Proto", "https")
}
},
Transport: transport,
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
logger.Error("HTTP handler: upstream error (%s %s -> %s): %v",
r.Method, r.URL.RequestURI(), cacheKey, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
actual, _ := h.proxyCache.LoadOrStore(cacheKey, proxy)
return actual.(*httputil.ReverseProxy)
}
// statusCapture wraps an http.ResponseWriter and records the HTTP status code
// written by the upstream handler. If WriteHeader is never called the status
// defaults to 200 (http.StatusOK), matching net/http semantics.
type statusCapture struct {
http.ResponseWriter
status int
}
func (sc *statusCapture) WriteHeader(code int) {
sc.status = code
sc.ResponseWriter.WriteHeader(code)
}
func (sc *statusCapture) Unwrap() http.ResponseWriter {
return sc.ResponseWriter
}
func (sc *statusCapture) Flush() {
if flusher, ok := sc.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func (sc *statusCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := sc.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("underlying response writer does not support hijacking")
}
return hijacker.Hijack()
}
// handleRequest is the http.Handler entry point. It retrieves the SubnetRule
// attached to the connection by ConnContext, selects the first configured
// downstream target, and forwards the request via the cached ReverseProxy.
//
// TODO: add host/path-based routing across multiple HTTPTargets once the
// configuration model evolves beyond a single target per rule.
func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
rule, _ := r.Context().Value(connCtxKey{}).(*SubnetRule)
if rule == nil || len(rule.HTTPTargets) == 0 {
logger.Error("HTTP handler: no downstream targets for request %s %s", r.Method, r.URL.RequestURI())
http.Error(w, "no targets configured", http.StatusBadGateway)
return
}
// If the rule is HTTPS but the incoming request arrived over plain HTTP
// (port 80), redirect to HTTPS. We use the isTLS flag stored on the
// connection context rather than r.TLS, because Go's http.Server calls
// ConnectionState() before the TLS handshake completes, so r.TLS.Version
// is 0 even for genuine TLS connections at that point.
isTLS, _ := r.Context().Value(connTLSKey{}).(bool)
if rule.Protocol == "https" && !isTLS {
host := r.Host
if host == "" {
host = r.URL.Host
}
httpsURL := "https://" + host + r.RequestURI
logger.Info("HTTP handler: redirecting %s %s -> %s (TLS cert present)", r.Method, r.URL.RequestURI(), httpsURL)
http.Redirect(w, r, httpsURL, http.StatusPermanentRedirect)
return
}
target := rule.HTTPTargets[0]
scheme := target.Scheme
logger.Info("HTTP handler: %s %s -> %s://%s:%d",
r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort)
timestamp := time.Now()
sc := &statusCapture{ResponseWriter: w, status: http.StatusOK}
h.getProxy(target).ServeHTTP(sc, r)
if h.requestLogger != nil && rule.ResourceId != 0 {
h.requestLogger.LogRequest(HTTPRequestLog{
ResourceID: rule.ResourceId,
Timestamp: timestamp,
Method: r.Method,
Scheme: rule.Protocol,
Host: r.Host,
Path: r.URL.Path,
RawQuery: r.URL.RawQuery,
UserAgent: r.UserAgent(),
SourceAddr: r.RemoteAddr,
TLS: rule.Protocol == "https",
})
}
}

View File

@@ -1,97 +0,0 @@
package netstack2
import (
"context"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gorilla/websocket"
)
func TestHTTPHandlerProxiesWebSocketUpgrade(t *testing.T) {
upgrader := websocket.Upgrader{}
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("upgrade failed: %v", err)
return
}
defer conn.Close()
messageType, payload, err := conn.ReadMessage()
if err != nil {
t.Errorf("read failed: %v", err)
return
}
if err := conn.WriteMessage(messageType, append([]byte("echo:"), payload...)); err != nil {
t.Errorf("write failed: %v", err)
}
}))
defer backend.Close()
backendURL, err := url.Parse(backend.URL)
if err != nil {
t.Fatalf("parse backend URL: %v", err)
}
backendHost, backendPort, err := net.SplitHostPort(backendURL.Host)
if err != nil {
t.Fatalf("split backend host: %v", err)
}
port, err := net.LookupPort("tcp", backendPort)
if err != nil {
t.Fatalf("parse backend port: %v", err)
}
handler := NewHTTPHandler(nil, nil)
rule := &SubnetRule{
Protocol: "http",
HTTPTargets: []HTTPTarget{
{
DestAddr: backendHost,
DestPort: uint16(port),
Scheme: backendURL.Scheme,
},
},
}
frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), connCtxKey{}, rule)
handler.handleRequest(w, r.WithContext(ctx))
}))
defer frontend.Close()
frontendURL, err := url.Parse(frontend.URL)
if err != nil {
t.Fatalf("parse frontend URL: %v", err)
}
wsURL := url.URL{
Scheme: "ws",
Host: frontendURL.Host,
Path: "/socket",
RawQuery: "token=test",
}
conn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), nil)
if err != nil {
t.Fatalf("dial websocket through proxy: %v", err)
}
defer conn.Close()
if err := conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
t.Fatalf("write websocket message: %v", err)
}
messageType, payload, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read websocket message: %v", err)
}
if messageType != websocket.TextMessage {
t.Fatalf("message type = %d, want %d", messageType, websocket.TextMessage)
}
if got, want := string(payload), "echo:hello"; got != want {
t.Fatalf("payload = %q, want %q", got, want)
}
}

View File

@@ -1,175 +0,0 @@
package netstack2
import (
"bytes"
"compress/zlib"
"encoding/base64"
"encoding/json"
"sync"
"time"
"github.com/fosrl/newt/logger"
)
// HTTPRequestLog represents a single HTTP/HTTPS request proxied through the handler.
type HTTPRequestLog struct {
RequestID string `json:"requestId"`
ResourceID int `json:"resourceId"`
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Scheme string `json:"scheme"`
Host string `json:"host"`
Path string `json:"path"`
RawQuery string `json:"rawQuery,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
SourceAddr string `json:"sourceAddr"`
TLS bool `json:"tls"`
}
// HTTPRequestLogger buffers HTTP request logs and periodically flushes them
// to the server via a configurable SendFunc.
type HTTPRequestLogger struct {
mu sync.Mutex
pending []HTTPRequestLog
sendFn SendFunc
stopCh chan struct{}
flushDone chan struct{}
}
// NewHTTPRequestLogger creates a new HTTPRequestLogger and starts its background flush loop.
func NewHTTPRequestLogger() *HTTPRequestLogger {
rl := &HTTPRequestLogger{
pending: make([]HTTPRequestLog, 0),
stopCh: make(chan struct{}),
flushDone: make(chan struct{}),
}
go rl.backgroundLoop()
return rl
}
// SetSendFunc sets the callback used to send compressed HTTP request log batches
// to the server. This can be called after construction once the websocket
// client is available.
func (rl *HTTPRequestLogger) SetSendFunc(fn SendFunc) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.sendFn = fn
}
// LogRequest adds an HTTP request log entry to the buffer. If the buffer
// reaches maxBufferedSessions entries a flush is triggered immediately.
func (rl *HTTPRequestLogger) LogRequest(log HTTPRequestLog) {
if log.RequestID == "" {
log.RequestID = generateSessionID()
}
rl.mu.Lock()
rl.pending = append(rl.pending, log)
shouldFlush := len(rl.pending) >= maxBufferedSessions
rl.mu.Unlock()
if shouldFlush {
rl.flush()
}
}
// backgroundLoop handles periodic flushing of buffered request logs.
func (rl *HTTPRequestLogger) backgroundLoop() {
defer close(rl.flushDone)
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
for {
select {
case <-rl.stopCh:
return
case <-ticker.C:
rl.flush()
}
}
}
// flush drains the pending buffer, compresses with zlib, and sends via the SendFunc.
// On send failure the batch is re-queued, capped at maxBufferedSessions*5 entries
// to prevent unbounded memory growth when the server is unreachable.
func (rl *HTTPRequestLogger) flush() {
rl.mu.Lock()
if len(rl.pending) == 0 {
rl.mu.Unlock()
return
}
batch := rl.pending
rl.pending = make([]HTTPRequestLog, 0)
sendFn := rl.sendFn
rl.mu.Unlock()
if sendFn == nil {
logger.Debug("HTTP request logger: no send function configured, discarding %d requests", len(batch))
return
}
compressed, err := compressRequestLogs(batch)
if err != nil {
logger.Error("HTTP request logger: failed to compress %d requests: %v", len(batch), err)
return
}
if err := sendFn(compressed); err != nil {
logger.Error("HTTP request logger: failed to send %d requests: %v", len(batch), err)
// Re-queue the batch so we don't lose data
rl.mu.Lock()
rl.pending = append(batch, rl.pending...)
// Cap re-queued data to prevent unbounded growth if server is unreachable
if len(rl.pending) > maxBufferedSessions*5 {
dropped := len(rl.pending) - maxBufferedSessions*5
rl.pending = rl.pending[:maxBufferedSessions*5]
logger.Warn("HTTP request logger: buffer overflow, dropped %d oldest requests", dropped)
}
rl.mu.Unlock()
return
}
logger.Info("HTTP request logger: sent %d requests to server", len(batch))
}
// compressRequestLogs JSON-encodes the request logs, compresses with zlib, and
// returns a base64-encoded string suitable for embedding in a JSON message.
func compressRequestLogs(logs []HTTPRequestLog) (string, error) {
jsonData, err := json.Marshal(logs)
if err != nil {
return "", err
}
var buf bytes.Buffer
w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression)
if err != nil {
return "", err
}
if _, err := w.Write(jsonData); err != nil {
w.Close()
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// Close shuts down the background loop and performs one final flush to send
// any remaining buffered requests to the server.
func (rl *HTTPRequestLogger) Close() {
select {
case <-rl.stopCh:
// Already closed
return
default:
close(rl.stopCh)
}
// Wait for the background loop to exit so we don't race on flush
<-rl.flushDone
rl.flush()
}

View File

@@ -6,7 +6,6 @@ import (
"net"
"net/netip"
"sync"
"sync/atomic"
"time"
"github.com/fosrl/newt/logger"
@@ -23,12 +22,6 @@ import (
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
)
const (
// udpAccessSessionTimeout is how long a UDP access session stays alive without traffic
// before being considered ended by the access logger
udpAccessSessionTimeout = 120 * time.Second
)
// PortRange represents an allowed range of ports (inclusive) with optional protocol filtering
// Protocol can be "tcp", "udp", or "" (empty string means both protocols)
type PortRange struct {
@@ -53,32 +46,115 @@ type SubnetRule struct {
DisableIcmp bool // If true, ICMP traffic is blocked for this subnet
RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name
PortRanges []PortRange // empty slice means all ports allowed
ResourceId int // Optional resource ID from the server for access logging
// HTTP proxy configuration (optional).
// When Protocol is non-empty the TCP connection is handled by HTTPHandler
// instead of the raw TCP forwarder.
Protocol string // "", "http", or "https" — controls the incoming (client-facing) protocol
HTTPTargets []HTTPTarget // downstream services to proxy requests to
TLSCert string // PEM-encoded certificate for incoming HTTPS termination
TLSKey string // PEM-encoded private key for incoming HTTPS termination
}
// GetAllRules returns a copy of all subnet rules
func (sl *SubnetLookup) GetAllRules() []SubnetRule {
// ruleKey is used as a map key for fast O(1) lookups
type ruleKey struct {
sourcePrefix string
destPrefix string
}
// SubnetLookup provides fast IP subnet and port matching with O(1) lookup performance
type SubnetLookup struct {
mu sync.RWMutex
rules map[ruleKey]*SubnetRule // Map for O(1) lookups by prefix combination
}
// NewSubnetLookup creates a new subnet lookup table
func NewSubnetLookup() *SubnetLookup {
return &SubnetLookup{
rules: make(map[ruleKey]*SubnetRule),
}
}
// AddSubnet adds a subnet rule with source and destination prefixes and optional port restrictions
// If portRanges is nil or empty, all ports are allowed for this subnet
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com")
func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
sl.mu.Lock()
defer sl.mu.Unlock()
key := ruleKey{
sourcePrefix: sourcePrefix.String(),
destPrefix: destPrefix.String(),
}
sl.rules[key] = &SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
DisableIcmp: disableIcmp,
RewriteTo: rewriteTo,
PortRanges: portRanges,
}
}
// RemoveSubnet removes a subnet rule from the lookup table
func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) {
sl.mu.Lock()
defer sl.mu.Unlock()
key := ruleKey{
sourcePrefix: sourcePrefix.String(),
destPrefix: destPrefix.String(),
}
delete(sl.rules, key)
}
// Match checks if a source IP, destination IP, port, and protocol match any subnet rule
// Returns the matched rule if ALL of these conditions are met:
// - The source IP is in the rule's source prefix
// - The destination IP is in the rule's destination prefix
// - The port is in an allowed range (or no port restrictions exist)
// - The protocol matches (or the port range allows both protocols)
//
// proto should be header.TCPProtocolNumber or header.UDPProtocolNumber
// Returns nil if no rule matches
func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16, proto tcpip.TransportProtocolNumber) *SubnetRule {
sl.mu.RLock()
defer sl.mu.RUnlock()
var rules []SubnetRule
for _, destTriePtr := range sl.sourceTrie.All() {
if destTriePtr == nil {
// Iterate through all rules to find matching source and destination prefixes
// This is O(n) but necessary since we need to check prefix containment, not exact match
for _, rule := range sl.rules {
// Check if source and destination IPs match their respective prefixes
if !rule.SourcePrefix.Contains(srcIP) {
continue
}
for _, rule := range destTriePtr.rules {
rules = append(rules, *rule)
if !rule.DestPrefix.Contains(dstIP) {
continue
}
if rule.DisableIcmp && (proto == header.ICMPv4ProtocolNumber || proto == header.ICMPv6ProtocolNumber) {
// ICMP is disabled for this subnet
return nil
}
// Both IPs match - now check port restrictions
// If no port ranges specified, all ports are allowed
if len(rule.PortRanges) == 0 {
return rule
}
// Check if port and protocol are in any of the allowed ranges
for _, pr := range rule.PortRanges {
if port >= pr.Min && port <= pr.Max {
// Check protocol compatibility
if pr.Protocol == "" {
// Empty protocol means allow both TCP and UDP
return rule
}
// Check if the packet protocol matches the port range protocol
if (pr.Protocol == "tcp" && proto == header.TCPProtocolNumber) ||
(pr.Protocol == "udp" && proto == header.UDPProtocolNumber) {
return rule
}
// Port matches but protocol doesn't - continue checking other ranges
}
}
}
return rules
return nil
}
// connKey uniquely identifies a connection for NAT tracking
@@ -90,17 +166,6 @@ type connKey struct {
proto uint8
}
// reverseConnKey uniquely identifies a connection for reverse NAT lookup (reply direction)
// Key structure: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto)
// This allows O(1) lookup of NAT entries for reply packets
type reverseConnKey struct {
rewrittenTo string // The address we rewrote to (becomes src in replies)
originalSrcIP string // Original source IP (becomes dst in replies)
originalSrcPort uint16 // Original source port (becomes dst port in replies)
originalDstPort uint16 // Original destination port (becomes src port in replies)
proto uint8
}
// destKey identifies a destination for handler lookups (without source port since it may change)
type destKey struct {
srcIP string
@@ -123,19 +188,13 @@ type ProxyHandler struct {
tcpHandler *TCPHandler
udpHandler *UDPHandler
icmpHandler *ICMPHandler
httpHandler *HTTPHandler
subnetLookup *SubnetLookup
natTable map[connKey]*natState
reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT
destRewriteTable map[destKey]netip.Addr // Maps original dest to rewritten dest for handler lookups
resourceTable map[destKey]int // Maps connection key to resource ID for access logging
destRewriteTable map[destKey]netip.Addr // Maps original dest to rewritten dest for handler lookups
natMu sync.RWMutex
enabled bool
icmpReplies chan []byte // Channel for ICMP reply packets to be sent back through the tunnel
notifiable channel.Notification // Notification handler for triggering reads
accessLogger *AccessLogger // Access logger for tracking sessions
httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests
blocked atomic.Bool // when true, all new connections are dropped
}
// ProxyHandlerOptions configures the proxy handler
@@ -156,11 +215,8 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
enabled: true,
subnetLookup: NewSubnetLookup(),
natTable: make(map[connKey]*natState),
reverseNatTable: make(map[reverseConnKey]*natState),
destRewriteTable: make(map[destKey]netip.Addr),
resourceTable: make(map[destKey]int),
icmpReplies: make(chan []byte, 256), // Buffer for ICMP reply packets
accessLogger: NewAccessLogger(udpAccessSessionTimeout),
proxyEp: channel.New(1024, uint32(options.MTU), ""),
proxyStack: stack.New(stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{
@@ -176,24 +232,12 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
}),
}
// Initialize TCP handler if enabled. The HTTP handler piggybacks on the
// TCP forwarder — TCPHandler.handleTCPConn checks the subnet rule for
// ports 80/443 and routes matching connections to the HTTP handler, so
// the HTTP handler is always initialised alongside TCP.
// Initialize TCP handler if enabled
if options.EnableTCP {
handler.tcpHandler = NewTCPHandler(handler.proxyStack, handler)
if err := handler.tcpHandler.InstallTCPHandler(); err != nil {
return nil, fmt.Errorf("failed to install TCP handler: %v", err)
}
handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler)
if err := handler.httpHandler.Start(); err != nil {
return nil, fmt.Errorf("failed to start HTTP handler: %v", err)
}
handler.httpRequestLogger = NewHTTPRequestLogger()
handler.httpHandler.SetRequestLogger(handler.httpRequestLogger)
logger.Debug("ProxyHandler: HTTP handler enabled")
}
// Initialize UDP handler if enabled
@@ -232,36 +276,16 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
return handler, nil
}
// AddSubnetRule adds a subnet rule to the proxy handler.
// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets,
// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP.
func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) {
// AddSubnetRule adds a subnet with optional port restrictions to the proxy handler
// sourcePrefix: The IP prefix of the peer sending the data
// destPrefix: The IP prefix of the destination
// rewriteTo: Optional address to rewrite destination to - can be IP/CIDR or domain name
// If portRanges is nil or empty, all ports are allowed for this subnet
func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
if p == nil || !p.enabled {
return
}
p.subnetLookup.AddSubnet(rule)
}
// SetBlocked enables or disables connection blocking on this proxy handler.
// When enabled, all new TCP/UDP connections from the tunnel are dropped immediately.
func (p *ProxyHandler) SetBlocked(v bool) {
if p == nil {
return
}
p.blocked.Store(v)
if v {
logger.Debug("ProxyHandler: connection blocking enabled")
} else {
logger.Debug("ProxyHandler: connection blocking disabled")
}
}
// IsBlocked returns true if connection blocking is currently enabled.
func (p *ProxyHandler) IsBlocked() bool {
if p == nil {
return false
}
return p.blocked.Load()
p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp)
}
// RemoveSubnetRule removes a subnet from the proxy handler
@@ -272,69 +296,6 @@ func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) {
p.subnetLookup.RemoveSubnet(sourcePrefix, destPrefix)
}
// GetAllRules returns all subnet rules from the proxy handler
func (p *ProxyHandler) GetAllRules() []SubnetRule {
if p == nil || !p.enabled {
return nil
}
return p.subnetLookup.GetAllRules()
}
// LookupResourceId looks up the resource ID for a connection
// Returns 0 if no resource ID is associated with this connection
func (p *ProxyHandler) LookupResourceId(srcIP, dstIP string, dstPort uint16, proto uint8) int {
if p == nil || !p.enabled {
return 0
}
key := destKey{
srcIP: srcIP,
dstIP: dstIP,
dstPort: dstPort,
proto: proto,
}
p.natMu.RLock()
defer p.natMu.RUnlock()
return p.resourceTable[key]
}
// GetAccessLogger returns the access logger for session tracking
func (p *ProxyHandler) GetAccessLogger() *AccessLogger {
if p == nil {
return nil
}
return p.accessLogger
}
// SetAccessLogSender configures the function used to send compressed access log
// batches to the server. This should be called once the websocket client is available.
func (p *ProxyHandler) SetAccessLogSender(fn SendFunc) {
if p == nil || !p.enabled || p.accessLogger == nil {
return
}
p.accessLogger.SetSendFunc(fn)
}
// GetHTTPRequestLogger returns the HTTP request logger.
func (p *ProxyHandler) GetHTTPRequestLogger() *HTTPRequestLogger {
if p == nil {
return nil
}
return p.httpRequestLogger
}
// SetHTTPRequestLogSender configures the function used to send compressed HTTP
// request log batches to the server. This should be called once the websocket
// client is available.
func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) {
if p == nil || !p.enabled || p.httpRequestLogger == nil {
return
}
p.httpRequestLogger.SetSendFunc(fn)
}
// LookupDestinationRewrite looks up the rewritten destination for a connection
// This is used by TCP/UDP handlers to find the actual target address
func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) {
@@ -497,22 +458,8 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
// Check if the source IP, destination IP, port, and protocol match any subnet rule
matchedRule := p.subnetLookup.Match(srcAddr, dstAddr, dstPort, protocol)
if matchedRule != nil {
logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d, resourceId=%d)",
srcAddr, dstAddr, protocol, dstPort, matchedRule.ResourceId)
// Store resource ID for connections without DNAT as well
if matchedRule.ResourceId != 0 && matchedRule.RewriteTo == "" {
dKey := destKey{
srcIP: srcAddr.String(),
dstIP: dstAddr.String(),
dstPort: dstPort,
proto: uint8(protocol),
}
p.natMu.Lock()
p.resourceTable[dKey] = matchedRule.ResourceId
p.natMu.Unlock()
}
logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d)",
srcAddr, dstAddr, protocol, dstPort)
// Check if we need to perform DNAT
if matchedRule.RewriteTo != "" {
// Create connection tracking key using original destination
@@ -544,13 +491,6 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
proto: uint8(protocol),
}
// Store resource ID for access logging if present
if matchedRule.ResourceId != 0 {
p.natMu.Lock()
p.resourceTable[dKey] = matchedRule.ResourceId
p.natMu.Unlock()
}
// Check if we already have a NAT entry for this connection
p.natMu.RLock()
existingEntry, exists := p.natTable[key]
@@ -577,37 +517,12 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
// Store NAT state for this connection
p.natMu.Lock()
natEntry := &natState{
p.natTable[key] = &natState{
originalDst: dstAddr,
rewrittenTo: newDst,
}
p.natTable[key] = natEntry
// Create reverse lookup key for O(1) reply packet lookups
// Key: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto)
reverseKey := reverseConnKey{
rewrittenTo: newDst.String(),
originalSrcIP: srcAddr.String(),
originalSrcPort: srcPort,
originalDstPort: dstPort,
proto: uint8(protocol),
}
p.reverseNatTable[reverseKey] = natEntry
// Store destination rewrite for handler lookups
p.destRewriteTable[dKey] = newDst
// Also store the resource ID under the rewritten destination key so that
// TCP/UDP handlers can find it after DNAT (they see the post-NAT dst IP).
if matchedRule.ResourceId != 0 {
rewrittenKey := destKey{
srcIP: srcAddr.String(),
dstIP: newDst.String(),
dstPort: dstPort,
proto: uint8(protocol),
}
p.resourceTable[rewrittenKey] = matchedRule.ResourceId
}
p.natMu.Unlock()
logger.Debug("New NAT entry for connection: %s -> %s", dstAddr, newDst)
}
@@ -636,7 +551,7 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
}
// logger.Debug("HandleIncomingPacket: No matching rule for %s -> %s (proto=%d, port=%d)",
// srcAddr, dstAddr, protocol, dstPort)
// srcAddr, dstAddr, protocol, dstPort)
return false
}
@@ -804,22 +719,20 @@ func (p *ProxyHandler) ReadOutgoingPacket() *buffer.View {
return view
}
// Look up NAT state for reverse translation using O(1) reverse lookup map
// Key: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto)
// For reply packets:
// - reply's srcIP = rewrittenTo (the address we rewrote to)
// - reply's dstIP = originalSrcIP (original source IP)
// - reply's srcPort = originalDstPort (original destination port)
// - reply's dstPort = originalSrcPort (original source port)
// Look up NAT state for reverse translation
// The key uses the original dst (before rewrite), so for replies we need to
// find the entry where the rewritten address matches the current source
p.natMu.RLock()
reverseKey := reverseConnKey{
rewrittenTo: srcIP.String(), // Reply's source is the rewritten address
originalSrcIP: dstIP.String(), // Reply's destination is the original source
originalSrcPort: dstPort, // Reply's destination port is the original source port
originalDstPort: srcPort, // Reply's source port is the original destination port
proto: uint8(protocol),
var natEntry *natState
for k, entry := range p.natTable {
// Match: reply's dst should be original src, reply's src should be rewritten dst
if k.srcIP == dstIP.String() && k.srcPort == dstPort &&
entry.rewrittenTo.String() == srcIP.String() && k.dstPort == srcPort &&
k.proto == uint8(protocol) {
natEntry = entry
break
}
}
natEntry := p.reverseNatTable[reverseKey]
p.natMu.RUnlock()
if natEntry != nil {
@@ -863,21 +776,6 @@ func (p *ProxyHandler) Close() error {
return nil
}
// Shut down access logger
if p.accessLogger != nil {
p.accessLogger.Close()
}
// Shut down HTTP request logger
if p.httpRequestLogger != nil {
p.httpRequestLogger.Close()
}
// Shut down HTTP handler
if p.httpHandler != nil {
p.httpHandler.Close()
}
// Close ICMP replies channel
if p.icmpReplies != nil {
close(p.icmpReplies)

View File

@@ -1,201 +0,0 @@
package netstack2
import (
"net/netip"
"sync"
"github.com/gaissmai/bart"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
)
// SubnetLookup provides fast IP subnet and port matching using BART (Binary Aggregated Range Tree)
// This uses BART Table for O(log n) prefix matching with Supernets() for efficient lookups
//
// Architecture:
// - Two-level BART structure for matching both source AND destination prefixes
// - Level 1: Source prefix -> Level 2 (destination prefix -> rules)
// - This reduces search space: only check destination prefixes for matching source prefixes
type SubnetLookup struct {
mu sync.RWMutex
// Two-level BART structure:
// Level 1: Source prefix -> Level 2 (destination prefix -> rules)
// This allows us to first match source prefix, then only check destination prefixes
// for matching source prefixes, reducing the search space significantly
sourceTrie *bart.Table[*destTrie]
}
// destTrie is a BART for destination prefixes, containing the actual rules
type destTrie struct {
trie *bart.Table[[]*SubnetRule]
rules []*SubnetRule // All rules for this source prefix (for iteration if needed)
}
// NewSubnetLookup creates a new subnet lookup table using BART
func NewSubnetLookup() *SubnetLookup {
return &SubnetLookup{
sourceTrie: &bart.Table[*destTrie]{},
}
}
// prefixEqual compares two prefixes after masking to handle host bits correctly.
// For example, 10.0.0.5/24 and 10.0.0.0/24 are treated as equal.
func prefixEqual(a, b netip.Prefix) bool {
return a.Masked() == b.Masked()
}
// AddSubnet adds a subnet rule to the lookup table.
// If rule.PortRanges is nil or empty, all ports are allowed.
// rule.RewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com").
// HTTP proxy behaviour is driven by rule.Protocol, rule.HTTPTargets, rule.TLSCert, and rule.TLSKey.
func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
sl.mu.Lock()
defer sl.mu.Unlock()
rulePtr := &rule
// Canonicalize source prefix to handle host bits correctly
canonicalSourcePrefix := rule.SourcePrefix.Masked()
// Get or create destination trie for this source prefix
destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix)
if !exists {
// Create new destination trie for this source prefix
destTriePtr = &destTrie{
trie: &bart.Table[[]*SubnetRule]{},
rules: make([]*SubnetRule, 0),
}
sl.sourceTrie.Insert(canonicalSourcePrefix, destTriePtr)
}
// Canonicalize destination prefix to handle host bits correctly
// BART masks prefixes internally, so we need to match that behavior in our bookkeeping
canonicalDestPrefix := rule.DestPrefix.Masked()
// Add rule to destination trie
// Original behavior: overwrite if same (sourcePrefix, destPrefix) exists
// Store as single-element slice to match original overwrite behavior
destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rulePtr})
// Update destTriePtr.rules - remove old rule with same canonical prefix if exists, then add new one
// Use canonical comparison to handle cases like 10.0.0.5/24 vs 10.0.0.0/24
newRules := make([]*SubnetRule, 0, len(destTriePtr.rules)+1)
for _, r := range destTriePtr.rules {
if !prefixEqual(r.DestPrefix, canonicalDestPrefix) || !prefixEqual(r.SourcePrefix, canonicalSourcePrefix) {
newRules = append(newRules, r)
}
}
newRules = append(newRules, rulePtr)
destTriePtr.rules = newRules
}
// RemoveSubnet removes a subnet rule from the lookup table
func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) {
sl.mu.Lock()
defer sl.mu.Unlock()
// Canonicalize prefixes to handle host bits correctly
canonicalSourcePrefix := sourcePrefix.Masked()
canonicalDestPrefix := destPrefix.Masked()
destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix)
if !exists {
return
}
// Remove the rule - original behavior: delete exact (sourcePrefix, destPrefix) combination
// BART masks prefixes internally, so Delete works with canonical form
destTriePtr.trie.Delete(canonicalDestPrefix)
// Also remove from destTriePtr.rules using canonical comparison
// This ensures we remove rules even if they were added with host bits set
newDestRules := make([]*SubnetRule, 0, len(destTriePtr.rules))
for _, r := range destTriePtr.rules {
if !prefixEqual(r.DestPrefix, canonicalDestPrefix) || !prefixEqual(r.SourcePrefix, canonicalSourcePrefix) {
newDestRules = append(newDestRules, r)
}
}
destTriePtr.rules = newDestRules
// Check if the trie is actually empty using BART's Size() method
// This is more efficient than iterating and ensures we clean up empty tries
// even if there were stale entries in the rules slice (which shouldn't happen
// with proper canonicalization, but this provides a definitive check)
if destTriePtr.trie.Size() == 0 {
sl.sourceTrie.Delete(canonicalSourcePrefix)
}
}
// Match checks if a source IP, destination IP, port, and protocol match any subnet rule
// Returns the matched rule if ALL of these conditions are met:
// - The source IP is in the rule's source prefix
// - The destination IP is in the rule's destination prefix
// - The port is in an allowed range (or no port restrictions exist)
// - The protocol matches (or the port range allows both protocols)
//
// proto should be header.TCPProtocolNumber, header.UDPProtocolNumber, or header.ICMPv4ProtocolNumber
// Returns nil if no rule matches
// This uses BART's Supernets() for O(log n) prefix matching instead of O(n) iteration
func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16, proto tcpip.TransportProtocolNumber) *SubnetRule {
sl.mu.RLock()
defer sl.mu.RUnlock()
// Convert IP addresses to /32 (IPv4) or /128 (IPv6) prefixes
// Supernets() finds all prefixes that contain this IP (i.e., are supernets of /32 or /128)
srcPrefix := netip.PrefixFrom(srcIP, srcIP.BitLen())
dstPrefix := netip.PrefixFrom(dstIP, dstIP.BitLen())
// Step 1: Find all source prefixes that contain srcIP using BART's Supernets
// This is O(log n) instead of O(n) iteration
// Supernets returns all prefixes that are supernets (contain) the given prefix
for _, destTriePtr := range sl.sourceTrie.Supernets(srcPrefix) {
if destTriePtr == nil {
continue
}
// Step 2: Find all destination prefixes that contain dstIP
// This is also O(log n) for each matching source prefix
for _, rules := range destTriePtr.trie.Supernets(dstPrefix) {
if rules == nil {
continue
}
// Step 3: Check each rule for ICMP and port restrictions
for _, rule := range rules {
// Handle ICMP before port range check — ICMP has no ports
if proto == header.ICMPv4ProtocolNumber || proto == header.ICMPv6ProtocolNumber {
if rule.DisableIcmp {
return nil
}
// ICMP is allowed; port ranges don't apply to ICMP
return rule
}
// Check port restrictions
if len(rule.PortRanges) == 0 {
// No port restrictions, match!
return rule
}
// Check if port and protocol are in any of the allowed ranges
for _, pr := range rule.PortRanges {
if port >= pr.Min && port <= pr.Max {
// Check protocol compatibility
if pr.Protocol == "" {
// Empty protocol means allow both TCP and UDP
return rule
}
// Check if the packet protocol matches the port range protocol
if (pr.Protocol == "tcp" && proto == header.TCPProtocolNumber) ||
(pr.Protocol == "udp" && proto == header.UDPProtocolNumber) {
return rule
}
// Port matches but protocol doesn't - continue checking other ranges
}
}
}
}
}
return nil
}

View File

@@ -1,3 +1,8 @@
/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
*/
package netstack2
import (
@@ -346,13 +351,13 @@ func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) {
return net.DialUDP(laddr, nil)
}
// AddProxySubnetRule adds a subnet rule to the proxy handler.
// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets,
// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP.
func (net *Net) AddProxySubnetRule(rule SubnetRule) {
// AddProxySubnetRule adds a subnet rule to the proxy handler
// If portRanges is nil or empty, all ports are allowed for this subnet
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com")
func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
tun := (*netTun)(net)
if tun.proxyHandler != nil {
tun.proxyHandler.AddSubnetRule(rule)
tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp)
}
}
@@ -364,15 +369,6 @@ func (net *Net) RemoveProxySubnetRule(sourcePrefix, destPrefix netip.Prefix) {
}
}
// GetProxySubnetRules returns all subnet rules from the proxy handler
func (net *Net) GetProxySubnetRules() []SubnetRule {
tun := (*netTun)(net)
if tun.proxyHandler != nil {
return tun.proxyHandler.GetAllRules()
}
return nil
}
// GetProxyHandler returns the proxy handler (for advanced use cases)
// Returns nil if proxy is not enabled
func (net *Net) GetProxyHandler() *ProxyHandler {
@@ -380,25 +376,6 @@ func (net *Net) GetProxyHandler() *ProxyHandler {
return tun.proxyHandler
}
// SetAccessLogSender configures the function used to send compressed access log
// batches to the server. This should be called once the websocket client is available.
func (net *Net) SetAccessLogSender(fn SendFunc) {
tun := (*netTun)(net)
if tun.proxyHandler != nil {
tun.proxyHandler.SetAccessLogSender(fn)
}
}
// SetHTTPRequestLogSender configures the function used to send compressed HTTP
// request log batches to the server. This should be called once the websocket
// client is available.
func (net *Net) SetHTTPRequestLogSender(fn SendFunc) {
tun := (*netTun)(net)
if tun.proxyHandler != nil {
tun.proxyHandler.SetHTTPRequestLogSender(fn)
}
}
type PingConn struct {
laddr PingAddr
raddr PingAddr

View File

@@ -120,7 +120,7 @@ func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
prefix, _ := ipNet.Mask.Size()
ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix)
cmd := exec.Command("/sbin/ifconfig", interfaceName, "inet", ipStr, ip.String(), "alias")
cmd := exec.Command("ifconfig", interfaceName, "inet", ipStr, ip.String(), "alias")
logger.Info("Running command: %v", cmd)
out, err := cmd.CombinedOutput()
@@ -129,7 +129,7 @@ func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
}
// Bring up the interface
cmd = exec.Command("/sbin/ifconfig", interfaceName, "up")
cmd = exec.Command("ifconfig", interfaceName, "up")
logger.Info("Running command: %v", cmd)
out, err = cmd.CombinedOutput()

View File

@@ -32,7 +32,7 @@ DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
; Uncomment the following line to run in non administrative install mode (install for current user only).
;PrivilegesRequired=lowest
OutputBaseFilename=newt_windows_installer
OutputBaseFilename=mysetup
SolidCompression=yes
WizardStyle=modern
; Add this to ensure PATH changes are applied and the system is prompted for a restart if needed

View File

@@ -1,3 +0,0 @@
.build
*.tgz
bin

View File

@@ -1,54 +0,0 @@
MODNAME := newt
VERSION := 1.12.5
RELEASE_URL := https://github.com/fosrl/newt/releases/download/$(VERSION)
PLATFORM ?= v4
# Map Advantech platform to newt release binary name
arch_v4 := arm64
arch_v4i := arm64
arch_v3 := arm32
arch_v2 := arm32
arch_v2i := arm32
NEWT_ARCH := $(arch_$(PLATFORM))
ifeq ($(NEWT_ARCH),)
$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2, v2i)
endif
BINARY := newt_linux_$(NEWT_ARCH)
BINDIR := bin
OUTFILE := $(MODNAME).$(PLATFORM).tgz
STAGEDIR := .build/$(MODNAME)
.PHONY: all clean
all: $(OUTFILE)
# Cache the downloaded binary in bin/ (re-download only if missing)
$(BINDIR)/$(BINARY):
mkdir -p $(BINDIR)
@echo "Downloading $(BINARY) $(VERSION) for platform $(PLATFORM)..."
wget -q -O $@ "$(RELEASE_URL)/$(BINARY)" 2>/dev/null || \
curl -fsSL -o $@ "$(RELEASE_URL)/$(BINARY)"
chmod +x $@
@echo "Binary ready: $@"
# Build the package
$(OUTFILE): $(BINDIR)/$(BINARY) $(shell find merge -type f 2>/dev/null)
@rm -rf $(STAGEDIR)
@mkdir -p $(STAGEDIR)/bin
@cp $(BINDIR)/$(BINARY) $(STAGEDIR)/bin/newt
@chmod +x $(STAGEDIR)/bin/newt
@cp -r merge/. $(STAGEDIR)/
@chmod +x \
$(STAGEDIR)/etc/init \
$(STAGEDIR)/etc/install \
$(STAGEDIR)/etc/uninstall \
$(STAGEDIR)/www/index.cgi
tar -c --owner=0 --group=0 --mtime="2001-01-01 UTC" \
-C .build $(MODNAME) | gzip -n > $@
@echo "Created: $@"
clean:
rm -rf .build $(MODNAME).*.tgz
@echo "Cleaned."

View File

@@ -1,61 +0,0 @@
MOD_PANGOLIN_SITE_ENABLED=0
MOD_PANGOLIN_SITE_ENDPOINT=
MOD_PANGOLIN_SITE_ID=
MOD_PANGOLIN_SITE_SECRET=
# Core networking
MOD_PANGOLIN_SITE_MTU=
MOD_PANGOLIN_SITE_DNS=
MOD_PANGOLIN_SITE_INTERFACE=
MOD_PANGOLIN_SITE_PORT=
MOD_PANGOLIN_SITE_UPDOWN_SCRIPT=
MOD_PANGOLIN_SITE_PREFER_ENDPOINT=
# Logging
MOD_PANGOLIN_SITE_LOG_LEVEL=
# Behavior toggles (true/false)
MOD_PANGOLIN_SITE_DISABLE_CLIENTS=
MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE=
MOD_PANGOLIN_SITE_ENFORCE_HC_CERT=
MOD_PANGOLIN_SITE_NO_CLOUD=
# Timers
MOD_PANGOLIN_SITE_PING_INTERVAL=
MOD_PANGOLIN_SITE_PING_TIMEOUT=
MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT=
# Docker integration
MOD_PANGOLIN_SITE_DOCKER_SOCKET=
MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION=
# Health / files
MOD_PANGOLIN_SITE_HEALTH_FILE=
MOD_PANGOLIN_SITE_BLUEPRINT_FILE=
MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE=
MOD_PANGOLIN_SITE_CONFIG_FILE=
# Provisioning
MOD_PANGOLIN_SITE_PROVISIONING_KEY=
MOD_PANGOLIN_SITE_NAME=
# Auth daemon
MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED=
MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD=
MOD_PANGOLIN_SITE_AD_KEY=
MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE=
MOD_PANGOLIN_SITE_AD_CA_CERT_PATH=
# Metrics / observability
MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED=
MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED=
MOD_PANGOLIN_SITE_ADMIN_ADDR=
MOD_PANGOLIN_SITE_REGION=
MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES=
MOD_PANGOLIN_SITE_PPROF_ENABLED=
# mTLS
MOD_PANGOLIN_SITE_TLS_CLIENT_CERT=
MOD_PANGOLIN_SITE_TLS_CLIENT_KEY=
MOD_PANGOLIN_SITE_TLS_CLIENT_CAS=
MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12=

View File

@@ -1,4 +0,0 @@
Pangolin Site (newt) is a WireGuard-based tunnel client that connects this router to a
Pangolin server, enabling secure remote access to local resources without opening
firewall ports. Configure the server endpoint, ID, and secret via the web
interface to establish the tunnel automatically on startup.

View File

@@ -1,124 +0,0 @@
#!/bin/sh
MODNAME=newt
PIDFILE=/tmp/newt.pid
LOGFILE=/tmp/newt.log
set_setting_raw() {
key="$1"
value="$2"
[ -f "/opt/$MODNAME/etc/settings" ] || cp "/opt/$MODNAME/etc/defaults" "/opt/$MODNAME/etc/settings" 2>/dev/null
awk -v k="$key" -v v="$value" '
BEGIN { done=0 }
index($0, k "=") == 1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "/opt/$MODNAME/etc/settings" > "/tmp/${MODNAME}.settings.tmp" && mv "/tmp/${MODNAME}.settings.tmp" "/opt/$MODNAME/etc/settings"
}
/usr/bin/logger -t $MODNAME "DEBUG: $0 $@"
case "$1" in
start)
. /opt/$MODNAME/etc/settings
if [ "$MOD_PANGOLIN_SITE_ENABLED" != "1" ]; then
echo "$MODNAME is disabled in settings, skipping start"
exit 0
fi
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
echo "$MODNAME is already running (PID: $(cat "$PIDFILE"))"
exit 0
fi
# Starting newt implies enable at boot.
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
# Map module settings to Newt environment variables.
[ -n "$MOD_PANGOLIN_SITE_ENDPOINT" ] && export PANGOLIN_ENDPOINT="$MOD_PANGOLIN_SITE_ENDPOINT"
[ -n "$MOD_PANGOLIN_SITE_ID" ] && export NEWT_ID="$MOD_PANGOLIN_SITE_ID"
[ -n "$MOD_PANGOLIN_SITE_SECRET" ] && export NEWT_SECRET="$MOD_PANGOLIN_SITE_SECRET"
[ -n "$MOD_PANGOLIN_SITE_MTU" ] && export MTU="$MOD_PANGOLIN_SITE_MTU"
[ -n "$MOD_PANGOLIN_SITE_DNS" ] && export DNS="$MOD_PANGOLIN_SITE_DNS"
[ -n "$MOD_PANGOLIN_SITE_LOG_LEVEL" ] && export LOG_LEVEL="$MOD_PANGOLIN_SITE_LOG_LEVEL"
[ -n "$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" ] && export UPDOWN_SCRIPT="$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT"
[ -n "$MOD_PANGOLIN_SITE_INTERFACE" ] && export INTERFACE="$MOD_PANGOLIN_SITE_INTERFACE"
[ -n "$MOD_PANGOLIN_SITE_PORT" ] && export PORT="$MOD_PANGOLIN_SITE_PORT"
[ -n "$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" ] && export DISABLE_CLIENTS="$MOD_PANGOLIN_SITE_DISABLE_CLIENTS"
[ -n "$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" ] && export USE_NATIVE_INTERFACE="$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE"
[ -n "$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" ] && export ENFORCE_HC_CERT="$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT"
[ -n "$MOD_PANGOLIN_SITE_DOCKER_SOCKET" ] && export DOCKER_SOCKET="$MOD_PANGOLIN_SITE_DOCKER_SOCKET"
[ -n "$MOD_PANGOLIN_SITE_PING_INTERVAL" ] && export PING_INTERVAL="$MOD_PANGOLIN_SITE_PING_INTERVAL"
[ -n "$MOD_PANGOLIN_SITE_PING_TIMEOUT" ] && export PING_TIMEOUT="$MOD_PANGOLIN_SITE_PING_TIMEOUT"
[ -n "$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" ] && export NEWT_UDP_PROXY_IDLE_TIMEOUT="$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT"
[ -n "$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" ] && export DOCKER_ENFORCE_NETWORK_VALIDATION="$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION"
[ -n "$MOD_PANGOLIN_SITE_HEALTH_FILE" ] && export HEALTH_FILE="$MOD_PANGOLIN_SITE_HEALTH_FILE"
[ -n "$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" ] && export AUTH_DAEMON_ENABLED="$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED"
[ -n "$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" ] && export AD_GENERATE_RANDOM_PASSWORD="$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD"
[ -n "$MOD_PANGOLIN_SITE_AD_KEY" ] && export AD_KEY="$MOD_PANGOLIN_SITE_AD_KEY"
[ -n "$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" ] && export AD_PRINCIPALS_FILE="$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE"
[ -n "$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" ] && export AD_CA_CERT_PATH="$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH"
[ -n "$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" ] && export NEWT_METRICS_PROMETHEUS_ENABLED="$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED"
[ -n "$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" ] && export NEWT_METRICS_OTLP_ENABLED="$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED"
[ -n "$MOD_PANGOLIN_SITE_ADMIN_ADDR" ] && export NEWT_ADMIN_ADDR="$MOD_PANGOLIN_SITE_ADMIN_ADDR"
[ -n "$MOD_PANGOLIN_SITE_REGION" ] && export NEWT_REGION="$MOD_PANGOLIN_SITE_REGION"
[ -n "$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" ] && export NEWT_METRICS_ASYNC_BYTES="$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES"
[ -n "$MOD_PANGOLIN_SITE_PPROF_ENABLED" ] && export NEWT_PPROF_ENABLED="$MOD_PANGOLIN_SITE_PPROF_ENABLED"
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" ] && export TLS_CLIENT_CERT="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT"
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" ] && export TLS_CLIENT_KEY="$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY"
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" ] && export TLS_CLIENT_CAS="$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS"
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" ] && export TLS_CLIENT_CERT_PKCS12="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12"
[ -n "$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" ] && export BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_BLUEPRINT_FILE"
[ -n "$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" ] && export PROVISIONING_BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE"
[ -n "$MOD_PANGOLIN_SITE_NO_CLOUD" ] && export NO_CLOUD="$MOD_PANGOLIN_SITE_NO_CLOUD"
[ -n "$MOD_PANGOLIN_SITE_PROVISIONING_KEY" ] && export NEWT_PROVISIONING_KEY="$MOD_PANGOLIN_SITE_PROVISIONING_KEY"
[ -n "$MOD_PANGOLIN_SITE_NAME" ] && export NEWT_NAME="$MOD_PANGOLIN_SITE_NAME"
[ -n "$MOD_PANGOLIN_SITE_CONFIG_FILE" ] && export CONFIG_FILE="$MOD_PANGOLIN_SITE_CONFIG_FILE"
export NEWT_SYSTEM_SUBSTRATE="ADVANTECH_ROUTER_APP"
export NEWT_PID_FILE="$PIDFILE"
echo "Starting $MODNAME..."
if [ -n "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" ]; then
/opt/$MODNAME/bin/newt --prefer-endpoint "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" >> "$LOGFILE" 2>&1 &
else
/opt/$MODNAME/bin/newt >> "$LOGFILE" 2>&1 &
fi
echo $! > "$PIDFILE"
echo "Started $MODNAME (PID: $(cat "$PIDFILE"))"
exit 0
;;
stop)
echo "Stopping $MODNAME..."
if [ -f "$PIDFILE" ]; then
kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null
rm -f "$PIDFILE"
fi
killall newt 2>/dev/null
# Stopping newt implies disable at boot.
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0"
echo "Stopped $MODNAME"
exit 0
;;
restart)
$0 stop
sleep 1
# Restart should remain enabled after reboot.
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
$0 start
;;
status)
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
echo "$MODNAME is running (PID: $(cat "$PIDFILE"))"
exit 0
else
echo "$MODNAME is not running"
exit 1
fi
;;
defaults)
cp /opt/$MODNAME/etc/defaults /opt/$MODNAME/etc/settings 2>/dev/null
;;
*)
echo "Usage: $0 {start|stop|restart|status|defaults}"
exit 1
esac

View File

@@ -1,15 +0,0 @@
#!/bin/sh
MODNAME=newt
# Ensure scripts are executable
chmod +x /opt/$MODNAME/etc/init
chmod +x /opt/$MODNAME/etc/install
chmod +x /opt/$MODNAME/etc/uninstall
chmod +x /opt/$MODNAME/bin/newt
chmod +x /opt/$MODNAME/www/index.cgi
# Secure the web interface with router authentication
ln -sf /etc/htpasswd /opt/$MODNAME/www/.htpasswd
exit 0

View File

@@ -1 +0,0 @@
Pangolin Site

View File

@@ -1 +0,0 @@
Tunnel client for Pangolin secure remote access.

View File

@@ -1,9 +0,0 @@
#!/bin/sh
MODNAME=newt
rm -f /opt/$MODNAME/www/.htpasswd
rm -f /tmp/newt.pid
rm -f /tmp/newt.log
exit 0

View File

@@ -1 +0,0 @@
1.12.5

View File

@@ -1,282 +0,0 @@
#!/bin/sh
MODNAME=newt
SETTINGS=/opt/$MODNAME/etc/settings
LOGFILE=/tmp/newt.log
PIDFILE=/tmp/newt.pid
# Escape special HTML characters
htmlesc() {
printf '%s' "$1" | sed \
-e 's/&/\&amp;/g' \
-e 's/</\&lt;/g' \
-e 's/>/\&gt;/g' \
-e 's/"/\&quot;/g'
}
# Decode URL-encoded form values (handles common chars in endpoints/ids/secrets)
urldecode() {
printf '%s' "$1" | sed \
-e 's/+/ /g' \
-e 's/%3[Aa]/:/g' -e 's/%3[aa]/:/g' \
-e 's/%2[Ff]/\//g' -e 's/%2[ff]/\//g' \
-e 's/%40/@/g' \
-e 's/%2[Ee]/./g' \
-e 's/%2[Dd]/-/g' \
-e 's/%5[Ff]/_/g' \
-e 's/%3[Dd]/=/g' \
-e 's/%3[Ff]/?/g' \
-e 's/%23/#/g' \
-e 's/%25/%/g'
}
# Extract a named field from URL-encoded POST data (awk splits on & without tr)
get_field() {
printf '%s' "$2" | awk -v f="${1}=" 'BEGIN{RS="&"} index($0,f)==1 {print substr($0,length(f)+1); exit}'
}
# Check whether newt process is alive
is_running() {
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null
}
ensure_settings_file() {
[ -f "$SETTINGS" ] && return
if [ -f "/opt/$MODNAME/etc/defaults" ]; then
cp "/opt/$MODNAME/etc/defaults" "$SETTINGS" 2>/dev/null
else
printf 'MOD_PANGOLIN_SITE_ENABLED=0\n' > "$SETTINGS"
fi
}
set_setting_raw() {
key="$1"
value="$2"
ensure_settings_file
awk -v k="$key" -v v="$value" '
BEGIN { done=0 }
index($0, k "=") == 1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS"
}
quote_sh_value() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
# ── Read POST body ──────────────────────────────────────────────────────────
POST_DATA=""
if [ "$REQUEST_METHOD" = "POST" ] && [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then
POST_DATA=$(dd bs="$CONTENT_LENGTH" count=1 2>/dev/null)
fi
# ── Load current settings ───────────────────────────────────────────────────
MOD_PANGOLIN_SITE_ENABLED=0
MOD_PANGOLIN_SITE_ENDPOINT=""
MOD_PANGOLIN_SITE_ID=""
MOD_PANGOLIN_SITE_SECRET=""
[ -f "$SETTINGS" ] && . "$SETTINGS"
# ── Handle actions ──────────────────────────────────────────────────────────
if [ -n "$POST_DATA" ]; then
ACTION=$(get_field "action" "$POST_DATA")
case "$ACTION" in
save)
EP=$(urldecode "$(get_field "endpoint" "$POST_DATA")")
ID=$(urldecode "$(get_field "id" "$POST_DATA")")
SEC=$(urldecode "$(get_field "secret" "$POST_DATA")")
set_setting_raw "MOD_PANGOLIN_SITE_ENDPOINT" "\"$(quote_sh_value "$EP")\""
set_setting_raw "MOD_PANGOLIN_SITE_ID" "\"$(quote_sh_value "$ID")\""
set_setting_raw "MOD_PANGOLIN_SITE_SECRET" "\"$(quote_sh_value "$SEC")\""
;;
start)
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
/opt/$MODNAME/etc/init start >/dev/null 2>&1
;;
stop)
/opt/$MODNAME/etc/init stop >/dev/null 2>&1
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0"
;;
restart)
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
/opt/$MODNAME/etc/init restart >/dev/null 2>&1
;;
clearlog)
printf '' > "$LOGFILE"
;;
esac
fi
# Reload settings after actions.
MOD_PANGOLIN_SITE_ENABLED=0
MOD_PANGOLIN_SITE_ENDPOINT=""
MOD_PANGOLIN_SITE_ID=""
MOD_PANGOLIN_SITE_SECRET=""
[ -f "$SETTINGS" ] && . "$SETTINGS"
# ── Status ──────────────────────────────────────────────────────────────────
if is_running; then
STATUS_TEXT="Running"
STATUS_CLASS="running"
DISABLED="disabled"
SETTINGS_HINT='<div class="notice">Stop Newt first to edit settings.</div>'
else
STATUS_TEXT="Stopped"
STATUS_CLASS="stopped"
DISABLED=""
SETTINGS_HINT=""
fi
EP_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ENDPOINT")
ID_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ID")
SEC_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_SECRET")
EP_INPUT_ESC="$EP_ESC"
[ -z "$MOD_PANGOLIN_SITE_ENDPOINT" ] && EP_INPUT_ESC="https://app.pangolin.net"
# ── Log (escape HTML and dollar signs to prevent shell expansion in heredoc) ─
LOG_HTML=""
if [ -f "$LOGFILE" ]; then
LOG_HTML=$(tail -100 "$LOGFILE" 2>/dev/null | sed \
-e 's/&/\&amp;/g' \
-e 's/</\&lt;/g' \
-e 's/>/\&gt;/g' \
-e 's/\$/\&#36;/g')
fi
# ── Output ──────────────────────────────────────────────────────────────────
printf 'Content-type: text/html\r\n\r\n'
# Static head — split around the conditional refresh meta tag
cat << 'HEAD_START'
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
HEAD_START
# Only auto-refresh while newt is running (avoids wiping settings form mid-edit)
[ "$STATUS_CLASS" = "running" ] && printf '<meta http-equiv="refresh" content="10">\n'
cat << 'STATIC_HEAD'
<title>Pangolin Site</title>
<style>
*{box-sizing:border-box}
body{margin:0;padding:8px;border-top:3px solid #79BD28;background:#fff;color:#000}
body,table,tr,td,a,input,select,textarea,button{font-family:Verdana,Arial,Helvetica,sans-serif;font-size:12px}
a{color:#004280;text-decoration:none}
a:hover{text-decoration:underline}
.topline{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:6px}
.app-title{background:#004280;color:#fff;font-weight:bold;padding:6px 8px;border:1px solid #00315f;border-bottom:none}
.window{background:#f4f4f4;border:2px solid #004280;padding:0}
.section{border-top:1px solid #9fb7d5}
.section:first-child{border-top:none}
.section-head{background:#c0e0ff;color:#000;font-weight:bold;padding:5px 8px;border-bottom:1px solid #9fb7d5;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
.section-body{padding:10px 8px}
.badge{display:inline-block;padding:2px 10px;border-radius:2px;color:#fff;font-weight:bold;line-height:1.4}
.running{background:#008000}
.stopped{background:#800000}
.row{display:flex;align-items:center;gap:8px;margin:6px 0}
.row label{width:90px;font-weight:bold}
.row input{width:360px;max-width:100%;padding:2px 4px;border:1px solid #7f9db9;background:#fff;height:23px}
.btn{height:24px;padding:0 10px;border:1px solid #7f9db9;background:#efefef;color:#000;cursor:pointer}
.btn:disabled{color:#777;background:#e5e5e5}
.btn + .btn{margin-left:6px}
.btn-start{border-color:#2d7a2d;background:#dff0df}
.btn-stop{border-color:#8a2c2c;background:#f7dddd}
.btn-restart{border-color:#9a6a1f;background:#f7ecd8}
.btn-save{border-color:#2e5f92;background:#dce9f8}
.hint{color:#808080;font-weight:normal}
.notice{margin:0 0 10px 0;color:#ff0000;font-style:italic}
.log{background:#fff;border:1px solid #7f9db9;color:#000;font-family:monospace;font-size:12px;line-height:1.35;padding:6px;height:300px;overflow-y:auto;white-space:pre-wrap;word-break:break-word}
</style>
</head>
<body>
<div class="topline">
<a href="/">&laquo; Back to Router</a>
</div>
<div class="app-title">Router Apps - Pangolin Site</div>
<div class="window">
STATIC_HEAD
# Status card (double-quoted heredoc — variables expand)
cat << STATUS_CARD
<div class="section">
<div class="section-head">Status</div>
<div class="section-body">
<div><span class="badge ${STATUS_CLASS}">${STATUS_TEXT}</span></div>
<div style="margin-top:8px">
<form method="post" style="display:inline">
<input type="hidden" name="action" value="start">
<button class="btn btn-start" type="submit">Start</button>
</form>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="stop">
<button class="btn btn-stop" type="submit">Stop</button>
</form>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="restart">
<button class="btn btn-restart" type="submit">Restart</button>
</form>
</div>
</div>
</div>
STATUS_CARD
# Settings card
cat << SETTINGS_CARD
<div class="section">
<div class="section-head">Settings</div>
<div class="section-body">
${SETTINGS_HINT}<form method="post">
<input type="hidden" name="action" value="save">
<div class="row">
<label>Endpoint</label>
<input type="text" name="endpoint" value="${EP_INPUT_ESC}" ${DISABLED}>
</div>
<div class="row">
<label>ID</label>
<input type="text" name="id" value="${ID_ESC}" ${DISABLED}>
</div>
<div class="row">
<label>Secret</label>
<input type="password" name="secret" value="${SEC_ESC}" ${DISABLED}>
</div>
<div style="margin-top:12px">
<button class="btn btn-save" type="submit" ${DISABLED}>Save</button>
</div>
</form>
</div>
</div>
SETTINGS_CARD
# Log card header
cat << 'LOG_HEADER'
<div class="section">
<div class="section-head">
<span>Log <span class="hint">(last 100 lines, auto-refreshes every 10s while running)</span></span>
<span style="display:inline-flex;gap:6px">
<form method="post" style="margin:0">
<input type="hidden" name="action" value="clearlog">
<button class="btn" type="submit">Clear Log</button>
</form>
<form method="get" style="margin:0">
<button class="btn" type="submit">Refresh</button>
</form>
</span>
</div>
<div class="section-body">
<div class="log">
LOG_HEADER
# Log content — printed with printf to prevent any shell expansion
printf '%s' "$LOG_HTML"
# Close tags (static)
cat << 'STATIC_FOOTER'
</div>
</div>
</div>
</body>
</html>
STATIC_FOOTER

View File

@@ -21,32 +21,7 @@ import (
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
)
const (
errUnsupportedProtoFmt = "unsupported protocol: %s"
maxUDPPacketSize = 65507 // Maximum UDP packet size
defaultUDPIdleTimeout = 90 * time.Second
)
// udpBufferPool provides reusable buffers for UDP packet handling.
// This reduces GC pressure from frequent large allocations.
var udpBufferPool = sync.Pool{
New: func() any {
buf := make([]byte, maxUDPPacketSize)
return &buf
},
}
// getUDPBuffer retrieves a buffer from the pool.
func getUDPBuffer() *[]byte {
return udpBufferPool.Get().(*[]byte)
}
// putUDPBuffer clears and returns a buffer to the pool.
func putUDPBuffer(buf *[]byte) {
// Clear the buffer to prevent data leakage
clear(*buf)
udpBufferPool.Put(buf)
}
const errUnsupportedProtoFmt = "unsupported protocol: %s"
// Target represents a proxy target with its address and port
type Target struct {
@@ -69,10 +44,6 @@ type ProxyManager struct {
tunnels map[string]*tunnelEntry
asyncBytes bool
flushStop chan struct{}
udpIdleTimeout time.Duration
// connection blocking
blocked atomic.Bool
}
// tunnelEntry holds per-tunnel attributes and (optional) async counters.
@@ -134,9 +105,13 @@ func classifyProxyError(err error) string {
if errors.Is(err, net.ErrClosed) {
return "closed"
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return "timeout"
if ne, ok := err.(net.Error); ok {
if ne.Timeout() {
return "timeout"
}
if ne.Temporary() {
return "temporary"
}
}
msg := strings.ToLower(err.Error())
switch {
@@ -152,13 +127,12 @@ func classifyProxyError(err error) string {
// NewProxyManager creates a new proxy manager instance
func NewProxyManager(tnet *netstack.Net) *ProxyManager {
return &ProxyManager{
tnet: tnet,
tcpTargets: make(map[string]map[int]string),
udpTargets: make(map[string]map[int]string),
listeners: make([]*gonet.TCPListener, 0),
udpConns: make([]*gonet.UDPConn, 0),
tunnels: make(map[string]*tunnelEntry),
udpIdleTimeout: defaultUDPIdleTimeout,
tnet: tnet,
tcpTargets: make(map[string]map[int]string),
udpTargets: make(map[string]map[int]string),
listeners: make([]*gonet.TCPListener, 0),
udpConns: make([]*gonet.UDPConn, 0),
tunnels: make(map[string]*tunnelEntry),
}
}
@@ -232,11 +206,10 @@ func (pm *ProxyManager) ClearTunnelID() {
// init function without tnet
func NewProxyManagerWithoutTNet() *ProxyManager {
return &ProxyManager{
tcpTargets: make(map[string]map[int]string),
udpTargets: make(map[string]map[int]string),
listeners: make([]*gonet.TCPListener, 0),
udpConns: make([]*gonet.UDPConn, 0),
udpIdleTimeout: defaultUDPIdleTimeout,
tcpTargets: make(map[string]map[int]string),
udpTargets: make(map[string]map[int]string),
listeners: make([]*gonet.TCPListener, 0),
udpConns: make([]*gonet.UDPConn, 0),
}
}
@@ -247,18 +220,6 @@ func (pm *ProxyManager) SetTNet(tnet *netstack.Net) {
pm.tnet = tnet
}
// SetBlocked enables or disables connection blocking.
// When enabled, all new incoming TCP connections are immediately closed
// and all incoming UDP packets are silently dropped.
func (pm *ProxyManager) SetBlocked(v bool) {
pm.blocked.Store(v)
if v {
logger.Debug("ProxyManager: connection blocking enabled, new connections will be dropped")
} else {
logger.Debug("ProxyManager: connection blocking disabled, accepting connections")
}
}
// AddTarget adds as new target for proxying
func (pm *ProxyManager) AddTarget(proto, listenIP string, port int, targetAddr string) error {
pm.mutex.Lock()
@@ -385,17 +346,6 @@ func (pm *ProxyManager) SetAsyncBytes(b bool) {
go pm.flushLoop()
}
}
// SetUDPIdleTimeout configures when idle UDP client flows are reclaimed.
func (pm *ProxyManager) SetUDPIdleTimeout(d time.Duration) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
if d <= 0 {
pm.udpIdleTimeout = defaultUDPIdleTimeout
return
}
pm.udpIdleTimeout = d
}
func (pm *ProxyManager) flushLoop() {
flushInterval := 2 * time.Second
if v := os.Getenv("OTEL_METRIC_EXPORT_INTERVAL"); v != "" {
@@ -487,6 +437,14 @@ func (pm *ProxyManager) Stop() error {
pm.udpConns = append(pm.udpConns[:i], pm.udpConns[i+1:]...)
}
// // Clear the target maps
// for k := range pm.tcpTargets {
// delete(pm.tcpTargets, k)
// }
// for k := range pm.udpTargets {
// delete(pm.udpTargets, k)
// }
// Give active connections a chance to close gracefully
time.Sleep(100 * time.Millisecond)
@@ -540,7 +498,7 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
if !pm.running {
return
}
if errors.Is(err, net.ErrClosed) {
if ne, ok := err.(net.Error); ok && !ne.Temporary() {
logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr())
return
}
@@ -550,12 +508,6 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
}
tunnelID := pm.currentTunnelID
// Drop connection if blocking is enabled
if pm.blocked.Load() {
conn.Close()
logger.Debug("TCP proxy: connection dropped (blocking enabled)")
continue
}
telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "success", "")
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionOpened)
if tunnelID != "" {
@@ -612,9 +564,7 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
}
func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
bufPtr := getUDPBuffer()
defer putUDPBuffer(bufPtr)
buffer := *bufPtr
buffer := make([]byte, 65507) // Max UDP packet size
clientConns := make(map[string]*net.UDPConn)
var clientsMutex sync.RWMutex
@@ -633,7 +583,7 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
}
// Check for connection closed conditions
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
logger.Info("UDP connection closed, stopping proxy handler")
// Clean up existing client connections
@@ -652,11 +602,6 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
}
clientKey := remoteAddr.String()
// Drop packet if blocking is enabled
if pm.blocked.Load() {
logger.Debug("UDP proxy: packet dropped (blocking enabled)")
continue
}
// bytes from client -> target (direction=in)
if pm.currentTunnelID != "" && n > 0 {
if pm.asyncBytes {
@@ -687,9 +632,6 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "udp", "failure", classifyProxyError(err))
continue
}
// Prevent idle UDP client goroutines from living forever and
// retaining large per-connection buffers.
_ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout))
tunnelID := pm.currentTunnelID
telemetry.IncProxyAccept(context.Background(), tunnelID, "udp", "success", "")
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionOpened)
@@ -705,10 +647,7 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
go func(clientKey string, targetConn *net.UDPConn, remoteAddr net.Addr, tunnelID string) {
start := time.Now()
result := "success"
bufPtr := getUDPBuffer()
defer func() {
// Return buffer to pool first
putUDPBuffer(bufPtr)
// Always clean up when this goroutine exits
clientsMutex.Lock()
if storedConn, exists := clientConns[clientKey]; exists && storedConn == targetConn {
@@ -723,18 +662,10 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionClosed)
}()
buffer := *bufPtr
buffer := make([]byte, 65507)
for {
n, _, err := targetConn.ReadFromUDP(buffer)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return
}
// Connection closed is normal during cleanup
if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
return // defer will handle cleanup, result stays "success"
}
logger.Error("Error reading from target: %v", err)
result = "failure"
return // defer will handle cleanup
@@ -773,8 +704,6 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
delete(clientConns, clientKey)
clientsMutex.Unlock()
} else if pm.currentTunnelID != "" && written > 0 {
// Extend idle timeout whenever client traffic is observed.
_ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout))
if pm.asyncBytes {
if e := pm.getEntry(pm.currentTunnelID); e != nil {
e.bytesInUDP.Add(uint64(written))
@@ -807,28 +736,3 @@ func (pm *ProxyManager) PrintTargets() {
}
}
}
// GetTargets returns a copy of the current TCP and UDP targets
// Returns map[listenIP]map[port]targetAddress for both TCP and UDP
func (pm *ProxyManager) GetTargets() (tcpTargets map[string]map[int]string, udpTargets map[string]map[int]string) {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
tcpTargets = make(map[string]map[int]string)
for listenIP, targets := range pm.tcpTargets {
tcpTargets[listenIP] = make(map[int]string)
for port, targetAddr := range targets {
tcpTargets[listenIP][port] = targetAddr
}
}
udpTargets = make(map[string]map[int]string)
for listenIP, targets := range pm.udpTargets {
udpTargets[listenIP] = make(map[int]string)
for port, targetAddr := range targets {
udpTargets[listenIP][port] = targetAddr
}
}
return tcpTargets, udpTargets
}

View File

@@ -1,21 +0,0 @@
//go:build !windows
package main
import (
"fmt"
"os"
"syscall"
)
// reexec replaces the current process image with a fresh copy of itself,
// preserving all arguments and environment variables. On success it never
// returns (execve replaces the process in-place). On failure it returns an
// error describing why the exec could not be performed.
func reexec() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
return syscall.Exec(exe, os.Args, os.Environ())
}

View File

@@ -1,30 +0,0 @@
//go:build windows
package main
import (
"fmt"
"os"
"os/exec"
)
// reexec spawns a new copy of the current process with the same arguments and
// environment, then exits the current process. On Windows, execve is not
// available, so we start a child process and exit. On success it never returns
// (os.Exit terminates the current process). On failure it returns an error.
func reexec() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start new process: %w", err)
}
os.Exit(0)
return nil // unreachable
}

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
: "${TAG:?}"
: "${GHCR_REF:?}"
: "${DIGEST:?}"
NOTES_FILE="$(mktemp)"
existing_body="$(gh release view "${TAG}" --json body --jq '.body')"
cat > "${NOTES_FILE}" <<EOF
${existing_body}
## Container Images
- GHCR: \`${GHCR_REF}\`
- Docker Hub: \`${DH_REF:-N/A}\`
**Digest:** \`${DIGEST}\`
EOF
gh release edit "${TAG}" --draft --notes-file "${NOTES_FILE}"
rm -f "${NOTES_FILE}"

11
scripts/nfpm.yaml.tmpl Normal file
View File

@@ -0,0 +1,11 @@
name: __PKG_NAME__
arch: __ARCH__
platform: linux
version: __VERSION__
section: net
priority: optional
maintainer: fosrl
description: Newt - userspace tunnel client and TCP/UDP proxy
contents:
- src: build/newt
dst: /usr/bin/newt

149
scripts/publish-apt.sh Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -euo pipefail
# ---- required env ----
: "${GH_REPO:?}"
: "${S3_BUCKET:?}"
: "${AWS_REGION:?}"
: "${CLOUDFRONT_DISTRIBUTION_ID:?}"
: "${PKG_NAME:?}"
: "${SUITE:?}"
: "${COMPONENT:?}"
: "${APT_GPG_PRIVATE_KEY:?}"
S3_PREFIX="${S3_PREFIX:-}"
if [[ -n "${S3_PREFIX}" && "${S3_PREFIX}" != */ ]]; then
S3_PREFIX="${S3_PREFIX}/"
fi
WORKDIR="$(pwd)"
mkdir -p repo/apt assets build
download_asset() {
local tag="$1"
local pattern="$2"
local attempts=12
for attempt in $(seq 1 "${attempts}"); do
if gh release download "${tag}" -R "${GH_REPO}" -p "${pattern}" -D assets; then
return 0
fi
echo "Asset ${pattern} not available yet (attempt ${attempt}/${attempts}); retrying..."
sleep 5
done
echo "ERROR: Failed to download asset ${pattern} for ${tag} after ${attempts} attempts"
return 1
}
echo "${APT_GPG_PRIVATE_KEY}" | gpg --batch --import >/dev/null 2>&1 || true
KEYID="$(gpg --list-secret-keys --with-colons | awk -F: '$1=="sec"{print $5; exit}')"
if [[ -z "${KEYID}" ]]; then
echo "ERROR: No GPG secret key available after import."
exit 1
fi
# Determine which tags to process
TAGS=""
if [[ "${BACKFILL_ALL:-false}" == "true" ]]; then
echo "Backfill mode: collecting all release tags..."
TAGS="$(gh release list -R "${GH_REPO}" --limit 200 --json tagName --jq '.[].tagName')"
else
if [[ -n "${INPUT_TAG:-}" ]]; then
TAGS="${INPUT_TAG}"
elif [[ -n "${EVENT_TAG:-}" ]]; then
TAGS="${EVENT_TAG}"
elif [[ -n "${PUSH_TAG:-}" ]]; then
TAGS="${PUSH_TAG}"
else
echo "No tag provided; using latest release tag..."
TAGS="$(gh release view -R "${GH_REPO}" --json tagName --jq '.tagName')"
fi
fi
echo "Tags to process:"
printf '%s\n' "${TAGS}"
# Pull existing repo from S3 so we keep older versions
echo "Sync existing repo from S3..."
aws s3 sync "s3://${S3_BUCKET}/${S3_PREFIX}apt/" repo/apt/ >/dev/null 2>&1 || true
# Build and add packages
while IFS= read -r TAG; do
[[ -z "${TAG}" ]] && continue
echo "=== Processing tag: ${TAG} ==="
rm -rf assets build
mkdir -p assets build
deb_amd64="${PKG_NAME}_${TAG}_amd64.deb"
deb_arm64="${PKG_NAME}_${TAG}_arm64.deb"
download_asset "${TAG}" "${deb_amd64}"
download_asset "${TAG}" "${deb_arm64}"
if [[ ! -f "assets/${deb_amd64}" ]]; then
echo "ERROR: Missing release asset: ${deb_amd64}"
exit 1
fi
if [[ ! -f "assets/${deb_arm64}" ]]; then
echo "ERROR: Missing release asset: ${deb_arm64}"
exit 1
fi
mkdir -p "repo/apt/pool/${COMPONENT}/${PKG_NAME:0:1}/${PKG_NAME}/"
cp -v assets/*.deb "repo/apt/pool/${COMPONENT}/${PKG_NAME:0:1}/${PKG_NAME}/"
done <<< "${TAGS}"
# Regenerate metadata
cd repo/apt
for arch in amd64 arm64; do
mkdir -p "dists/${SUITE}/${COMPONENT}/binary-${arch}"
dpkg-scanpackages -a "${arch}" pool > "dists/${SUITE}/${COMPONENT}/binary-${arch}/Packages"
gzip -fk "dists/${SUITE}/${COMPONENT}/binary-${arch}/Packages"
done
# Release file with hashes
cat > apt-ftparchive.conf <<EOF
APT::FTPArchive::Release::Origin "fosrl";
APT::FTPArchive::Release::Label "newt";
APT::FTPArchive::Release::Suite "${SUITE}";
APT::FTPArchive::Release::Codename "${SUITE}";
APT::FTPArchive::Release::Architectures "amd64 arm64";
APT::FTPArchive::Release::Components "${COMPONENT}";
APT::FTPArchive::Release::Description "Newt APT repository";
EOF
apt-ftparchive -c apt-ftparchive.conf release "dists/${SUITE}" > "dists/${SUITE}/Release"
# Sign Release
cd "dists/${SUITE}"
gpg --batch --yes --pinentry-mode loopback \
${APT_GPG_PASSPHRASE:+--passphrase "${APT_GPG_PASSPHRASE}"} \
--local-user "${KEYID}" \
--clearsign -o InRelease Release
gpg --batch --yes --pinentry-mode loopback \
${APT_GPG_PASSPHRASE:+--passphrase "${APT_GPG_PASSPHRASE}"} \
--local-user "${KEYID}" \
-abs -o Release.gpg Release
# Export public key into apt repo root
cd ../../..
gpg --batch --yes --armor --export "${KEYID}" > "${WORKDIR}/repo/apt/public.key"
# Upload to S3
echo "Uploading to S3..."
aws s3 sync "${WORKDIR}/repo/apt" "s3://${S3_BUCKET}/${S3_PREFIX}apt/" --delete
# Invalidate metadata
echo "CloudFront invalidation..."
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/${S3_PREFIX}apt/dists/*" "/${S3_PREFIX}apt/public.key"
echo "Done. Repo base: ${REPO_BASE_URL}"

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
@@ -648,7 +649,7 @@ func setupWindowsEventLog() {
// Set the custom logger output
logger.GetLogger().SetOutput(file)
logger.Debug("Newt service logging initialized - log file: %s", logFile)
log.Printf("Newt service logging initialized - log file: %s", logFile)
}
// handleServiceCommand checks for service management commands and returns true if handled

View File

@@ -1,60 +0,0 @@
import asyncio
import sys
import websockets
# Argument parsing: Check if HOST and PORT are provided
if len(sys.argv) < 3 or len(sys.argv) > 4:
print("Usage: python ws_client.py <HOST_IP> <HOST_PORT> [ws|wss]")
# Example: python ws_client.py 127.0.0.1 8765
# Example: python ws_client.py 127.0.0.1 8765 wss
sys.exit(1)
HOST = sys.argv[1]
try:
PORT = int(sys.argv[2])
except ValueError:
print("Error: HOST_PORT must be an integer.")
sys.exit(1)
if len(sys.argv) == 4:
SCHEME = sys.argv[3].lower()
if SCHEME not in ("ws", "wss"):
print("Error: scheme must be 'ws' or 'wss'.")
sys.exit(1)
else:
SCHEME = "ws"
URI = f"{SCHEME}://{HOST}:{PORT}"
# The message to send to the server
MESSAGE = "Hello WebSocket Server! How are you?"
async def main():
print(f"Connecting to {URI}...")
try:
async with websockets.connect(URI) as websocket:
print(f"Connected to server.")
print(f"Sending message: '{MESSAGE}'")
await websocket.send(MESSAGE)
response = await websocket.recv()
print("-" * 30)
print(f"Received response from server:")
print(f"-> Data: '{response}'")
except ConnectionRefusedError:
print(f"Error: Connection to {URI} was refused. Is the server running?")
except websockets.exceptions.InvalidMessage as e:
print(f"Error: Server did not respond with a valid WebSocket handshake: {e}")
except Exception as e:
print(f"Error during communication: {e}")
print("-" * 30)
print("Client finished.")
asyncio.run(main())

View File

@@ -1,49 +0,0 @@
import asyncio
import sys
import websockets
# Optionally take in a positional arg for the port
if len(sys.argv) > 1:
try:
PORT = int(sys.argv[1])
except ValueError:
print("Invalid port number. Using default port 8765.")
PORT = 8765
else:
PORT = 8765
# Define the server host
HOST = "0.0.0.0"
async def handle_client(websocket):
client_address = websocket.remote_address
print(f"Client connected: {client_address[0]}:{client_address[1]}")
try:
async for message in websocket:
print("-" * 30)
print(f"Received message from {client_address[0]}:{client_address[1]}:")
print(f"-> Data: '{message}'")
response = f"Hello client! Server received: '{message.upper()}'"
await websocket.send(response)
print(f"Sent response back to client.")
except websockets.exceptions.ConnectionClosedOK:
print(f"Client {client_address[0]}:{client_address[1]} disconnected cleanly.")
except websockets.exceptions.ConnectionClosedError as e:
print(f"Client {client_address[0]}:{client_address[1]} disconnected with error: {e}")
async def main():
print(f"WebSocket Server listening on {HOST}:{PORT}")
async with websockets.serve(handle_client, HOST, PORT):
await asyncio.Future() # Run forever
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nServer stopped.")

View File

@@ -1,48 +0,0 @@
//go:build !windows
package updates
import (
"fmt"
"os"
"path/filepath"
"github.com/fosrl/newt/logger"
)
const advantechVersionFile = "/opt/newt/etc/version"
// postUpdateAdvantech performs Advantech Router App-specific post-update steps:
// - Writes the new version string to /opt/newt/etc/version so that the
// router firmware's package management reflects the installed version.
// - Updates the PID file at pidFile (if non-empty) with the current process
// PID, in case the re-exec lands with a new PID on platforms where
// syscall.Exec behaviour differs.
func postUpdateAdvantech(newVersion, pidFile string) error {
// --- Write version file ---
versionDir := filepath.Dir(advantechVersionFile)
if err := os.MkdirAll(versionDir, 0755); err != nil {
return fmt.Errorf("advantech: failed to create version directory %s: %w", versionDir, err)
}
if err := os.WriteFile(advantechVersionFile, []byte(newVersion+"\n"), 0644); err != nil {
return fmt.Errorf("advantech: failed to write version file %s: %w", advantechVersionFile, err)
}
logger.Debug("postUpdateAdvantech: wrote version %s to %s", newVersion, advantechVersionFile)
// --- Update PID file ---
// syscall.Exec replaces the process image in-place so the PID is preserved.
// We update the PID file here anyway so that any race between the old and
// new binary is covered (e.g. on platforms that fork before exec).
if pidFile != "" {
pid := fmt.Sprintf("%d\n", os.Getpid())
if err := os.WriteFile(pidFile, []byte(pid), 0644); err != nil {
// Non-fatal: log and continue so the update still proceeds.
logger.Debug("postUpdateAdvantech: warning: failed to update PID file %s: %v", pidFile, err)
} else {
logger.Debug("postUpdateAdvantech: updated PID file %s with PID %d", pidFile, os.Getpid())
}
}
return nil
}

View File

@@ -1,8 +0,0 @@
//go:build windows
package updates
// postUpdateAdvantech is not supported on Windows.
func postUpdateAdvantech(newVersion, pidFile string) error {
return nil
}

View File

@@ -1,14 +0,0 @@
//go:build !windows
package updates
import (
"os"
"syscall"
)
// reexec replaces the current process image with the binary at exePath,
// forwarding all original arguments and environment variables.
func reexec(exePath string) error {
return syscall.Exec(exePath, os.Args, os.Environ())
}

View File

@@ -1,28 +0,0 @@
//go:build windows
package updates
import (
"fmt"
"os"
"os/exec"
)
// reexec on Windows cannot use syscall.Exec (there is no exec syscall that
// replaces the process image). Instead we start a new process and exit the
// current one.
func reexec(exePath string) error {
cmd := exec.Command(exePath, os.Args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start updated binary: %w", err)
}
// Exit the current process so the new binary takes over.
os.Exit(0)
return nil // unreachable
}

View File

@@ -1,295 +0,0 @@
package updates
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/fosrl/newt/logger"
)
// SelfUpdateConfig holds the configuration required to perform a self-update.
type SelfUpdateConfig struct {
// Endpoint is the base URL of the pangolin server (e.g. "https://app.pangolin.net")
Endpoint string
// NewtID is the newt client identifier used for authentication.
NewtID string
// Secret is the newt client secret used for authentication.
Secret string
// CurrentVersion is the version of the currently running binary.
CurrentVersion string
// Platform is the OS+arch string embedded at build time via ldflags
// (e.g. "linux_amd64", "darwin_arm64"). When non-empty it is used
// directly; when empty the value is derived from runtime.GOOS/GOARCH.
Platform string
// TLSConfig is an optional TLS configuration for the HTTP client (may be nil).
TLSConfig *tls.Config
}
// versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version
type versionResponse struct {
Data struct {
LatestVersion string `json:"latestVersion"`
CurrentIsLatest bool `json:"currentIsLatest"`
DownloadUrl string `json:"downloadUrl"`
Sha256 string `json:"sha256"`
} `json:"data"`
Success bool `json:"success"`
Message string `json:"message"`
}
// isOfficialContainer returns true when the process is running inside an
// official Fossorial-built container image. The image sets
// NEWT_SYSTEM_SUBSTRATE="CONTAINER" at build time; users running newt in their own
// containers (or bare-metal) will not have this variable set.
func isOfficialContainer() bool {
return os.Getenv("NEWT_SYSTEM_SUBSTRATE") == "CONTAINER"
}
// platform returns the OS+arch string used in the newt release binary names,
// e.g. "linux_amd64", "darwin_arm64", "windows_amd64".
// It is used as a fallback when no platform was embedded at build time.
func platform() string {
goarch := runtime.GOARCH
goos := runtime.GOOS
// Map Go arch names to the names used by newt releases
archMap := map[string]string{
"amd64": "amd64",
"arm64": "arm64",
"arm": "arm32",
"riscv64": "riscv64",
}
arch, ok := archMap[goarch]
if !ok {
arch = goarch
}
return fmt.Sprintf("%s_%s", goos, arch)
}
// verifySHA256 checks that the file at path has the expected SHA-256 hex digest.
func verifySHA256(path, expected string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open file for hashing: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("failed to hash file: %w", err)
}
got := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(got, expected) {
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expected, got)
}
return nil
}
// CheckAndSelfUpdate contacts the pangolin server, checks whether a newer
// version of newt is available, downloads it if so, replaces the running
// binary on disk, and re-executes from the new binary.
//
// It returns an error when the check or update fails. On a successful update
// the function does not return the process is replaced by the new binary via
// syscall.Exec.
func CheckAndSelfUpdate(cfg SelfUpdateConfig) error {
logger.Debug("checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion)
if isOfficialContainer() {
logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update")
return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead")
}
if cfg.CurrentVersion == "version_replaceme" {
logger.Debug("checkAndSelfUpdate: development build detected, skipping auto-update")
return fmt.Errorf("cannot auto-update a development build (version_replaceme)")
}
baseEndpoint := strings.TrimRight(cfg.Endpoint, "/")
// Build the HTTP client.
httpClient := &http.Client{Timeout: 30 * time.Second}
if cfg.TLSConfig != nil {
httpClient.Transport = &http.Transport{TLSClientConfig: cfg.TLSConfig}
}
// Check the current binary path before we do anything else so we can fail
// fast if the executable path cannot be determined.
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine current executable path: %w", err)
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return fmt.Errorf("failed to resolve symlinks for executable path: %w", err)
}
logger.Debug("checkAndSelfUpdate: current executable path: %s", exePath)
// --- Step 1: Ask the server for the latest version ---
plat := cfg.Platform
if plat == "" {
plat = platform()
}
logger.Debug("checkAndSelfUpdate: querying server for latest version (platform=%s, endpoint=%s)", plat, baseEndpoint)
reqBody, err := json.Marshal(map[string]string{
"newtId": cfg.NewtID,
"secret": cfg.Secret,
"platform": plat,
})
if err != nil {
return fmt.Errorf("failed to marshal version request: %w", err)
}
versionURL, err := url.JoinPath(baseEndpoint, "/api/v1/auth/newt/version")
if err != nil {
return fmt.Errorf("failed to build version URL: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(reqBody))
if err != nil {
return fmt.Errorf("failed to create version request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "x-csrf-protection")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to request version info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent { // updates are disabled
logger.Debug("checkAndSelfUpdate: server indicated updates are disabled (204 No Content)")
return nil
}
if resp.StatusCode == http.StatusNotFound { // older server without version endpoint
logger.Debug("checkAndSelfUpdate: server does not support version endpoint (404 Not Found), skipping")
return nil
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
}
var verResp versionResponse
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
return fmt.Errorf("failed to parse version response: %w", err)
}
if !verResp.Success {
return fmt.Errorf("server error: %s", verResp.Message)
}
if verResp.Data.CurrentIsLatest {
logger.Debug("checkAndSelfUpdate: already up to date (%s)", cfg.CurrentVersion)
return nil
}
logger.Debug("checkAndSelfUpdate: update available %s → %s", cfg.CurrentVersion, verResp.Data.LatestVersion)
// --- Pre-download: verify we can write to the binary's directory ---
// Do this before downloading so a permission failure doesn't waste bandwidth.
exeDir := filepath.Dir(exePath)
logger.Debug("checkAndSelfUpdate: verifying write access to %s", exeDir)
writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*")
if err != nil {
return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err)
}
writeTestFile.Close()
_ = os.Remove(writeTestFile.Name())
// --- Step 2: Download the new binary ---
logger.Debug("checkAndSelfUpdate: beginning download of new binary")
dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer dlCancel()
dlReq, err := http.NewRequestWithContext(dlCtx, "GET", verResp.Data.DownloadUrl, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
dlResp, err := httpClient.Do(dlReq)
if err != nil {
return fmt.Errorf("failed to download new binary: %w", err)
}
defer dlResp.Body.Close()
if dlResp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status %d", dlResp.StatusCode)
}
// Write to a temp file in the same directory as the current binary so that
// an atomic rename works even across filesystem boundaries.
tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*")
tmpPath := tmpFile.Name()
// Ensure the temp file is cleaned up on any error path.
defer func() {
_ = os.Remove(tmpPath)
}()
if _, err := io.Copy(tmpFile, dlResp.Body); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write downloaded binary: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// --- Verify SHA256 checksum if the server provided one ---
if verResp.Data.Sha256 != "" {
logger.Debug("checkAndSelfUpdate: verifying SHA256 checksum")
if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil {
return fmt.Errorf("binary integrity check failed: %w", err)
}
logger.Debug("checkAndSelfUpdate: SHA256 checksum verified")
} else {
logger.Debug("checkAndSelfUpdate: no SHA256 checksum provided by server, skipping verification")
}
// Make the new binary executable.
if err := os.Chmod(tmpPath, 0755); err != nil {
return fmt.Errorf("failed to set executable permission: %w", err)
}
// --- Step 3: Replace the running binary ---
// On Unix an atomic rename works even while the file is running.
logger.Debug("checkAndSelfUpdate: replacing binary at %s", exePath)
if err := os.Rename(tmpPath, exePath); err != nil {
return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err)
}
systemSubstrate := os.Getenv("NEWT_SYSTEM_SUBSTRATE")
logger.Debug("checkAndSelfUpdate: system substrate: %s", systemSubstrate)
if systemSubstrate == "ADVANTECH_ROUTER_APP" {
pidFile := os.Getenv("NEWT_PID_FILE")
if err := postUpdateAdvantech(verResp.Data.LatestVersion, pidFile); err != nil {
logger.Debug("checkAndSelfUpdate: advantech post-update steps failed: %v", err)
}
}
// --- Step 4: Re-exec ---
logger.Debug("checkAndSelfUpdate: re-executing new binary")
return reexec(exePath)
}

View File

@@ -1,7 +1,6 @@
package util
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/hex"
@@ -15,99 +14,6 @@ import (
"golang.zx2c4.com/wireguard/device"
)
func ResolveDomainUpstream(domain string, publicDNS []string) (string, error) {
// trim whitespace
domain = strings.TrimSpace(domain)
// Remove any protocol prefix if present (do this first, before splitting host/port)
domain = strings.TrimPrefix(domain, "http://")
domain = strings.TrimPrefix(domain, "https://")
// if there are any trailing slashes, remove them
domain = strings.TrimSuffix(domain, "/")
// Check if there's a port in the domain
host, port, err := net.SplitHostPort(domain)
if err != nil {
// No port found, use the domain as is
host = domain
port = ""
}
// Check if host is already an IP address (IPv4 or IPv6)
// For IPv6, the host from SplitHostPort will already have brackets stripped
// but if there was no port, we need to handle bracketed IPv6 addresses
cleanHost := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
if ip := net.ParseIP(cleanHost); ip != nil {
// It's already an IP address, no need to resolve
ipAddr := ip.String()
if port != "" {
return net.JoinHostPort(ipAddr, port), nil
}
return ipAddr, nil
}
// Lookup IP addresses using the upstream DNS servers if provided
var ips []net.IP
if len(publicDNS) > 0 {
var lastErr error
for _, server := range publicDNS {
// Ensure the upstream DNS address has a port
dnsAddr := server
if _, _, err := net.SplitHostPort(dnsAddr); err != nil {
// No port specified, default to 53
dnsAddr = net.JoinHostPort(server, "53")
}
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "udp", dnsAddr)
},
}
ips, lastErr = resolver.LookupIP(context.Background(), "ip", host)
if lastErr == nil {
break
}
}
if lastErr != nil {
return "", fmt.Errorf("DNS lookup failed using all upstream servers: %v", lastErr)
}
} else {
ips, err = net.LookupIP(host)
if err != nil {
return "", fmt.Errorf("DNS lookup failed: %v", err)
}
}
if len(ips) == 0 {
return "", fmt.Errorf("no IP addresses found for domain %s", host)
}
// Get the first IPv4 address if available
var ipAddr string
for _, ip := range ips {
if ipv4 := ip.To4(); ipv4 != nil {
ipAddr = ipv4.String()
break
}
}
// If no IPv4 found, use the first IP (might be IPv6)
if ipAddr == "" {
ipAddr = ips[0].String()
}
// Add port back if it existed
if port != "" {
ipAddr = net.JoinHostPort(ipAddr, port)
}
return ipAddr, nil
}
func ResolveDomain(domain string) (string, error) {
// trim whitespace
domain = strings.TrimSpace(domain)

View File

@@ -2,7 +2,6 @@ package websocket
import (
"bytes"
"compress/gzip"
"crypto/tls"
"crypto/x509"
"encoding/json"
@@ -38,22 +37,16 @@ type Client struct {
isConnected bool
reconnectMux sync.RWMutex
pingInterval time.Duration
pingTimeout time.Duration
onConnect func() error
onTokenUpdate func(token string)
writeMux sync.Mutex
clientType string // Type of client (e.g., "newt", "olm")
configFilePath string // Optional override for the config file path
tlsConfig TLSConfig
metricsCtxMu sync.RWMutex
metricsCtx context.Context
configNeedsSave bool // Flag to track if config needs to be saved
serverVersion string
configVersion int64 // Latest config version received from server
configVersionMux sync.RWMutex
processingMessage bool // Flag to track if a message is currently being processed
processingMux sync.RWMutex // Protects processingMessage
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials
}
type ClientOption func(*Client)
@@ -79,12 +72,6 @@ func WithBaseURL(url string) ClientOption {
}
// WithTLSConfig sets the TLS configuration for the client
func WithConfigFile(path string) ClientOption {
return func(c *Client) {
c.configFilePath = path
}
}
func WithTLSConfig(config TLSConfig) ClientOption {
return func(c *Client) {
c.tlsConfig = config
@@ -103,16 +90,6 @@ func (c *Client) OnTokenUpdate(callback func(token string)) {
c.onTokenUpdate = callback
}
// WasJustProvisioned reports whether the client exchanged a provisioning key
// for permanent credentials during the most recent connection attempt. It
// consumes the flag subsequent calls return false until provisioning occurs
// again (which, in practice, never happens once credentials are persisted).
func (c *Client) WasJustProvisioned() bool {
v := c.justProvisioned
c.justProvisioned = false
return v
}
func (c *Client) metricsContext() context.Context {
c.metricsCtxMu.RLock()
defer c.metricsCtxMu.RUnlock()
@@ -134,7 +111,7 @@ func (c *Client) MetricsContext() context.Context {
}
// NewClient creates a new websocket client
func NewClient(clientType string, ID, secret string, endpoint string, pingInterval time.Duration, opts ...ClientOption) (*Client, error) {
func NewClient(clientType string, ID, secret string, endpoint string, pingInterval time.Duration, pingTimeout time.Duration, opts ...ClientOption) (*Client, error) {
config := &Config{
ID: ID,
Secret: secret,
@@ -149,6 +126,7 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv
reconnectInterval: 3 * time.Second,
isConnected: false,
pingInterval: pingInterval,
pingTimeout: pingTimeout,
clientType: clientType,
}
@@ -172,29 +150,10 @@ func (c *Client) GetConfig() *Config {
return c.config
}
// GetConfigFilePath returns the resolved path to the config file used by this client.
func (c *Client) GetConfigFilePath() string {
return getConfigPath(c.clientType, c.configFilePath)
}
func (c *Client) GetServerVersion() string {
return c.serverVersion
}
// GetConfigVersion returns the latest config version received from server
func (c *Client) GetConfigVersion() int64 {
c.configVersionMux.RLock()
defer c.configVersionMux.RUnlock()
return c.configVersion
}
// setConfigVersion updates the config version
func (c *Client) setConfigVersion(version int64) {
c.configVersionMux.Lock()
defer c.configVersionMux.Unlock()
c.configVersion = version
}
// Connect establishes the WebSocket connection
func (c *Client) Connect() error {
go c.connectWithRetry()
@@ -276,17 +235,13 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter
stopChan := make(chan struct{})
go func() {
count := 0
maxAttempts := 16
maxAttempts := 10
c.reconnectMux.RLock()
connected := c.isConnected
c.reconnectMux.RUnlock()
err := c.SendMessage(messageType, data) // Send immediately
if err != nil {
logger.Error("Failed to send initial message: %v", err)
} else if connected {
count++
}
count++
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -297,15 +252,11 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter
logger.Info("SendMessageInterval timed out after %d attempts for message type: %s", maxAttempts, messageType)
return
}
c.reconnectMux.RLock()
connected = c.isConnected
c.reconnectMux.RUnlock()
err = c.SendMessage(messageType, data)
if err != nil {
logger.Error("Failed to send message: %v", err)
} else if connected {
count++
}
count++
case <-stopChan:
return
}
@@ -512,11 +463,6 @@ func (c *Client) connectWithRetry() {
func (c *Client) establishConnection() error {
ctx := context.Background()
// Exchange provisioning key for permanent credentials if needed.
if err := c.provisionIfNeeded(); err != nil {
return fmt.Errorf("failed to provision newt credentials: %w", err)
}
// Get token for authentication
token, err := c.getToken()
if err != nil {
@@ -695,61 +641,7 @@ func (c *Client) setupPKCS12TLS() (*tls.Config, error) {
}
// pingMonitor sends pings at a short interval and triggers reconnect on failure
func (c *Client) sendPing() {
if c.conn == nil {
return
}
// Skip ping if a message is currently being processed
c.processingMux.RLock()
isProcessing := c.processingMessage
c.processingMux.RUnlock()
if isProcessing {
logger.Debug("Skipping ping, message is being processed")
return
}
c.configVersionMux.RLock()
configVersion := c.configVersion
c.configVersionMux.RUnlock()
pingMsg := WSMessage{
Type: "newt/ping",
Data: map[string]interface{}{},
ConfigVersion: configVersion,
}
c.writeMux.Lock()
if c.conn == nil {
c.writeMux.Unlock()
return
}
err := c.conn.WriteJSON(pingMsg)
if err == nil {
telemetry.IncWSMessage(c.metricsContext(), "out", "ping")
}
c.writeMux.Unlock()
if err != nil {
// Check if we're shutting down before logging error and reconnecting
select {
case <-c.done:
// Expected during shutdown
return
default:
logger.Error("Ping failed: %v", err)
telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write")
telemetry.IncWSReconnect(c.metricsContext(), "ping_write")
c.reconnect()
return
}
}
}
func (c *Client) pingMonitor() {
// Send an immediate ping as soon as we connect
c.sendPing()
ticker := time.NewTicker(c.pingInterval)
defer ticker.Stop()
@@ -758,7 +650,29 @@ func (c *Client) pingMonitor() {
case <-c.done:
return
case <-ticker.C:
c.sendPing()
if c.conn == nil {
return
}
c.writeMux.Lock()
err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(c.pingTimeout))
if err == nil {
telemetry.IncWSMessage(c.metricsContext(), "out", "ping")
}
c.writeMux.Unlock()
if err != nil {
// Check if we're shutting down before logging error and reconnecting
select {
case <-c.done:
// Expected during shutdown
return
default:
logger.Error("Ping failed: %v", err)
telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write")
telemetry.IncWSReconnect(c.metricsContext(), "ping_write")
c.reconnect()
return
}
}
}
}
}
@@ -795,13 +709,10 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
disconnectResult = "success"
return
default:
msgType, p, err := c.conn.ReadMessage()
var msg WSMessage
err := c.conn.ReadJSON(&msg)
if err == nil {
if msgType == websocket.BinaryMessage {
telemetry.IncWSMessage(c.metricsContext(), "in", "binary")
} else {
telemetry.IncWSMessage(c.metricsContext(), "in", "text")
}
telemetry.IncWSMessage(c.metricsContext(), "in", "text")
}
if err != nil {
// Check if we're shutting down before logging error
@@ -826,47 +737,9 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
}
}
// Update config version from incoming message
var data []byte
if msgType == websocket.BinaryMessage {
gr, err := gzip.NewReader(bytes.NewReader(p))
if err != nil {
logger.Error("WebSocket failed to create gzip reader: %v", err)
continue
}
data, err = io.ReadAll(gr)
gr.Close()
if err != nil {
logger.Error("WebSocket failed to decompress message: %v", err)
continue
}
} else {
data = p
}
var msg WSMessage
if err = json.Unmarshal(data, &msg); err != nil {
logger.Error("WebSocket failed to parse message: %v", err)
continue
}
c.setConfigVersion(msg.ConfigVersion)
c.handlersMux.RLock()
if handler, ok := c.handlers[msg.Type]; ok {
// Mark that we're processing a message
c.processingMux.Lock()
c.processingMessage = true
c.processingMux.Unlock()
c.processingWg.Add(1)
handler(msg)
// Mark that we're done processing
c.processingWg.Done()
c.processingMux.Lock()
c.processingMessage = false
c.processingMux.Unlock()
}
c.handlersMux.RUnlock()
}
@@ -876,12 +749,10 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
func (c *Client) reconnect() {
c.setConnected(false)
telemetry.SetWSConnectionState(false)
c.writeMux.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.writeMux.Unlock()
// Only reconnect if we're not shutting down
select {
@@ -936,19 +807,3 @@ func loadClientCertificate(p12Path string) (*tls.Config, error) {
RootCAs: rootCAs,
}, nil
}
// BuildTLSConfig creates a *tls.Config from the provided certificate files.
// Pass empty strings / nil slice for fields that are not used.
// Returns nil, nil when no TLS credentials are provided.
func BuildTLSConfig(certFile, keyFile string, caFiles []string, pkcs12File string) (*tls.Config, error) {
c := &Client{
tlsConfig: TLSConfig{
ClientCertFile: certFile,
ClientKeyFile: keyFile,
CAFiles: caFiles,
PKCS12File: pkcs12File,
},
config: &Config{},
}
return c.setupTLS()
}

View File

@@ -1,28 +1,16 @@
package websocket
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"log"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/fosrl/newt/logger"
)
func getConfigPath(clientType string, overridePath string) string {
if overridePath != "" {
return overridePath
}
func getConfigPath(clientType string) string {
configFile := os.Getenv("CONFIG_FILE")
if configFile == "" {
var configDir string
@@ -37,7 +25,7 @@ func getConfigPath(clientType string, overridePath string) string {
}
if err := os.MkdirAll(configDir, 0755); err != nil {
logger.Debug("Failed to create config directory: %v", err)
log.Printf("Failed to create config directory: %v", err)
}
return filepath.Join(configDir, "config.json")
@@ -48,7 +36,7 @@ func getConfigPath(clientType string, overridePath string) string {
func (c *Client) loadConfig() error {
originalConfig := *c.config // Store original config to detect changes
configPath := getConfigPath(c.clientType, c.configFilePath)
configPath := getConfigPath(c.clientType)
if c.config.ID != "" && c.config.Secret != "" && c.config.Endpoint != "" {
logger.Debug("Config already provided, skipping loading from file")
@@ -70,11 +58,6 @@ func (c *Client) loadConfig() error {
}
return err
}
if len(bytes.TrimSpace(data)) == 0 {
logger.Info("Config file at %s is empty, will initialize it with provided values", configPath)
c.configNeedsSave = true
return nil
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
@@ -100,14 +83,6 @@ func (c *Client) loadConfig() error {
c.config.Endpoint = config.Endpoint
c.baseURL = config.Endpoint
}
// Always load the provisioning key from the file if not already set
if c.config.ProvisioningKey == "" {
c.config.ProvisioningKey = config.ProvisioningKey
}
// Always load the name from the file if not already set
if c.config.Name == "" {
c.config.Name = config.Name
}
// Check if CLI args provided values that override file values
if (!fileHadID && originalConfig.ID != "") ||
@@ -130,7 +105,7 @@ func (c *Client) saveConfig() error {
return nil
}
configPath := getConfigPath(c.clientType, c.configFilePath)
configPath := getConfigPath(c.clientType)
data, err := json.MarshalIndent(c.config, "", " ")
if err != nil {
return err
@@ -143,139 +118,3 @@ func (c *Client) saveConfig() error {
}
return err
}
// interpolateString replaces {{env.VAR}} tokens in s with the corresponding
// environment variable values. Tokens that do not match a supported scheme are
// left unchanged, mirroring the blueprint interpolation logic.
func interpolateString(s string) string {
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
return re.ReplaceAllStringFunc(s, func(match string) string {
inner := strings.TrimSpace(match[2 : len(match)-2])
if strings.HasPrefix(inner, "env.") {
varName := strings.TrimPrefix(inner, "env.")
return os.Getenv(varName)
}
return match
})
}
// provisionIfNeeded checks whether a provisioning key is present and, if so,
// exchanges it for a newt ID and secret by calling the registration endpoint.
// On success the config is updated in-place and flagged for saving so that
// subsequent runs use the permanent credentials directly.
func (c *Client) provisionIfNeeded() error {
if c.config.ProvisioningKey == "" {
return nil
}
// If we already have both credentials there is nothing to provision.
if c.config.ID != "" && c.config.Secret != "" {
logger.Debug("Credentials already present, skipping provisioning")
return nil
}
logger.Info("Provisioning key found exchanging for newt credentials...")
baseURL, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("failed to parse base URL for provisioning: %w", err)
}
baseEndpoint := strings.TrimRight(baseURL.String(), "/")
// Interpolate any {{env.VAR}} tokens in the name before sending.
name := interpolateString(c.config.Name)
reqBody := map[string]interface{}{
"provisioningKey": c.config.ProvisioningKey,
}
if name != "" {
reqBody["name"] = name
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal provisioning request: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(
ctx,
"POST",
baseEndpoint+"/api/v1/auth/newt/register",
bytes.NewBuffer(jsonData),
)
if err != nil {
return fmt.Errorf("failed to create provisioning request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "x-csrf-protection")
// Mirror the TLS setup used by getToken so mTLS / self-signed CAs work.
var tlsCfg *tls.Config
if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" ||
len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" {
tlsCfg, err = c.setupTLS()
if err != nil {
return fmt.Errorf("failed to setup TLS for provisioning: %w", err)
}
}
if os.Getenv("SKIP_TLS_VERIFY") == "true" {
if tlsCfg == nil {
tlsCfg = &tls.Config{}
}
tlsCfg.InsecureSkipVerify = true
logger.Debug("TLS certificate verification disabled for provisioning via SKIP_TLS_VERIFY")
}
httpClient := &http.Client{}
if tlsCfg != nil {
httpClient.Transport = &http.Transport{TLSClientConfig: tlsCfg}
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("provisioning request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
logger.Debug("Provisioning response body: %s", string(body))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("provisioning endpoint returned status %d: %s", resp.StatusCode, string(body))
}
var provResp ProvisioningResponse
if err := json.Unmarshal(body, &provResp); err != nil {
return fmt.Errorf("failed to decode provisioning response: %w", err)
}
if !provResp.Success {
return fmt.Errorf("provisioning failed: %s", provResp.Message)
}
if provResp.Data.NewtID == "" || provResp.Data.Secret == "" {
return fmt.Errorf("provisioning response is missing newt ID or secret")
}
logger.Info("Successfully provisioned newt ID: %s", provResp.Data.NewtID)
// Persist the returned credentials and clear the one-time provisioning key
// so subsequent runs authenticate normally.
c.config.ID = provResp.Data.NewtID
c.config.Secret = provResp.Data.Secret
c.config.ProvisioningKey = ""
c.config.Name = ""
c.configNeedsSave = true
c.justProvisioned = true
// Save immediately so that if the subsequent connection attempt fails the
// provisioning key is already gone from disk and the next retry uses the
// permanent credentials instead of trying to provision again.
if err := c.saveConfig(); err != nil {
logger.Error("Failed to save config after provisioning: %v", err)
}
return nil
}

View File

@@ -1,35 +0,0 @@
package websocket
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) {
t.Setenv("CONFIG_FILE", "")
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
if err := os.WriteFile(configPath, []byte(""), 0o644); err != nil {
t.Fatalf("failed to create empty config file: %v", err)
}
client := &Client{
config: &Config{
Endpoint: "https://example.com",
ProvisioningKey: "spk-test",
},
clientType: "newt",
configFilePath: configPath,
}
if err := client.loadConfig(); err != nil {
t.Fatalf("loadConfig returned error for empty file: %v", err)
}
if !client.configNeedsSave {
t.Fatal("expected empty config file to mark configNeedsSave")
}
}

View File

@@ -1,13 +1,10 @@
package websocket
type Config struct {
ID string `json:"id"`
Secret string `json:"secret"`
Endpoint string `json:"endpoint"`
TlsClientCert string `json:"tlsClientCert"`
ProvisioningKey string `json:"provisioningKey,omitempty"`
Name string `json:"name,omitempty"`
Blocked bool `json:"blocked,omitempty"`
ID string `json:"id"`
Secret string `json:"secret"`
Endpoint string `json:"endpoint"`
TlsClientCert string `json:"tlsClientCert"`
}
type TokenResponse struct {
@@ -19,17 +16,7 @@ type TokenResponse struct {
Message string `json:"message"`
}
type ProvisioningResponse struct {
Data struct {
NewtID string `json:"newtId"`
Secret string `json:"secret"`
} `json:"data"`
Success bool `json:"success"`
Message string `json:"message"`
}
type WSMessage struct {
Type string `json:"type"`
Data interface{} `json:"data"`
ConfigVersion int64 `json:"configVersion,omitempty"`
Type string `json:"type"`
Data interface{} `json:"data"`
}