Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d2bd24df |
No files matched your search
@@ -5,8 +5,6 @@
|
|||||||
# below are thin shims that call them.
|
# below are thin shims that call them.
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
VERSION ?= $(shell git describe --tags --always)
|
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
|
|
||||||
@@ -38,14 +36,13 @@ hooks:
|
|||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
build:
|
build:
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags "-X main.Version=$(VERSION)" \
|
CGO_ENABLED=0 go build -trimpath -o bin/rtnetmon ./cmd/rtnetmon
|
||||||
-o bin/rtnetmon ./cmd/rtnetmon
|
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/rtnetmon
|
./bin/rtnetmon
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
go run -ldflags "-X main.Version=$(VERSION)" ./cmd/rtnetmon
|
go run ./cmd/rtnetmon
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
go mod download
|
go mod download
|
||||||
|
|||||||
@@ -46,32 +46,22 @@ single pane.
|
|||||||
setup, unchanged. When neither exists, the single default-route interface is
|
setup, unchanged. When neither exists, the single default-route interface is
|
||||||
monitored instead.
|
monitored instead.
|
||||||
|
|
||||||
**macOS.** The physical internet interface is found from the default route. The
|
**macOS.** The physical internet interface is found from the default route. When
|
||||||
VPN pane appears only while a VPN is connected, meaning its `utun` tunnel
|
a VPN client is running (Mullvad and similar clients create a `utun` tunnel that
|
||||||
carries the IPv4 default route: in `netstat -rn -f inet` that is a `default` row
|
carries a default route or holds a routable address), that tunnel is monitored
|
||||||
on the tunnel, or both a `0/1` and a `128.0/1` row on it, which some VPN clients
|
as the primary pane alongside the physical interface. With no VPN running, only
|
||||||
install instead of replacing the default. That tunnel is then monitored as the
|
the physical interface is monitored. Interface names are detected on macOS; 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. The physical interface is the one non-tunnel
|
|
||||||
`default` row with a next-hop gateway (flag `G`); it keeps that gateway even
|
|
||||||
while a VPN holds the default route and its own default is scoped. An on-link
|
|
||||||
`default` row with no gateway, such as the one vmnet or Internet Sharing
|
|
||||||
installs on `bridge100` for a VM's subnet, is ignored. Interface names are
|
|
||||||
detected on macOS; the
|
|
||||||
`--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still
|
`--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still
|
||||||
set the pane labels.
|
set the pane labels.
|
||||||
|
|
||||||
### Supported matrix
|
### Supported matrix
|
||||||
|
|
||||||
| OS | Interfaces monitored |
|
| OS | Interfaces monitored |
|
||||||
| ----- | --------------------------------------------------------------------- |
|
| ----- | ----------------------------------------------------------- |
|
||||||
| Linux | `gu0` + `backhaul0` when both exist (two panes) |
|
| Linux | `gu0` + `backhaul0` when both exist (two panes) |
|
||||||
| Linux | the single default-route interface otherwise (one pane) |
|
| Linux | the single default-route interface otherwise (one pane) |
|
||||||
| macOS | connected VPN tunnel + physical default-route interface (two panes) |
|
| macOS | VPN tunnel + physical default-route interface (two panes) |
|
||||||
| macOS | the physical default-route interface with no VPN connected (one pane) |
|
| macOS | the physical default-route interface with no VPN (one pane) |
|
||||||
|
|
||||||
Anything outside this matrix — on Linux, only one of the named pair present, or
|
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
|
no/multiple default routes when neither is present; on macOS, no default route
|
||||||
|
|||||||
@@ -5,13 +5,38 @@ One issue per unit of work, one branch and one PR per issue:
|
|||||||
- ensure a tracked issue exists with a definition of done
|
- ensure a tracked issue exists with a definition of done
|
||||||
- branch from `next` (never from `main`)
|
- branch from `next` (never from `main`)
|
||||||
- do the work; open a PR based on `next` (never on `main`)
|
- do the work; open a PR based on `next` (never on `main`)
|
||||||
- pass an independent review, then `clawbot` squash-merges it into `next`
|
- pass an independent review, then the change is squash-merged into `next`
|
||||||
- push; nothing stays local-only
|
- push; nothing stays local-only
|
||||||
|
|
||||||
`next` is the branch for the next milestone and must stay green and mergeable to
|
`next` is the branch for the next milestone and must stay green and
|
||||||
`main` without notice. Only `sneak` merges `next` into `main`. No commits land
|
mergeable to `main` without notice. Only `sneak` merges `next` into
|
||||||
directly on `main` or `next`, only merges via PRs. A deploy is a squash commit
|
`main`, and for now only `sneak` merges into `next`. No commits land
|
||||||
from `main` onto `prod`; `prod` never follows `main` automatically.
|
directly on `main` or `next` — only merges via PRs.
|
||||||
|
|
||||||
Other issue branches do not touch this file; it changes only in its own PR to
|
Issue branches do NOT touch this file — it is maintained on `next`.
|
||||||
`next`, because every branch editing `TODO.md` conflicts with every other.
|
Every branch editing `TODO.md` conflicts with every other.
|
||||||
|
|
||||||
|
# Status
|
||||||
|
|
||||||
|
The repository has been brought up to current repo standards: `script/`
|
||||||
|
Scripts to Rule Them All entrypoints with the `Makefile` reduced to thin
|
||||||
|
shims, a `Dockerfile` whose `lint` and `test` phases gate the build, a
|
||||||
|
`.gitea/workflows/` CI workflow running `script/cibuild`, the vendored
|
||||||
|
`.golangci.yml`, `REPO_POLICIES.md`, `.editorconfig`, `.dockerignore`, a
|
||||||
|
`LICENSE` file, and a comprehensive `.gitignore`.
|
||||||
|
|
||||||
|
Lint is clean under the standard `default: all` configuration, with the
|
||||||
|
findings fixed rather than suppressed. The only annotations are
|
||||||
|
justified `//nolint:gosec` on the `ping`/`curl` subprocess calls (G204)
|
||||||
|
and on opening the operator-chosen log file (G304): fixed argv with no
|
||||||
|
shell, so these are false positives, annotated as the reference repos do.
|
||||||
|
|
||||||
|
# Next Step
|
||||||
|
|
||||||
|
Feature work, each on its own branch and PR from `next`:
|
||||||
|
|
||||||
|
- https://git.eeqj.de/sneak/rtnetmon/issues/2 — macOS support:
|
||||||
|
VPN-aware interface detection and a single-interface UI when only one
|
||||||
|
interface exists.
|
||||||
|
- https://git.eeqj.de/sneak/rtnetmon/issues/3 — show Starlink status
|
||||||
|
lines when Starlink is the non-VPN gateway.
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version is the git tag or short commit, set at link time with -X by the
|
// 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.
|
// Dockerfile. Builds that do not set it report dev.
|
||||||
var Version = "dev" //nolint:gochecknoglobals // set at link time with -X
|
var Version = "dev" //nolint:gochecknoglobals // set at link time with -X
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
|
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
|
||||||
)
|
)
|
||||||
@@ -60,9 +59,6 @@ func (m *Monitor) TCPHosts() []string { return m.tcpHosts }
|
|||||||
// Interfaces returns the monitored interfaces.
|
// Interfaces returns the monitored interfaces.
|
||||||
func (m *Monitor) Interfaces() []*InterfaceStatus { return m.interfaces }
|
func (m *Monitor) Interfaces() []*InterfaceStatus { return m.interfaces }
|
||||||
|
|
||||||
// ClockStrings exposes clockStrings for external tests.
|
|
||||||
func ClockStrings(now time.Time) (string, string) { return clockStrings(now) }
|
|
||||||
|
|
||||||
// PingArgs exposes pingArgs for external tests.
|
// PingArgs exposes pingArgs for external tests.
|
||||||
func PingArgs(goos, iface, host string) []string { return pingArgs(goos, iface, host) }
|
func PingArgs(goos, iface, host string) []string { return pingArgs(goos, iface, host) }
|
||||||
|
|
||||||
|
|||||||
+10
-11
@@ -337,15 +337,6 @@ func drawICMPStats(scr tcell.Screen, y int, st *InterfaceStatus) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// uiLoop runs the UI event loop.
|
// uiLoop runs the UI event loop.
|
||||||
// clockFormat is the layout of both header clocks.
|
|
||||||
const clockFormat = time.RFC1123Z
|
|
||||||
|
|
||||||
// clockStrings renders the header clocks: UTC first, then the host's local
|
|
||||||
// time in whatever location now carries.
|
|
||||||
func clockStrings(now time.Time) (string, string) {
|
|
||||||
return now.UTC().Format(clockFormat), now.Format(clockFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Monitor) uiLoop(ctx context.Context) {
|
func (m *Monitor) uiLoop(ctx context.Context) {
|
||||||
m.logf("UI loop started")
|
m.logf("UI loop started")
|
||||||
|
|
||||||
@@ -357,6 +348,13 @@ func (m *Monitor) uiLoop(ctx context.Context) {
|
|||||||
m.logf("Screen finalized")
|
m.logf("Screen finalized")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
pstLoc, err := time.LoadLocation("America/Los_Angeles")
|
||||||
|
if err != nil {
|
||||||
|
m.logf("Error loading PST location: %v", err)
|
||||||
|
|
||||||
|
pstLoc = time.UTC
|
||||||
|
}
|
||||||
|
|
||||||
timestampSpinFrame := 0
|
timestampSpinFrame := 0
|
||||||
lastTimestampSpinUpdate := time.Now()
|
lastTimestampSpinUpdate := time.Now()
|
||||||
|
|
||||||
@@ -367,7 +365,8 @@ func (m *Monitor) uiLoop(ctx context.Context) {
|
|||||||
Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault)
|
Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
timeStr, localTimeStr := clockStrings(now)
|
timeStr := now.Format(time.RFC1123Z)
|
||||||
|
pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z)
|
||||||
|
|
||||||
if time.Since(lastTimestampSpinUpdate) >= time.Second {
|
if time.Since(lastTimestampSpinUpdate) >= time.Second {
|
||||||
timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins())
|
timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins())
|
||||||
@@ -381,7 +380,7 @@ func (m *Monitor) uiLoop(ctx context.Context) {
|
|||||||
Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault)
|
Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault)
|
||||||
DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors)
|
DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors)
|
||||||
DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock,
|
DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock,
|
||||||
localTimeStr, colors)
|
pstTimeStr, colors)
|
||||||
Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault)
|
Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault)
|
||||||
|
|
||||||
runtime := time.Since(m.startTime).Round(time.Second).String()
|
runtime := time.Since(m.startTime).Round(time.Second).String()
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package monitor_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestClockStrings checks that the header shows UTC first and the host's
|
|
||||||
// local time second, so the two differ whenever the host is not on UTC.
|
|
||||||
func TestClockStrings(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
berlin := time.FixedZone("CET", 3600)
|
|
||||||
now := time.Date(2026, time.November, 6, 20, 30, 0, 0, berlin)
|
|
||||||
|
|
||||||
utc, local := monitor.ClockStrings(now)
|
|
||||||
|
|
||||||
if want := "Fri, 06 Nov 2026 19:30:00 +0000"; utc != want {
|
|
||||||
t.Errorf("utc = %q, want %q", utc, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if want := "Fri, 06 Nov 2026 20:30:00 +0100"; local != want {
|
|
||||||
t.Errorf("local = %q, want %q", local, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,16 +41,11 @@ type Interface struct {
|
|||||||
IPv4 []string
|
IPv4 []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route is one routing-table entry reduced to what detection needs. Gateway
|
// Route is one routing-table entry reduced to what detection needs.
|
||||||
// is the next-hop address, empty for an on-link route that has none: a
|
|
||||||
// tunnel, or the pseudo-default that vmnet and Internet Sharing install on a
|
|
||||||
// bridge so the hosted subnet works. Scoped marks a macOS interface-scoped
|
|
||||||
// route (flag I), which only traffic bound to that interface uses.
|
|
||||||
type Route struct {
|
type Route struct {
|
||||||
Iface string
|
Iface string
|
||||||
Gateway string
|
Gateway string
|
||||||
Default bool
|
Default bool
|
||||||
Scoped bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pane names one interface to display, with its label.
|
// Pane names one interface to display, with its label.
|
||||||
@@ -138,7 +133,7 @@ func selectLinux(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// selectDarwin monitors the physical default-route interface, plus a VPN
|
// selectDarwin monitors the physical default-route interface, plus a VPN
|
||||||
// tunnel as the primary pane while one is connected.
|
// tunnel as the primary pane when one is running.
|
||||||
func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
|
func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
|
||||||
vpn := findVPN(ifaces, routes)
|
vpn := findVPN(ifaces, routes)
|
||||||
|
|
||||||
@@ -157,16 +152,15 @@ func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findVPN returns the name of the connected VPN tunnel, or "" if there is
|
// findVPN returns the name of a VPN tunnel interface, or "" if none is
|
||||||
// none. A tunnel is the VPN only while it carries the default route; one that
|
// running. A tunnel counts as a running VPN when it carries a default route,
|
||||||
// is merely up, even with a routable address (Tailscale without an exit node,
|
// or when it is up with a routable (non-link-local) IPv4 address. Idle system
|
||||||
// or a tunnel a disconnected client left behind), is not. A default route
|
// tunnels have only a link-local IPv6 address and are skipped.
|
||||||
// scoped to the tunnel does not count: only traffic bound there uses it.
|
|
||||||
func findVPN(ifaces []Interface, routes []Route) string {
|
func findVPN(ifaces []Interface, routes []Route) string {
|
||||||
routeIfaces := map[string]bool{}
|
routeIfaces := map[string]bool{}
|
||||||
|
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
if r.Default && !r.Scoped {
|
if r.Default {
|
||||||
routeIfaces[r.Iface] = true
|
routeIfaces[r.Iface] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +176,10 @@ func findVPN(ifaces []Interface, routes []Route) string {
|
|||||||
if routeIfaces[ifi.Name] {
|
if routeIfaces[ifi.Name] {
|
||||||
return ifi.Name
|
return ifi.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ifi.Up && hasRoutableIPv4(ifi) {
|
||||||
|
return ifi.Name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
@@ -190,9 +188,7 @@ func findVPN(ifaces []Interface, routes []Route) string {
|
|||||||
// onlyDefaultRoute returns the single default-route interface, or an error if
|
// onlyDefaultRoute returns the single default-route interface, or an error if
|
||||||
// there is not exactly one.
|
// there is not exactly one.
|
||||||
func onlyDefaultRoute(routes []Route) (string, error) {
|
func onlyDefaultRoute(routes []Route) (string, error) {
|
||||||
names := defaultRouteIfaces(routes, func(r Route) bool {
|
names := defaultRouteIfaces(routes, "")
|
||||||
return !isTunnel(r.Iface)
|
|
||||||
})
|
|
||||||
|
|
||||||
switch len(names) {
|
switch len(names) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -206,16 +202,9 @@ func onlyDefaultRoute(routes []Route) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// onlyPhysicalDefaultRoute returns the single non-tunnel default-route
|
// onlyPhysicalDefaultRoute returns the single non-tunnel default-route
|
||||||
// interface (ignoring the VPN), or an error if there is not exactly one. Only
|
// interface (ignoring the VPN), or an error if there is not exactly one.
|
||||||
// a default route with a next-hop gateway counts: scoped routes do, since
|
|
||||||
// while a VPN holds the default route the physical interface keeps only a
|
|
||||||
// default route scoped to itself, but that route still names the gateway. An
|
|
||||||
// on-link default without one (vmnet's on bridge100) reaches nothing beyond
|
|
||||||
// its own subnet and is not an uplink.
|
|
||||||
func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
|
func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
|
||||||
names := defaultRouteIfaces(routes, func(r Route) bool {
|
names := defaultRouteIfaces(routes, vpn)
|
||||||
return r.Iface != vpn && !isTunnel(r.Iface) && r.Gateway != ""
|
|
||||||
})
|
|
||||||
|
|
||||||
switch len(names) {
|
switch len(names) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -229,14 +218,14 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// defaultRouteIfaces returns the sorted, unique interface names that carry a
|
// defaultRouteIfaces returns the sorted, unique interface names that carry a
|
||||||
// default route accepted by keep.
|
// default route, excluding the named VPN interface and any other tunnel.
|
||||||
func defaultRouteIfaces(routes []Route, keep func(Route) bool) []string {
|
func defaultRouteIfaces(routes []Route, vpn string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|
||||||
var names []string
|
var names []string
|
||||||
|
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
if !r.Default || !keep(r) || seen[r.Iface] {
|
if !r.Default || r.Iface == vpn || isTunnel(r.Iface) || seen[r.Iface] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +255,21 @@ func isTunnel(name string) bool {
|
|||||||
return strings.HasPrefix(name, "utun")
|
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,
|
// singleLabel is the label for a lone pane: the explicit --labelA if given,
|
||||||
// otherwise a plain description.
|
// otherwise a plain description.
|
||||||
func singleLabel(f Flags) string {
|
func singleLabel(f Flags) string {
|
||||||
@@ -336,60 +340,23 @@ func parseProcNetRoute(out string) []Route {
|
|||||||
return routes
|
return routes
|
||||||
}
|
}
|
||||||
|
|
||||||
// netstat route flags (see `man netstat`).
|
// parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose
|
||||||
const (
|
// destination is "default" are default routes; the Netif column (field 4)
|
||||||
netstatFlagGateway = "G" // destination is reached through a next-hop gateway
|
// names the interface.
|
||||||
netstatFlagScoped = "I" // route is scoped to its 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. Its Gateway column is the
|
|
||||||
// next hop only when the flags include G; otherwise it names the link itself
|
|
||||||
// (link#N) and the route is on-link, with no gateway. 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 {
|
func parseNetstat(out string) []Route {
|
||||||
const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional
|
|
||||||
|
|
||||||
var routes []Route
|
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") {
|
for _, line := range strings.Split(out, "\n") {
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) < columns {
|
if len(fields) < 4 || fields[0] != "default" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
dest, gateway, flags, iface := fields[0], fields[1], fields[2], fields[3]
|
routes = append(routes, Route{
|
||||||
|
Iface: fields[3],
|
||||||
switch dest {
|
Gateway: fields[1],
|
||||||
case "default":
|
Default: true,
|
||||||
if !strings.Contains(flags, netstatFlagGateway) {
|
})
|
||||||
gateway = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
routes = append(routes, Route{
|
|
||||||
Iface: iface,
|
|
||||||
Gateway: gateway,
|
|
||||||
Default: true,
|
|
||||||
Scoped: strings.Contains(flags, netstatFlagScoped),
|
|
||||||
})
|
|
||||||
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
|
return routes
|
||||||
|
|||||||
@@ -17,125 +17,13 @@ const (
|
|||||||
ifaceEth0 = "eth0"
|
ifaceEth0 = "eth0"
|
||||||
ifaceWlan0 = "wlan0"
|
ifaceWlan0 = "wlan0"
|
||||||
ifaceEn0 = "en0"
|
ifaceEn0 = "en0"
|
||||||
ifaceBridge = "bridge100"
|
|
||||||
ifaceUtun0 = "utun0"
|
|
||||||
ifaceUtun3 = "utun3"
|
|
||||||
ifaceUtun4 = "utun4"
|
ifaceUtun4 = "utun4"
|
||||||
|
|
||||||
labelGu = "gu LAN - VPN outbound"
|
labelGu = "gu LAN - VPN outbound"
|
||||||
labelCox = "Cox cable direct"
|
labelCox = "Cox cable direct"
|
||||||
labelDefault = "default route"
|
labelDefault = "default route"
|
||||||
labelVPN = "VPN"
|
|
||||||
|
|
||||||
addrEn0 = "192.168.1.20"
|
addrEn0 = "192.168.1.20"
|
||||||
gatewayEn0 = "192.168.1.1"
|
|
||||||
addrBridge = "192.168.64.1"
|
|
||||||
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
|
|
||||||
`
|
|
||||||
|
|
||||||
// A VM with NAT networking: vmnet installs an on-link default route,
|
|
||||||
// scoped to its bridge and with no gateway, next to the real one.
|
|
||||||
netstatVMBridge = netstatHeader + `
|
|
||||||
default 192.168.1.1 UGScg en0
|
|
||||||
default link#45 UCSIg bridge100 !
|
|
||||||
127.0.0.1 127.0.0.1 UH lo0
|
|
||||||
192.168.64 link#45 UC bridge100 !
|
|
||||||
`
|
|
||||||
|
|
||||||
// The same VM bridge while a VPN holds the default route.
|
|
||||||
netstatVMBridgeAndVPN = netstatHeader + `
|
|
||||||
default link#15 UCSg utun4
|
|
||||||
default 192.168.1.1 UGScIg en0
|
|
||||||
default link#45 UCSIg bridge100 !
|
|
||||||
10.64.0.2 10.64.0.2 UH utun4
|
|
||||||
127.0.0.1 127.0.0.1 UH lo0
|
|
||||||
192.168.64 link#45 UC bridge100 !
|
|
||||||
`
|
|
||||||
|
|
||||||
// Only the VM bridge's on-link default route, no uplink at all.
|
|
||||||
netstatVMBridgeOnly = netstatHeader + `
|
|
||||||
default link#45 UCSIg bridge100 !
|
|
||||||
127.0.0.1 127.0.0.1 UH lo0
|
|
||||||
192.168.64 link#45 UC bridge100 !
|
|
||||||
`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// linuxFlags describes the Linux bridge interfaces rtnetmon was built for.
|
// linuxFlags describes the Linux bridge interfaces rtnetmon was built for.
|
||||||
@@ -146,17 +34,6 @@ 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.
|
// selectCase is one Select scenario with fake interfaces and routes.
|
||||||
type selectCase struct {
|
type selectCase struct {
|
||||||
name string
|
name string
|
||||||
@@ -289,16 +166,30 @@ func TestSelectDarwinPanes(t *testing.T) {
|
|||||||
goos: osDarwin,
|
goos: osDarwin,
|
||||||
ifaces: []netdetect.Interface{
|
ifaces: []netdetect.Interface{
|
||||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||||
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
|
{Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}},
|
||||||
{Name: ifaceUtun0, Up: true, IPv4: nil},
|
{Name: "utun0", Up: true, IPv4: nil},
|
||||||
},
|
},
|
||||||
routes: []netdetect.Route{
|
routes: []netdetect.Route{
|
||||||
{Iface: ifaceUtun4, Default: true},
|
{Iface: ifaceUtun4, Default: true},
|
||||||
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
|
{Iface: ifaceEn0, Default: true},
|
||||||
},
|
},
|
||||||
flags: linuxFlags(),
|
flags: linuxFlags(),
|
||||||
want: []netdetect.Pane{
|
want: []netdetect.Pane{
|
||||||
{Name: ifaceUtun4, Label: labelVPN},
|
{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: ifaceEn0, Label: labelDefault},
|
{Name: ifaceEn0, Label: labelDefault},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -307,11 +198,11 @@ func TestSelectDarwinPanes(t *testing.T) {
|
|||||||
goos: osDarwin,
|
goos: osDarwin,
|
||||||
ifaces: []netdetect.Interface{
|
ifaces: []netdetect.Interface{
|
||||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||||
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
|
{Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}},
|
||||||
},
|
},
|
||||||
routes: []netdetect.Route{
|
routes: []netdetect.Route{
|
||||||
{Iface: ifaceUtun4, Default: true},
|
{Iface: ifaceUtun4, Default: true},
|
||||||
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
|
{Iface: ifaceEn0, Default: true},
|
||||||
},
|
},
|
||||||
flags: netdetect.Flags{
|
flags: netdetect.Flags{
|
||||||
LabelA: "Mullvad", LabelASet: true,
|
LabelA: "Mullvad", LabelASet: true,
|
||||||
@@ -334,21 +225,10 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
|||||||
goos: osDarwin,
|
goos: osDarwin,
|
||||||
ifaces: []netdetect.Interface{
|
ifaces: []netdetect.Interface{
|
||||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||||
{Name: ifaceUtun0, Up: true, IPv4: nil},
|
{Name: "utun0", Up: true, IPv4: nil},
|
||||||
{Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}},
|
{Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}},
|
||||||
},
|
},
|
||||||
routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}},
|
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, Gateway: gatewayEn0, Default: true}},
|
|
||||||
flags: linuxFlags(),
|
flags: linuxFlags(),
|
||||||
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
||||||
},
|
},
|
||||||
@@ -370,8 +250,8 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
|||||||
{Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}},
|
{Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}},
|
||||||
},
|
},
|
||||||
routes: []netdetect.Route{
|
routes: []netdetect.Route{
|
||||||
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true},
|
{Iface: ifaceEn0, Default: true},
|
||||||
{Iface: "en1", Gateway: "192.168.2.1", Default: true},
|
{Iface: "en1", Default: true},
|
||||||
},
|
},
|
||||||
flags: linuxFlags(),
|
flags: linuxFlags(),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
@@ -384,138 +264,6 @@ 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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSelectDarwinVMBridge covers a VM's NAT bridge: vmnet installs a default
|
|
||||||
// route scoped to the bridge with no gateway, which is not an uplink.
|
|
||||||
func TestSelectDarwinVMBridge(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
vpn := netdetect.Interface{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}}
|
|
||||||
bridge := netdetect.Interface{Name: ifaceBridge, Up: true, IPv4: []string{addrBridge}}
|
|
||||||
physicalOnly := []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}
|
|
||||||
vpnAndPhysical := []netdetect.Pane{
|
|
||||||
{Name: ifaceUtun4, Label: labelVPN},
|
|
||||||
{Name: ifaceEn0, Label: labelDefault},
|
|
||||||
}
|
|
||||||
|
|
||||||
runSelectCases(t, []selectCase{
|
|
||||||
{
|
|
||||||
name: "on-link default route is not an uplink",
|
|
||||||
goos: osDarwin,
|
|
||||||
ifaces: macIfaces(bridge),
|
|
||||||
routes: []netdetect.Route{
|
|
||||||
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true},
|
|
||||||
{Iface: ifaceBridge, Default: true, Scoped: true},
|
|
||||||
},
|
|
||||||
flags: linuxFlags(),
|
|
||||||
want: physicalOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only an on-link default route",
|
|
||||||
goos: osDarwin,
|
|
||||||
ifaces: macIfaces(bridge),
|
|
||||||
routes: []netdetect.Route{{Iface: ifaceBridge, Default: true, Scoped: true}},
|
|
||||||
flags: linuxFlags(),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "netstat: vm bridge next to the physical default",
|
|
||||||
goos: osDarwin,
|
|
||||||
ifaces: macIfaces(bridge),
|
|
||||||
routes: netdetect.ParseNetstat(netstatVMBridge),
|
|
||||||
want: physicalOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "netstat: vm bridge next to a connected vpn",
|
|
||||||
goos: osDarwin,
|
|
||||||
ifaces: macIfaces(bridge, vpn),
|
|
||||||
routes: netdetect.ParseNetstat(netstatVMBridgeAndVPN),
|
|
||||||
want: vpnAndPhysical,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "netstat: vm bridge with no uplink",
|
|
||||||
goos: osDarwin,
|
|
||||||
ifaces: macIfaces(bridge),
|
|
||||||
routes: netdetect.ParseNetstat(netstatVMBridgeOnly),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseIPRoute(t *testing.T) {
|
func TestParseIPRoute(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -549,13 +297,11 @@ func TestParseNetstat(t *testing.T) {
|
|||||||
"Destination Gateway Flags Netif Expire\n" +
|
"Destination Gateway Flags Netif Expire\n" +
|
||||||
"default 10.0.0.1 UGScg en0\n" +
|
"default 10.0.0.1 UGScg en0\n" +
|
||||||
"default link#15 UCSg utun4\n" +
|
"default link#15 UCSg utun4\n" +
|
||||||
"default link#45 UCSIg bridge100 !\n" +
|
|
||||||
"127.0.0.1 127.0.0.1 UH lo0\n"
|
"127.0.0.1 127.0.0.1 UH lo0\n"
|
||||||
got := netdetect.ParseNetstat(out)
|
got := netdetect.ParseNetstat(out)
|
||||||
want := []netdetect.Route{
|
want := []netdetect.Route{
|
||||||
{Iface: ifaceEn0, Gateway: "10.0.0.1", Default: true},
|
{Iface: ifaceEn0, Gateway: "10.0.0.1", Default: true},
|
||||||
{Iface: ifaceUtun4, Default: true},
|
{Iface: ifaceUtun4, Gateway: "link#15", Default: true},
|
||||||
{Iface: ifaceBridge, Default: true, Scoped: true},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ detect_pkgmgr() {
|
|||||||
if [ "$(id -u)" != "0" ]; then
|
if [ "$(id -u)" != "0" ]; then
|
||||||
SUDO="sudo"
|
SUDO="sudo"
|
||||||
fi
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user