Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
18 changes: 16 additions & 2 deletions cmd/status/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
})

}
Expand All @@ -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 {
Expand Down
64 changes: 46 additions & 18 deletions cmd/up/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")

Expand All @@ -154,13 +158,24 @@ 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 {
apiClient := api.FromContext(cmd.Context())
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)
Expand Down Expand Up @@ -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...)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
version = "0.13.0";
src = ./.;

vendorHash = "sha256-yHU/xfO+I5YncycGkPkpKGUM11YyMrAkS17N/nAmUc0=";
vendorHash = "sha256-S4di9hVoExIgtlTpV9cEHI2Ah6GGSTmsojFE+tPfCb0=";

ldflags = [
"-s"
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
45 changes: 45 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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(), ", "))
}
Expand Down Expand Up @@ -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(), ", "))
}
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions internal/olm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
33 changes: 33 additions & 0 deletions scripts/update-vendor-hash.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading