mirror of
https://github.com/fosrl/newt.git
synced 2026-07-18 03:49:51 +00:00
Refactor
This commit is contained in:
@@ -1,14 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -16,64 +12,6 @@ const (
|
||||
defaultCACertPath = "/etc/ssh/ca.pem"
|
||||
)
|
||||
|
||||
var (
|
||||
errPresharedKeyRequired = errors.New("auth-daemon-key is required when --auth-daemon is enabled")
|
||||
errRootRequired = errors.New("auth-daemon must be run as root (use sudo)")
|
||||
authDaemonServer *authdaemon.Server // Global auth daemon server instance
|
||||
)
|
||||
|
||||
// startAuthDaemon initializes and starts the auth daemon in the background.
|
||||
// It validates requirements (Linux, root, preshared key) and starts the server
|
||||
// in a goroutine so it runs alongside normal newt operation.
|
||||
func startAuthDaemon(ctx context.Context) error {
|
||||
// Validation
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return errRootRequired
|
||||
}
|
||||
|
||||
// Use defaults if not set
|
||||
principalsFile := authDaemonPrincipalsFile
|
||||
if principalsFile == "" {
|
||||
principalsFile = defaultPrincipalsPath
|
||||
}
|
||||
caCertPath := authDaemonCACertPath
|
||||
if caCertPath == "" {
|
||||
caCertPath = defaultCACertPath
|
||||
}
|
||||
|
||||
// Create auth daemon server
|
||||
cfg := authdaemon.Config{
|
||||
DisableHTTPS: true, // We run without HTTP server in newt
|
||||
PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation
|
||||
PrincipalsFilePath: principalsFile,
|
||||
CACertPath: caCertPath,
|
||||
Force: true,
|
||||
GenerateRandomPassword: authDaemonGenerateRandomPassword,
|
||||
}
|
||||
|
||||
srv, err := authdaemon.NewServer(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth daemon server: %w", err)
|
||||
}
|
||||
|
||||
authDaemonServer = srv
|
||||
|
||||
// Start the auth daemon in a goroutine so it runs alongside newt
|
||||
go func() {
|
||||
logger.Debug("Auth daemon starting (native mode, no HTTP server)")
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("Auth daemon error: %v", err)
|
||||
}
|
||||
logger.Info("Auth daemon stopped")
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPrincipalsCmd executes the principals subcommand logic
|
||||
func runPrincipalsCmd(args []string) {
|
||||
opts := struct {
|
||||
PrincipalsFile string
|
||||
@@ -82,7 +20,6 @@ func runPrincipalsCmd(args []string) {
|
||||
PrincipalsFile: defaultPrincipalsPath,
|
||||
}
|
||||
|
||||
// Parse flags manually
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--principals-file":
|
||||
@@ -109,14 +46,12 @@ func runPrincipalsCmd(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validation
|
||||
if opts.Username == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: username is required\n")
|
||||
printPrincipalsHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get principals
|
||||
list, err := authdaemon.GetPrincipals(opts.PrincipalsFile, opts.Username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
|
||||
120
clients.go
120
clients.go
@@ -1,120 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/clients"
|
||||
wgnetstack "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/clients/permissions"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
var wgService *clients.WireGuardService
|
||||
var ready bool
|
||||
|
||||
// checkNativeMainPermissions returns an error if the process lacks the
|
||||
// privileges needed to create a native TUN interface for the main tunnel.
|
||||
func checkNativeMainPermissions() error {
|
||||
return permissions.CheckNativeInterfacePermissions()
|
||||
}
|
||||
|
||||
func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) {
|
||||
var host = endpoint
|
||||
if strings.HasPrefix(host, "http://") {
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
} else if strings.HasPrefix(host, "https://") {
|
||||
host = strings.TrimPrefix(host, "https://")
|
||||
}
|
||||
|
||||
host = strings.TrimSuffix(host, "/")
|
||||
|
||||
logger.Debug("Setting up clients with netstack2...")
|
||||
|
||||
// if useNativeInterface is true make sure we have permission to use native interface
|
||||
if useNativeInterface {
|
||||
logger.Debug("Checking permissions for native interface")
|
||||
err := permissions.CheckNativeInterfacePermissions()
|
||||
if err != nil {
|
||||
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create WireGuard service
|
||||
wgService, err = wgnetstack.NewWireGuardService(interfaceName, port, mtuInt, host, id, client, dns, useNativeInterface)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create WireGuard service: %v", err)
|
||||
}
|
||||
|
||||
wgService.SetCredentialStore(credStore)
|
||||
|
||||
client.OnTokenUpdate(func(token string) {
|
||||
wgService.SetToken(token)
|
||||
})
|
||||
|
||||
ready = true
|
||||
}
|
||||
|
||||
func setDownstreamTNetstack(tnet *netstack.Net) {
|
||||
if wgService != nil {
|
||||
wgService.SetOthertnet(tnet)
|
||||
}
|
||||
}
|
||||
|
||||
func closeClients() {
|
||||
logger.Info("Closing clients...")
|
||||
if wgService != nil {
|
||||
wgService.Close()
|
||||
wgService = nil
|
||||
}
|
||||
}
|
||||
|
||||
// setClientsBlocked enables or disables connection blocking on the WireGuard service.
|
||||
func setClientsBlocked(v bool) {
|
||||
if wgService != nil {
|
||||
wgService.SetBlocked(v)
|
||||
}
|
||||
}
|
||||
|
||||
func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
|
||||
// split off the port from the endpoint
|
||||
parts := strings.Split(endpoint, ":")
|
||||
if len(parts) < 2 {
|
||||
logger.Error("Invalid endpoint format: %s", endpoint)
|
||||
return
|
||||
}
|
||||
endpoint = strings.Join(parts[:len(parts)-1], ":")
|
||||
|
||||
if wgService != nil {
|
||||
wgService.StartHolepunch(publicKey, endpoint, relayPort)
|
||||
}
|
||||
}
|
||||
|
||||
func clientsOnConnect() {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
if wgService != nil {
|
||||
wgService.LoadRemoteConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// clientsStartDirectRelay starts a direct UDP relay from the main tunnel netstack
|
||||
// to the clients' WireGuard, bypassing the proxy for better performance.
|
||||
func clientsStartDirectRelay(tunnelIP string) {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
if wgService != nil {
|
||||
if err := wgService.StartDirectUDPRelay(tunnelIP); err != nil {
|
||||
logger.Error("Failed to start direct UDP relay: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
692
common.go
692
common.go
@@ -1,692 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"math/rand"
|
||||
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"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"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// pingFunc is a function that sends one ICMP echo to dst and returns the RTT.
|
||||
type pingFunc func(dst string, timeout time.Duration) (time.Duration, error)
|
||||
|
||||
// pingNative tests reachability of dst using the host OS ping command.
|
||||
// Used when the main tunnel is a native TUN interface with no netstack.
|
||||
func pingNative(dst string, timeout time.Duration) (time.Duration, error) {
|
||||
timeoutSecs := int(timeout.Seconds())
|
||||
if timeoutSecs < 1 {
|
||||
timeoutSecs = 1
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
|
||||
case "darwin":
|
||||
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
|
||||
default:
|
||||
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", timeoutSecs), dst)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := cmd.Run(); err != nil {
|
||||
return 0, fmt.Errorf("native ping to %s failed: %w", dst, err)
|
||||
}
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
const msgHealthFileWriteFailed = "Failed to write health file: %v"
|
||||
|
||||
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
|
||||
// logger.Debug("Pinging %s", dst)
|
||||
socket, err := tnet.Dial("ping4", dst)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
|
||||
}
|
||||
defer socket.Close()
|
||||
|
||||
// Set socket buffer sizes to handle high bandwidth scenarios
|
||||
if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok {
|
||||
tcpConn.SetReadBuffer(64 * 1024)
|
||||
}
|
||||
if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok {
|
||||
tcpConn.SetWriteBuffer(64 * 1024)
|
||||
}
|
||||
|
||||
requestPing := icmp.Echo{
|
||||
Seq: rand.Intn(1 << 16),
|
||||
Data: []byte("newtping"),
|
||||
}
|
||||
|
||||
icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
|
||||
}
|
||||
|
||||
if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return 0, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = socket.Write(icmpBytes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
// Use larger buffer for reading to handle potential network congestion
|
||||
readBuffer := make([]byte, 1500)
|
||||
n, err := socket.Read(readBuffer)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPacket, err := icmp.ParseMessage(1, readBuffer[:n])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPing, ok := replyPacket.Body.(*icmp.Echo)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body)
|
||||
}
|
||||
|
||||
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
|
||||
return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q",
|
||||
replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data)
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
|
||||
// logger.Debug("Ping to %s successful, latency: %v", dst, latency)
|
||||
|
||||
return latency, nil
|
||||
}
|
||||
|
||||
// reliablePing performs multiple ping attempts with adaptive timeout
|
||||
func reliablePing(fn pingFunc, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) {
|
||||
var lastErr error
|
||||
var totalLatency time.Duration
|
||||
successCount := 0
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
// Adaptive timeout: increase timeout for later attempts
|
||||
timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
|
||||
timeout += jitter
|
||||
|
||||
latency, err := fn(dst, timeout)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err)
|
||||
|
||||
// Brief pause between attempts with exponential backoff
|
||||
if attempt < maxAttempts {
|
||||
backoff := time.Duration(attempt) * 50 * time.Millisecond
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
totalLatency += latency
|
||||
successCount++
|
||||
|
||||
// Return on first success
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
func pingWithRetry(fn pingFunc, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
|
||||
|
||||
if healthFile != "" {
|
||||
err = os.Remove(healthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
initialMaxAttempts = 5
|
||||
initialRetryDelay = 2 * time.Second
|
||||
maxRetryDelay = 60 * time.Second // Cap the maximum delay
|
||||
)
|
||||
|
||||
stopChan = make(chan struct{})
|
||||
attempt := 1
|
||||
retryDelay := initialRetryDelay
|
||||
|
||||
// First try with the initial parameters
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
if latency, err := fn(dst, timeout); err == nil {
|
||||
// Successful ping
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if healthFile != "" {
|
||||
err := os.WriteFile(healthFile, []byte("ok"), 0644)
|
||||
if err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return stopChan, nil
|
||||
} else {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
// Start a goroutine that will attempt pings indefinitely with increasing delays
|
||||
go func() {
|
||||
attempt = 2 // Continue from attempt 2
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
default:
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
|
||||
if latency, err := fn(dst, timeout); err != nil {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
|
||||
// Increase delay after certain thresholds but cap it
|
||||
if attempt%5 == 0 && retryDelay < maxRetryDelay {
|
||||
retryDelay = time.Duration(float64(retryDelay) * 1.5)
|
||||
if retryDelay > maxRetryDelay {
|
||||
retryDelay = maxRetryDelay
|
||||
}
|
||||
logger.Info("Increasing ping retry delay to %v", retryDelay)
|
||||
}
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
attempt++
|
||||
} else {
|
||||
// Successful ping
|
||||
logger.Debug("Ping succeeded after %d attempts", attempt)
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if healthFile != "" {
|
||||
err := os.WriteFile(healthFile, []byte("ok"), 0644)
|
||||
if err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-pingStopChan:
|
||||
// Stop the goroutine when signaled
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Return an error for the first batch of attempts (to maintain compatibility with existing code)
|
||||
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||
}
|
||||
|
||||
// shouldFireRecovery decides whether the data-plane recovery flow in
|
||||
// startPingCheck should run on this tick. Recovery fires once when the
|
||||
// consecutive-failure counter first crosses the threshold; the connectionLost
|
||||
// flag prevents re-firing until a successful ping resets the state.
|
||||
//
|
||||
// This condition was previously inlined into startPingCheck and AND-ed with
|
||||
// `currentInterval < maxInterval`, which silently broke recovery once
|
||||
// pingInterval's default was bumped to 15s while maxInterval stayed at 6s
|
||||
// (commit 8161fa6, March 2026): the gate became permanently false on default
|
||||
// settings, so the recovery code never executed and ping failures climbed
|
||||
// forever — the proximate cause of fosrl/newt#284, #310 and pangolin#1004.
|
||||
//
|
||||
// Recovery and backoff are independent concerns; the backoff ramp is now
|
||||
// computed separately in the caller. Do not re-introduce currentInterval
|
||||
// here.
|
||||
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
|
||||
return consecutiveFailures >= failureThreshold && !connectionLost
|
||||
}
|
||||
|
||||
func startPingCheck(fn pingFunc, serverIP string, client *websocket.Client, tunnelID string) chan struct{} {
|
||||
maxInterval := 6 * time.Second
|
||||
currentInterval := pingInterval
|
||||
consecutiveFailures := 0
|
||||
connectionLost := false
|
||||
|
||||
// Track recent latencies for adaptive timeout calculation
|
||||
recentLatencies := make([]time.Duration, 0, 10)
|
||||
|
||||
pingStopChan := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(currentInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Calculate adaptive timeout based on recent latencies
|
||||
adaptiveTimeout := pingTimeout
|
||||
if len(recentLatencies) > 0 {
|
||||
var sum time.Duration
|
||||
for _, lat := range recentLatencies {
|
||||
sum += lat
|
||||
}
|
||||
avgLatency := sum / time.Duration(len(recentLatencies))
|
||||
// Use 3x average latency as timeout, with minimum of pingTimeout
|
||||
adaptiveTimeout = avgLatency * 3
|
||||
if adaptiveTimeout < pingTimeout {
|
||||
adaptiveTimeout = pingTimeout
|
||||
}
|
||||
if adaptiveTimeout > 15*time.Second {
|
||||
adaptiveTimeout = 15 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// Use reliable ping with multiple attempts
|
||||
maxAttempts := 2
|
||||
if consecutiveFailures > 4 {
|
||||
maxAttempts = 4 // More attempts when connection is unstable
|
||||
}
|
||||
|
||||
latency, err := reliablePing(fn, serverIP, adaptiveTimeout, maxAttempts)
|
||||
if err != nil {
|
||||
consecutiveFailures++
|
||||
|
||||
// Track recent latencies (add a high value for failures)
|
||||
recentLatencies = append(recentLatencies, adaptiveTimeout)
|
||||
if len(recentLatencies) > 10 {
|
||||
recentLatencies = recentLatencies[1:]
|
||||
}
|
||||
|
||||
if consecutiveFailures < 2 {
|
||||
logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
|
||||
} else {
|
||||
logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
|
||||
}
|
||||
|
||||
// More lenient threshold for declaring connection lost under load
|
||||
failureThreshold := 4
|
||||
if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) {
|
||||
connectionLost = true
|
||||
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||
if tunnelID != "" {
|
||||
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
|
||||
}
|
||||
pingChainId := generateChainId()
|
||||
pendingPingChainId = pingChainId
|
||||
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{
|
||||
"chainId": pingChainId,
|
||||
}, 3*time.Second)
|
||||
// Send registration message to the server for backward compatibility
|
||||
bcChainId := generateChainId()
|
||||
pendingRegisterChainId = bcChainId
|
||||
err := client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||
"publicKey": publicKey.String(),
|
||||
"backwardsCompatible": true,
|
||||
"chainId": bcChainId,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to send registration message: %v", err)
|
||||
}
|
||||
if healthFile != "" {
|
||||
err = os.Remove(healthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backoff: ramp the periodic-ping interval up while we are
|
||||
// past the failure threshold, capped at maxInterval. Kept
|
||||
// independent of the recovery trigger above so the trigger
|
||||
// fires on every outage regardless of pingInterval.
|
||||
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 1.3)
|
||||
if currentInterval > maxInterval {
|
||||
currentInterval = maxInterval
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Track recent latencies
|
||||
recentLatencies = append(recentLatencies, latency)
|
||||
// Record tunnel latency (limit sampling to this periodic check)
|
||||
if tunnelID != "" {
|
||||
telemetry.ObserveTunnelLatency(context.Background(), tunnelID, "wireguard", latency.Seconds())
|
||||
}
|
||||
if len(recentLatencies) > 10 {
|
||||
recentLatencies = recentLatencies[1:]
|
||||
}
|
||||
|
||||
if connectionLost {
|
||||
connectionLost = false
|
||||
logger.Info("Connection to server restored after %d failures!", consecutiveFailures)
|
||||
if healthFile != "" {
|
||||
err := os.WriteFile(healthFile, []byte("ok"), 0644)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to write health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentInterval > pingInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 0.9) // Slower decrease
|
||||
if currentInterval < pingInterval {
|
||||
currentInterval = pingInterval
|
||||
}
|
||||
ticker.Reset(currentInterval)
|
||||
logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
}
|
||||
case <-pingStopChan:
|
||||
logger.Info("Stopping ping check")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return pingStopChan
|
||||
}
|
||||
|
||||
func parseTargetData(data interface{}) (TargetData, error) {
|
||||
var targetData TargetData
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
logger.Info("Error marshaling data: %v", err)
|
||||
return targetData, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &targetData); err != nil {
|
||||
logger.Info("Error unmarshaling target data: %v", err)
|
||||
return targetData, err
|
||||
}
|
||||
return targetData, nil
|
||||
}
|
||||
|
||||
// parseTargetString parses a target string in the format "listenPort:host:targetPort"
|
||||
// It properly handles IPv6 addresses which must be in brackets: "listenPort:[ipv6]:targetPort"
|
||||
// Examples:
|
||||
// - IPv4: "3001:192.168.1.1:80"
|
||||
// - IPv6: "3001:[::1]:8080" or "3001:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:80"
|
||||
//
|
||||
// Returns listenPort, targetAddress (in host:port format suitable for net.Dial), and error
|
||||
func parseTargetString(target string) (int, string, error) {
|
||||
// Find the first colon to extract the listen port
|
||||
firstColon := strings.Index(target, ":")
|
||||
if firstColon == -1 {
|
||||
return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target)
|
||||
}
|
||||
|
||||
listenPortStr := target[:firstColon]
|
||||
var listenPort int
|
||||
_, err := fmt.Sscanf(listenPortStr, "%d", &listenPort)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr)
|
||||
}
|
||||
if listenPort <= 0 || listenPort > 65535 {
|
||||
return 0, "", fmt.Errorf("listen port out of range: %d", listenPort)
|
||||
}
|
||||
|
||||
// The remainder is host:targetPort - use net.SplitHostPort which handles IPv6 brackets
|
||||
remainder := target[firstColon+1:]
|
||||
host, targetPort, err := net.SplitHostPort(remainder)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err)
|
||||
}
|
||||
|
||||
// Reject empty host or target port
|
||||
if host == "" {
|
||||
return 0, "", fmt.Errorf("empty host in target: %s", target)
|
||||
}
|
||||
if targetPort == "" {
|
||||
return 0, "", fmt.Errorf("empty target port in target: %s", target)
|
||||
}
|
||||
|
||||
// Reconstruct the target address using JoinHostPort (handles IPv6 properly)
|
||||
targetAddr := net.JoinHostPort(host, targetPort)
|
||||
|
||||
return listenPort, targetAddr, nil
|
||||
}
|
||||
|
||||
func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
|
||||
for _, t := range targetData.Targets {
|
||||
// Parse the target string, handling both IPv4 and IPv6 addresses
|
||||
port, target, err := parseTargetString(t)
|
||||
if err != nil {
|
||||
logger.Info("Invalid target format: %s (%v)", t, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
// Call updown script if provided
|
||||
processedTarget := target
|
||||
if updownScript != "" {
|
||||
newTarget, err := executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
} else if newTarget != "" {
|
||||
processedTarget = newTarget
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove the specific target if it exists
|
||||
err := pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
// Ignore "target not found" errors as this is expected for new targets
|
||||
if !strings.Contains(err.Error(), "target not found") {
|
||||
logger.Error("Failed to remove existing target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new target
|
||||
pm.AddTarget(proto, tunnelIP, port, processedTarget)
|
||||
|
||||
case "remove":
|
||||
logger.Info("Removing target with port %d", port)
|
||||
|
||||
// Call updown script if provided
|
||||
if updownScript != "" {
|
||||
_, err := executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove target: %v", err)
|
||||
return err
|
||||
}
|
||||
default:
|
||||
logger.Info("Unknown action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeUpdownScript(action, proto, target string) (string, error) {
|
||||
if updownScript == "" {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// Split the updownScript in case it contains spaces (like "/usr/bin/python3 script.py")
|
||||
parts := strings.Fields(updownScript)
|
||||
if len(parts) == 0 {
|
||||
return target, fmt.Errorf("invalid updown script command")
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if len(parts) == 1 {
|
||||
// If it's a single executable
|
||||
logger.Info("Executing updown script: %s %s %s %s", updownScript, action, proto, target)
|
||||
cmd = exec.Command(parts[0], action, proto, target)
|
||||
} else {
|
||||
// If it includes interpreter and script
|
||||
args := append(parts[1:], action, proto, target)
|
||||
logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target)
|
||||
cmd = exec.Command(parts[0], args...)
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("updown script execution failed (exit code %d): %s",
|
||||
exitErr.ExitCode(), string(exitErr.Stderr))
|
||||
}
|
||||
return "", fmt.Errorf("updown script execution failed: %v", err)
|
||||
}
|
||||
|
||||
// If the script returns a new target, use it
|
||||
newTarget := strings.TrimSpace(string(output))
|
||||
if newTarget != "" {
|
||||
logger.Info("Updown script returned new target: %s", newTarget)
|
||||
return newTarget, nil
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and
|
||||
// replaces recognised schemes with their resolved values. Currently supported:
|
||||
//
|
||||
// - env.<VAR> – replaced with the value of the named environment variable
|
||||
//
|
||||
// Any token that does not match a supported scheme is left as-is so that
|
||||
// future schemes (e.g. tag., api.) are preserved rather than silently dropped.
|
||||
func interpolateBlueprint(data []byte) []byte {
|
||||
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
|
||||
return re.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||
// strip the surrounding {{ }}
|
||||
inner := strings.TrimSpace(string(match[2 : len(match)-2]))
|
||||
|
||||
if strings.HasPrefix(inner, "env.") {
|
||||
varName := strings.TrimPrefix(inner, "env.")
|
||||
return []byte(os.Getenv(varName))
|
||||
}
|
||||
|
||||
// unrecognised scheme – leave the token untouched
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func sendBlueprint(client *websocket.Client, file string) error {
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
// try to read the blueprint file
|
||||
blueprintData, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
logger.Error("Failed to read blueprint file: %v", err)
|
||||
} else {
|
||||
// interpolate {{env.VAR}} (and any future schemes) before parsing
|
||||
blueprintData = interpolateBlueprint(blueprintData)
|
||||
|
||||
// first we should convert the yaml to json and error if the yaml is bad
|
||||
var yamlObj interface{}
|
||||
var blueprintJsonData string
|
||||
|
||||
err = yaml.Unmarshal(blueprintData, &yamlObj)
|
||||
if err != nil {
|
||||
logger.Error("Failed to parse blueprint YAML: %v", err)
|
||||
} else {
|
||||
// convert to json
|
||||
jsonBytes, err := json.Marshal(yamlObj)
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert blueprint to JSON: %v", err)
|
||||
} else {
|
||||
blueprintJsonData = string(jsonBytes)
|
||||
logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData)
|
||||
}
|
||||
}
|
||||
|
||||
// if we have valid json data, we can send it to the server
|
||||
if blueprintJsonData == "" {
|
||||
logger.Error("No valid blueprint JSON data to send to server")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Sending blueprint to server for application")
|
||||
|
||||
// send the blueprint data to the server
|
||||
err = client.SendMessage("newt/blueprint/apply", map[string]interface{}{
|
||||
"blueprint": blueprintJsonData,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
365
config.go
Normal file
365
config.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
newtpkg "github.com/fosrl/newt/newt"
|
||||
)
|
||||
|
||||
type stringSlice []string
|
||||
|
||||
func (s *stringSlice) String() string {
|
||||
return strings.Join(*s, ",")
|
||||
}
|
||||
|
||||
func (s *stringSlice) Set(value string) error {
|
||||
*s = append(*s, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTLSConfig validates that TLS config fields are consistent and that
|
||||
// referenced files exist.
|
||||
func validateTLSConfig(cfg newtpkg.Config) error {
|
||||
pkcs12Specified := cfg.TLSPrivateKey != ""
|
||||
separateFilesSpecified := cfg.TLSClientCert != "" || cfg.TLSClientKey != "" || len(cfg.TLSClientCAs) > 0
|
||||
|
||||
if pkcs12Specified && separateFilesSpecified {
|
||||
return fmt.Errorf("cannot use both PKCS12 format (--tls-client-cert) and separate certificate files (--tls-client-cert-file, --tls-client-key, --tls-client-ca)")
|
||||
}
|
||||
|
||||
if (cfg.TLSClientCert != "" && cfg.TLSClientKey == "") || (cfg.TLSClientCert == "" && cfg.TLSClientKey != "") {
|
||||
return fmt.Errorf("both --tls-client-cert-file and --tls-client-key must be specified together")
|
||||
}
|
||||
|
||||
if cfg.TLSClientCert != "" {
|
||||
if _, err := os.Stat(cfg.TLSClientCert); os.IsNotExist(err) {
|
||||
return fmt.Errorf("client certificate file does not exist: %s", cfg.TLSClientCert)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.TLSClientKey != "" {
|
||||
if _, err := os.Stat(cfg.TLSClientKey); os.IsNotExist(err) {
|
||||
return fmt.Errorf("client key file does not exist: %s", cfg.TLSClientKey)
|
||||
}
|
||||
}
|
||||
|
||||
for _, caFile := range cfg.TLSClientCAs {
|
||||
if _, err := os.Stat(caFile); os.IsNotExist(err) {
|
||||
return fmt.Errorf("CA certificate file does not exist: %s", caFile)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.TLSPrivateKey != "" {
|
||||
if _, err := os.Stat(cfg.TLSPrivateKey); os.IsNotExist(err) {
|
||||
return fmt.Errorf("PKCS12 certificate file does not exist: %s", cfg.TLSPrivateKey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDurationEnvOrFlag parses s as a duration, using defaultVal on failure.
|
||||
func parseDurationEnvOrFlag(s string, defaultVal time.Duration, label string) time.Duration {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil || d <= 0 {
|
||||
fmt.Printf("Invalid %s value: %s, using default %v\n", label, s, defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// loadNewtConfig reads environment variables and command-line flags, then
|
||||
// returns a populated newtpkg.Config. This function calls flag.Parse internally
|
||||
// and will exit the process if --version is passed.
|
||||
func loadNewtConfig() newtpkg.Config {
|
||||
// ---- read environment variables first ----
|
||||
cfg := newtpkg.Config{
|
||||
Version: newtVersion,
|
||||
Platform: newtPlatform,
|
||||
|
||||
Endpoint: os.Getenv("PANGOLIN_ENDPOINT"),
|
||||
ID: os.Getenv("NEWT_ID"),
|
||||
Secret: os.Getenv("NEWT_SECRET"),
|
||||
DNS: os.Getenv("DNS"),
|
||||
LogLevel: os.Getenv("LOG_LEVEL"),
|
||||
UpdownScript: os.Getenv("UPDOWN_SCRIPT"),
|
||||
InterfaceName: os.Getenv("INTERFACE"),
|
||||
DockerSocket: os.Getenv("DOCKER_SOCKET"),
|
||||
HealthFile: os.Getenv("HEALTH_FILE"),
|
||||
BlueprintFile: os.Getenv("BLUEPRINT_FILE"),
|
||||
ConfigFile: os.Getenv("CONFIG_FILE"),
|
||||
ProvisioningKey: os.Getenv("NEWT_PROVISIONING_KEY"),
|
||||
NewtName: os.Getenv("NEWT_NAME"),
|
||||
TLSClientCert: os.Getenv("TLS_CLIENT_CERT"),
|
||||
TLSClientKey: os.Getenv("TLS_CLIENT_KEY"),
|
||||
TLSPrivateKey: os.Getenv("TLS_CLIENT_CERT_PKCS12"),
|
||||
|
||||
AuthDaemonKey: os.Getenv("AD_KEY"),
|
||||
AuthDaemonPrincipalsFile: os.Getenv("AD_PRINCIPALS_FILE"),
|
||||
AuthDaemonCACertPath: os.Getenv("AD_CA_CERT_PATH"),
|
||||
|
||||
NativeMainInterfaceName: os.Getenv("INTERFACE_MAIN"),
|
||||
ProvisioningBlueprintFile: os.Getenv("PROVISIONING_BLUEPRINT_FILE"),
|
||||
Region: os.Getenv("NEWT_REGION"),
|
||||
AdminAddr: os.Getenv("NEWT_ADMIN_ADDR"),
|
||||
}
|
||||
|
||||
// Legacy PKCS12 backward-compat: fall back to TLS_CLIENT_CERT for PKCS12
|
||||
// when the newer env vars are not set.
|
||||
if cfg.TLSPrivateKey == "" && cfg.TLSClientKey == "" && len(cfg.TLSClientCAs) == 0 {
|
||||
cfg.TLSPrivateKey = os.Getenv("TLS_CLIENT_CERT")
|
||||
}
|
||||
|
||||
// TLS CA files: comma-separated list from env
|
||||
if tlsClientCAsEnv := os.Getenv("TLS_CLIENT_CAS"); tlsClientCAsEnv != "" {
|
||||
for _, ca := range strings.Split(tlsClientCAsEnv, ",") {
|
||||
cfg.TLSClientCAs = append(cfg.TLSClientCAs, strings.TrimSpace(ca))
|
||||
}
|
||||
}
|
||||
|
||||
// Boolean env vars
|
||||
disableClientsEnv := os.Getenv("DISABLE_CLIENTS")
|
||||
disableSSHEnv := os.Getenv("DISABLE_SSH")
|
||||
useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE")
|
||||
useNativeMainInterfaceEnv := os.Getenv("USE_NATIVE_MAIN_INTERFACE")
|
||||
enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT")
|
||||
noCloudEnv := os.Getenv("NO_CLOUD")
|
||||
adGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD")
|
||||
|
||||
cfg.DisableClients = disableClientsEnv == "true"
|
||||
cfg.DisableSSH = disableSSHEnv == "true"
|
||||
cfg.UseNativeInterface = useNativeInterfaceEnv == "true"
|
||||
cfg.UseNativeMainInterface = useNativeMainInterfaceEnv == "true"
|
||||
cfg.EnforceHealthcheckCert = enforceHealthcheckCertEnv == "true"
|
||||
cfg.NoCloud = noCloudEnv == "true"
|
||||
|
||||
if v, err := strconv.ParseBool(adGenerateRandomPasswordEnv); err == nil {
|
||||
cfg.AuthDaemonGenerateRandomPassword = v
|
||||
}
|
||||
|
||||
// Metrics env vars (parsing happens below after flag.Parse)
|
||||
metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED")
|
||||
otlpEnabledEnv := os.Getenv("NEWT_METRICS_OTLP_ENABLED")
|
||||
asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES")
|
||||
pprofEnabledEnv := os.Getenv("NEWT_PPROF_ENABLED")
|
||||
|
||||
if metricsEnabledEnv != "" {
|
||||
if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil {
|
||||
cfg.MetricsEnabled = v
|
||||
} else {
|
||||
cfg.MetricsEnabled = true
|
||||
}
|
||||
}
|
||||
if v, err := strconv.ParseBool(otlpEnabledEnv); err == nil {
|
||||
cfg.OTLPEnabled = v
|
||||
}
|
||||
if v, err := strconv.ParseBool(asyncBytesEnv); err == nil {
|
||||
cfg.MetricsAsyncBytes = v
|
||||
}
|
||||
if v, err := strconv.ParseBool(pprofEnabledEnv); err == nil {
|
||||
cfg.PprofEnabled = v
|
||||
}
|
||||
|
||||
// Numeric / duration env vars (kept as strings; parsed after flag.Parse)
|
||||
mtuStr := os.Getenv("MTU")
|
||||
portStr := os.Getenv("PORT")
|
||||
pingIntervalStr := os.Getenv("PING_INTERVAL")
|
||||
pingTimeoutStr := os.Getenv("PING_TIMEOUT")
|
||||
udpProxyIdleTimeoutStr := os.Getenv("NEWT_UDP_PROXY_IDLE_TIMEOUT")
|
||||
dockerEnforceStr := os.Getenv("DOCKER_ENFORCE_NETWORK_VALIDATION")
|
||||
|
||||
// ---- register CLI flags (only when env was not set) ----
|
||||
|
||||
if cfg.Endpoint == "" {
|
||||
flag.StringVar(&cfg.Endpoint, "endpoint", "", "Endpoint of your pangolin server")
|
||||
}
|
||||
if cfg.ID == "" {
|
||||
flag.StringVar(&cfg.ID, "id", "", "Newt ID")
|
||||
}
|
||||
if cfg.Secret == "" {
|
||||
flag.StringVar(&cfg.Secret, "secret", "", "Newt secret")
|
||||
}
|
||||
if mtuStr == "" {
|
||||
flag.StringVar(&mtuStr, "mtu", "1280", "MTU to use")
|
||||
}
|
||||
if cfg.DNS == "" {
|
||||
flag.StringVar(&cfg.DNS, "dns", "9.9.9.9", "DNS server to use")
|
||||
}
|
||||
if cfg.LogLevel == "" {
|
||||
flag.StringVar(&cfg.LogLevel, "log-level", "INFO", "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
|
||||
}
|
||||
if cfg.UpdownScript == "" {
|
||||
flag.StringVar(&cfg.UpdownScript, "updown", "", "Path to updown script to be called when targets are added or removed")
|
||||
}
|
||||
if cfg.InterfaceName == "" {
|
||||
flag.StringVar(&cfg.InterfaceName, "interface", "newt", "Name of the WireGuard interface")
|
||||
}
|
||||
if portStr == "" {
|
||||
flag.StringVar(&portStr, "port", "", "Port for client WireGuard interface")
|
||||
}
|
||||
if useNativeInterfaceEnv == "" {
|
||||
flag.BoolVar(&cfg.UseNativeInterface, "native", false, "Use native WireGuard interface for client tunnels")
|
||||
}
|
||||
if useNativeMainInterfaceEnv == "" {
|
||||
flag.BoolVar(&cfg.UseNativeMainInterface, "native-main", false, "Use native WireGuard interface for the main tunnel (instead of netstack)")
|
||||
}
|
||||
if cfg.NativeMainInterfaceName == "" {
|
||||
flag.StringVar(&cfg.NativeMainInterfaceName, "interface-main", "newtm", "Name of the native main tunnel WireGuard interface (used with --native-main)")
|
||||
}
|
||||
if disableClientsEnv == "" {
|
||||
flag.BoolVar(&cfg.DisableClients, "disable-clients", false, "Disable clients on the WireGuard interface")
|
||||
}
|
||||
if disableSSHEnv == "" {
|
||||
flag.BoolVar(&cfg.DisableSSH, "disable-ssh", false, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)")
|
||||
}
|
||||
if enforceHealthcheckCertEnv == "" {
|
||||
flag.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)")
|
||||
}
|
||||
if cfg.DockerSocket == "" {
|
||||
flag.StringVar(&cfg.DockerSocket, "docker-socket", "", "Path or address to Docker socket (typically unix:///var/run/docker.sock)")
|
||||
}
|
||||
if pingIntervalStr == "" {
|
||||
flag.StringVar(&pingIntervalStr, "ping-interval", "15s", "Interval for pinging the server (default 15s)")
|
||||
}
|
||||
if pingTimeoutStr == "" {
|
||||
flag.StringVar(&pingTimeoutStr, "ping-timeout", "7s", "Timeout for each ping (default 7s)")
|
||||
}
|
||||
if udpProxyIdleTimeoutStr == "" {
|
||||
flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", "90s", "Idle timeout for UDP proxied client flows before cleanup")
|
||||
}
|
||||
flag.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", "", "Prefer this endpoint for the connection (if set, will override the endpoint from the server)")
|
||||
if cfg.ProvisioningKey == "" {
|
||||
flag.StringVar(&cfg.ProvisioningKey, "provisioning-key", "", "One-time provisioning key used to obtain a newt ID and secret from the server")
|
||||
}
|
||||
if cfg.NewtName == "" {
|
||||
flag.StringVar(&cfg.NewtName, "name", "", "Name for the site created during provisioning (supports {{env.VAR}} interpolation)")
|
||||
}
|
||||
if cfg.ConfigFile == "" {
|
||||
flag.StringVar(&cfg.ConfigFile, "config-file", "", "Path to config file (overrides CONFIG_FILE env var and default location)")
|
||||
}
|
||||
if cfg.TLSClientCert == "" {
|
||||
flag.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", "", "Path to client certificate file (PEM/DER format)")
|
||||
}
|
||||
if cfg.TLSClientKey == "" {
|
||||
flag.StringVar(&cfg.TLSClientKey, "tls-client-key", "", "Path to client private key file (PEM/DER format)")
|
||||
}
|
||||
// Backward-compat dummy flag (auth daemon is always enabled now)
|
||||
flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)")
|
||||
|
||||
var tlsClientCAsFlag stringSlice
|
||||
flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)")
|
||||
|
||||
if cfg.TLSPrivateKey == "" {
|
||||
flag.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", "", "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead")
|
||||
}
|
||||
if dockerEnforceStr == "" {
|
||||
flag.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", "false", "Enforce validation of container on newt network (true or false)")
|
||||
}
|
||||
if cfg.HealthFile == "" {
|
||||
flag.StringVar(&cfg.HealthFile, "health-file", "", "Path to health file (if unset, health file won't be written)")
|
||||
}
|
||||
if cfg.BlueprintFile == "" {
|
||||
flag.StringVar(&cfg.BlueprintFile, "blueprint-file", "", "Path to blueprint file (if unset, no blueprint will be applied)")
|
||||
}
|
||||
if cfg.ProvisioningBlueprintFile == "" {
|
||||
flag.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", "", "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)")
|
||||
}
|
||||
if noCloudEnv == "" {
|
||||
flag.BoolVar(&cfg.NoCloud, "no-cloud", false, "Disable cloud failover")
|
||||
}
|
||||
if metricsEnabledEnv == "" {
|
||||
flag.BoolVar(&cfg.MetricsEnabled, "metrics", false, "Enable Prometheus metrics exporter")
|
||||
}
|
||||
if otlpEnabledEnv == "" {
|
||||
flag.BoolVar(&cfg.OTLPEnabled, "otlp", false, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
}
|
||||
if cfg.AdminAddr == "" {
|
||||
flag.StringVar(&cfg.AdminAddr, "metrics-admin-addr", "127.0.0.1:2112", "Admin/metrics bind address")
|
||||
}
|
||||
if asyncBytesEnv == "" {
|
||||
flag.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", false, "Enable async bytes counting (background flush; lower hot path overhead)")
|
||||
}
|
||||
if pprofEnabledEnv == "" {
|
||||
flag.BoolVar(&cfg.PprofEnabled, "pprof", false, "Enable pprof debug endpoints on admin server")
|
||||
}
|
||||
if cfg.Region == "" {
|
||||
flag.StringVar(&cfg.Region, "region", "", "Optional region resource attribute (also NEWT_REGION)")
|
||||
}
|
||||
if cfg.AuthDaemonKey == "" {
|
||||
flag.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", "", "Pre-shared key for auth daemon authentication")
|
||||
}
|
||||
if cfg.AuthDaemonPrincipalsFile == "" {
|
||||
flag.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", "/var/run/auth-daemon/principals", "Path to the principals file for auth daemon")
|
||||
}
|
||||
if cfg.AuthDaemonCACertPath == "" {
|
||||
flag.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon")
|
||||
}
|
||||
if adGenerateRandomPasswordEnv == "" {
|
||||
flag.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users")
|
||||
}
|
||||
|
||||
version := flag.Bool("version", false, "Print the version")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// ---- post-parse processing ----
|
||||
|
||||
// Merge CLI CA files with env CA files
|
||||
if len(tlsClientCAsFlag) > 0 {
|
||||
cfg.TLSClientCAs = append(cfg.TLSClientCAs, tlsClientCAsFlag...)
|
||||
}
|
||||
|
||||
// Version check (exits process)
|
||||
if *version {
|
||||
fmt.Println("Newt version " + newtVersion)
|
||||
os.Exit(0)
|
||||
} else {
|
||||
logger.Info("Newt version %s", newtVersion)
|
||||
}
|
||||
|
||||
// Parse port
|
||||
if portStr != "" {
|
||||
portInt, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to parse PORT, choosing a random port")
|
||||
} else {
|
||||
cfg.Port = uint16(portInt)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse MTU
|
||||
if mtuStr == "" {
|
||||
mtuStr = "1280"
|
||||
}
|
||||
mtuInt, err := strconv.Atoi(mtuStr)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to parse MTU: %v", err)
|
||||
}
|
||||
cfg.MTU = mtuInt
|
||||
|
||||
// Parse docker network validation flag
|
||||
if dockerEnforceStr != "" {
|
||||
if v, err := strconv.ParseBool(dockerEnforceStr); err == nil {
|
||||
cfg.DockerEnforceNetworkValidation = v
|
||||
} else {
|
||||
logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'")
|
||||
cfg.DockerEnforceNetworkValidation = false
|
||||
}
|
||||
}
|
||||
|
||||
// Parse durations (after flag.Parse so CLI flags take effect)
|
||||
cfg.PingInterval = parseDurationEnvOrFlag(pingIntervalStr, 15*time.Second, "PING_INTERVAL")
|
||||
cfg.PingTimeout = parseDurationEnvOrFlag(pingTimeoutStr, 7*time.Second, "PING_TIMEOUT")
|
||||
cfg.UDPProxyIdleTimeout = parseDurationEnvOrFlag(udpProxyIdleTimeoutStr, 90*time.Second, "NEWT_UDP_PROXY_IDLE_TIMEOUT")
|
||||
|
||||
return cfg
|
||||
}
|
||||
60
newt/authdaemon.go
Normal file
60
newt/authdaemon.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPrincipalsPath = "/var/run/auth-daemon/principals"
|
||||
defaultCACertPath = "/etc/ssh/ca.pem"
|
||||
)
|
||||
|
||||
func (n *Newt) startAuthDaemon(ctx context.Context) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("auth-daemon must be run as root (use sudo)")
|
||||
}
|
||||
|
||||
principalsFile := n.config.AuthDaemonPrincipalsFile
|
||||
if principalsFile == "" {
|
||||
principalsFile = defaultPrincipalsPath
|
||||
}
|
||||
caCertPath := n.config.AuthDaemonCACertPath
|
||||
if caCertPath == "" {
|
||||
caCertPath = defaultCACertPath
|
||||
}
|
||||
|
||||
cfg := authdaemon.Config{
|
||||
DisableHTTPS: true,
|
||||
PresharedKey: "this-key-is-not-used",
|
||||
PrincipalsFilePath: principalsFile,
|
||||
CACertPath: caCertPath,
|
||||
Force: true,
|
||||
GenerateRandomPassword: n.config.AuthDaemonGenerateRandomPassword,
|
||||
}
|
||||
|
||||
srv, err := authdaemon.NewServer(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth daemon server: %w", err)
|
||||
}
|
||||
|
||||
n.authDaemonServer = srv
|
||||
|
||||
go func() {
|
||||
logger.Debug("Auth daemon starting (native mode, no HTTP server)")
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("Auth daemon error: %v", err)
|
||||
}
|
||||
logger.Info("Auth daemon stopped")
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
127
newt/blueprint.go
Normal file
127
newt/blueprint.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// interpolateBlueprint replaces {{env.VAR}} tokens with their environment variable values.
|
||||
func interpolateBlueprint(data []byte) []byte {
|
||||
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
|
||||
return re.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||
inner := strings.TrimSpace(string(match[2 : len(match)-2]))
|
||||
|
||||
if strings.HasPrefix(inner, "env.") {
|
||||
varName := strings.TrimPrefix(inner, "env.")
|
||||
return []byte(os.Getenv(varName))
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func sendBlueprint(client *websocket.Client, file string) error {
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
blueprintData, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
logger.Error("Failed to read blueprint file: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
blueprintData = interpolateBlueprint(blueprintData)
|
||||
|
||||
var yamlObj interface{}
|
||||
if err := yaml.Unmarshal(blueprintData, &yamlObj); err != nil {
|
||||
logger.Error("Failed to parse blueprint YAML: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(yamlObj)
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert blueprint to JSON: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
blueprintJsonData := string(jsonBytes)
|
||||
logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData)
|
||||
|
||||
if blueprintJsonData == "" {
|
||||
logger.Error("No valid blueprint JSON data to send to server")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Sending blueprint to server for application")
|
||||
|
||||
return client.SendMessage("newt/blueprint/apply", map[string]interface{}{
|
||||
"blueprint": blueprintJsonData,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
114
newt/clients.go
Normal file
114
newt/clients.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
wgnetstack "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/clients/permissions"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
func checkNativeMainPermissions() error {
|
||||
return permissions.CheckNativeInterfacePermissions()
|
||||
}
|
||||
|
||||
func (n *Newt) setupClients() {
|
||||
host := n.config.Endpoint
|
||||
if strings.HasPrefix(host, "http://") {
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
} else if strings.HasPrefix(host, "https://") {
|
||||
host = strings.TrimPrefix(host, "https://")
|
||||
}
|
||||
host = strings.TrimSuffix(host, "/")
|
||||
|
||||
logger.Debug("Setting up clients with netstack2...")
|
||||
|
||||
if n.config.UseNativeInterface {
|
||||
logger.Debug("Checking permissions for native interface")
|
||||
if err := permissions.CheckNativeInterfacePermissions(); err != nil {
|
||||
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
n.wgService, err = wgnetstack.NewWireGuardService(
|
||||
n.config.InterfaceName,
|
||||
n.config.Port,
|
||||
n.config.MTU,
|
||||
host,
|
||||
n.config.ID,
|
||||
n.client,
|
||||
n.config.DNS,
|
||||
n.config.UseNativeInterface,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create WireGuard service: %v", err)
|
||||
}
|
||||
|
||||
n.wgService.SetCredentialStore(n.sshCredStore)
|
||||
|
||||
n.client.OnTokenUpdate(func(token string) {
|
||||
n.wgService.SetToken(token)
|
||||
})
|
||||
|
||||
n.ready = true
|
||||
}
|
||||
|
||||
func (n *Newt) setDownstreamTNetstack(tnet *netstack.Net) {
|
||||
if n.wgService != nil {
|
||||
n.wgService.SetOthertnet(tnet)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) closeClients() {
|
||||
logger.Info("Closing clients...")
|
||||
if n.wgService != nil {
|
||||
n.wgService.Close()
|
||||
n.wgService = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) setClientsBlocked(v bool) {
|
||||
if n.wgService != nil {
|
||||
n.wgService.SetBlocked(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(endpoint, ":")
|
||||
if len(parts) < 2 {
|
||||
logger.Error("Invalid endpoint format: %s", endpoint)
|
||||
return
|
||||
}
|
||||
endpoint = strings.Join(parts[:len(parts)-1], ":")
|
||||
|
||||
if n.wgService != nil {
|
||||
n.wgService.StartHolepunch(publicKey, endpoint, relayPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsOnConnect() {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
if n.wgService != nil {
|
||||
n.wgService.LoadRemoteConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsStartDirectRelay(tunnelIP string) {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
if n.wgService != nil {
|
||||
if err := n.wgService.StartDirectUDPRelay(tunnelIP); err != nil {
|
||||
logger.Error("Failed to start direct UDP relay: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
newt/config.go
Normal file
73
newt/config.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package newt
|
||||
|
||||
import "time"
|
||||
|
||||
// Config holds all runtime configuration for a Newt instance.
|
||||
type Config struct {
|
||||
// Build info
|
||||
Version string
|
||||
Platform string
|
||||
|
||||
// Logging
|
||||
LogLevel string
|
||||
|
||||
// Connection
|
||||
Endpoint string
|
||||
ID string
|
||||
Secret string
|
||||
ProvisioningKey string
|
||||
NewtName string
|
||||
ConfigFile string
|
||||
|
||||
// Network
|
||||
MTU int
|
||||
DNS string
|
||||
InterfaceName string
|
||||
Port uint16
|
||||
UseNativeInterface bool
|
||||
UseNativeMainInterface bool
|
||||
NativeMainInterfaceName string
|
||||
NoCloud bool
|
||||
PreferEndpoint string
|
||||
|
||||
// Timing
|
||||
PingInterval time.Duration
|
||||
PingTimeout time.Duration
|
||||
UDPProxyIdleTimeout time.Duration
|
||||
|
||||
// Features
|
||||
DisableClients bool
|
||||
DisableSSH bool
|
||||
EnforceHealthcheckCert bool
|
||||
HealthFile string
|
||||
BlueprintFile string
|
||||
ProvisioningBlueprintFile string
|
||||
UpdownScript string
|
||||
|
||||
// Docker
|
||||
DockerSocket string
|
||||
DockerEnforceNetworkValidation bool
|
||||
|
||||
// Auth daemon
|
||||
AuthDaemonKey string
|
||||
AuthDaemonPrincipalsFile string
|
||||
AuthDaemonCACertPath string
|
||||
AuthDaemonGenerateRandomPassword bool
|
||||
|
||||
// TLS (mTLS)
|
||||
TLSClientCert string
|
||||
TLSClientKey string
|
||||
TLSClientCAs []string
|
||||
TLSPrivateKey string
|
||||
|
||||
// Metrics/observability
|
||||
MetricsEnabled bool
|
||||
OTLPEnabled bool
|
||||
AdminAddr string
|
||||
Region string
|
||||
MetricsAsyncBytes bool
|
||||
PprofEnabled bool
|
||||
|
||||
// Callbacks
|
||||
OnRestart func() error
|
||||
}
|
||||
1438
newt/handlers.go
Normal file
1438
newt/handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
287
newt/newt.go
Normal file
287
newt/newt.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/browsergateway"
|
||||
wgclients "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/docker"
|
||||
"github.com/fosrl/newt/healthcheck"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/util"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
wtun "golang.zx2c4.com/wireguard/tun"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// Newt holds all runtime state for a newt tunnel instance.
|
||||
type Newt struct {
|
||||
config Config
|
||||
client *websocket.Client
|
||||
privateKey wgtypes.Key
|
||||
publicKey wgtypes.Key
|
||||
loggerLevel logger.LogLevel
|
||||
tlsOpt websocket.ClientOption
|
||||
|
||||
// WireGuard tunnel state
|
||||
tun wtun.Device
|
||||
tnet *netstack.Net
|
||||
dev *device.Device
|
||||
|
||||
// Proxy / networking
|
||||
pm *proxy.ProxyManager
|
||||
currentPM atomic.Pointer[proxy.ProxyManager]
|
||||
connectionBlocked atomic.Bool
|
||||
activeRemoteSubnets []string
|
||||
|
||||
// Ping state
|
||||
pingStopChan chan struct{}
|
||||
pingWithRetryStopChan chan struct{}
|
||||
|
||||
// Connection / messaging state
|
||||
connected bool
|
||||
stopFunc func()
|
||||
pendingRegisterChainId string
|
||||
pendingPingChainId string
|
||||
|
||||
// Browser gateway
|
||||
browserGateway *browsergateway.Gateway
|
||||
browserGatewayStop func()
|
||||
|
||||
// Health monitoring
|
||||
healthMonitor *healthcheck.Monitor
|
||||
|
||||
// Downstream WireGuard client management
|
||||
wgService *wgclients.WireGuardService
|
||||
ready bool
|
||||
sshCredStore *nativessh.CredentialStore
|
||||
|
||||
// Auth daemon (Linux only)
|
||||
authDaemonServer *authdaemon.Server
|
||||
|
||||
// Docker monitoring
|
||||
dockerEventMonitor *docker.EventMonitor
|
||||
|
||||
// Current tunnel data
|
||||
wgData WgData
|
||||
}
|
||||
|
||||
// Init creates and initialises a Newt instance. It sets up the websocket
|
||||
// client, generates WireGuard keys, and starts the auth daemon if enabled.
|
||||
// Callers should invoke Start after any additional setup (telemetry, etc.).
|
||||
func Init(ctx context.Context, cfg Config) (*Newt, error) {
|
||||
n := &Newt{config: cfg}
|
||||
|
||||
n.loggerLevel = util.ParseLogLevel(cfg.LogLevel)
|
||||
|
||||
if !cfg.DisableSSH {
|
||||
if err := n.startAuthDaemon(ctx); err != nil {
|
||||
logger.Warn("Did not start on site auth daemon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.GetLogger().SetLevel(n.loggerLevel)
|
||||
|
||||
if cfg.TLSPrivateKey != "" {
|
||||
logger.Warn("Using deprecated PKCS12 format for mTLS. Consider migrating to separate certificate files using --tls-client-cert-file, --tls-client-key, and --tls-client-ca")
|
||||
}
|
||||
|
||||
privateKey, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate private key: %w", err)
|
||||
}
|
||||
n.privateKey = privateKey
|
||||
|
||||
if cfg.TLSClientCert != "" && cfg.TLSClientKey != "" {
|
||||
n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{
|
||||
ClientCertFile: cfg.TLSClientCert,
|
||||
ClientKeyFile: cfg.TLSClientKey,
|
||||
CAFiles: cfg.TLSClientCAs,
|
||||
})
|
||||
logger.Debug("Using separate certificate files for mTLS")
|
||||
logger.Debug("Client cert: %s", cfg.TLSClientCert)
|
||||
logger.Debug("Client key: %s", cfg.TLSClientKey)
|
||||
logger.Debug("CA files: %v", cfg.TLSClientCAs)
|
||||
} else if cfg.TLSPrivateKey != "" {
|
||||
n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{
|
||||
PKCS12File: cfg.TLSPrivateKey,
|
||||
})
|
||||
logger.Debug("Using PKCS12 file for mTLS: %s", cfg.TLSPrivateKey)
|
||||
}
|
||||
|
||||
client, err := websocket.NewClient(
|
||||
"newt",
|
||||
cfg.ID,
|
||||
cfg.Secret,
|
||||
cfg.Endpoint,
|
||||
30*time.Second,
|
||||
n.tlsOpt,
|
||||
websocket.WithConfigFile(cfg.ConfigFile),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create websocket client: %w", err)
|
||||
}
|
||||
n.client = client
|
||||
|
||||
if cfg.ProvisioningKey != "" && client.GetConfig().ProvisioningKey == "" {
|
||||
client.GetConfig().ProvisioningKey = cfg.ProvisioningKey
|
||||
}
|
||||
if cfg.NewtName != "" && client.GetConfig().Name == "" {
|
||||
client.GetConfig().Name = cfg.NewtName
|
||||
}
|
||||
|
||||
// Update config from resolved client values (provisioning / config file).
|
||||
n.config.Endpoint = client.GetConfig().Endpoint
|
||||
n.config.ID = client.GetConfig().ID
|
||||
n.config.Secret = client.GetConfig().Secret
|
||||
|
||||
if !cfg.DisableSSH {
|
||||
n.sshCredStore = nativessh.NewCredentialStore()
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetConfig returns the (potentially resolved) configuration.
|
||||
func (n *Newt) GetConfig() Config {
|
||||
return n.config
|
||||
}
|
||||
|
||||
// GetTLSClientOpt returns the websocket TLS option, so callers can reuse the
|
||||
// same TLS configuration for other HTTP clients (e.g. self-update).
|
||||
func (n *Newt) GetTLSClientOpt() websocket.ClientOption {
|
||||
return n.tlsOpt
|
||||
}
|
||||
|
||||
// Start sets up all WebSocket handlers, connects to the server, and blocks
|
||||
// until ctx is cancelled.
|
||||
func (n *Newt) Start(ctx context.Context) {
|
||||
if !n.config.DisableClients {
|
||||
n.setupClients()
|
||||
}
|
||||
|
||||
n.connectionBlocked.Store(n.client.GetConfig().Blocked)
|
||||
if n.connectionBlocked.Load() {
|
||||
logger.Info("Connection blocking is enabled (from config)")
|
||||
n.setClientsBlocked(true)
|
||||
}
|
||||
|
||||
n.healthMonitor = healthcheck.NewMonitor(func(targets map[int]*healthcheck.Target) {
|
||||
logger.Debug("Health check status update for %d targets", len(targets))
|
||||
|
||||
healthStatuses := make(map[int]interface{})
|
||||
for id, target := range targets {
|
||||
healthStatuses[id] = map[string]interface{}{
|
||||
"status": target.Status.String(),
|
||||
"lastCheck": target.LastCheck.Format(time.RFC3339),
|
||||
"checkCount": target.CheckCount,
|
||||
"lastError": target.LastError,
|
||||
"config": target.Config,
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Health check status: %+v", healthStatuses)
|
||||
|
||||
if err := n.client.SendMessage("newt/healthcheck/status", map[string]interface{}{
|
||||
"targets": healthStatuses,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send health check status update: %v", err)
|
||||
}
|
||||
}, n.config.EnforceHealthcheckCert)
|
||||
|
||||
n.registerHandlers(ctx)
|
||||
|
||||
if err := n.client.Connect(); err != nil {
|
||||
logger.Fatal("Failed to connect to server: %v", err)
|
||||
}
|
||||
defer n.client.Close()
|
||||
|
||||
if n.config.DockerSocket != "" {
|
||||
logger.Debug("Initializing Docker event monitoring")
|
||||
var err error
|
||||
n.dockerEventMonitor, err = docker.NewEventMonitor(
|
||||
n.config.DockerSocket,
|
||||
n.config.DockerEnforceNetworkValidation,
|
||||
func(containers []docker.Container) {
|
||||
logger.Debug("Docker event detected, sending updated container list (%d containers)", len(containers))
|
||||
if err := n.client.SendMessage("newt/socket/containers", map[string]interface{}{
|
||||
"containers": containers,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send updated container list after Docker event: %v", err)
|
||||
} else {
|
||||
logger.Debug("Updated container list sent successfully")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Docker event monitor: %v", err)
|
||||
} else {
|
||||
if err := n.dockerEventMonitor.Start(); err != nil {
|
||||
logger.Error("Failed to start Docker event monitoring: %v", err)
|
||||
} else {
|
||||
logger.Debug("Docker event monitoring started successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if n.config.BlueprintFile != "" {
|
||||
go watchBlueprintFile(ctx, n.config.BlueprintFile, func() error {
|
||||
return sendBlueprint(n.client, n.config.BlueprintFile)
|
||||
})
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
n.closeClients()
|
||||
|
||||
if n.dockerEventMonitor != nil {
|
||||
n.dockerEventMonitor.Stop()
|
||||
}
|
||||
|
||||
if n.healthMonitor != nil {
|
||||
n.healthMonitor.Stop()
|
||||
}
|
||||
|
||||
if n.dev != nil {
|
||||
n.dev.Close()
|
||||
}
|
||||
|
||||
if n.pm != nil {
|
||||
n.pm.Stop()
|
||||
}
|
||||
|
||||
n.client.SendMessage("newt/disconnecting", map[string]any{})
|
||||
|
||||
if n.client != nil {
|
||||
n.client.Close()
|
||||
}
|
||||
logger.Info("Exiting...")
|
||||
}
|
||||
|
||||
// Close performs an emergency shutdown: closes the tunnel, clients, health
|
||||
// monitor, and websocket connection. Typically used before re-exec.
|
||||
func (n *Newt) Close() {
|
||||
n.closeWgTunnel()
|
||||
n.closeClients()
|
||||
if n.healthMonitor != nil {
|
||||
n.healthMonitor.Stop()
|
||||
}
|
||||
if n.client != nil {
|
||||
n.client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func generateChainId() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -317,7 +317,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial.
|
||||
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -345,18 +344,7 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
|
||||
// TestShouldFireRecovery is the regression guard for the broken trigger gate
|
||||
// that prevented data-plane recovery from ever firing under default settings
|
||||
// (fosrl/newt#284, #310, pangolin#1004). The pre-fix condition was
|
||||
//
|
||||
// consecutiveFailures >= failureThreshold && currentInterval < maxInterval
|
||||
//
|
||||
// which became permanently false once pingInterval's default was bumped from
|
||||
// 3s to 15s in commit 8161fa6 — currentInterval starts at pingInterval=15s,
|
||||
// maxInterval stayed at 6s, so 15<6 is false and the recovery branch never
|
||||
// executed.
|
||||
//
|
||||
// The fix is to drop currentInterval from the trigger condition entirely;
|
||||
// backoff is a separate concern computed in the caller. The cases below
|
||||
// exercise the documented contract.
|
||||
// (fosrl/newt#284, #310, pangolin#1004).
|
||||
func TestShouldFireRecovery(t *testing.T) {
|
||||
const threshold = 4
|
||||
cases := []struct {
|
||||
@@ -380,4 +368,4 @@ func TestShouldFireRecovery(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
339
newt/ping.go
Normal file
339
newt/ping.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
type pingFunc func(dst string, timeout time.Duration) (time.Duration, error)
|
||||
|
||||
const msgHealthFileWriteFailed = "Failed to write health file: %v"
|
||||
|
||||
func pingNative(dst string, timeout time.Duration) (time.Duration, error) {
|
||||
timeoutSecs := int(timeout.Seconds())
|
||||
if timeoutSecs < 1 {
|
||||
timeoutSecs = 1
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
|
||||
case "darwin":
|
||||
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
|
||||
default:
|
||||
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", timeoutSecs), dst)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := cmd.Run(); err != nil {
|
||||
return 0, fmt.Errorf("native ping to %s failed: %w", dst, err)
|
||||
}
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
|
||||
socket, err := tnet.Dial("ping4", dst)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
|
||||
}
|
||||
defer socket.Close()
|
||||
|
||||
if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok {
|
||||
tcpConn.SetReadBuffer(64 * 1024)
|
||||
}
|
||||
if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok {
|
||||
tcpConn.SetWriteBuffer(64 * 1024)
|
||||
}
|
||||
|
||||
requestPing := icmp.Echo{
|
||||
Seq: rand.Intn(1 << 16),
|
||||
Data: []byte("newtping"),
|
||||
}
|
||||
|
||||
icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
|
||||
}
|
||||
|
||||
if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return 0, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = socket.Write(icmpBytes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
readBuffer := make([]byte, 1500)
|
||||
n, err := socket.Read(readBuffer)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPacket, err := icmp.ParseMessage(1, readBuffer[:n])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPing, ok := replyPacket.Body.(*icmp.Echo)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body)
|
||||
}
|
||||
|
||||
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
|
||||
return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q",
|
||||
replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data)
|
||||
}
|
||||
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
func reliablePing(fn pingFunc, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) {
|
||||
var lastErr error
|
||||
var totalLatency time.Duration
|
||||
successCount := 0
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond
|
||||
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
|
||||
timeout += jitter
|
||||
|
||||
latency, err := fn(dst, timeout)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err)
|
||||
|
||||
if attempt < maxAttempts {
|
||||
backoff := time.Duration(attempt) * 50 * time.Millisecond
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
totalLatency += latency
|
||||
successCount++
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
// shouldFireRecovery decides whether the data-plane recovery flow should run on
|
||||
// this tick. See startPingCheck for the rationale behind separating recovery
|
||||
// from the backoff ramp.
|
||||
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
|
||||
return consecutiveFailures >= failureThreshold && !connectionLost
|
||||
}
|
||||
|
||||
func (n *Newt) pingWithRetry(fn pingFunc, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
|
||||
if n.config.HealthFile != "" {
|
||||
err = os.Remove(n.config.HealthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
initialRetryDelay = 2 * time.Second
|
||||
maxRetryDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
stopChan = make(chan struct{})
|
||||
attempt := 1
|
||||
retryDelay := initialRetryDelay
|
||||
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
if latency, err := fn(dst, timeout); err == nil {
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return stopChan, nil
|
||||
} else {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
attempt = 2
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
default:
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
|
||||
if latency, err := fn(dst, timeout); err != nil {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
|
||||
if attempt%5 == 0 && retryDelay < maxRetryDelay {
|
||||
retryDelay = time.Duration(float64(retryDelay) * 1.5)
|
||||
if retryDelay > maxRetryDelay {
|
||||
retryDelay = maxRetryDelay
|
||||
}
|
||||
logger.Info("Increasing ping retry delay to %v", retryDelay)
|
||||
}
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
attempt++
|
||||
} else {
|
||||
logger.Debug("Ping succeeded after %d attempts", attempt)
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-n.pingStopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||
}
|
||||
|
||||
func (n *Newt) startPingCheck(fn pingFunc, serverIP, tunnelID string) chan struct{} {
|
||||
maxInterval := 6 * time.Second
|
||||
currentInterval := n.config.PingInterval
|
||||
consecutiveFailures := 0
|
||||
connectionLost := false
|
||||
|
||||
recentLatencies := make([]time.Duration, 0, 10)
|
||||
|
||||
pingStopChan := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(currentInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
adaptiveTimeout := n.config.PingTimeout
|
||||
if len(recentLatencies) > 0 {
|
||||
var sum time.Duration
|
||||
for _, lat := range recentLatencies {
|
||||
sum += lat
|
||||
}
|
||||
avgLatency := sum / time.Duration(len(recentLatencies))
|
||||
adaptiveTimeout = avgLatency * 3
|
||||
if adaptiveTimeout < n.config.PingTimeout {
|
||||
adaptiveTimeout = n.config.PingTimeout
|
||||
}
|
||||
if adaptiveTimeout > 15*time.Second {
|
||||
adaptiveTimeout = 15 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
maxAttempts := 2
|
||||
if consecutiveFailures > 4 {
|
||||
maxAttempts = 4
|
||||
}
|
||||
|
||||
latency, err := reliablePing(fn, serverIP, adaptiveTimeout, maxAttempts)
|
||||
if err != nil {
|
||||
consecutiveFailures++
|
||||
|
||||
recentLatencies = append(recentLatencies, adaptiveTimeout)
|
||||
if len(recentLatencies) > 10 {
|
||||
recentLatencies = recentLatencies[1:]
|
||||
}
|
||||
|
||||
if consecutiveFailures < 2 {
|
||||
logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
|
||||
} else {
|
||||
logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
|
||||
}
|
||||
|
||||
failureThreshold := 4
|
||||
if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) {
|
||||
connectionLost = true
|
||||
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||
if tunnelID != "" {
|
||||
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
|
||||
}
|
||||
pingChainId := generateChainId()
|
||||
n.pendingPingChainId = pingChainId
|
||||
n.stopFunc = n.client.SendMessageInterval("newt/ping/request", map[string]interface{}{
|
||||
"chainId": pingChainId,
|
||||
}, 3*time.Second)
|
||||
bcChainId := generateChainId()
|
||||
n.pendingRegisterChainId = bcChainId
|
||||
if err := n.client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||
"publicKey": n.publicKey.String(),
|
||||
"backwardsCompatible": true,
|
||||
"chainId": bcChainId,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send registration message: %v", err)
|
||||
}
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.Remove(n.config.HealthFile); err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 1.3)
|
||||
if currentInterval > maxInterval {
|
||||
currentInterval = maxInterval
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recentLatencies = append(recentLatencies, latency)
|
||||
if tunnelID != "" {
|
||||
telemetry.ObserveTunnelLatency(context.Background(), tunnelID, "wireguard", latency.Seconds())
|
||||
}
|
||||
if len(recentLatencies) > 10 {
|
||||
recentLatencies = recentLatencies[1:]
|
||||
}
|
||||
|
||||
if connectionLost {
|
||||
connectionLost = false
|
||||
logger.Info("Connection to server restored after %d failures!", consecutiveFailures)
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn("Failed to write health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentInterval > n.config.PingInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 0.9)
|
||||
if currentInterval < n.config.PingInterval {
|
||||
currentInterval = n.config.PingInterval
|
||||
}
|
||||
ticker.Reset(currentInterval)
|
||||
logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
}
|
||||
case <-pingStopChan:
|
||||
logger.Info("Stopping ping check")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return pingStopChan
|
||||
}
|
||||
150
newt/targets.go
Normal file
150
newt/targets.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
)
|
||||
|
||||
func parseTargetData(data interface{}) (TargetData, error) {
|
||||
var targetData TargetData
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
logger.Info("Error marshaling data: %v", err)
|
||||
return targetData, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &targetData); err != nil {
|
||||
logger.Info("Error unmarshaling target data: %v", err)
|
||||
return targetData, err
|
||||
}
|
||||
return targetData, nil
|
||||
}
|
||||
|
||||
// parseTargetString parses "listenPort:host:targetPort", handling IPv6 brackets.
|
||||
func parseTargetString(target string) (int, string, error) {
|
||||
firstColon := strings.Index(target, ":")
|
||||
if firstColon == -1 {
|
||||
return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target)
|
||||
}
|
||||
|
||||
listenPortStr := target[:firstColon]
|
||||
var listenPort int
|
||||
_, err := fmt.Sscanf(listenPortStr, "%d", &listenPort)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr)
|
||||
}
|
||||
if listenPort <= 0 || listenPort > 65535 {
|
||||
return 0, "", fmt.Errorf("listen port out of range: %d", listenPort)
|
||||
}
|
||||
|
||||
remainder := target[firstColon+1:]
|
||||
host, targetPort, err := net.SplitHostPort(remainder)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err)
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return 0, "", fmt.Errorf("empty host in target: %s", target)
|
||||
}
|
||||
if targetPort == "" {
|
||||
return 0, "", fmt.Errorf("empty target port in target: %s", target)
|
||||
}
|
||||
|
||||
return listenPort, net.JoinHostPort(host, targetPort), nil
|
||||
}
|
||||
|
||||
func (n *Newt) updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
|
||||
for _, t := range targetData.Targets {
|
||||
port, target, err := parseTargetString(t)
|
||||
if err != nil {
|
||||
logger.Info("Invalid target format: %s (%v)", t, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
processedTarget := target
|
||||
if n.config.UpdownScript != "" {
|
||||
newTarget, err := n.executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
} else if newTarget != "" {
|
||||
processedTarget = newTarget
|
||||
}
|
||||
}
|
||||
|
||||
err := pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "target not found") {
|
||||
logger.Error("Failed to remove existing target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
pm.AddTarget(proto, tunnelIP, port, processedTarget)
|
||||
|
||||
case "remove":
|
||||
logger.Info("Removing target with port %d", port)
|
||||
|
||||
if n.config.UpdownScript != "" {
|
||||
_, err := n.executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove target: %v", err)
|
||||
return err
|
||||
}
|
||||
default:
|
||||
logger.Info("Unknown action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Newt) executeUpdownScript(action, proto, target string) (string, error) {
|
||||
if n.config.UpdownScript == "" {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
parts := strings.Fields(n.config.UpdownScript)
|
||||
if len(parts) == 0 {
|
||||
return target, fmt.Errorf("invalid updown script command")
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if len(parts) == 1 {
|
||||
logger.Info("Executing updown script: %s %s %s %s", n.config.UpdownScript, action, proto, target)
|
||||
cmd = exec.Command(parts[0], action, proto, target)
|
||||
} else {
|
||||
args := append(parts[1:], action, proto, target)
|
||||
logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target)
|
||||
cmd = exec.Command(parts[0], args...)
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("updown script execution failed (exit code %d): %s",
|
||||
exitErr.ExitCode(), string(exitErr.Stderr))
|
||||
}
|
||||
return "", fmt.Errorf("updown script execution failed: %v", err)
|
||||
}
|
||||
|
||||
newTarget := strings.TrimSpace(string(output))
|
||||
if newTarget != "" {
|
||||
logger.Info("Updown script returned new target: %s", newTarget)
|
||||
return newTarget, nil
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
51
newt/tunnel.go
Normal file
51
newt/tunnel.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/network"
|
||||
)
|
||||
|
||||
func (n *Newt) closeWgTunnel() {
|
||||
if n.pingStopChan != nil {
|
||||
close(n.pingStopChan)
|
||||
n.pingStopChan = nil
|
||||
}
|
||||
|
||||
if n.browserGatewayStop != nil {
|
||||
n.browserGatewayStop()
|
||||
n.browserGatewayStop = nil
|
||||
n.browserGateway = nil
|
||||
}
|
||||
|
||||
if n.pm != nil {
|
||||
n.pm.Stop()
|
||||
n.currentPM.Store(nil)
|
||||
n.pm = nil
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
toRemove := make([]string, 0, len(n.activeRemoteSubnets)+1)
|
||||
if n.wgData.ServerIP != "" {
|
||||
toRemove = append(toRemove, n.wgData.ServerIP+"/32")
|
||||
}
|
||||
toRemove = append(toRemove, n.activeRemoteSubnets...)
|
||||
if len(toRemove) > 0 {
|
||||
if err := network.RemoveRoutes(toRemove); err != nil {
|
||||
logger.Warn("Failed to remove native main tunnel routes: %v", err)
|
||||
}
|
||||
}
|
||||
n.activeRemoteSubnets = nil
|
||||
}
|
||||
|
||||
if n.dev != nil {
|
||||
n.dev.Close()
|
||||
n.dev = nil
|
||||
}
|
||||
|
||||
if n.tnet != nil {
|
||||
n.tnet = nil
|
||||
}
|
||||
if n.tun != nil {
|
||||
n.tun = nil
|
||||
}
|
||||
}
|
||||
61
newt/types.go
Normal file
61
newt/types.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package newt
|
||||
|
||||
import "github.com/fosrl/newt/healthcheck"
|
||||
|
||||
type BrowserGatewayTarget struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Destination string `json:"destination"`
|
||||
DestinationPort int `json:"destinationPort"`
|
||||
AuthToken string `json:"authToken"`
|
||||
}
|
||||
|
||||
type WgData struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
RelayPort uint16 `json:"relayPort"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
ServerIP string `json:"serverIP"`
|
||||
TunnelIP string `json:"tunnelIP"`
|
||||
Targets TargetsByType `json:"targets"`
|
||||
HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"`
|
||||
BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"`
|
||||
RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"`
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
|
||||
type TargetsByType struct {
|
||||
UDP []string `json:"udp"`
|
||||
TCP []string `json:"tcp"`
|
||||
}
|
||||
|
||||
type TargetData struct {
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
|
||||
type ExitNodeData struct {
|
||||
ExitNodes []ExitNode `json:"exitNodes"`
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
|
||||
type ExitNode struct {
|
||||
ID int `json:"exitNodeId"`
|
||||
Name string `json:"exitNodeName"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Weight float64 `json:"weight"`
|
||||
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
|
||||
}
|
||||
|
||||
type ExitNodePingResult struct {
|
||||
ExitNodeID int `json:"exitNodeId"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
Weight float64 `json:"weight"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Name string `json:"exitNodeName"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
|
||||
}
|
||||
|
||||
type BlueprintResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user