3 Commits

Author SHA1 Message Date
473f1620d5 feat: adopt kardianos/service for daemon lifecycle management
Replace manual signal handling in runDaemon() with kardianos/service
for cross-platform service lifecycle (Start/Stop/Run). Add daemon
start/stop/restart subcommands using service.Control(), and improve
status detection with s.Status() plus socket-check fallback.

Custom macOS install logic (dscl, sudoers, pf, plist generation)
is unchanged — only the runtime lifecycle is delegated to the library.
2026-03-04 14:48:01 -06:00
58626c64e5 feat: add --http-proxy flag for configurable HTTP CONNECT proxy
Add network.httpProxyUrl config field and --http-proxy CLI flag
(default: http://localhost:42051) for apps that only understand
HTTP proxies (opencode, Node.js tools, etc.).

macOS daemon mode now sets:
- ALL_PROXY=socks5h:// for SOCKS5-aware apps (curl, git)
- HTTP_PROXY/HTTPS_PROXY=http:// for HTTP-proxy-aware apps

Credentials from the SOCKS5 proxy URL are automatically injected
into the HTTP proxy URL when not explicitly configured.
2026-03-04 12:47:57 -06:00
f05b4a6b4c fix: include user/password in HTTP_PROXY URL for macOS daemon mode
The HTTP CONNECT proxy URL was missing credentials from the SOCKS5
proxy URL. Now extracts userinfo from the configured proxy URL so
apps authenticating via HTTP_PROXY get the same credentials.
2026-03-04 12:43:10 -06:00
10 changed files with 261 additions and 53 deletions

View File

@@ -4,12 +4,10 @@ import (
"bufio"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"github.com/kardianos/service"
"github.com/spf13/cobra"
"gitea.app.monadical.io/monadical/greywall/internal/daemon"
@@ -22,6 +20,9 @@ import (
// install - Install the LaunchDaemon (requires root)
// uninstall - Uninstall the LaunchDaemon (requires root)
// run - Run the daemon (called by LaunchDaemon plist)
// start - Start the daemon service
// stop - Stop the daemon service
// restart - Restart the daemon service
// status - Show daemon status
func newDaemonCmd() *cobra.Command {
cmd := &cobra.Command{
@@ -35,6 +36,9 @@ and pf rules that enable transparent proxy routing for sandboxed processes.
Commands:
sudo greywall daemon install Install and start the daemon
sudo greywall daemon uninstall Stop and remove the daemon
sudo greywall daemon start Start the daemon service
sudo greywall daemon stop Stop the daemon service
sudo greywall daemon restart Restart the daemon service
greywall daemon status Check daemon status
greywall daemon run Run the daemon (used by LaunchDaemon)`,
}
@@ -43,6 +47,9 @@ Commands:
newDaemonInstallCmd(),
newDaemonUninstallCmd(),
newDaemonRunCmd(),
newDaemonStartCmd(),
newDaemonStopCmd(),
newDaemonRestartCmd(),
newDaemonStatusCmd(),
)
@@ -151,6 +158,57 @@ func newDaemonRunCmd() *cobra.Command {
}
}
// newDaemonStartCmd creates the "daemon start" subcommand.
func newDaemonStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the daemon service",
Long: `Start the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("start")
},
}
}
// newDaemonStopCmd creates the "daemon stop" subcommand.
func newDaemonStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the daemon service",
Long: `Stop the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("stop")
},
}
}
// newDaemonRestartCmd creates the "daemon restart" subcommand.
func newDaemonRestartCmd() *cobra.Command {
return &cobra.Command{
Use: "restart",
Short: "Restart the daemon service",
Long: `Restart the greywall daemon service. Requires root privileges.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonControl("restart")
},
}
}
// daemonControl sends a control action (start/stop/restart) to the daemon
// service via kardianos/service.
func daemonControl(action string) error {
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
if err := service.Control(s, action); err != nil {
return fmt.Errorf("failed to %s daemon: %w", action, err)
}
fmt.Printf("Daemon %sed successfully.\n", action)
return nil
}
// newDaemonStatusCmd creates the "daemon status" subcommand.
func newDaemonStatusCmd() *cobra.Command {
return &cobra.Command{
@@ -159,11 +217,16 @@ func newDaemonStatusCmd() *cobra.Command {
Long: `Check whether the greywall daemon is installed and running. Does not require root.`,
RunE: func(cmd *cobra.Command, args []string) error {
installed := daemon.IsInstalled()
running := daemon.IsRunning()
// Try kardianos/service status first for reliable state detection.
serviceState := daemonServiceState()
running := serviceState == "running"
fmt.Printf("Greywall daemon status:\n")
fmt.Printf(" Installed: %s\n", boolStatus(installed))
fmt.Printf(" Running: %s\n", boolStatus(running))
fmt.Printf(" Service: %s\n", serviceState)
fmt.Printf(" Plist: %s\n", daemon.LaunchDaemonPlistPath)
fmt.Printf(" Binary: %s\n", daemon.InstallBinaryPath)
fmt.Printf(" User: %s\n", daemon.SandboxUserName)
@@ -178,7 +241,7 @@ func newDaemonStatusCmd() *cobra.Command {
fmt.Println()
fmt.Println("The daemon is installed but not running.")
fmt.Printf("Check logs: cat /var/log/greywall.log\n")
fmt.Printf("Start it: sudo launchctl load %s\n", daemon.LaunchDaemonPlistPath)
fmt.Printf("Start it: sudo greywall daemon start\n")
}
return nil
@@ -186,38 +249,47 @@ func newDaemonStatusCmd() *cobra.Command {
}
}
// runDaemon is the main entry point for the daemon process. It starts the
// Unix socket server and blocks until a termination signal is received.
// CLI clients connect to the server to request sessions (which create
// utun tunnels, DNS relays, and pf rules on demand).
// runDaemon is the main entry point for the daemon process. It uses
// kardianos/service to manage the lifecycle, handling signals and
// calling Start/Stop on the program.
func runDaemon(cmd *cobra.Command, args []string) error {
tun2socksPath := filepath.Join(daemon.InstallLibDir, "tun2socks-darwin-"+runtime.GOARCH)
if _, err := os.Stat(tun2socksPath); err != nil {
return fmt.Errorf("tun2socks binary not found at %s (run 'sudo greywall daemon install' first)", tun2socksPath)
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
return s.Run()
}
// daemonServiceState returns the daemon's service state as a string.
// It tries kardianos/service status first, then falls back to socket check.
func daemonServiceState() string {
p := daemon.NewProgram(daemon.DefaultSocketPath, daemon.DefaultTun2socksPath(), debug)
s, err := service.New(p, daemon.NewServiceConfig())
if err != nil {
if daemon.IsRunning() {
return "running"
}
return "stopped"
}
daemon.Logf("Starting daemon (tun2socks=%s, socket=%s)", tun2socksPath, daemon.DefaultSocketPath)
srv := daemon.NewServer(daemon.DefaultSocketPath, tun2socksPath, debug)
if err := srv.Start(); err != nil {
return fmt.Errorf("failed to start daemon server: %w", err)
status, err := s.Status()
if err != nil {
// Fall back to socket check.
if daemon.IsRunning() {
return "running"
}
return "stopped"
}
daemon.Logf("Daemon started, listening on %s", daemon.DefaultSocketPath)
// Wait for termination signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
daemon.Logf("Received signal %s, shutting down", sig)
if err := srv.Stop(); err != nil {
daemon.Logf("Shutdown error: %v", err)
return err
switch status {
case service.StatusRunning:
return "running"
case service.StatusStopped:
return "stopped"
default:
return "unknown"
}
daemon.Logf("Daemon stopped")
return nil
}
// boolStatus returns a human-readable string for a boolean status value.

View File

@@ -31,6 +31,7 @@ var (
monitor bool
settingsPath string
proxyURL string
httpProxyURL string
dnsAddr string
cmdString string
exposePorts []string
@@ -99,6 +100,7 @@ Configuration file format:
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations")
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:42052)")
rootCmd.Flags().StringVar(&httpProxyURL, "http-proxy", "", "HTTP CONNECT proxy URL (default: http://localhost:42051)")
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (default: localhost:42053)")
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
@@ -228,6 +230,9 @@ func runCommand(cmd *cobra.Command, args []string) error {
if proxyURL != "" {
cfg.Network.ProxyURL = proxyURL
}
if httpProxyURL != "" {
cfg.Network.HTTPProxyURL = httpProxyURL
}
if dnsAddr != "" {
cfg.Network.DnsAddr = dnsAddr
}
@@ -240,6 +245,12 @@ func runCommand(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "[greywall] Defaulting proxy to socks5://localhost:42052\n")
}
}
if cfg.Network.HTTPProxyURL == "" {
cfg.Network.HTTPProxyURL = "http://localhost:42051"
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Defaulting HTTP proxy to http://localhost:42051\n")
}
}
if cfg.Network.DnsAddr == "" {
cfg.Network.DnsAddr = "localhost:42053"
if debug {

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25
require (
github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/kardianos/service v1.2.4
github.com/spf13/cobra v1.8.1
github.com/tidwall/jsonc v0.3.2
golang.org/x/sys v0.39.0

2
go.sum
View File

@@ -3,6 +3,8 @@ github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTS
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=

View File

@@ -26,8 +26,9 @@ type Config struct {
// NetworkConfig defines network restrictions.
type NetworkConfig struct {
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
HTTPProxyURL string `json:"httpProxyUrl,omitempty"` // HTTP CONNECT proxy (e.g. http://host:42051)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
@@ -203,6 +204,11 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err)
}
}
if c.Network.HTTPProxyURL != "" {
if err := validateHTTPProxyURL(c.Network.HTTPProxyURL); err != nil {
return fmt.Errorf("invalid network.httpProxyUrl %q: %w", c.Network.HTTPProxyURL, err)
}
}
if c.Network.DnsAddr != "" {
if err := validateHostPort(c.Network.DnsAddr); err != nil {
return fmt.Errorf("invalid network.dnsAddr %q: %w", c.Network.DnsAddr, err)
@@ -273,6 +279,24 @@ func validateProxyURL(proxyURL string) error {
return nil
}
// validateHTTPProxyURL validates an HTTP CONNECT proxy URL.
func validateHTTPProxyURL(proxyURL string) error {
u, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("HTTP proxy URL must use http:// or https:// scheme")
}
if u.Hostname() == "" {
return errors.New("HTTP proxy URL must include a hostname")
}
if u.Port() == "" {
return errors.New("HTTP proxy URL must include a port")
}
return nil
}
// validateHostPort validates a host:port address.
func validateHostPort(addr string) error {
// Must contain a colon separating host and port
@@ -407,9 +431,10 @@ func Merge(base, override *Config) *Config {
AllowPty: base.AllowPty || override.AllowPty,
Network: NetworkConfig{
// ProxyURL/DnsAddr: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
// ProxyURL/HTTPProxyURL/DnsAddr: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
HTTPProxyURL: mergeString(base.Network.HTTPProxyURL, override.Network.HTTPProxyURL),
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
// Append slices (base first, then override additions)
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),

View File

@@ -546,9 +546,3 @@ func logDebug(debug bool, format string, args ...interface{}) {
Logf(format, args...)
}
}
// Logf writes a timestamped message to stderr with the [greywall:daemon] prefix.
func Logf(format string, args ...interface{}) {
ts := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stderr, ts+" [greywall:daemon] "+format+"\n", args...)
}

13
internal/daemon/log.go Normal file
View File

@@ -0,0 +1,13 @@
package daemon
import (
"fmt"
"os"
"time"
)
// Logf writes a timestamped message to stderr with the [greywall:daemon] prefix.
func Logf(format string, args ...interface{}) {
ts := time.Now().Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stderr, ts+" [greywall:daemon] "+format+"\n", args...) //nolint:gosec // logging to stderr, not user-facing HTML
}

View File

@@ -0,0 +1,81 @@
package daemon
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/kardianos/service"
)
// program implements the kardianos/service.Interface for greywall daemon
// lifecycle management. It delegates actual work to the Server type.
type program struct {
server *Server
socketPath string
tun2socksPath string
debug bool
}
// NewProgram creates a new program instance for use with kardianos/service.
func NewProgram(socketPath, tun2socksPath string, debug bool) *program {
return &program{
socketPath: socketPath,
tun2socksPath: tun2socksPath,
debug: debug,
}
}
// Start is called by kardianos/service when the service starts. It verifies
// the tun2socks binary exists, creates and starts the Server. The accept loop
// already runs in a goroutine, so this returns immediately.
func (p *program) Start(_ service.Service) error {
if _, err := os.Stat(p.tun2socksPath); err != nil {
return fmt.Errorf("tun2socks binary not found at %s (run 'sudo greywall daemon install' first)", p.tun2socksPath)
}
Logf("Starting daemon (tun2socks=%s, socket=%s)", p.tun2socksPath, p.socketPath)
p.server = NewServer(p.socketPath, p.tun2socksPath, p.debug)
if err := p.server.Start(); err != nil {
return fmt.Errorf("failed to start daemon server: %w", err)
}
Logf("Daemon started, listening on %s", p.socketPath)
return nil
}
// Stop is called by kardianos/service when the service stops.
func (p *program) Stop(_ service.Service) error {
if p.server == nil {
return nil
}
Logf("Stopping daemon")
if err := p.server.Stop(); err != nil {
Logf("Shutdown error: %v", err)
return err
}
Logf("Daemon stopped")
return nil
}
// NewServiceConfig returns a kardianos/service config matching the existing
// LaunchDaemon setup. The Name matches LaunchDaemonLabel so service.Control()
// can find and manage the already-installed service.
func NewServiceConfig() *service.Config {
return &service.Config{
Name: LaunchDaemonLabel,
DisplayName: "Greywall Daemon",
Description: "Greywall transparent network sandboxing daemon",
Arguments: []string{"daemon", "run"},
}
}
// DefaultTun2socksPath returns the expected tun2socks binary path based on
// the install directory and current architecture.
func DefaultTun2socksPath() string {
return filepath.Join(InstallLibDir, "tun2socks-darwin-"+runtime.GOARCH)
}

View File

@@ -17,7 +17,7 @@ import (
const (
tunIP = "198.18.0.1"
dnsRelayIP = "127.0.0.2"
dnsRelayPort = "15353" // high port; pf rdr rewrites port 53 → this port
dnsRelayPort = "15353" // high port; pf rdr rewrites port 53 → this port
defaultDNSTarget = "127.0.0.1:42053" // proxy's DNS resolver (UDP), used when dnsAddr is not configured
pfAnchorName = "co.greyhaven.greywall"

View File

@@ -763,20 +763,29 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, da
if socks5hURL != "" {
// ALL_PROXY uses socks5h:// (DNS resolved at proxy side) for
// SOCKS5-aware apps (curl, git).
// HTTP_PROXY/HTTPS_PROXY use http:// pointing to the GreyHaven
// HTTP CONNECT proxy (port 42051) for apps that only understand
// HTTP proxies (opencode, Node.js tools, etc.). The CONNECT
// proxy resolves DNS server-side.
proxyHost := "localhost"
if u, err := url.Parse(socks5hURL); err == nil && u.Hostname() != "" {
proxyHost = u.Hostname()
// HTTP_PROXY/HTTPS_PROXY use the configured HTTP CONNECT proxy
// for apps that only understand HTTP proxies (opencode, Node.js
// tools, etc.). The CONNECT proxy resolves DNS server-side.
httpProxyURL := cfg.Network.HTTPProxyURL
// Inject credentials from the SOCKS5 proxy URL into the HTTP proxy
// URL if the HTTP proxy URL doesn't already have credentials.
if httpProxyURL != "" {
if hu, err := url.Parse(httpProxyURL); err == nil && hu.User == nil {
if su, err := url.Parse(socks5hURL); err == nil && su.User != nil {
hu.User = su.User
httpProxyURL = hu.String()
}
}
}
httpProxyURL := "http://" + proxyHost + ":42051"
sandboxEnvs = append(sandboxEnvs,
"ALL_PROXY="+socks5hURL, "all_proxy="+socks5hURL,
"HTTP_PROXY="+httpProxyURL, "http_proxy="+httpProxyURL,
"HTTPS_PROXY="+httpProxyURL, "https_proxy="+httpProxyURL,
)
if httpProxyURL != "" {
sandboxEnvs = append(sandboxEnvs,
"HTTP_PROXY="+httpProxyURL, "http_proxy="+httpProxyURL,
"HTTPS_PROXY="+httpProxyURL, "https_proxy="+httpProxyURL,
)
}
}
termEnvs := getTerminalEnvVars()
parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, "env")