Compare commits

...

17 Commits

Author SHA1 Message Date
Owen
20907685f3 Groundwork for monitoring and updating the dns 2026-06-26 14:41:21 -04:00
Owen
689acff6f8 Pull the version from the new api 2026-06-25 15:37:51 -04:00
Owen
0019d0dc51 Update log message to not print 2026-06-12 15:44:43 -07:00
Owen
df93f01e74 Remove binary
Former-commit-id: baf38fac2f
2026-06-10 11:13:03 -07:00
Owen
ea3d9869c5 Add more context to error message
Former-commit-id: 8540cf1c34
2026-06-09 20:50:02 -07:00
Owen
9f895ac7ec Support backward compatability with external
Former-commit-id: c2f2f68858
2026-06-09 20:43:43 -07:00
Owen
364073d28c Add some preflight check for vnc reachability
Former-commit-id: 6d3312fe89
2026-06-09 16:01:45 -07:00
Owen
8eb9661827 Dont time out the health checks for http
Former-commit-id: 740c9a8c22
2026-06-08 15:27:08 -07:00
Owen
25960e4354 Support using the cert for openssh connections
Former-commit-id: bdb14e8c0f
2026-06-08 15:00:12 -07:00
Owen
060fcb18d5 Merge branch 'main' into dev
Former-commit-id: a5fd9110ca
2026-06-06 16:09:45 -07:00
Owen Schwartz
12af1f77fd Merge pull request #272 from eleboucher/watch-blueprint
feat: watch blueprint.yaml for update and auto resend
Former-commit-id: 9ef04039b1
2026-06-06 15:34:53 -07:00
Owen
8c698ffaee Add file to function call
Former-commit-id: 090ad07991
2026-06-06 15:34:22 -07:00
Marc Schäfer
d360392214 Merge branch 'main' into watch-blueprint
Former-commit-id: 2ad89915c4
2026-06-06 23:23:22 +02:00
Owen
1dbd1936ba Quiet some logs
Former-commit-id: 1546e8a89a
2026-06-05 17:09:58 -07:00
Owen
40223eac90 Support commands over the ssh tunnel
Former-commit-id: b87b9e10dc
2026-06-05 13:55:43 -07:00
Owen
17c4fe9b2b Use logger not printf
Former-commit-id: 3ef2550687
2026-06-04 16:57:51 -07:00
Erwan Leboucher
17a4db6810 feat: watch blueprint.yaml for update and auto resend
Add a file watcher that re-pushes the blueprint to the server whenever
the blueprint file is modified while Newt is running, matching the
reactive behavior of the Docker event monitor.


Former-commit-id: 025497138d
2026-03-15 16:14:00 +01:00
22 changed files with 562 additions and 150 deletions

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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])
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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)
}
}
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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)

View File

@@ -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()

View File

@@ -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
View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -1 +0,0 @@
34062da7bd1b98e4a7953d2f8b43860d2222add0

View File

@@ -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

View File

@@ -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" {

View File

@@ -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

View File

@@ -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
}
}