Compare commits
3
Commits
05689e6f72
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3578d18777 | ||
|
|
66fb8bf149 | ||
|
|
bce8bdbb2e |
+25
-10
@@ -1,19 +1,28 @@
|
||||
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||
# depth-independent pattern therefore needs `**/`; only genuinely
|
||||
# root-anchored entries go unprefixed. Never transplant these into
|
||||
# .gitignore, where `**/` is wrong.
|
||||
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||
# `certs/server.key` still ship while this file reads as solved. Only
|
||||
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||
# into .gitignore, where `**/` is wrong.
|
||||
#
|
||||
# Matching is case-sensitive, so secrets use character ranges rather
|
||||
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||
#
|
||||
# Extend with this repo's own host-built artifacts, written anchored:
|
||||
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||
# deletes the package directory from the context.
|
||||
|
||||
# Excluding .git means `git describe` cannot run in any build stage and
|
||||
# fails quietly there; rtnetmon embeds no version, so this is safe.
|
||||
.git
|
||||
# .git is sent without its config. Without a VERSION build argument the
|
||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||
# does not need .git/config; that file can hold a credential, such as a
|
||||
# password in a remote URL or the token the CI checkout step stores there.
|
||||
.git/config
|
||||
|
||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||
# Anchored because agents run at the repo root here.
|
||||
# Anchored because it occurs once where agents run at the repo root.
|
||||
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||
# `services/api/.claude/` and must add its own anchored entry.
|
||||
.claude
|
||||
|
||||
# This repo's own host-built artifacts, root-anchored so `bin/` is not
|
||||
@@ -22,12 +31,15 @@
|
||||
/rtnetmon
|
||||
main.go.old
|
||||
|
||||
# Environment files.
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# convention. Re-include a committed template with a negation if the
|
||||
# build needs one: `!docs/example.env`.
|
||||
**/*.[eE][nN][vV]
|
||||
**/.[eE][nN][vV].*
|
||||
**/.[eE][nN][vV][rR][cC]
|
||||
|
||||
# Private keys and the bundles carrying them.
|
||||
# Private keys and the bundles carrying them. Public certificates
|
||||
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||
**/*.[pP][eE][mM]
|
||||
**/*.[kK][eE][yY]
|
||||
**/*.[pP]12
|
||||
@@ -37,11 +49,14 @@ main.go.old
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
**/[iI][dD]_[eE][dD]25519
|
||||
|
||||
# Dependencies: restored inside the image, never copied in.
|
||||
**/node_modules
|
||||
|
||||
# OS metadata.
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
|
||||
# Editor state.
|
||||
# Editor state: never a build input, and it churns COPY.
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
**/*~
|
||||
|
||||
+15
-1
@@ -34,4 +34,18 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /rtnetmon ./cmd/rtnetmon/
|
||||
# The version stamped into the binary: the VERSION build argument when one
|
||||
# is given, otherwise `git describe --tags --always` of the .git the build
|
||||
# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after
|
||||
# one, the short commit when no tag is reachable. git ships in this base
|
||||
# image. A context that carries .git and still yields no version fails the
|
||||
# build. With neither, as from a source tarball, the binary reports dev.
|
||||
ARG VERSION
|
||||
RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
||||
[ "$version" = unknown ]; }; then \
|
||||
echo "no version could be derived although the build context carries .git" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-X main.Version=${version:-dev}" \
|
||||
-o /rtnetmon ./cmd/rtnetmon/
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
# below are thin shims that call them.
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always)
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
@@ -36,13 +38,14 @@ hooks:
|
||||
@script/install-precommit
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -trimpath -o bin/rtnetmon ./cmd/rtnetmon
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "-X main.Version=$(VERSION)" \
|
||||
-o bin/rtnetmon ./cmd/rtnetmon
|
||||
|
||||
run: build
|
||||
./bin/rtnetmon
|
||||
|
||||
dev:
|
||||
go run ./cmd/rtnetmon
|
||||
go run -ldflags "-X main.Version=$(VERSION)" ./cmd/rtnetmon
|
||||
|
||||
deps:
|
||||
go mod download
|
||||
|
||||
@@ -46,22 +46,27 @@ single pane.
|
||||
setup, unchanged. When neither exists, the single default-route interface is
|
||||
monitored instead.
|
||||
|
||||
**macOS.** The physical internet interface is found from the default route. When
|
||||
a VPN client is running (Mullvad and similar clients create a `utun` tunnel that
|
||||
carries a default route or holds a routable address), that tunnel is monitored
|
||||
as the primary pane alongside the physical interface. With no VPN running, only
|
||||
the physical interface is monitored. Interface names are detected on macOS; the
|
||||
**macOS.** The physical internet interface is found from the default route. The
|
||||
VPN pane appears only while a VPN is connected, meaning its `utun` tunnel
|
||||
carries the IPv4 default route: in `netstat -rn -f inet` that is a `default` row
|
||||
on the tunnel, or both a `0/1` and a `128.0/1` row on it, which some VPN clients
|
||||
install instead of replacing the default. That tunnel is then monitored as the
|
||||
primary pane alongside the physical interface. A tunnel that is up without the
|
||||
default route is ignored, whatever its address: Tailscale without an exit node,
|
||||
or a tunnel a disconnected client left behind. A default route scoped to the
|
||||
tunnel alone (flag `I`) does not count either. With no VPN connected, only the
|
||||
physical interface is monitored. Interface names are detected on macOS; the
|
||||
`--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still
|
||||
set the pane labels.
|
||||
|
||||
### Supported matrix
|
||||
|
||||
| OS | Interfaces monitored |
|
||||
| ----- | ----------------------------------------------------------- |
|
||||
| Linux | `gu0` + `backhaul0` when both exist (two panes) |
|
||||
| Linux | the single default-route interface otherwise (one pane) |
|
||||
| macOS | VPN tunnel + physical default-route interface (two panes) |
|
||||
| macOS | the physical default-route interface with no VPN (one pane) |
|
||||
| OS | Interfaces monitored |
|
||||
| ----- | --------------------------------------------------------------------- |
|
||||
| Linux | `gu0` + `backhaul0` when both exist (two panes) |
|
||||
| Linux | the single default-route interface otherwise (one pane) |
|
||||
| macOS | connected VPN tunnel + physical default-route interface (two panes) |
|
||||
| macOS | the physical default-route interface with no VPN connected (one pane) |
|
||||
|
||||
Anything outside this matrix — on Linux, only one of the named pair present, or
|
||||
no/multiple default routes when neither is present; on macOS, no default route
|
||||
|
||||
@@ -8,8 +8,12 @@ import (
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/cli"
|
||||
)
|
||||
|
||||
// Version is the git tag or short commit, set at link time with -X by the
|
||||
// Makefile and the Dockerfile. Builds that do not set it report dev.
|
||||
var Version = "dev" //nolint:gochecknoglobals // set at link time with -X
|
||||
|
||||
func main() {
|
||||
err := cli.Execute()
|
||||
err := cli.Execute(Version)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@ package cli
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// NewRootCmd exposes newRootCmd for external tests.
|
||||
func NewRootCmd() *cobra.Command { return newRootCmd() }
|
||||
func NewRootCmd() *cobra.Command { return newRootCmd("dev") }
|
||||
|
||||
@@ -51,8 +51,9 @@ func defaultTCPHosts() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// newRootCmd builds the cobra root command with its flags bound.
|
||||
func newRootCmd() *cobra.Command {
|
||||
// newRootCmd builds the cobra root command with its flags bound. version is
|
||||
// logged at startup.
|
||||
func newRootCmd(version string) *cobra.Command {
|
||||
cfg := &config{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "rtnetmon",
|
||||
@@ -60,7 +61,7 @@ func newRootCmd() *cobra.Command {
|
||||
Long: `rtnetmon is a dual-interface network monitoring dashboard that provides
|
||||
real-time visibility into network health, packet loss, and latency.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runMonitor(cmd, cfg)
|
||||
return runMonitor(cmd, cfg, version)
|
||||
},
|
||||
}
|
||||
registerFlags(cmd, cfg)
|
||||
@@ -88,8 +89,8 @@ func registerFlags(cmd *cobra.Command, cfg *config) {
|
||||
|
||||
// runMonitor detects the interfaces to monitor, then constructs and runs the
|
||||
// monitor from cfg.
|
||||
func runMonitor(cmd *cobra.Command, cfg *config) error {
|
||||
monitor.Logf(cfg.LogFile, "Starting rtnetmon")
|
||||
func runMonitor(cmd *cobra.Command, cfg *config, version string) error {
|
||||
monitor.Logf(cfg.LogFile, "Starting rtnetmon %s", version)
|
||||
|
||||
specs, err := detectInterfaces(cmd, cfg)
|
||||
if err != nil {
|
||||
@@ -169,7 +170,7 @@ func detectInterfaces(
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// Execute builds the root command and runs it.
|
||||
func Execute() error {
|
||||
return newRootCmd().Execute()
|
||||
// Execute builds the root command and runs it. version is logged at startup.
|
||||
func Execute(version string) error {
|
||||
return newRootCmd(version).Execute()
|
||||
}
|
||||
|
||||
@@ -41,11 +41,14 @@ type Interface struct {
|
||||
IPv4 []string
|
||||
}
|
||||
|
||||
// Route is one routing-table entry reduced to what detection needs.
|
||||
// Route is one routing-table entry reduced to what detection needs. Scoped
|
||||
// marks a macOS interface-scoped route (flag I), which only traffic bound to
|
||||
// that interface uses.
|
||||
type Route struct {
|
||||
Iface string
|
||||
Gateway string
|
||||
Default bool
|
||||
Scoped bool
|
||||
}
|
||||
|
||||
// Pane names one interface to display, with its label.
|
||||
@@ -133,7 +136,7 @@ func selectLinux(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
|
||||
}
|
||||
|
||||
// selectDarwin monitors the physical default-route interface, plus a VPN
|
||||
// tunnel as the primary pane when one is running.
|
||||
// tunnel as the primary pane while one is connected.
|
||||
func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
|
||||
vpn := findVPN(ifaces, routes)
|
||||
|
||||
@@ -152,15 +155,16 @@ func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// findVPN returns the name of a VPN tunnel interface, or "" if none is
|
||||
// running. A tunnel counts as a running VPN when it carries a default route,
|
||||
// or when it is up with a routable (non-link-local) IPv4 address. Idle system
|
||||
// tunnels have only a link-local IPv6 address and are skipped.
|
||||
// findVPN returns the name of the connected VPN tunnel, or "" if there is
|
||||
// none. A tunnel is the VPN only while it carries the default route; one that
|
||||
// is merely up, even with a routable address (Tailscale without an exit node,
|
||||
// or a tunnel a disconnected client left behind), is not. A default route
|
||||
// scoped to the tunnel does not count: only traffic bound there uses it.
|
||||
func findVPN(ifaces []Interface, routes []Route) string {
|
||||
routeIfaces := map[string]bool{}
|
||||
|
||||
for _, r := range routes {
|
||||
if r.Default {
|
||||
if r.Default && !r.Scoped {
|
||||
routeIfaces[r.Iface] = true
|
||||
}
|
||||
}
|
||||
@@ -176,10 +180,6 @@ func findVPN(ifaces []Interface, routes []Route) string {
|
||||
if routeIfaces[ifi.Name] {
|
||||
return ifi.Name
|
||||
}
|
||||
|
||||
if ifi.Up && hasRoutableIPv4(ifi) {
|
||||
return ifi.Name
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
@@ -219,6 +219,8 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
|
||||
|
||||
// defaultRouteIfaces returns the sorted, unique interface names that carry a
|
||||
// default route, excluding the named VPN interface and any other tunnel.
|
||||
// Scoped routes count: while a VPN holds the default route, the physical
|
||||
// interface keeps only a default route scoped to itself.
|
||||
func defaultRouteIfaces(routes []Route, vpn string) []string {
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -255,21 +257,6 @@ func isTunnel(name string) bool {
|
||||
return strings.HasPrefix(name, "utun")
|
||||
}
|
||||
|
||||
// hasRoutableIPv4 reports whether the interface has an IPv4 address that is
|
||||
// neither loopback nor link-local.
|
||||
func hasRoutableIPv4(ifi Interface) bool {
|
||||
for _, s := range ifi.IPv4 {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// singleLabel is the label for a lone pane: the explicit --labelA if given,
|
||||
// otherwise a plain description.
|
||||
func singleLabel(f Flags) string {
|
||||
@@ -340,23 +327,47 @@ func parseProcNetRoute(out string) []Route {
|
||||
return routes
|
||||
}
|
||||
|
||||
// parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose
|
||||
// destination is "default" are default routes; the Netif column (field 4)
|
||||
// names the interface.
|
||||
// parseNetstat reads `netstat -rn -f inet` output (macOS); the Netif column
|
||||
// (field 4) names the interface. A row whose destination is "default" is a
|
||||
// default route, scoped when its flags include I. A "0/1" row and a "128.0/1"
|
||||
// row on one interface together also make a default route there: VPN clients
|
||||
// that leave the physical default in place send all traffic through them.
|
||||
func parseNetstat(out string) []Route {
|
||||
const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional
|
||||
|
||||
var routes []Route
|
||||
|
||||
var lowHalf []string // interfaces with a 0/1 row
|
||||
|
||||
highHalf := map[string]bool{} // interfaces with a 128.0/1 row
|
||||
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 || fields[0] != "default" {
|
||||
if len(fields) < columns {
|
||||
continue
|
||||
}
|
||||
|
||||
routes = append(routes, Route{
|
||||
Iface: fields[3],
|
||||
Gateway: fields[1],
|
||||
Default: true,
|
||||
})
|
||||
dest, gateway, flags, iface := fields[0], fields[1], fields[2], fields[3]
|
||||
|
||||
switch dest {
|
||||
case "default":
|
||||
routes = append(routes, Route{
|
||||
Iface: iface,
|
||||
Gateway: gateway,
|
||||
Default: true,
|
||||
Scoped: strings.Contains(flags, "I"),
|
||||
})
|
||||
case "0/1":
|
||||
lowHalf = append(lowHalf, iface)
|
||||
case "128.0/1":
|
||||
highHalf[iface] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, iface := range lowHalf {
|
||||
if highHalf[iface] {
|
||||
routes = append(routes, Route{Iface: iface, Default: true})
|
||||
}
|
||||
}
|
||||
|
||||
return routes
|
||||
|
||||
@@ -17,13 +17,96 @@ const (
|
||||
ifaceEth0 = "eth0"
|
||||
ifaceWlan0 = "wlan0"
|
||||
ifaceEn0 = "en0"
|
||||
ifaceUtun0 = "utun0"
|
||||
ifaceUtun3 = "utun3"
|
||||
ifaceUtun4 = "utun4"
|
||||
|
||||
labelGu = "gu LAN - VPN outbound"
|
||||
labelCox = "Cox cable direct"
|
||||
labelDefault = "default route"
|
||||
labelVPN = "VPN"
|
||||
|
||||
addrEn0 = "192.168.1.20"
|
||||
addrEn0 = "192.168.1.20"
|
||||
addrVPN = "10.64.0.2"
|
||||
addrTailscale = "100.101.102.103"
|
||||
)
|
||||
|
||||
// netstatHeader starts `netstat -rn -f inet` output on macOS. Each output
|
||||
// below adds the rows of one routing state: en0 is the physical interface,
|
||||
// utun3 is Tailscale's tunnel and utun4 a VPN client's.
|
||||
const netstatHeader = `Routing tables
|
||||
|
||||
Internet:
|
||||
Destination Gateway Flags Netif Expire`
|
||||
|
||||
const (
|
||||
netstatNoTunnel = netstatHeader + `
|
||||
default 192.168.1.1 UGScg en0
|
||||
127 127.0.0.1 UCS lo0
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
192.168.1 link#6 UCS en0 !
|
||||
192.168.1.1 a4:2b:b0:12:34:56 UHLWIir en0 1187
|
||||
`
|
||||
|
||||
// Tailscale on without an exit node: routes for its own range only.
|
||||
netstatTailscaleIdle = netstatHeader + `
|
||||
default 192.168.1.1 UGScg en0
|
||||
100.64/10 link#22 UCS utun3
|
||||
100.100.100.100/32 link#22 UCS utun3
|
||||
100.101.102.103/32 link#22 UCS utun3
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
`
|
||||
|
||||
// A tunnel a disconnected client left behind, still holding its address.
|
||||
netstatTunnelLeftBehind = netstatHeader + `
|
||||
default 192.168.1.1 UGScg en0
|
||||
10.64.0.2 10.64.0.2 UH utun4
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
`
|
||||
|
||||
// A tunnel whose only default route is scoped to it.
|
||||
netstatTunnelScopedDefault = netstatHeader + `
|
||||
default 192.168.1.1 UGScg en0
|
||||
default link#22 UCSIg utun3
|
||||
100.64/10 link#22 UCS utun3
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
`
|
||||
|
||||
// A tunnel with the lower half of the address space but not the upper.
|
||||
netstatVPNOneHalf = netstatHeader + `
|
||||
0/1 utun4 USc utun4
|
||||
default 192.168.1.1 UGScg en0
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
`
|
||||
|
||||
// A VPN holding the default route; the physical default is now scoped.
|
||||
netstatVPNDefault = netstatHeader + `
|
||||
default link#15 UCSg utun4
|
||||
default 192.168.1.1 UGScIg en0
|
||||
10.64.0.2 10.64.0.2 UH utun4
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
`
|
||||
|
||||
// A VPN on both halves, leaving the physical default in place.
|
||||
netstatVPNHalves = netstatHeader + `
|
||||
0/1 utun4 USc utun4
|
||||
default 192.168.1.1 UGScg en0
|
||||
default 192.168.1.1 UGScIg en0
|
||||
10.64.0.2 10.64.0.2 UH utun4
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
128.0/1 utun4 USc utun4
|
||||
`
|
||||
|
||||
// Idle Tailscale next to a VPN on both halves.
|
||||
netstatTailscaleAndVPN = netstatHeader + `
|
||||
0/1 utun4 USc utun4
|
||||
default 192.168.1.1 UGScg en0
|
||||
10.64.0.2 10.64.0.2 UH utun4
|
||||
100.64/10 link#22 UCS utun3
|
||||
100.101.102.103/32 link#22 UCS utun3
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
128.0/1 utun4 USc utun4
|
||||
`
|
||||
)
|
||||
|
||||
// linuxFlags describes the Linux bridge interfaces rtnetmon was built for.
|
||||
@@ -34,6 +117,17 @@ func linuxFlags() netdetect.Flags {
|
||||
}
|
||||
}
|
||||
|
||||
// macIfaces returns the interfaces every Mac in these tests has (en0,
|
||||
// loopback, and an idle system tunnel with no IPv4 address) plus the given
|
||||
// tunnels.
|
||||
func macIfaces(tunnels ...netdetect.Interface) []netdetect.Interface {
|
||||
return append([]netdetect.Interface{
|
||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||
{Name: "lo0", Up: true, IPv4: []string{"127.0.0.1"}},
|
||||
{Name: ifaceUtun0, Up: true},
|
||||
}, tunnels...)
|
||||
}
|
||||
|
||||
// selectCase is one Select scenario with fake interfaces and routes.
|
||||
type selectCase struct {
|
||||
name string
|
||||
@@ -166,8 +260,8 @@ func TestSelectDarwinPanes(t *testing.T) {
|
||||
goos: osDarwin,
|
||||
ifaces: []netdetect.Interface{
|
||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||
{Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}},
|
||||
{Name: "utun0", Up: true, IPv4: nil},
|
||||
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
|
||||
{Name: ifaceUtun0, Up: true, IPv4: nil},
|
||||
},
|
||||
routes: []netdetect.Route{
|
||||
{Iface: ifaceUtun4, Default: true},
|
||||
@@ -175,21 +269,7 @@ func TestSelectDarwinPanes(t *testing.T) {
|
||||
},
|
||||
flags: linuxFlags(),
|
||||
want: []netdetect.Pane{
|
||||
{Name: ifaceUtun4, Label: "VPN"},
|
||||
{Name: ifaceEn0, Label: labelDefault},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vpn detected by routable address without its own route",
|
||||
goos: osDarwin,
|
||||
ifaces: []netdetect.Interface{
|
||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||
{Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}},
|
||||
},
|
||||
routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}},
|
||||
flags: linuxFlags(),
|
||||
want: []netdetect.Pane{
|
||||
{Name: "utun6", Label: "VPN"},
|
||||
{Name: ifaceUtun4, Label: labelVPN},
|
||||
{Name: ifaceEn0, Label: labelDefault},
|
||||
},
|
||||
},
|
||||
@@ -198,7 +278,7 @@ func TestSelectDarwinPanes(t *testing.T) {
|
||||
goos: osDarwin,
|
||||
ifaces: []netdetect.Interface{
|
||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||
{Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}},
|
||||
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
|
||||
},
|
||||
routes: []netdetect.Route{
|
||||
{Iface: ifaceUtun4, Default: true},
|
||||
@@ -225,13 +305,24 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
||||
goos: osDarwin,
|
||||
ifaces: []netdetect.Interface{
|
||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||
{Name: "utun0", Up: true, IPv4: nil},
|
||||
{Name: ifaceUtun0, Up: true, IPv4: nil},
|
||||
{Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}},
|
||||
},
|
||||
routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}},
|
||||
flags: linuxFlags(),
|
||||
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
||||
},
|
||||
{
|
||||
name: "tunnel with a routable address but no default route",
|
||||
goos: osDarwin,
|
||||
ifaces: []netdetect.Interface{
|
||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||
{Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}},
|
||||
},
|
||||
routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}},
|
||||
flags: linuxFlags(),
|
||||
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
||||
},
|
||||
{
|
||||
name: "no default route",
|
||||
goos: osDarwin,
|
||||
@@ -264,6 +355,81 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSelectDarwinFromNetstat runs macOS routing tables through the netstat
|
||||
// parser into Select: only a tunnel carrying the default route is the VPN.
|
||||
func TestSelectDarwinFromNetstat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tailscale := netdetect.Interface{
|
||||
Name: ifaceUtun3, Up: true, IPv4: []string{addrTailscale},
|
||||
}
|
||||
vpn := netdetect.Interface{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}}
|
||||
physicalOnly := []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}
|
||||
vpnAndPhysical := []netdetect.Pane{
|
||||
{Name: ifaceUtun4, Label: labelVPN},
|
||||
{Name: ifaceEn0, Label: labelDefault},
|
||||
}
|
||||
|
||||
runSelectCases(t, []selectCase{
|
||||
{
|
||||
name: "no tunnel",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(),
|
||||
routes: netdetect.ParseNetstat(netstatNoTunnel),
|
||||
want: physicalOnly,
|
||||
},
|
||||
{
|
||||
name: "tailscale without an exit node",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(tailscale),
|
||||
routes: netdetect.ParseNetstat(netstatTailscaleIdle),
|
||||
want: physicalOnly,
|
||||
},
|
||||
{
|
||||
name: "tunnel left behind by a disconnected client",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(vpn),
|
||||
routes: netdetect.ParseNetstat(netstatTunnelLeftBehind),
|
||||
want: physicalOnly,
|
||||
},
|
||||
{
|
||||
name: "tunnel with only a scoped default route",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(tailscale),
|
||||
routes: netdetect.ParseNetstat(netstatTunnelScopedDefault),
|
||||
want: physicalOnly,
|
||||
},
|
||||
{
|
||||
name: "tunnel with only one half of the address space",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(vpn),
|
||||
routes: netdetect.ParseNetstat(netstatVPNOneHalf),
|
||||
want: physicalOnly,
|
||||
},
|
||||
{
|
||||
name: "vpn on the default route",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(vpn),
|
||||
routes: netdetect.ParseNetstat(netstatVPNDefault),
|
||||
want: vpnAndPhysical,
|
||||
},
|
||||
{
|
||||
name: "vpn on both halves",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(vpn),
|
||||
routes: netdetect.ParseNetstat(netstatVPNHalves),
|
||||
want: vpnAndPhysical,
|
||||
},
|
||||
{
|
||||
name: "idle tailscale next to a connected vpn",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(tailscale, vpn),
|
||||
routes: netdetect.ParseNetstat(netstatTailscaleAndVPN),
|
||||
want: vpnAndPhysical,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseIPRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ detect_pkgmgr() {
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
# Fresh images, the CI runner's among them, ship with empty
|
||||
# package lists. This runs once, on the first install.
|
||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user