Compare commits

Author SHA1 Message Date
clawbot 51d2bd24df Stamp the git tag or short commit into the binary (closes #10)
check / check (push) Failing after 3s
rtnetmon had no version. main.Version is now set at link time and logged
in the first startup line. The Dockerfile takes it from the VERSION build
argument when one is given, otherwise from `git describe --tags --always`
of the .git the build context now carries, and fails the build if the
context carries .git and no version comes out. .dockerignore follows the
canonical copy: .git is sent, .git/config, which can hold a credential,
is not.

Model: opus-5-5
2026-10-02 06:24:22 +00:00
7 changed files with 94 additions and 281 deletions
+2 -5
View File
@@ -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
+11 -16
View File
@@ -46,27 +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. 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
+25 -24
View File
@@ -5,37 +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 # Status
On `next`: 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`.
- Repo standards (https://git.eeqj.de/sneak/rtnetmon/issues/1): the `script/` Lint is clean under the standard `default: all` configuration, with the
entrypoints, the vendored `.golangci.yml`, and `REPO_POLICIES.md`. findings fixed rather than suppressed. The only annotations are
- Interface detection on Linux and macOS justified `//nolint:gosec` on the `ping`/`curl` subprocess calls (G204)
(https://git.eeqj.de/sneak/rtnetmon/issues/2). and on opening the operator-chosen log file (G304): fixed argv with no
- The fix for macOS VPN detection (https://git.eeqj.de/sneak/rtnetmon/issues/8). shell, so these are false positives, annotated as the reference repos do.
- Starlink status lines (https://git.eeqj.de/sneak/rtnetmon/issues/3).
- A version stamp: built with `make build` or a plain `docker build .`, the
binary logs its git tag or short commit at startup
(https://git.eeqj.de/sneak/rtnetmon/issues/10).
- CI that runs `script/cibuild` on every push to a branch
(https://git.eeqj.de/sneak/rtnetmon/issues/12).
# Next Step # Next Step
- `sneak` merges the milestone PR, https://git.eeqj.de/sneak/rtnetmon/pulls/6, Feature work, each on its own branch and PR from `next`:
carrying `next` into `main`.
- A first run on a real Mac of what is on `next` now. The fix for - https://git.eeqj.de/sneak/rtnetmon/issues/2 — macOS support:
https://git.eeqj.de/sneak/rtnetmon/issues/8 has not run on a Mac yet; VPN-aware interface detection and a single-interface UI when only one
`sneak`'s first run on his Mac is its test. interface exists.
- https://git.eeqj.de/sneak/rtnetmon/issues/3 — show Starlink status
lines when Starlink is the non-VPN gateway.
+1 -1
View File
@@ -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() {
+35 -46
View File
@@ -41,14 +41,11 @@ type Interface struct {
IPv4 []string IPv4 []string
} }
// Route is one routing-table entry reduced to what detection needs. Scoped // Route is one routing-table entry reduced to what detection needs.
// 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.
@@ -136,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)
@@ -155,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
} }
} }
@@ -180,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 ""
@@ -219,8 +219,6 @@ 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, excluding the named VPN interface and any other tunnel. // 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 { func defaultRouteIfaces(routes []Route, vpn string) []string {
seen := map[string]bool{} seen := map[string]bool{}
@@ -257,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 {
@@ -327,47 +340,23 @@ func parseProcNetRoute(out string) []Route {
return routes return routes
} }
// parseNetstat reads `netstat -rn -f inet` output (macOS); the Netif column // parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose
// (field 4) names the interface. A row whose destination is "default" is a // destination is "default" are default routes; the Netif column (field 4)
// default route, scoped when its flags include I. A "0/1" row and a "128.0/1" // names the interface.
// 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,
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 return routes
+20 -186
View File
@@ -17,96 +17,13 @@ const (
ifaceEth0 = "eth0" ifaceEth0 = "eth0"
ifaceWlan0 = "wlan0" ifaceWlan0 = "wlan0"
ifaceEn0 = "en0" ifaceEn0 = "en0"
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"
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. // linuxFlags describes the Linux bridge interfaces rtnetmon was built for.
@@ -117,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
@@ -260,8 +166,8 @@ 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},
@@ -269,7 +175,21 @@ func TestSelectDarwinPanes(t *testing.T) {
}, },
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},
}, },
}, },
@@ -278,7 +198,7 @@ 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},
@@ -305,24 +225,13 @@ 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, Default: true}}, routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}},
flags: linuxFlags(), flags: linuxFlags(),
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, 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", name: "no default route",
goos: osDarwin, goos: osDarwin,
@@ -355,81 +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,
},
})
}
func TestParseIPRoute(t *testing.T) { func TestParseIPRoute(t *testing.T) {
t.Parallel() t.Parallel()
-3
View File
@@ -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
} }