mirror of
https://github.com/fosrl/newt.git
synced 2026-07-20 06:31:29 +02:00
Merge branch 'rdp-ssh' into dev
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
121
browsergateway/browsergateway.go
Normal file
121
browsergateway/browsergateway.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
88
browsergateway/rdcleanpath.go
Normal file
88
browsergateway/rdcleanpath.go
Normal file
@@ -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
|
||||
}
|
||||
271
browsergateway/rdp.go
Normal file
271
browsergateway/rdp.go
Normal file
@@ -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
|
||||
}
|
||||
255
browsergateway/ssh.go
Normal file
255
browsergateway/ssh.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"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"
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
|
||||
token := r.URL.Query().Get("authToken")
|
||||
|
||||
var nativeSSH = false
|
||||
|
||||
// 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 gateway token then read the target username.
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 {
|
||||
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); 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)
|
||||
}
|
||||
92
browsergateway/ssh_native.go
Normal file
92
browsergateway/ssh_native.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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) 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.Authenticate(username, authMsg.Password, authMsg.PrivateKey); 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
|
||||
}
|
||||
98
browsergateway/vnc.go
Normal file
98
browsergateway/vnc.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
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"
|
||||
@@ -109,6 +110,9 @@ 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
|
||||
@@ -197,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 == "" {
|
||||
@@ -215,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 {
|
||||
@@ -909,6 +926,16 @@ 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
|
||||
@@ -1590,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
|
||||
}
|
||||
|
||||
3
go.mod
3
go.mod
@@ -3,10 +3,13 @@ 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/gorilla/websocket v1.5.3
|
||||
github.com/moby/moby/api v1.54.2
|
||||
github.com/moby/moby/client v0.4.1
|
||||
github.com/msteinert/pam/v2 v2.1.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
|
||||
|
||||
8
go.sum
8
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=
|
||||
@@ -53,6 +57,8 @@ github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/msteinert/pam/v2 v2.1.0 h1:er5F9TKV5nGFuTt12ubtqPHEUdeBwReP7vd3wovidGY=
|
||||
github.com/msteinert/pam/v2 v2.1.0/go.mod h1:KT28NNIcDFf3PcBmNI2mIGO4zZJ+9RSs/At2PB3IDVc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -119,6 +125,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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
|
||||
302
main.go
302
main.go
@@ -23,9 +23,11 @@ import (
|
||||
"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"
|
||||
@@ -41,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 {
|
||||
@@ -124,6 +135,7 @@ var (
|
||||
port uint16
|
||||
portStr string
|
||||
disableClients bool
|
||||
disableSSH bool
|
||||
updownScript string
|
||||
dockerSocket string
|
||||
dockerEnforceNetworkValidation string
|
||||
@@ -135,6 +147,8 @@ var (
|
||||
pingStopChan chan struct{}
|
||||
stopFunc func()
|
||||
pendingRegisterChainId string
|
||||
browserGateway *browsergateway.Gateway
|
||||
browserGatewayStop func()
|
||||
pendingPingChainId string
|
||||
healthFile string
|
||||
useNativeInterface bool
|
||||
@@ -145,7 +159,6 @@ var (
|
||||
authDaemonKey string
|
||||
authDaemonPrincipalsFile string
|
||||
authDaemonCACertPath string
|
||||
authDaemonEnabled bool
|
||||
authDaemonGenerateRandomPassword bool
|
||||
// Build/version (can be overridden via -ldflags "-X main.newtVersion=...")
|
||||
newtVersion = "version_replaceme"
|
||||
@@ -244,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
|
||||
@@ -257,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")
|
||||
@@ -329,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)")
|
||||
}
|
||||
@@ -363,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
|
||||
@@ -474,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 {
|
||||
@@ -519,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.GetLogger().SetLevel(loggerLevel)
|
||||
|
||||
// Initialize telemetry after flags are parsed (so flags override env)
|
||||
@@ -740,8 +754,15 @@ func runNewtMain(ctx context.Context) {
|
||||
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
|
||||
@@ -787,6 +808,13 @@ 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()
|
||||
@@ -996,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{})
|
||||
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) {
|
||||
@@ -1716,14 +1780,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"`
|
||||
@@ -1746,31 +1810,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 == "" {
|
||||
@@ -1867,6 +1945,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
|
||||
@@ -1879,6 +1999,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{})
|
||||
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)
|
||||
|
||||
76
nativessh/auth.go
Normal file
76
nativessh/auth.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
if !SystemUserExists(username) {
|
||||
return fmt.Errorf("user %q does not exist", username)
|
||||
}
|
||||
if privateKeyPEM != "" {
|
||||
signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM))
|
||||
if err == nil && CheckAuthorizedKeys(username, signer.PublicKey()) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
if err := VerifySystemPassword(username, password); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("authentication failed for user %q", username)
|
||||
}
|
||||
34
nativessh/pam_linux.go
Normal file
34
nativessh/pam_linux.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build linux
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/msteinert/pam/v2"
|
||||
)
|
||||
|
||||
// VerifySystemPassword authenticates username/password via PAM using the
|
||||
// "sshd" service stack. It returns nil on success and an error on failure.
|
||||
// The caller must not reveal the error detail to the client.
|
||||
func VerifySystemPassword(username, password string) error {
|
||||
tx, err := pam.StartFunc("sshd", username, func(s pam.Style, msg string) (string, error) {
|
||||
switch s {
|
||||
case pam.PromptEchoOff, pam.PromptEchoOn:
|
||||
return password, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("PAM start: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Authenticate(0); err != nil {
|
||||
return fmt.Errorf("PAM authenticate: %w", err)
|
||||
}
|
||||
if err := tx.AcctMgmt(0); err != nil {
|
||||
return fmt.Errorf("PAM acct_mgmt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
nativessh/pam_other.go
Normal file
11
nativessh/pam_other.go
Normal file
@@ -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")
|
||||
}
|
||||
89
nativessh/pty.go
Normal file
89
nativessh/pty.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"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"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
69
nativessh/pty_unix.go
Normal file
69
nativessh/pty_unix.go
Normal file
@@ -0,0 +1,69 @@
|
||||
//go:build !windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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 := findShell()
|
||||
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 = u.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
|
||||
}
|
||||
343
nativessh/server.go
Normal file
343
nativessh/server.go
Normal file
@@ -0,0 +1,343 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 = NewPTYSession()
|
||||
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)
|
||||
},
|
||||
}
|
||||
perms, err := checker.Authenticate(meta, key)
|
||||
if err == nil {
|
||||
if len(userPrincipals) == 0 {
|
||||
return nil, fmt.Errorf("user %q not in allowed principals list", meta.User())
|
||||
}
|
||||
log.Printf("nativessh: CA cert auth for user %q", meta.User())
|
||||
return perms, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user