mirror of
https://github.com/fosrl/newt.git
synced 2026-07-12 11:15:32 -05:00
Compare commits
17 Commits
1.13.0-rc.
...
watch-dns
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20907685f3 | ||
|
|
689acff6f8 | ||
|
|
0019d0dc51 | ||
|
|
df93f01e74 | ||
|
|
ea3d9869c5 | ||
|
|
9f895ac7ec | ||
|
|
364073d28c | ||
|
|
8eb9661827 | ||
|
|
25960e4354 | ||
|
|
060fcb18d5 | ||
|
|
12af1f77fd | ||
|
|
8c698ffaee | ||
|
|
d360392214 | ||
|
|
1dbd1936ba | ||
|
|
40223eac90 | ||
|
|
17c4fe9b2b | ||
|
|
17a4db6810 |
@@ -63,7 +63,7 @@ func startAuthDaemon(ctx context.Context) error {
|
||||
|
||||
// Start the auth daemon in a goroutine so it runs alongside newt
|
||||
go func() {
|
||||
logger.Info("Auth daemon starting (native mode, no HTTP server)")
|
||||
logger.Debug("Auth daemon starting (native mode, no HTTP server)")
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("Auth daemon error: %v", err)
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ import (
|
||||
|
||||
type Config struct {
|
||||
// DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events.
|
||||
DisableHTTPS bool
|
||||
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
|
||||
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
|
||||
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
|
||||
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
|
||||
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
|
||||
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
|
||||
DisableHTTPS bool
|
||||
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
|
||||
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
|
||||
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
|
||||
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
|
||||
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
|
||||
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -136,7 +136,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
// When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if s.cfg.DisableHTTPS {
|
||||
logger.Info("auth-daemon running (HTTPS disabled)")
|
||||
logger.Debug("auth-daemon running (HTTPS disabled)")
|
||||
<-ctx.Done()
|
||||
s.cleanupPrincipalsFile()
|
||||
return nil
|
||||
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections.
|
||||
@@ -24,7 +24,7 @@ func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) {
|
||||
Subprotocols: []string{"binary"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("websocket upgrade failed: %v", err)
|
||||
logger.Debug("websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
// Disable per-message read size cap (default is 32 KiB which would break
|
||||
@@ -33,7 +33,7 @@ func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) {
|
||||
defer ws.CloseNow() //nolint:errcheck
|
||||
|
||||
if err := g.serveSession(ctx, ws); err != nil {
|
||||
log.Printf("session error: %v", err)
|
||||
logger.Debug("session error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
|
||||
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)
|
||||
logger.Debug("Connecting to RDP server %s", target)
|
||||
|
||||
// -- Open TCP connection to the destination RDP server --
|
||||
serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second)
|
||||
@@ -128,7 +128,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
|
||||
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)",
|
||||
logger.Debug("Server TLS handshake OK (version=0x%04x cipher=0x%04x)",
|
||||
tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite)
|
||||
|
||||
// Collect the raw DER server certificate chain to return to the client.
|
||||
@@ -150,7 +150,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
|
||||
return fmt.Errorf("write RDCleanPath response: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr)
|
||||
logger.Debug("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr)
|
||||
|
||||
// -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream --
|
||||
return forward(stream, tlsConn)
|
||||
@@ -219,7 +219,7 @@ func readX224(r io.Reader) ([]byte, error) {
|
||||
// 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))
|
||||
logger.Debug("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu))
|
||||
return
|
||||
}
|
||||
switch pdu[11] {
|
||||
@@ -236,12 +236,12 @@ func logX224Negotiation(pdu []byte) {
|
||||
case 8:
|
||||
name = "HYBRID_EX"
|
||||
}
|
||||
log.Printf("Server selected RDP protocol 0x%x (%s)", proto, name)
|
||||
logger.Debug("Server selected RDP protocol 0x%x (%s)", proto, name)
|
||||
case 0x03:
|
||||
code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24
|
||||
log.Printf("Server returned RDP negotiation failure code 0x%x", code)
|
||||
logger.Debug("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])
|
||||
logger.Debug("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -18,13 +17,13 @@ import (
|
||||
// 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"
|
||||
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"
|
||||
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.
|
||||
@@ -83,7 +82,7 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) {
|
||||
Subprotocols: []string{"ssh"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("SSH websocket upgrade failed: %v", err)
|
||||
logger.Debug("SSH websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(-1)
|
||||
@@ -91,11 +90,11 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if nativeSSH {
|
||||
if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil {
|
||||
log.Printf("SSH native session error: %v", err)
|
||||
logger.Debug("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)
|
||||
logger.Debug("SSH session error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,8 +111,10 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
|
||||
}
|
||||
password := authMsg.Password
|
||||
privateKey := authMsg.PrivateKey
|
||||
certificate := authMsg.Certificate
|
||||
|
||||
// Build the list of auth methods. Private key takes priority when provided.
|
||||
// Build the list of auth methods. Certificate+private key takes priority
|
||||
// when both are provided, then private key, then password.
|
||||
var authMethods []ssh.AuthMethod
|
||||
if privateKey != "" {
|
||||
signer, err := ssh.ParsePrivateKey([]byte(privateKey))
|
||||
@@ -121,7 +122,29 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
|
||||
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 certificate != "" {
|
||||
certPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse certificate: %v", err))
|
||||
return fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, ok := certPubKey.(*ssh.Certificate)
|
||||
if !ok {
|
||||
sendSSHError(ctx, ws, "Provided certificate is not a valid SSH certificate")
|
||||
return fmt.Errorf("provided certificate is not ssh certificate")
|
||||
}
|
||||
|
||||
certSigner, err := ssh.NewCertSigner(cert, signer)
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to combine private key and certificate: %v", err))
|
||||
return fmt.Errorf("new cert signer: %w", err)
|
||||
}
|
||||
authMethods = append(authMethods, ssh.PublicKeys(certSigner))
|
||||
} else {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
authMethods = append(authMethods, ssh.Password(password))
|
||||
@@ -130,7 +153,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
|
||||
sendSSHError(ctx, ws, "No authentication credentials provided")
|
||||
return fmt.Errorf("no auth credentials")
|
||||
}
|
||||
log.Printf("SSH: connecting to %s as %s", target, username)
|
||||
logger.Debug("SSH: connecting to %s as %s", target, username)
|
||||
sshCfg := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: authMethods,
|
||||
@@ -183,7 +206,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
|
||||
return fmt.Errorf("ssh shell: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("SSH: session established with %s", target)
|
||||
logger.Debug("SSH: session established with %s", target)
|
||||
|
||||
// -- Pump SSH stdout/stderr → WebSocket --
|
||||
sessCtx, cancelSess := context.WithCancel(ctx)
|
||||
@@ -253,7 +276,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
|
||||
|
||||
// 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)
|
||||
logger.Debug("SSH error: %s", msg)
|
||||
b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg})
|
||||
_ = ws.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username str
|
||||
return fmt.Errorf("auth for user %q: %w", username, err)
|
||||
}
|
||||
|
||||
log.Printf("SSH native: spawning shell as user %q", username)
|
||||
logger.Debug("SSH native: spawning shell as user %q", username)
|
||||
|
||||
sess, err := nativessh.NewPTYSessionAs(username)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,14 +2,15 @@ package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -44,6 +45,18 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
target := net.JoinHostPort(host, port)
|
||||
|
||||
// Optional HTTP probe used by the web UI to surface backend reachability
|
||||
// failures before attempting a WebSocket session.
|
||||
if r.URL.Query().Get("checkOnly") == "1" {
|
||||
if err := dialVNCBackend(r.Context(), target); err != nil {
|
||||
logger.Debug("vnc: preflight failed (%s): %v", target, err)
|
||||
http.Error(w, fmt.Sprintf("failed to connect to VNC backend: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Accept the WebSocket. noVNC negotiates the "binary" subprotocol;
|
||||
// fall back gracefully when the client sends "base64" as well.
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
@@ -51,7 +64,7 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
|
||||
Subprotocols: []string{"binary", "base64"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("vnc: websocket upgrade failed: %v", err)
|
||||
logger.Debug("vnc: websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(-1)
|
||||
@@ -59,10 +72,24 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
if err := serveVNC(ctx, ws, target); err != nil {
|
||||
log.Printf("vnc: session error (%s): %v", target, err)
|
||||
logger.Debug("vnc: session error (%s): %v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func dialVNCBackend(ctx context.Context, target string) error {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: vncDialTimeout,
|
||||
KeepAlive: vncKeepAlive,
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error {
|
||||
// Dial the VNC backend TCP server.
|
||||
dialer := &net.Dialer{
|
||||
|
||||
59
common.go
59
common.go
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
@@ -600,3 +601,61 @@ func sendBlueprint(client *websocket.Client, file string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchBlueprintFile(ctx context.Context, filePath string, send func() error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
logger.Error("blueprint watcher: failed to create: %v", err)
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := watcher.Add(filePath); err != nil {
|
||||
logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Watching blueprint file for changes: %s", filePath)
|
||||
|
||||
var debounce *time.Timer
|
||||
scheduleSend := func() {
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
debounce = time.AfterFunc(500*time.Millisecond, func() {
|
||||
logger.Info("Blueprint file changed, resending...")
|
||||
if err := send(); err != nil {
|
||||
logger.Error("blueprint watcher: resend failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case event.Has(fsnotify.Write) || event.Has(fsnotify.Create):
|
||||
if event.Has(fsnotify.Create) {
|
||||
_ = watcher.Add(filePath)
|
||||
}
|
||||
scheduleSend()
|
||||
case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename):
|
||||
_ = watcher.Add(filePath)
|
||||
scheduleSend()
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
logger.Error("blueprint watcher: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
common_test.go
156
common_test.go
@@ -1,10 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchBlueprintFile_WriteTriggersSend(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, f.Name(), func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if err := os.WriteFile(f.Name(), []byte("content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 send call, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_DebounceCoalescesEvents(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, f.Name(), func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.WriteFile(f.Name(), []byte("change"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 send call after debounce, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_ContextCancellationStops(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchBlueprintFile(ctx, f.Name(), func() error { return nil })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("watchBlueprintFile did not exit after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_AtomicWriteTriggersSend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "blueprint.yaml")
|
||||
if err := os.WriteFile(target, []byte("initial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, target, func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
tmp := filepath.Join(dir, "blueprint.yaml.tmp")
|
||||
if err := os.WriteFile(tmp, []byte("updated"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() < 1 {
|
||||
t.Errorf("expected at least 1 send call after atomic write, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_MissingFileReturnsGracefully(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchBlueprintFile(ctx, "/nonexistent/path/blueprint.yaml", func() error { return nil })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("watchBlueprintFile did not return for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -13,7 +154,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr string
|
||||
wantErr bool
|
||||
}{
|
||||
// IPv4 test cases
|
||||
{
|
||||
name: "valid IPv4 basic",
|
||||
input: "3001:192.168.1.1:80",
|
||||
@@ -35,8 +175,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "10.0.0.1:443",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// IPv6 test cases
|
||||
{
|
||||
name: "valid IPv6 loopback",
|
||||
input: "3001:[::1]:8080",
|
||||
@@ -72,8 +210,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "[::ffff:192.168.1.1]:6000",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Hostname test cases
|
||||
{
|
||||
name: "valid hostname",
|
||||
input: "8080:example.com:80",
|
||||
@@ -95,8 +231,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "localhost:3000",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "invalid - no colons",
|
||||
input: "invalid",
|
||||
@@ -169,7 +303,7 @@ func TestParseTargetString(t *testing.T) {
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
return // Don't check other values if we expected an error
|
||||
return
|
||||
}
|
||||
|
||||
if listenPort != tt.wantListenPort {
|
||||
@@ -183,7 +317,7 @@ func TestParseTargetString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial
|
||||
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial.
|
||||
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -201,8 +335,6 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
|
||||
// Verify the format is valid for net.Dial by checking it can be split back
|
||||
// This doesn't actually dial, just validates the format
|
||||
_, _, err = net.SplitHostPort(targetAddr)
|
||||
if err != nil {
|
||||
t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err)
|
||||
@@ -248,4 +380,4 @@ func TestShouldFireRecovery(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
1
go.mod
1
go.mod
@@ -46,6 +46,7 @@ require (
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -24,6 +24,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/go-crypt/crypt v0.14.15 h1:q1i5OMpL05r935IxWmXgpDAVF0nvi4SMoHhGXLBQUEQ=
|
||||
|
||||
@@ -212,6 +212,10 @@ func (m *Monitor) addTargetUnsafe(config Config) error {
|
||||
return nil
|
||||
}(),
|
||||
Transport: &http.Transport{
|
||||
// Disable keep-alives so each health check uses a fresh connection.
|
||||
// Reusing idle connections causes spurious timeouts when the remote
|
||||
// server closes the connection between long check intervals.
|
||||
DisableKeepAlives: true,
|
||||
TLSClientConfig: &tls.Config{
|
||||
// Configure TLS settings based on certificate enforcement
|
||||
InsecureSkipVerify: !m.enforceCert,
|
||||
@@ -432,7 +436,9 @@ func (m *Monitor) performTCPCheck(target *Target) (bool, string) {
|
||||
logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)",
|
||||
target.Config.ID, address, timeout)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
ctx, cancel := context.WithTimeout(target.ctx, timeout)
|
||||
defer cancel()
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("TCP dial failed: %v", err)
|
||||
logger.Warn("Target %d: %s", target.Config.ID, msg)
|
||||
@@ -467,8 +473,9 @@ func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
|
||||
target.Config.ID, m.enforceCert)
|
||||
}
|
||||
|
||||
// Create request with timeout context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
|
||||
// Create request with timeout context, derived from the target's context so
|
||||
// that in-flight requests are cancelled immediately when the target is replaced.
|
||||
ctx, cancel := context.WithTimeout(target.ctx, time.Duration(target.Config.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)
|
||||
|
||||
@@ -27,17 +27,17 @@ type ExitNode struct {
|
||||
|
||||
// Manager handles UDP hole punching operations
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
sharedBind *bind.SharedBind
|
||||
ID string
|
||||
token string
|
||||
publicKey string
|
||||
clientType string
|
||||
exitNodes map[string]ExitNode // key is endpoint
|
||||
updateChan chan struct{} // signals the goroutine to refresh exit nodes
|
||||
publicDNS []string
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
sharedBind *bind.SharedBind
|
||||
ID string
|
||||
token string
|
||||
publicKey string
|
||||
clientType string
|
||||
exitNodes map[string]ExitNode // key is endpoint
|
||||
updateChan chan struct{} // signals the goroutine to refresh exit nodes
|
||||
publicDNS []string
|
||||
|
||||
sendHolepunchInterval time.Duration
|
||||
sendHolepunchIntervalMin time.Duration
|
||||
@@ -56,7 +56,7 @@ func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publi
|
||||
ID: ID,
|
||||
clientType: clientType,
|
||||
publicKey: publicKey,
|
||||
publicDNS: publicDNS,
|
||||
publicDNS: publicDNS,
|
||||
exitNodes: make(map[string]ExitNode),
|
||||
sendHolepunchInterval: defaultSendHolepunchIntervalMin,
|
||||
sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin,
|
||||
@@ -73,6 +73,14 @@ func (m *Manager) SetToken(token string) {
|
||||
m.token = token
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the list of DNS servers used to resolve exit-node
|
||||
// endpoints. The servers must be in "host:port" format (e.g. "8.8.8.8:53").
|
||||
func (m *Manager) SetPublicDNS(servers []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.publicDNS = servers
|
||||
}
|
||||
|
||||
// IsRunning returns whether hole punching is currently active
|
||||
func (m *Manager) IsRunning() bool {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -43,17 +43,17 @@ func DefaultTestOptions() TestConnectionOptions {
|
||||
|
||||
// cachedAddr holds a cached resolved UDP address
|
||||
type cachedAddr struct {
|
||||
addr *net.UDPAddr
|
||||
addr *net.UDPAddr
|
||||
resolvedAt time.Time
|
||||
}
|
||||
|
||||
// HolepunchTester monitors holepunch connectivity using magic packets
|
||||
type HolepunchTester struct {
|
||||
sharedBind *bind.SharedBind
|
||||
publicDNS []string
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
sharedBind *bind.SharedBind
|
||||
publicDNS []string
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
|
||||
// Pending requests waiting for responses (key: echo data as string)
|
||||
pendingRequests sync.Map // map[string]*pendingRequest
|
||||
@@ -88,12 +88,24 @@ type pendingRequest struct {
|
||||
func NewHolepunchTester(sharedBind *bind.SharedBind, publicDNS []string) *HolepunchTester {
|
||||
return &HolepunchTester{
|
||||
sharedBind: sharedBind,
|
||||
publicDNS: publicDNS,
|
||||
publicDNS: publicDNS,
|
||||
addrCache: make(map[string]*cachedAddr),
|
||||
addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the list of DNS servers used to resolve peer endpoints.
|
||||
// The servers must be in "host:port" format (e.g. "8.8.8.8:53").
|
||||
func (t *HolepunchTester) SetPublicDNS(servers []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.publicDNS = servers
|
||||
// Invalidate the address cache so endpoints are re-resolved with the new DNS.
|
||||
t.addrCacheMu.Lock()
|
||||
t.addrCache = make(map[string]*cachedAddr)
|
||||
t.addrCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// SetCallback sets the callback for connection status changes
|
||||
func (t *HolepunchTester) SetCallback(callback HolepunchStatusCallback) {
|
||||
t.mu.Lock()
|
||||
|
||||
41
main.go
41
main.go
@@ -691,6 +691,10 @@ func runNewtMain(ctx context.Context) {
|
||||
Platform: newtPlatform,
|
||||
TLSConfig: tlsCfg,
|
||||
}); err != nil {
|
||||
if errors.Is(err, updates.ErrAutoUpdateUnsupportedInOfficialContainer) {
|
||||
logger.Debug("checkAndSelfUpdate: auto-update skipped: %v", err)
|
||||
return
|
||||
}
|
||||
logger.Error("Auto-update check failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1053,7 +1057,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
} else {
|
||||
browserGatewayStop = func() { _ = ln.Close() }
|
||||
go func() {
|
||||
logger.Info("Browser gateway started on port %d", browsergateway.ListenPort)
|
||||
logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort)
|
||||
if startErr := browserGateway.Start(ln); startErr != nil {
|
||||
logger.Error("Browser gateway stopped with error: %v", startErr)
|
||||
}
|
||||
@@ -1776,14 +1780,15 @@ 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"`
|
||||
AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native
|
||||
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"`
|
||||
ExternalAuthDaemon bool `json:"externalAuthDaemon"`
|
||||
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"`
|
||||
@@ -1805,9 +1810,15 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
logger.Error("Error unmarshaling SSH cert data: %v", err)
|
||||
return
|
||||
}
|
||||
var authDaemonMode = "site"
|
||||
if certData.AuthDaemonMode != "" {
|
||||
authDaemonMode = certData.AuthDaemonMode
|
||||
} else if certData.ExternalAuthDaemon { // this is for backward compatibility with older server versions that don't send authDaemonMode
|
||||
authDaemonMode = "remote"
|
||||
}
|
||||
|
||||
// Use a switch statement for AuthDaemonMode
|
||||
switch certData.AuthDaemonMode {
|
||||
switch authDaemonMode {
|
||||
case "site":
|
||||
// Call ProcessConnection directly when running internally
|
||||
logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username)
|
||||
@@ -1835,7 +1846,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{
|
||||
"messageId": certData.MessageId,
|
||||
"complete": true,
|
||||
"error": "auth daemon server not initialized",
|
||||
"error": "auth daemon server not initialized (enable by running Newt site connector as root)",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to send SSH cert failure response: %v", err)
|
||||
@@ -2028,7 +2039,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
} else {
|
||||
browserGatewayStop = func() { _ = ln.Close() }
|
||||
go func() {
|
||||
logger.Info("Browser gateway started on port %d", browsergateway.ListenPort)
|
||||
logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort)
|
||||
if startErr := browserGateway.Start(ln); startErr != nil {
|
||||
logger.Error("Browser gateway stopped with error: %v", startErr)
|
||||
}
|
||||
@@ -2245,6 +2256,12 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
}
|
||||
}
|
||||
|
||||
if blueprintFile != "" {
|
||||
go watchBlueprintFile(ctx, blueprintFile, func() error {
|
||||
return sendBlueprint(client, blueprintFile)
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for context cancellation (from signal or service stop)
|
||||
<-ctx.Done()
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ package nativessh
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -17,12 +17,12 @@ 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 }
|
||||
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
|
||||
@@ -80,9 +80,9 @@ func Authenticate(username, password, privateKeyPEM string) error {
|
||||
// 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 != "")
|
||||
logger.Debug("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "")
|
||||
if !SystemUserExists(username) {
|
||||
log.Printf("nativessh: user %q not found on system", username)
|
||||
logger.Debug("nativessh: user %q not found on system", username)
|
||||
return fmt.Errorf("user %q does not exist", username)
|
||||
}
|
||||
|
||||
@@ -90,35 +90,35 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri
|
||||
if privateKeyPEM != "" {
|
||||
parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM))
|
||||
if err != nil {
|
||||
log.Printf("nativessh: failed to parse private key for %q: %v", username, err)
|
||||
logger.Debug("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)
|
||||
logger.Debug("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)
|
||||
logger.Debug("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)
|
||||
logger.Debug("nativessh: certificate provided for %q but no matching private key was provided", username)
|
||||
} else {
|
||||
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
|
||||
if err != nil {
|
||||
log.Printf("nativessh: failed to parse certificate for %q: %v", username, err)
|
||||
logger.Debug("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)
|
||||
logger.Debug("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)
|
||||
logger.Debug("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)
|
||||
logger.Debug("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)
|
||||
logger.Debug("nativessh: no allowed principals found for certificate auth user %q", username)
|
||||
} else {
|
||||
checker := &ssh.CertChecker{
|
||||
IsUserAuthority: func(auth ssh.PublicKey) bool {
|
||||
@@ -130,13 +130,13 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri
|
||||
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)
|
||||
logger.Debug("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)
|
||||
logger.Debug("nativessh: certificate auth failed for %q: %v", username, lastErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,13 +146,13 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri
|
||||
|
||||
if password != "" {
|
||||
if err := VerifySystemPassword(username, password); err != nil {
|
||||
log.Printf("nativessh: password auth failed for %q: %v", username, err)
|
||||
logger.Debug("nativessh: password auth failed for %q: %v", username, err)
|
||||
} else {
|
||||
log.Printf("nativessh: password auth succeeded for %q", username)
|
||||
logger.Debug("nativessh: password auth succeeded for %q", username)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
log.Printf("nativessh: no password provided for %q", username)
|
||||
logger.Debug("nativessh: no password provided for %q", username)
|
||||
}
|
||||
return fmt.Errorf("authentication failed for user %q", username)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/go-crypt/crypt"
|
||||
"github.com/go-crypt/x/yescrypt"
|
||||
)
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
func VerifySystemPassword(username, password string) error {
|
||||
hash, err := readShadowHash(username)
|
||||
if err != nil {
|
||||
log.Printf("nativessh/pam: readShadowHash for %q failed: %v", username, err)
|
||||
logger.Debug("nativessh/pam: readShadowHash for %q failed: %v", username, err)
|
||||
return fmt.Errorf("shadow: %w", err)
|
||||
}
|
||||
|
||||
@@ -33,17 +33,17 @@ func VerifySystemPassword(username, password string) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Printf("nativessh/pam: verifying password for %q using scheme %s", username, scheme)
|
||||
logger.Debug("nativessh/pam: verifying password for %q using scheme %s", username, scheme)
|
||||
|
||||
// Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly.
|
||||
if strings.HasPrefix(hash, "$y$") {
|
||||
computed, err := yescrypt.Hash([]byte(password), []byte(hash))
|
||||
if err != nil {
|
||||
log.Printf("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err)
|
||||
logger.Debug("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)
|
||||
logger.Debug("nativessh/pam: yescrypt mismatch for %q", username)
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
return nil
|
||||
@@ -56,17 +56,17 @@ func VerifySystemPassword(username, password string) error {
|
||||
|
||||
digest, err := decoder.Decode(hash)
|
||||
if err != nil {
|
||||
log.Printf("nativessh/pam: failed to decode hash for %q: %v", username, err)
|
||||
logger.Debug("nativessh/pam: failed to decode hash for %q: %v", username, err)
|
||||
return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err)
|
||||
}
|
||||
|
||||
match, err := digest.MatchAdvanced(password)
|
||||
if err != nil {
|
||||
log.Printf("nativessh/pam: MatchAdvanced for %q failed: %v", username, err)
|
||||
logger.Debug("nativessh/pam: MatchAdvanced for %q failed: %v", username, err)
|
||||
return err
|
||||
}
|
||||
if !match {
|
||||
log.Printf("nativessh/pam: password mismatch for %q", username)
|
||||
logger.Debug("nativessh/pam: password mismatch for %q", username)
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -5,13 +5,19 @@ package nativessh
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -126,7 +132,7 @@ func (s *Server) Serve(ln net.Listener) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("nativessh: server listening on %s", ln.Addr())
|
||||
logger.Debug("nativessh: server listening on %s", ln.Addr())
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
@@ -151,11 +157,11 @@ 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)
|
||||
logger.Debug("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())
|
||||
logger.Debug("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User())
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
@@ -166,7 +172,7 @@ func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) {
|
||||
}
|
||||
ch, requests, err := newChan.Accept()
|
||||
if err != nil {
|
||||
log.Printf("nativessh: channel accept error: %v", err)
|
||||
logger.Debug("nativessh: channel accept error: %v", err)
|
||||
return
|
||||
}
|
||||
go s.handleSession(ch, requests, sshConn.User())
|
||||
@@ -190,7 +196,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use
|
||||
if sess == nil {
|
||||
sess, err = NewPTYSessionAs(username)
|
||||
if err != nil {
|
||||
log.Printf("nativessh: PTY start error: %v", err)
|
||||
logger.Debug("nativessh: PTY start error: %v", err)
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
@@ -232,6 +238,16 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use
|
||||
_, _ = io.Copy(sess, ch)
|
||||
}()
|
||||
|
||||
case "exec":
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
cmd := parseExecPayload(req.Payload)
|
||||
go s.runExecAs(ch, username, cmd)
|
||||
// Drain remaining requests; the goroutine owns the channel now.
|
||||
go ssh.DiscardRequests(requests)
|
||||
return
|
||||
|
||||
case "window-change":
|
||||
if sess != nil {
|
||||
cols, rows := parseWindowChange(req.Payload)
|
||||
@@ -253,6 +269,94 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use
|
||||
}
|
||||
}
|
||||
|
||||
// runExecAs runs command as username, wiring stdin/stdout/stderr directly to
|
||||
// the SSH channel (no PTY). This supports scp, rsync, and plain exec requests.
|
||||
func (s *Server) runExecAs(ch ssh.Channel, username, command string) {
|
||||
defer ch.Close()
|
||||
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec user lookup %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
|
||||
uid, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec parse uid for %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
gid, err := strconv.ParseUint(u.Gid, 10, 32)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec parse gid for %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
|
||||
groupIDs, _ := u.GroupIds()
|
||||
var groups []uint32
|
||||
for _, g := range groupIDs {
|
||||
gval, err := strconv.ParseUint(g, 10, 32)
|
||||
if err == nil {
|
||||
groups = append(groups, uint32(gval))
|
||||
}
|
||||
}
|
||||
|
||||
homeDir := u.HomeDir
|
||||
if _, err := os.Stat(homeDir); err != nil {
|
||||
homeDir = "/"
|
||||
}
|
||||
|
||||
cmd := exec.Command("/bin/sh", "-c", command)
|
||||
cmd.Env = []string{
|
||||
"HOME=" + u.HomeDir,
|
||||
"USER=" + username,
|
||||
"LOGNAME=" + username,
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
cmd.Dir = homeDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{
|
||||
Uid: uint32(uid),
|
||||
Gid: uint32(gid),
|
||||
Groups: groups,
|
||||
},
|
||||
}
|
||||
cmd.Stdin = ch
|
||||
cmd.Stdout = ch
|
||||
cmd.Stderr = ch.Stderr()
|
||||
|
||||
exitCode := 0
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = 1
|
||||
}
|
||||
logger.Debug("nativessh: exec %q as %q exited: %v", command, username, err)
|
||||
}
|
||||
|
||||
sendExitStatus(ch, exitCode)
|
||||
_ = ch.CloseWrite()
|
||||
}
|
||||
|
||||
// sendExitStatus sends an SSH exit-status request on ch.
|
||||
func sendExitStatus(ch ssh.Channel, code int) {
|
||||
payload := ssh.Marshal(struct{ Status uint32 }{uint32(code)})
|
||||
_, _ = ch.SendRequest("exit-status", false, payload)
|
||||
}
|
||||
|
||||
// parseExecPayload decodes the command string from an SSH exec request payload.
|
||||
func parseExecPayload(payload []byte) string {
|
||||
var msg struct{ Command string }
|
||||
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return msg.Command
|
||||
}
|
||||
|
||||
// makePublicKeyCallback returns a PublicKeyCallback that tries, in order:
|
||||
// 1. Host authorized_keys – matches any key in the OS user's
|
||||
// ~/.ssh/authorized_keys file.
|
||||
@@ -264,7 +368,7 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu
|
||||
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())
|
||||
logger.Debug("nativessh: authorized_keys auth for user %q", meta.User())
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
|
||||
@@ -286,14 +390,14 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu
|
||||
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)
|
||||
logger.Debug("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)
|
||||
logger.Debug("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,10 +413,10 @@ func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, er
|
||||
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)
|
||||
logger.Debug("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())
|
||||
logger.Debug("nativessh: password auth for user %q", meta.User())
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
}
|
||||
@@ -324,7 +428,7 @@ func generateHostKey() (ssh.Signer, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate host key: %w", err)
|
||||
}
|
||||
log.Printf("nativessh: generated ephemeral Ed25519 host key")
|
||||
logger.Debug("nativessh: generated ephemeral Ed25519 host key")
|
||||
return ssh.NewSignerFromKey(priv)
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
34062da7bd1b98e4a7953d2f8b43860d2222add0
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -649,7 +648,7 @@ func setupWindowsEventLog() {
|
||||
// Set the custom logger output
|
||||
logger.GetLogger().SetOutput(file)
|
||||
|
||||
log.Printf("Newt service logging initialized - log file: %s", logFile)
|
||||
logger.Debug("Newt service logging initialized - log file: %s", logFile)
|
||||
}
|
||||
|
||||
// handleServiceCommand checks for service management commands and returns true if handled
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -50,6 +51,10 @@ type versionResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrAutoUpdateUnsupportedInOfficialContainer indicates auto-update is not
|
||||
// available when running inside official Fossorial container images.
|
||||
var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in official Fossorial container images")
|
||||
|
||||
// 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
|
||||
@@ -113,7 +118,7 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error {
|
||||
|
||||
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")
|
||||
return fmt.Errorf("%w; pull a new image tag instead", ErrAutoUpdateUnsupportedInOfficialContainer)
|
||||
}
|
||||
|
||||
if cfg.CurrentVersion == "version_replaceme" {
|
||||
|
||||
@@ -9,11 +9,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// GitHubRelease represents the GitHub API response for a release
|
||||
type GitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
// VersionsAPIEntry represents one component's version payload.
|
||||
type VersionsAPIEntry struct {
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
}
|
||||
|
||||
// VersionsAPIResponse represents the versions API response.
|
||||
type VersionsAPIResponse struct {
|
||||
Data map[string]VersionsAPIEntry `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// Version represents a semantic version
|
||||
@@ -75,14 +83,15 @@ func (v Version) String() string {
|
||||
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
}
|
||||
|
||||
// CheckForUpdate checks GitHub for a newer version and prints an update banner if found
|
||||
// CheckForUpdate checks the Fossorial versions API for a newer version and prints an update banner if found.
|
||||
func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
if currentVersion == "version_replaceme" {
|
||||
return nil
|
||||
}
|
||||
_ = owner
|
||||
|
||||
// GitHub API URL for latest release
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||
// Versions API URL
|
||||
url := "https://api.fossorial.io/api/v1/versions"
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
@@ -97,13 +106,18 @@ func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GitHub API returned status: %d", resp.StatusCode)
|
||||
return fmt.Errorf("versions API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
var release GitHubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return fmt.Errorf("failed to parse release info: %w", err)
|
||||
// Parse the JSON response.
|
||||
var versionsResp VersionsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&versionsResp); err != nil {
|
||||
return fmt.Errorf("failed to parse versions response: %w", err)
|
||||
}
|
||||
|
||||
componentVersion, ok := versionsResp.Data[repo]
|
||||
if !ok {
|
||||
return fmt.Errorf("component %q not found in versions API response", repo)
|
||||
}
|
||||
|
||||
// Parse current and latest versions
|
||||
@@ -112,14 +126,18 @@ func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
return fmt.Errorf("invalid current version: %w", err)
|
||||
}
|
||||
|
||||
latestVer, err := parseVersion(release.TagName)
|
||||
latestVer, err := parseVersion(componentVersion.LatestVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid latest version: %w", err)
|
||||
}
|
||||
|
||||
// Check if update is available
|
||||
if currentVer.isNewer(latestVer) {
|
||||
printUpdateBanner(currentVer.String(), latestVer.String(), "curl -fsSL https://static.pangolin.net/get-newt.sh | bash")
|
||||
releaseNotes := componentVersion.ReleaseNotes
|
||||
if releaseNotes == "" {
|
||||
releaseNotes = "curl -fsSL https://static.pangolin.net/get-newt.sh | bash"
|
||||
}
|
||||
printUpdateBanner(currentVer.String(), latestVer.String(), releaseNotes)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -38,7 +37,7 @@ func getConfigPath(clientType string, overridePath string) string {
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
log.Printf("Failed to create config directory: %v", err)
|
||||
logger.Debug("Failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, "config.json")
|
||||
@@ -279,4 +278,4 @@ func (c *Client) provisionIfNeeded() error {
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user