diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml index 41dbe7b..c945608 100644 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -14,12 +14,13 @@ body: label: Environment description: Please fill out the relevant details below for your environment. value: | - - OS Type & Version: (e.g., Ubuntu 22.04) + - OS Type & Version: - Pangolin Version: + - Edition (Community or Enterprise): - Gerbil Version: - Traefik Version: - Newt Version: - - Olm Version: (if applicable) + - Client Version: validations: required: true diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e48254c..6852a3d 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -897,6 +897,20 @@ jobs: set -euo pipefail make -j 10 go-build-release VERSION="${TAG}" + - name: Build Advantech packages + shell: bash + run: | + set -euo pipefail + ADVANTECH_DIR="packages/advantech" + + mkdir -p "${ADVANTECH_DIR}/bin" + install -m 0755 "bin/newt_linux_arm64" "${ADVANTECH_DIR}/bin/newt_linux_arm64" + install -m 0755 "bin/newt_linux_arm32" "${ADVANTECH_DIR}/bin/newt_linux_arm32" + + for platform in v2 v2i v3 v4 v4i; do + make -C "${ADVANTECH_DIR}" PLATFORM="${platform}" + done + - name: Create GitHub Release (draft) uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: @@ -905,6 +919,7 @@ jobs: prerelease: ${{ env.IS_RC == 'true' }} files: | bin/* + packages/advantech/*.tgz fail_on_unmatched_files: true draft: true body: | diff --git a/Dockerfile b/Dockerfile index ea870c2..10309d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,10 @@ 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 diff --git a/Makefile b/Makefile index 759656f..5595dac 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ VERSION ?= dev LDFLAGS = -X main.newtVersion=$(VERSION) local: - CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o ./bin/newt + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=$(shell go env GOOS)_$(shell go env GOARCH)" -o ./bin/newt test: go test ./... @@ -46,31 +46,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)" -o bin/newt_linux_arm64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm64" -o bin/newt_linux_arm64 go-build-release-linux-arm32-v7: - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32" -o bin/newt_linux_arm32 go-build-release-linux-arm32-v6: - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32v6 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32v6" -o bin/newt_linux_arm32v6 go-build-release-linux-amd64: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_amd64" -o bin/newt_linux_amd64 go-build-release-linux-riscv64: - CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_riscv64 + CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_riscv64" -o bin/newt_linux_riscv64 go-build-release-darwin-arm64: - CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_arm64 + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_arm64" -o bin/newt_darwin_arm64 go-build-release-darwin-amd64: - CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_amd64 + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_amd64" -o bin/newt_darwin_amd64 go-build-release-windows-amd64: - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_windows_amd64.exe + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=windows_amd64" -o bin/newt_windows_amd64.exe go-build-release-freebsd-amd64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_amd64 + CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_amd64" -o bin/newt_freebsd_amd64 go-build-release-freebsd-arm64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64 + CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_arm64" -o bin/newt_freebsd_arm64 diff --git a/authdaemon/connection.go b/authdaemon/connection.go index 3fb44c5..82ddae6 100644 --- a/authdaemon/connection.go +++ b/authdaemon/connection.go @@ -19,7 +19,7 @@ func (s *Server) ProcessConnection(req ConnectionRequest) { if err := ensureUser(req.Username, req.Metadata, s.cfg.GenerateRandomPassword); err != nil { logger.Warn("auth-daemon: ensure user: %v", err) } - if cfg.PrincipalsFilePath != "" { + if cfg.PrincipalsFilePath != "" && req.NiceId != "" { if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil { logger.Warn("auth-daemon: write principals: %v", err) } diff --git a/authdaemon/routes.go b/authdaemon/routes.go index 8457c60..3d1be3e 100644 --- a/authdaemon/routes.go +++ b/authdaemon/routes.go @@ -14,9 +14,9 @@ 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" + SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands" Homedir bool `json:"homedir"` - Groups []string `json:"groups"` // system groups to add the user to + Groups []string `json:"groups"` // system groups to add the user to } // ConnectionRequest is the JSON body for POST /connection. diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go new file mode 100644 index 0000000..df54b29 --- /dev/null +++ b/browsergateway/browsergateway.go @@ -0,0 +1,144 @@ +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) +} diff --git a/browsergateway/rdcleanpath.go b/browsergateway/rdcleanpath.go new file mode 100644 index 0000000..28dd486 --- /dev/null +++ b/browsergateway/rdcleanpath.go @@ -0,0 +1,88 @@ +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 +} diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go new file mode 100644 index 0000000..1df5a13 --- /dev/null +++ b/browsergateway/rdp.go @@ -0,0 +1,271 @@ +package browsergateway + +import ( + "context" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "strconv" + "time" + + "github.com/coder/websocket" +) + +// 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 { + log.Printf("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 { + log.Printf("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) + } + + log.Printf("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) + } + log.Printf("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) + } + + log.Printf("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 { + log.Printf("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" + } + log.Printf("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 + log.Printf("Server returned RDP negotiation failure code 0x%x", code) + default: + log.Printf("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 +} diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go new file mode 100644 index 0000000..06ca9a0 --- /dev/null +++ b/browsergateway/ssh.go @@ -0,0 +1,259 @@ +package browsergateway + +import ( + "context" + "encoding/json" + "fmt" + "log" + "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 { + log.Printf("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 { + log.Printf("SSH native session error: %v", err) + } + } else { + if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil { + log.Printf("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 + + // Build the list of auth methods. Private key takes priority when provided. + 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) + } + 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") + } + log.Printf("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) + } + + log.Printf("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) { + log.Printf("SSH error: %s", msg) + b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg}) + _ = ws.Write(ctx, websocket.MessageText, b) +} diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go new file mode 100644 index 0000000..87adf92 --- /dev/null +++ b/browsergateway/ssh_native.go @@ -0,0 +1,94 @@ +//go:build !windows + +package browsergateway + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/coder/websocket" + "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) + } + + log.Printf("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 +} diff --git a/browsergateway/ssh_native_windows.go b/browsergateway/ssh_native_windows.go new file mode 100644 index 0000000..3caff0a --- /dev/null +++ b/browsergateway/ssh_native_windows.go @@ -0,0 +1,16 @@ +//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") +} diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go new file mode 100644 index 0000000..5d917a4 --- /dev/null +++ b/browsergateway/vnc.go @@ -0,0 +1,98 @@ +package browsergateway + +import ( + "context" + "io" + "log" + "net" + "net/http" + "strconv" + "time" + + "github.com/coder/websocket" +) + +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) + + // 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 { + log.Printf("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 { + log.Printf("vnc: session error (%s): %v", target, err) + } +} + +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 +} diff --git a/clients.go b/clients.go index d650eeb..de96f2c 100644 --- a/clients.go +++ b/clients.go @@ -7,6 +7,7 @@ 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" ) @@ -14,7 +15,7 @@ import ( var wgService *clients.WireGuardService var ready bool -func setupClients(client *websocket.Client) { +func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) { var host = endpoint if strings.HasPrefix(host, "http://") { host = strings.TrimPrefix(host, "http://") @@ -42,6 +43,8 @@ func setupClients(client *websocket.Client) { logger.Fatal("Failed to create WireGuard service: %v", err) } + wgService.SetCredentialStore(credStore) + client.OnTokenUpdate(func(token string) { wgService.SetToken(token) }) @@ -63,6 +66,13 @@ 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 diff --git a/clients/clients.go b/clients/clients.go index 3862160..4568e09 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -13,12 +13,14 @@ 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" @@ -108,12 +110,18 @@ 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. @@ -193,6 +201,13 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string 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 == "" { @@ -211,6 +226,12 @@ 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 { @@ -286,6 +307,21 @@ 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 @@ -890,7 +926,25 @@ 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 } @@ -1563,3 +1617,33 @@ 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 +} diff --git a/flake.nix b/flake.nix index 3657fc3..488f75d 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-M3MjtU4t0iGskNZhAdN3RKny8TOZbiuljK4HThShfXs="; + vendorHash = "sha256-X70emc3uPN2YpsbudtoeEZDHilaTvFMqfRaDmIgRVZE="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; diff --git a/go.mod b/go.mod index 60b2447..f9e1603 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,11 @@ module github.com/fosrl/newt go 1.25.0 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/gorilla/websocket v1.5.3 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 diff --git a/go.sum b/go.sum index 1ce4da1..c73ea67 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,14 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x 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/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/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= @@ -22,6 +26,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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= @@ -119,6 +127,8 @@ 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/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/main.go b/main.go index 2a5bdbf..15209d1 100644 --- a/main.go +++ b/main.go @@ -18,13 +18,16 @@ import ( "os/signal" "strconv" "strings" + "sync/atomic" "syscall" "time" "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/browsergateway" "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -40,15 +43,24 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +type BrowserGatewayTarget struct { + ID int `json:"id"` + Type string `json:"type"` + Destination string `json:"destination"` + DestinationPort int `json:"destinationPort"` + AuthToken string `json:"authToken"` +} + type WgData struct { - Endpoint string `json:"endpoint"` - RelayPort uint16 `json:"relayPort"` - PublicKey string `json:"publicKey"` - ServerIP string `json:"serverIP"` - TunnelIP string `json:"tunnelIP"` - Targets TargetsByType `json:"targets"` - HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` - ChainId string `json:"chainId"` + Endpoint string `json:"endpoint"` + RelayPort uint16 `json:"relayPort"` + PublicKey string `json:"publicKey"` + ServerIP string `json:"serverIP"` + TunnelIP string `json:"tunnelIP"` + Targets TargetsByType `json:"targets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` + ChainId string `json:"chainId"` } type TargetsByType struct { @@ -123,6 +135,7 @@ var ( port uint16 portStr string disableClients bool + disableSSH bool updownScript string dockerSocket string dockerEnforceNetworkValidation string @@ -134,6 +147,8 @@ var ( pingStopChan chan struct{} stopFunc func() pendingRegisterChainId string + browserGateway *browsergateway.Gateway + browserGatewayStop func() pendingPingChainId string healthFile string useNativeInterface bool @@ -144,21 +159,21 @@ var ( authDaemonKey string authDaemonPrincipalsFile string authDaemonCACertPath string - authDaemonEnabled bool authDaemonGenerateRandomPassword bool // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") - newtVersion = "version_replaceme" + newtVersion = "version_replaceme" + newtPlatform = "" // embedded at build time via -X main.newtPlatform=_ // Observability/metrics flags - metricsEnabled bool - otlpEnabled bool - adminAddr string - region string - metricsAsyncBytes bool - pprofEnabled bool - blueprintFile string - provisioningBlueprintFile string - noCloud bool + metricsEnabled bool + otlpEnabled bool + adminAddr string + region string + metricsAsyncBytes bool + pprofEnabled bool + blueprintFile string + provisioningBlueprintFile string + noCloud bool // New mTLS configuration variables tlsClientCert string @@ -242,7 +257,6 @@ func runNewtMain(ctx context.Context) { authDaemonKey = os.Getenv("AD_KEY") authDaemonPrincipalsFile = os.Getenv("AD_PRINCIPALS_FILE") authDaemonCACertPath = os.Getenv("AD_CA_CERT_PATH") - authDaemonEnabledEnv := os.Getenv("AUTH_DAEMON_ENABLED") authDaemonGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD") // Metrics/observability env mirrors @@ -255,6 +269,8 @@ func runNewtMain(ctx context.Context) { disableClientsEnv := os.Getenv("DISABLE_CLIENTS") disableClients = disableClientsEnv == "true" + disableSSHEnv := os.Getenv("DISABLE_SSH") + disableSSH = disableSSHEnv == "true" useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE") useNativeInterface = useNativeInterfaceEnv == "true" enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT") @@ -327,6 +343,9 @@ func runNewtMain(ctx context.Context) { if disableClientsEnv == "" { flag.BoolVar(&disableClients, "disable-clients", false, "Disable clients on the WireGuard interface") } + if disableSSHEnv == "" { + flag.BoolVar(&disableSSH, "disable-ssh", false, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)") + } if enforceHealthcheckCertEnv == "" { flag.BoolVar(&enforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)") } @@ -361,6 +380,8 @@ func runNewtMain(ctx context.Context) { if tlsClientKey == "" { flag.StringVar(&tlsClientKey, "tls-client-key", "", "Path to client private key file (PEM/DER format)") } + // add a dummy input for --auth-daemon but ignore it since the auth daemon is always enabled now and this is just for backward compatibility with older versions + flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)") // Handle multiple CA files var tlsClientCAsFlag stringSlice @@ -472,13 +493,7 @@ func runNewtMain(ctx context.Context) { if authDaemonCACertPath == "" { flag.StringVar(&authDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon") } - if authDaemonEnabledEnv == "" { - flag.BoolVar(&authDaemonEnabled, "auth-daemon", false, "Enable auth daemon mode (runs alongside normal newt operation)") - } else { - if v, err := strconv.ParseBool(authDaemonEnabledEnv); err == nil { - authDaemonEnabled = v - } - } + if authDaemonGenerateRandomPasswordEnv == "" { flag.BoolVar(&authDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users") } else { @@ -517,11 +532,12 @@ func runNewtMain(ctx context.Context) { loggerLevel := util.ParseLogLevel(logLevel) // Start auth daemon if enabled - if authDaemonEnabled { + if !disableSSH { if err := startAuthDaemon(ctx); err != nil { - logger.Fatal("Failed to start auth daemon: %v", err) + logger.Warn("Did not start on site auth daemon: %v", err) } } + logger.GetLogger().SetLevel(loggerLevel) // Initialize telemetry after flags are parsed (so flags override env) @@ -656,9 +672,44 @@ func runNewtMain(ctx context.Context) { endpoint = client.GetConfig().Endpoint // Update endpoint from config id = client.GetConfig().ID // Update ID from config + secret = client.GetConfig().Secret // Update secret from config // Update site labels for metrics with the resolved ID telemetry.UpdateSiteInfo(id, region) + var tlsCfg *tls.Config + if opt != nil { + // Reuse the TLS configuration already set up for the websocket client. + tlsCfg, _ = websocket.BuildTLSConfig(tlsClientCert, tlsClientKey, tlsClientCAs, tlsPrivateKey) + } + doUpdate := func() { + logger.Debug("checkAndSelfUpdate: running periodic update check") + if err := updates.CheckAndSelfUpdate(updates.SelfUpdateConfig{ + Endpoint: endpoint, + NewtID: id, + Secret: secret, + CurrentVersion: newtVersion, + Platform: newtPlatform, + TLSConfig: tlsCfg, + }); err != nil { + logger.Error("Auto-update check failed: %v", err) + } + } + go func() { + // wait for 2 minutes after startup before the first check to avoid interfering with initial provisioning and registration + time.Sleep(2 * time.Minute) + doUpdate() // run once at startup + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + doUpdate() + case <-ctx.Done(): + return + } + } + }() + // output env var values if set logger.Debug("Endpoint: %v", endpoint) logger.Debug("Log Level: %v", logLevel) @@ -700,9 +751,25 @@ func runNewtMain(ctx context.Context) { var connected bool var wgData WgData var dockerEventMonitor *docker.EventMonitor + var connectionBlocked atomic.Bool + var currentPM atomic.Pointer[proxy.ProxyManager] + + // In-memory SSH credentials shared with the native SSH server started in + // the clients netstack once the WireGuard interface is ready. + var sshCredStore *nativessh.CredentialStore + if !disableSSH { + sshCredStore = nativessh.NewCredentialStore() + } if !disableClients { - setupClients(client) + setupClients(client, sshCredStore) + } + + // Initialize connection blocked state from config + connectionBlocked.Store(client.GetConfig().Blocked) + if connectionBlocked.Load() { + logger.Info("Connection blocking is enabled (from config)") + setClientsBlocked(true) } // Initialize health check monitor with status change callback @@ -741,9 +808,17 @@ func runNewtMain(ctx context.Context) { pingStopChan = nil } + // Shutdown browser gateway if running + if browserGatewayStop != nil { + browserGatewayStop() + browserGatewayStop = nil + browserGateway = nil + } + // Stop proxy manager if running if pm != nil { pm.Stop() + currentPM.Store(nil) pm = nil } @@ -914,6 +989,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( pm.SetUDPIdleTimeout(udpProxyIdleTimeout) // Set tunnel_id for metrics (WireGuard peer public key) pm.SetTunnelID(wgData.PublicKey) + pm.SetBlocked(connectionBlocked.Load()) + currentPM.Store(pm) connected = true @@ -947,6 +1024,42 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( if err != nil { logger.Error("Failed to start proxy manager: %v", err) } + + // Start browser gateway if targets are present + if len(wgData.BrowserGatewayTargets) > 0 { + // Shutdown any existing gateway first + if browserGatewayStop != nil { + browserGatewayStop() + browserGatewayStop = nil + } + + bgTargets := make([]browsergateway.Target, 0, len(wgData.BrowserGatewayTargets)) + for _, t := range wgData.BrowserGatewayTargets { + bgTargets = append(bgTargets, browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, + }) + } + + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) + browserGateway.SetTargets(bgTargets) + + ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + } else { + browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := browserGateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + } + } }) client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) { @@ -983,6 +1096,19 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( logger.Info("Tunnel destroyed, ready for reconnection") }) + client.RegisterHandler("newt/wg/restart", func(msg websocket.WSMessage) { + closeWgTunnel() + closeClients() + if healthMonitor != nil { + healthMonitor.Stop() + } + client.Close() + if err := reexec(); err != nil { + logger.Error("Failed to restart: %v", err) + os.Exit(1) + } + }) + client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) { logger.Info("Received termination message") if wgData.PublicKey != "" { @@ -1650,14 +1776,14 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Define the structure of the incoming message type SSHCertData struct { - MessageId int `json:"messageId"` - AgentPort int `json:"agentPort"` - AgentHost string `json:"agentHost"` - ExternalAuthDaemon bool `json:"externalAuthDaemon"` - CACert string `json:"caCert"` - Username string `json:"username"` - NiceID string `json:"niceId"` - Metadata struct { + MessageId int `json:"messageId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native + CACert string `json:"caCert"` + Username string `json:"username"` + NiceID string `json:"niceId"` + Metadata struct { SudoMode string `json:"sudoMode"` SudoCommands []string `json:"sudoCommands"` Homedir bool `json:"homedir"` @@ -1680,31 +1806,45 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } - // Check if we're running the auth daemon internally - if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled + // Use a switch statement for AuthDaemonMode + switch certData.AuthDaemonMode { + case "site": // Call ProcessConnection directly when running internally logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) - authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ - CaCert: certData.CACert, - NiceId: certData.NiceID, - Username: certData.Username, - Metadata: authdaemon.ConnectionMetadata{ - SudoMode: certData.Metadata.SudoMode, - SudoCommands: certData.Metadata.SudoCommands, - Homedir: certData.Metadata.Homedir, - Groups: certData.Metadata.Groups, - }, - }) - - // Send success response back to cloud - err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - }) + if authDaemonServer != nil { + authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: certData.CACert, + NiceId: certData.NiceID, + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, + }, + }) + // Send success response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }) + } else { + logger.Error("Auth daemon server is not initialized, cannot process connection") + // Send failure response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon server not initialized", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) - } else { + case "remote": // External auth daemon mode - make HTTP request // Check if auth daemon key is configured if authDaemonKey == "" { @@ -1801,6 +1941,48 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) + case "native": + logger.Debug("Processing SSH cert for native SSH server for user %s", certData.Username) + if authDaemonServer != nil && sshCredStore != nil { + authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: "", // dont write the cert to the host + NiceId: "", // dont write the cert to the host + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ // but push the user + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, + }, + }) + + // Update in-memory credentials used by the native SSH server. + if err := sshCredStore.SetCAKey(certData.CACert); err != nil { + logger.Error("nativessh: failed to set CA key: %v", err) + } + sshCredStore.AddPrincipals(certData.Username, certData.NiceID) + logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) + } else { + logger.Error("Auth daemon server or SSH credential store not initialized, cannot process connection") + // Send failure response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon server or SSH credential store not initialized", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } + default: + logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("unknown auth daemon mode: %s", certData.AuthDaemonMode), + }) + return } // Send success response back to cloud @@ -1813,6 +1995,94 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } }) + // Register handler for adding browser gateway targets dynamically + client.RegisterHandler("newt/browsergateway/add", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway add message") + + type BrowserGatewayAddData struct { + Targets []BrowserGatewayTarget `json:"targets"` + } + + var addData BrowserGatewayAddData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway add data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &addData); err != nil { + logger.Error("Error unmarshaling browser gateway add data: %v", err) + return + } + + if len(addData.Targets) == 0 { + return + } + + // If the gateway doesn't exist yet but we have a tunnel, start it + if browserGateway == nil && tnet != nil { + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) + ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + browserGateway = nil + } else { + browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := browserGateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + } + } + + if browserGateway == nil { + logger.Warn("Browser gateway not available, cannot add targets") + return + } + + for _, t := range addData.Targets { + browserGateway.AddTarget(browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, + }) + logger.Debug("Added browser gateway target %d", t.ID) + } + }) + + // Register handler for removing browser gateway targets dynamically + client.RegisterHandler("newt/browsergateway/remove", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway remove message") + + type BrowserGatewayRemoveData struct { + IDs []int `json:"ids"` + } + + var removeData BrowserGatewayRemoveData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway remove data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &removeData); err != nil { + logger.Error("Error unmarshaling browser gateway remove data: %v", err) + return + } + + if browserGateway == nil { + logger.Warn("Browser gateway not available, cannot remove targets") + return + } + + for _, id := range removeData.IDs { + browserGateway.RemoveTarget(id) + logger.Debug("Removed browser gateway target %d", id) + } + }) + client.OnConnect(func() error { publicKey = privateKey.PublicKey() logger.Debug("Public key: %s", publicKey) @@ -1837,7 +2107,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } else { logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT") } - + sendBlueprint(client, blueprintFile) if client.WasJustProvisioned() { logger.Info("Provisioning detected – sending provisioning blueprint") @@ -1885,6 +2155,62 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return nil }) + // Handle SIGHUP for config reload + sighupChan := make(chan os.Signal, 1) + signal.Notify(sighupChan, syscall.SIGHUP) + go func() { + defer signal.Stop(sighupChan) + for { + select { + case <-sighupChan: + logger.Info("SIGHUP received, reloading config...") + cfgPath := client.GetConfigFilePath() + data, err := os.ReadFile(cfgPath) + if err != nil { + logger.Error("Failed to read config file on SIGHUP: %v", err) + continue + } + var newCfg websocket.Config + if err := json.Unmarshal(data, &newCfg); err != nil { + logger.Error("Failed to parse config file on SIGHUP: %v", err) + continue + } + oldCfg := client.GetConfig() + // If credentials changed, clean up and re-exec ourselves with the same args + if newCfg.Endpoint != oldCfg.Endpoint || newCfg.ID != oldCfg.ID || newCfg.Secret != oldCfg.Secret { + logger.Info("Config credentials changed (endpoint/id/secret), restarting...") + closeWgTunnel() + closeClients() + if healthMonitor != nil { + healthMonitor.Stop() + } + client.Close() + if err := reexec(); err != nil { + logger.Error("Failed to restart: %v", err) + os.Exit(1) + } + } + // If blocked state changed, apply in-place without restart + if newCfg.Blocked != connectionBlocked.Load() { + connectionBlocked.Store(newCfg.Blocked) + if newCfg.Blocked { + logger.Debug("Config reload: connection blocking enabled") + } else { + logger.Debug("Config reload: connection blocking disabled") + } + if p := currentPM.Load(); p != nil { + p.SetBlocked(newCfg.Blocked) + } + setClientsBlocked(newCfg.Blocked) + } else { + logger.Debug("Config reload: no relevant changes detected") + } + case <-ctx.Done(): + return + } + } + }() + // Connect to the WebSocket server if err := client.Connect(); err != nil { logger.Fatal("Failed to connect to server: %v", err) diff --git a/nativessh/auth.go b/nativessh/auth.go new file mode 100644 index 0000000..9f9289b --- /dev/null +++ b/nativessh/auth.go @@ -0,0 +1,158 @@ +package nativessh + +import ( + "bufio" + "fmt" + "log" + "net" + "os" + "os/user" + "path/filepath" + "strings" + + "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 { + log.Printf("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "") + if !SystemUserExists(username) { + log.Printf("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 { + log.Printf("nativessh: failed to parse private key for %q: %v", username, err) + } else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) { + log.Printf("nativessh: private key auth succeeded for %q", username) + return nil + } else { + signer = parsedSigner + log.Printf("nativessh: private key not in authorized_keys for %q", username) + } + } + + if store != nil && certificate != "" { + if signer == nil { + log.Printf("nativessh: certificate provided for %q but no matching private key was provided", username) + } else { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate)) + if err != nil { + log.Printf("nativessh: failed to parse certificate for %q: %v", username, err) + } else { + cert, ok := pub.(*ssh.Certificate) + if !ok { + log.Printf("nativessh: provided cert data for %q is not an SSH certificate", username) + } else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) { + log.Printf("nativessh: certificate key mismatch for %q", username) + } else { + caKey, userPrincipals := store.get(username) + if caKey == nil { + log.Printf("nativessh: CA key is not set for certificate auth user %q", username) + } else if len(userPrincipals) == 0 { + log.Printf("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 { + log.Printf("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal) + return nil + } + lastErr = authErr + } + if lastErr != nil { + log.Printf("nativessh: certificate auth failed for %q: %v", username, lastErr) + } + } + } + } + } + } + + if password != "" { + if err := VerifySystemPassword(username, password); err != nil { + log.Printf("nativessh: password auth failed for %q: %v", username, err) + } else { + log.Printf("nativessh: password auth succeeded for %q", username) + return nil + } + } else { + log.Printf("nativessh: no password provided for %q", username) + } + return fmt.Errorf("authentication failed for user %q", username) +} diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go new file mode 100644 index 0000000..f33828b --- /dev/null +++ b/nativessh/pam_linux.go @@ -0,0 +1,99 @@ +//go:build linux + +package nativessh + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "log" + "os" + "strings" + + "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 { + log.Printf("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 + } + } + log.Printf("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 { + log.Printf("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err) + return fmt.Errorf("yescrypt: %w", err) + } + if !bytes.Equal(computed, []byte(hash)) { + log.Printf("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 { + log.Printf("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 { + log.Printf("nativessh/pam: MatchAdvanced for %q failed: %v", username, err) + return err + } + if !match { + log.Printf("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") +} diff --git a/nativessh/pam_other.go b/nativessh/pam_other.go new file mode 100644 index 0000000..624c847 --- /dev/null +++ b/nativessh/pam_other.go @@ -0,0 +1,11 @@ +//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") +} diff --git a/nativessh/pty.go b/nativessh/pty.go new file mode 100644 index 0000000..42cdd05 --- /dev/null +++ b/nativessh/pty.go @@ -0,0 +1,130 @@ +//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 +} diff --git a/nativessh/pty_unix.go b/nativessh/pty_unix.go new file mode 100644 index 0000000..1203535 --- /dev/null +++ b/nativessh/pty_unix.go @@ -0,0 +1,78 @@ +//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 +} diff --git a/nativessh/server.go b/nativessh/server.go new file mode 100644 index 0000000..20a13d9 --- /dev/null +++ b/nativessh/server.go @@ -0,0 +1,363 @@ +//go:build !windows + +package nativessh + +import ( + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "log" + "net" + "strings" + "sync" + + "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 + } + log.Printf("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 { + log.Printf("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err) + return + } + defer sshConn.Close() + log.Printf("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 { + log.Printf("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 { + log.Printf("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 "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 + } +} + +// 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) { + log.Printf("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 { + log.Printf("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal) + return perms, nil + } + lastErr = err + } + + if lastErr != nil { + log.Printf("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. + log.Printf("nativessh: password auth failed for user %q: %v", meta.User(), err) + return nil, fmt.Errorf("permission denied") + } + log.Printf("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) + } + log.Printf("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) +} diff --git a/nativessh/server_windows.go b/nativessh/server_windows.go new file mode 100644 index 0000000..3394e13 --- /dev/null +++ b/nativessh/server_windows.go @@ -0,0 +1,64 @@ +//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") +} diff --git a/netstack2/handlers.go b/netstack2/handlers.go index e28a543..e6ea62e 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( @@ -144,6 +139,13 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo 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 @@ -315,6 +317,12 @@ 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 { diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index ba0495f..674412f 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 95fab6a..00d6763 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "sync" + "sync/atomic" "time" "github.com/fosrl/newt/logger" @@ -134,6 +135,7 @@ type ProxyHandler struct { 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 @@ -240,6 +242,28 @@ func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) { 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() +} + // RemoveSubnetRule removes a subnet from the proxy handler func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) { if p == nil || !p.enabled { @@ -612,7 +636,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 } diff --git a/netstack2/tun.go b/netstack2/tun.go index fae90dd..d104f2e 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( diff --git a/newt b/newt new file mode 100755 index 0000000..34062da Binary files /dev/null and b/newt differ diff --git a/packages/advantech/.gitignore b/packages/advantech/.gitignore new file mode 100644 index 0000000..0377a32 --- /dev/null +++ b/packages/advantech/.gitignore @@ -0,0 +1,3 @@ +.build +*.tgz +bin \ No newline at end of file diff --git a/packages/advantech/Makefile b/packages/advantech/Makefile new file mode 100644 index 0000000..9598e62 --- /dev/null +++ b/packages/advantech/Makefile @@ -0,0 +1,54 @@ +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." diff --git a/packages/advantech/merge/etc/defaults b/packages/advantech/merge/etc/defaults new file mode 100644 index 0000000..e566be4 --- /dev/null +++ b/packages/advantech/merge/etc/defaults @@ -0,0 +1,61 @@ +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= diff --git a/packages/advantech/merge/etc/description b/packages/advantech/merge/etc/description new file mode 100644 index 0000000..04f09a4 --- /dev/null +++ b/packages/advantech/merge/etc/description @@ -0,0 +1,4 @@ +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. diff --git a/packages/advantech/merge/etc/init b/packages/advantech/merge/etc/init new file mode 100644 index 0000000..cf90ae0 --- /dev/null +++ b/packages/advantech/merge/etc/init @@ -0,0 +1,124 @@ +#!/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 diff --git a/packages/advantech/merge/etc/install b/packages/advantech/merge/etc/install new file mode 100644 index 0000000..f83fb05 --- /dev/null +++ b/packages/advantech/merge/etc/install @@ -0,0 +1,15 @@ +#!/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 diff --git a/packages/advantech/merge/etc/name b/packages/advantech/merge/etc/name new file mode 100644 index 0000000..0656222 --- /dev/null +++ b/packages/advantech/merge/etc/name @@ -0,0 +1 @@ +Pangolin Site diff --git a/packages/advantech/merge/etc/summary b/packages/advantech/merge/etc/summary new file mode 100644 index 0000000..64f982c --- /dev/null +++ b/packages/advantech/merge/etc/summary @@ -0,0 +1 @@ +Tunnel client for Pangolin secure remote access. diff --git a/packages/advantech/merge/etc/uninstall b/packages/advantech/merge/etc/uninstall new file mode 100644 index 0000000..70a9e38 --- /dev/null +++ b/packages/advantech/merge/etc/uninstall @@ -0,0 +1,9 @@ +#!/bin/sh + +MODNAME=newt + +rm -f /opt/$MODNAME/www/.htpasswd +rm -f /tmp/newt.pid +rm -f /tmp/newt.log + +exit 0 diff --git a/packages/advantech/merge/etc/version b/packages/advantech/merge/etc/version new file mode 100644 index 0000000..e0a6b34 --- /dev/null +++ b/packages/advantech/merge/etc/version @@ -0,0 +1 @@ +1.12.5 diff --git a/packages/advantech/merge/www/index.cgi b/packages/advantech/merge/www/index.cgi new file mode 100644 index 0000000..c21233e --- /dev/null +++ b/packages/advantech/merge/www/index.cgi @@ -0,0 +1,282 @@ +#!/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/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/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='
Stop Newt first to edit settings.
' +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/&/\&/g' \ + -e 's//\>/g' \ + -e 's/\$/\$/g') +fi + +# ── Output ────────────────────────────────────────────────────────────────── +printf 'Content-type: text/html\r\n\r\n' + +# Static head — split around the conditional refresh meta tag +cat << 'HEAD_START' + + + + +HEAD_START + +# Only auto-refresh while newt is running (avoids wiping settings form mid-edit) +[ "$STATUS_CLASS" = "running" ] && printf '\n' + +cat << 'STATIC_HEAD' +Pangolin Site + + + + +
Router Apps - Pangolin Site
+
+STATIC_HEAD + +# Status card (double-quoted heredoc — variables expand) +cat << STATUS_CARD +
+
Status
+
+
${STATUS_TEXT}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+STATUS_CARD + +# Settings card +cat << SETTINGS_CARD +
+
Settings
+
+${SETTINGS_HINT}
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+SETTINGS_CARD + +# Log card header +cat << 'LOG_HEADER' +
+
+Log (last 100 lines, auto-refreshes every 10s while running) + +
+ + +
+
+ +
+
+
+
+
+LOG_HEADER + +# Log content — printed with printf to prevent any shell expansion +printf '%s' "$LOG_HTML" + +# Close tags (static) +cat << 'STATIC_FOOTER' +
+
+
+ + +STATIC_FOOTER diff --git a/packages/openwrt/.gitkeep b/packages/openwrt/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/teltonika/.gitkeep b/packages/teltonika/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/proxy/manager.go b/proxy/manager.go index 0d1f750..9203f18 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -70,6 +70,9 @@ type ProxyManager struct { asyncBytes bool flushStop chan struct{} udpIdleTimeout time.Duration + + // connection blocking + blocked atomic.Bool } // tunnelEntry holds per-tunnel attributes and (optional) async counters. @@ -149,12 +152,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), + 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, } } @@ -229,10 +232,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), + 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, } } @@ -244,6 +247,18 @@ 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() @@ -535,6 +550,12 @@ 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 != "" { @@ -631,6 +652,11 @@ 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 { diff --git a/reexec_unix.go b/reexec_unix.go new file mode 100644 index 0000000..b6c01cc --- /dev/null +++ b/reexec_unix.go @@ -0,0 +1,21 @@ +//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()) +} diff --git a/reexec_windows.go b/reexec_windows.go new file mode 100644 index 0000000..770f544 --- /dev/null +++ b/reexec_windows.go @@ -0,0 +1,30 @@ +//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 +} diff --git a/updates/advantech.go b/updates/advantech.go new file mode 100644 index 0000000..18cacdf --- /dev/null +++ b/updates/advantech.go @@ -0,0 +1,48 @@ +//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 +} diff --git a/updates/advantech_windows.go b/updates/advantech_windows.go new file mode 100644 index 0000000..e6e302c --- /dev/null +++ b/updates/advantech_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package updates + +// postUpdateAdvantech is not supported on Windows. +func postUpdateAdvantech(newVersion, pidFile string) error { + return nil +} diff --git a/updates/reexec_unix.go b/updates/reexec_unix.go new file mode 100644 index 0000000..b439d10 --- /dev/null +++ b/updates/reexec_unix.go @@ -0,0 +1,14 @@ +//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()) +} diff --git a/updates/reexec_windows.go b/updates/reexec_windows.go new file mode 100644 index 0000000..6174037 --- /dev/null +++ b/updates/reexec_windows.go @@ -0,0 +1,28 @@ +//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 +} diff --git a/updates/selfupdate.go b/updates/selfupdate.go new file mode 100644 index 0000000..c828236 --- /dev/null +++ b/updates/selfupdate.go @@ -0,0 +1,295 @@ +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) +} diff --git a/websocket/client.go b/websocket/client.go index 5068471..0f4ea39 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -172,6 +172,11 @@ 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 } @@ -931,3 +936,19 @@ 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() +} diff --git a/websocket/types.go b/websocket/types.go index 195e06f..58b304e 100644 --- a/websocket/types.go +++ b/websocket/types.go @@ -7,6 +7,7 @@ type Config struct { TlsClientCert string `json:"tlsClientCert"` ProvisioningKey string `json:"provisioningKey,omitempty"` Name string `json:"name,omitempty"` + Blocked bool `json:"blocked,omitempty"` } type TokenResponse struct { @@ -31,4 +32,4 @@ type WSMessage struct { Type string `json:"type"` Data interface{} `json:"data"` ConfigVersion int64 `json:"configVersion,omitempty"` -} \ No newline at end of file +}