diff --git a/cmd/config/config.go b/cmd/config/config.go index 4d5a93e..50255bd 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -134,6 +134,9 @@ func dumpConfig(cfg *config.Config) error { if cfg.IsSet("up.upstream_dns") { up["upstream_dns"] = cfg.GetStringSlice("up.upstream_dns") } + if cfg.IsSet("up.prefer_local_routes") { + up["prefer_local_routes"] = cfg.GetBool("up.prefer_local_routes") + } if len(up) > 0 { out["up"] = up } diff --git a/cmd/status/client/client.go b/cmd/status/client/client.go index 1ccd925..3fd72c3 100644 --- a/cmd/status/client/client.go +++ b/cmd/status/client/client.go @@ -90,7 +90,7 @@ func printStatusTable(status *olm.StatusResponse) { // Print peers if there are any if len(status.PeerStatuses) > 0 { fmt.Println("") - peerHeaders := []string{"SITE", "ENDPOINT", "STATUS", "LAST SEEN", "RELAY"} + peerHeaders := []string{"SITE", "ENDPOINT", "STATUS", "LAST SEEN", "CONNECTION"} peerRows := [][]string{} for _, peer := range status.PeerStatuses { @@ -101,7 +101,7 @@ func printStatusTable(status *olm.StatusResponse) { peer.Endpoint, formatStatus(peer.Connected, true), // Peers don't have registered field, use true lastSeen, - fmt.Sprintf("%t", peer.IsRelay), + formatConnectionMode(peer.IsLocal, peer.IsRelay), }) } @@ -111,6 +111,20 @@ func printStatusTable(status *olm.StatusResponse) { } } +// formatConnectionMode summarizes how a peer is currently connected. Local and relay are +// mutually exclusive; when neither applies the peer is connected directly to its public +// endpoint. +func formatConnectionMode(isLocal, isRelay bool) string { + switch { + case isLocal: + return "Local" + case isRelay: + return "Relay" + default: + return "Direct" + } +} + // formatStatus formats the connection status // Status is only "Connected" when both connected and registered are true func formatStatus(connected, registered bool) string { diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index 21d262e..09b3db2 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -35,24 +35,26 @@ const ( ) type ClientUpCmdOpts struct { - ID string - Secret string - Endpoint string - OrgID string - MTU int - DNS string - InterfaceName string - LogLevel string - HTTPAddr string - PingInterval time.Duration - PingTimeout time.Duration - Holepunch bool - TlsClientCert string - Attached bool - Silent bool - OverrideDNS bool - TunnelDNS bool - UpstreamDNS []string + ID string + Secret string + Endpoint string + OrgID string + MTU int + DNS string + InterfaceName string + LogLevel string + HTTPAddr string + PingInterval time.Duration + PingTimeout time.Duration + Holepunch bool + TlsClientCert string + Attached bool + Silent bool + OverrideDNS bool + TunnelDNS bool + UpstreamDNS []string + MatchDomains []string + PreferLocalRoutes bool } // validateDNSIP ensures the given DNS server string is a valid IP address. @@ -133,6 +135,8 @@ func ClientUpCmd() *cobra.Command { cmd.Flags().BoolVar(&opts.OverrideDNS, "override-dns", true, "When enabled, the client uses custom DNS servers to resolve internal resources and aliases. This overrides your system's default DNS settings. Queries that cannot be resolved as a Pangolin resource will be forwarded to your configured Upstream DNS Server.") cmd.Flags().BoolVar(&opts.TunnelDNS, "tunnel-dns", false, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server.") cmd.Flags().StringSliceVar(&opts.UpstreamDNS, "upstream-dns", []string{}, "List of DNS servers to use for external DNS resolution if overriding system DNS") + cmd.Flags().StringSliceVar(&opts.MatchDomains, "match-domains", nil, "FQDN wildcard patterns (e.g. '*.proxy.internal') to check against local records/upstream DNS; queries for non-matching domains go directly to the system's DNS servers (default: match all domains, or the value from config if set)") + cmd.Flags().BoolVar(&opts.PreferLocalRoutes, "prefer-local-routes", false, "Add tunnel routes with a high metric so overlapping local/connected routes take precedence (default false)") cmd.Flags().BoolVar(&opts.Attached, "attach", false, "Run in attached (foreground) mode, (default: detached (background) mode)") cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Disable TUI and run silently when detached") @@ -154,6 +158,9 @@ func applyUpDefaults(cmd *cobra.Command, opts *ClientUpCmdOpts, cfg *config.Conf if !cmd.Flags().Changed("upstream-dns") && cfg.IsSet("up.upstream_dns") { opts.UpstreamDNS = cfg.GetStringSlice("up.upstream_dns") } + if !cmd.Flags().Changed("prefer-local-routes") && cfg.IsSet("up.prefer_local_routes") { + opts.PreferLocalRoutes = cfg.GetBool("up.prefer_local_routes") + } } func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) error { @@ -161,6 +168,14 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) accountStore := config.AccountStoreFromContext(cmd.Context()) cfg := config.ConfigFromContext(cmd.Context()) + // Fall back to the persisted config value when --match-domains wasn't + // explicitly passed. Resolved here (rather than left to the subprocess, + // which runs as root and may not have access to the user's config) so it + // can be forwarded to the subprocess unconditionally below. + if !cmd.Flags().Changed("match-domains") && cfg.IsSet("up.match_domains_dns") { + opts.MatchDomains = cfg.GetStringSlice("up.match_domains_dns") + } + if runtime.GOOS == "windows" { err := errors.New("this command is currently unsupported on Windows") logger.Error("Error: %v", err) @@ -366,6 +381,17 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) // Comma sep cmdArgs = append(cmdArgs, "--upstream-dns", strings.Join(opts.UpstreamDNS, ",")) } + if len(opts.MatchDomains) > 0 { + // Always forwarded (rather than gated on Changed) since it may have + // come from the persisted config rather than the flag, and the + // subprocess (running as root) may not have access to that config. + cmdArgs = append(cmdArgs, "--match-domains", strings.Join(opts.MatchDomains, ",")) + } + if opts.PreferLocalRoutes { + // Always forwarded when true (rather than gated on Changed) for the + // same reason as MatchDomains above - it may have come from config. + cmdArgs = append(cmdArgs, "--prefer-local-routes") + } // Add positional args if any cmdArgs = append(cmdArgs, extraArgs...) @@ -605,6 +631,8 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) OverrideDNS: opts.OverrideDNS, TunnelDNS: opts.TunnelDNS, UpstreamDNS: upstreamDNS, + MatchDomains: opts.MatchDomains, + PreferLocalRoutes: opts.PreferLocalRoutes, UserToken: userToken, InitialFingerprint: initialFingerprint, InitialPostures: initialPostures, diff --git a/flake.nix b/flake.nix index 64dc9b6..13e1cc9 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,7 @@ version = "0.13.0"; src = ./.; - vendorHash = "sha256-yHU/xfO+I5YncycGkPkpKGUM11YyMrAkS17N/nAmUc0="; + vendorHash = "sha256-S4di9hVoExIgtlTpV9cEHI2Ah6GGSTmsojFE+tPfCb0="; ldflags = [ "-s" diff --git a/go.mod b/go.mod index 2d38a98..4f01339 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,8 @@ require ( github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 - github.com/fosrl/newt v1.14.0 - github.com/fosrl/olm v1.7.0 + github.com/fosrl/newt v1.15.0 + github.com/fosrl/olm v1.8.0 github.com/mattn/go-isatty v0.0.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 87e9625..dfdc6e0 100644 --- a/go.sum +++ b/go.sum @@ -50,10 +50,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/fosrl/newt v1.14.0 h1:9jpyfCNAtsH7rPojyIGJwqOqnLZdxm8b+njY5ZuY/6c= -github.com/fosrl/newt v1.14.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o= -github.com/fosrl/olm v1.7.0 h1:Om/wXKNcbNz1hN46olKrkwQd3bUknzqP2qxCYPDEyXI= -github.com/fosrl/olm v1.7.0/go.mod h1:6TXwb4EXb1DfqB3Ks2QPZotsOtg+aJsa/t4VWaMIPHI= +github.com/fosrl/newt v1.15.0 h1:WpL0whZM1FMjUe2Vy5jSH1bgbxm1O9k1qCyF/mqZT+s= +github.com/fosrl/newt v1.15.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o= +github.com/fosrl/olm v1.8.0 h1:9X+3GRLZzVYXrdvJmL3czRuDK/RD7Y2BLbzonm7SpVM= +github.com/fosrl/olm v1.8.0/go.mod h1:r4GTKfN0sf5L6AQgStwx9lyURsNcog+faX/dn94JOLM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/internal/config/config.go b/internal/config/config.go index d040fec..4b2810c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,21 @@ type UpConfig struct { TunnelDNS *bool `mapstructure:"tunnel_dns" json:"tunnel_dns,omitempty"` UpstreamDNS []string `mapstructure:"upstream_dns" json:"upstream_dns,omitempty"` OverrideDNS *bool `mapstructure:"override_dns" json:"override_dns,omitempty"` + + // MatchDomains lists FQDN wildcard patterns (using * and ? wildcards, e.g. + // "*.proxy.internal") that olm should check against local records/upstream + // DNS. Queries for domains that don't match any pattern are sent directly to + // the host's system DNS servers. Used as the default for `pangolin up`'s + // --match-domains flag when the flag isn't passed explicitly. Empty means + // match every domain (the feature is disabled). + MatchDomains []string `mapstructure:"match_domains_dns" json:"match_domains_dns"` + + // PreferLocalRoutes, when enabled, adds tunnel routes with a high metric so + // overlapping local/connected routes take precedence over the VPN route to + // the same destination. Used as the default for `pangolin up`'s + // --prefer-local-routes flag when the flag isn't passed explicitly. + // Defaults to false. + PreferLocalRoutes *bool `mapstructure:"prefer_local_routes" json:"prefer_local_routes,omitempty"` } // CompanionAppDataDirs holds per-platform overrides for the desktop app data directory. @@ -48,6 +63,8 @@ var ConfigOptions = []string{ "up.tunnel_dns", "up.upstream_dns", "up.override_dns", + "up.match_domains_dns", + "up.prefer_local_routes", } // SupportedConfigKeys returns the settable config keys. @@ -98,6 +115,7 @@ func newConfigViper() (*viper.Viper, error) { v.SetDefault("disable_update_check", false) v.SetDefault("disable_companion_mode", false) v.SetDefault("companion_app_data_dirs", map[string]string{}) + v.SetDefault("up.match_domains_dns", []string{}) return v, nil } @@ -222,6 +240,17 @@ func (c *Config) SetKey(key, value string) error { servers := splitCommaList(value) c.Up.UpstreamDNS = servers c.v.Set(key, servers) + case "up.match_domains_dns": + domains := splitCommaList(value) + c.Up.MatchDomains = domains + c.v.Set(key, domains) + case "up.prefer_local_routes": + b, err := parseBool(value) + if err != nil { + return err + } + c.Up.PreferLocalRoutes = &b + c.v.Set(key, b) default: return fmt.Errorf("unknown config key %q; supported keys: %s", key, strings.Join(SupportedConfigKeys(), ", ")) } @@ -254,6 +283,16 @@ func (c *Config) GetKey(key string) (string, error) { return "", errConfigKeyUnset(key) } return strings.Join(c.GetStringSlice(key), ","), nil + case "up.match_domains_dns": + if !c.IsSet(key) { + return "", errConfigKeyUnset(key) + } + return strings.Join(c.GetStringSlice(key), ","), nil + case "up.prefer_local_routes": + if !c.IsSet(key) { + return "", errConfigKeyUnset(key) + } + return fmt.Sprintf("%t", c.GetBool(key)), nil default: return "", fmt.Errorf("unknown config key %q; supported keys: %s", key, strings.Join(SupportedConfigKeys(), ", ")) } @@ -304,6 +343,12 @@ func (c *Config) Save() error { if c.Up.UpstreamDNS != nil { c.v.Set("up.upstream_dns", c.Up.UpstreamDNS) } + if c.Up.MatchDomains != nil { + c.v.Set("up.match_domains_dns", c.Up.MatchDomains) + } + if c.Up.PreferLocalRoutes != nil { + c.v.Set("up.prefer_local_routes", *c.Up.PreferLocalRoutes) + } dir, err := GetPangolinConfigDir() if err != nil { diff --git a/internal/olm/client.go b/internal/olm/client.go index 4263ca9..323f2c7 100644 --- a/internal/olm/client.go +++ b/internal/olm/client.go @@ -47,6 +47,7 @@ type OLMPeerStatus struct { LastSeen time.Time `json:"lastSeen"` Endpoint string `json:"endpoint,omitempty"` IsRelay bool `json:"isRelay"` + IsLocal bool `json:"isLocal"` // true when connected via a local network endpoint, bypassing both the public endpoint and relay PeerIP string `json:"peerAddress,omitempty"` } diff --git a/scripts/update-vendor-hash.sh b/scripts/update-vendor-hash.sh new file mode 100755 index 0000000..47eba60 --- /dev/null +++ b/scripts/update-vendor-hash.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Recompute flake.nix's vendorHash using an ephemeral nixos/nix container, +# so no Nix install touches the host system. Requires docker (or podman). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FLAKE="$REPO_ROOT/flake.nix" +RUNTIME="${CONTAINER_RUNTIME:-docker}" + +current_hash="$(grep -oP '(?<=vendorHash = ")[^"]+' "$FLAKE")" + +cleanup() { + sed -i "s|vendorHash = pkgs.lib.fakeHash;|vendorHash = \"$current_hash\";|" "$FLAKE" +} +trap cleanup EXIT + +sed -i "s|vendorHash = \"$current_hash\";|vendorHash = pkgs.lib.fakeHash;|" "$FLAKE" + +output="$("$RUNTIME" run --rm -e NIXPKGS_ALLOW_UNFREE=1 -v "$REPO_ROOT":/src -w /src nixos/nix \ + sh -c "git config --global --add safe.directory /src && nix --extra-experimental-features 'nix-command flakes' build --impure .#pangolin-cli 2>&1" || true)" + +new_hash="$(echo "$output" | grep -oP '(?<=got:\s{4})sha256-\S+' || true)" + +if [ -z "$new_hash" ]; then + echo "Could not determine new vendorHash. Full output:" >&2 + echo "$output" >&2 + exit 1 +fi + +sed -i "s|vendorHash = pkgs.lib.fakeHash;|vendorHash = \"$new_hash\";|" "$FLAKE" +trap - EXIT + +echo "vendorHash updated to: $new_hash"