diff --git a/modules/installer/setup.nix b/modules/installer/setup.nix index 278d5ef..da4f1ab 100644 --- a/modules/installer/setup.nix +++ b/modules/installer/setup.nix @@ -39,15 +39,17 @@ let ]; text = '' usage() { - echo "usage: router-setup [--local FILE]" >&2 + echo "usage: router-setup [--local FILE] [--config-only DIR]" >&2 echo " Asks a few questions, then ERASES the chosen disk and installs the router." >&2 echo " --local FILE extra NixOS module for this box (installed as config/local.nix)" >&2 + echo " --config-only DIR only write the settings and secrets to DIR; install nothing" >&2 exit 2 } - local_nix="" + local_nix="" config_only="" while [ $# -gt 0 ]; do case $1 in --local) shift; [ $# -gt 0 ] || usage; local_nix=$1 ;; + --config-only) shift; [ $# -gt 0 ] || usage; config_only=$1 ;; *) usage ;; esac shift @@ -88,6 +90,17 @@ let echo " please pick one of the above" >&2 done } + yes_no() { # yes_no VAR PROMPT DEFAULT(y|n) + local yn + while :; do + ask yn "$2 (y/n)" "$3" + case $yn in + y|Y|yes) printf -v "$1" '%s' y; return ;; + n|N|no) printf -v "$1" '%s' n; return ;; + esac + echo " y or n" >&2 + done + } ipv4_ok() { [[ $1 =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1 for o in "''${BASH_REMATCH[@]:1}"; do [ "$o" -le 255 ] || return 1; done @@ -100,7 +113,7 @@ let } # Set by ask/choose (printf -v). - disk="" wan="" lan="" wan_type="" host="" lan_address="" ssh_key="" answer="" + disk="" wan="" lan="" wan_type="" want_vlans="" want_v6="" vlan_parent="" vlan_id="" host="" lan_address="" ssh_key="" answer="" webui_password="" webui_password2="" reuse="" reuse_label="" new="" work=$(mktemp -d /run/router-setup.XXXXXX) @@ -252,34 +265,65 @@ let fi else # a new router + # Devices WAN and LAN can be assigned to: the NICs, plus VLANs on them. + devices=("''${nics[@]}") + echo + echo "VLANs are separate networks sharing one cable: some providers deliver the internet" + echo "tagged with a VLAN ID, and one NIC can then also serve the LAN untagged." + yes_no want_vlans "Create VLANs now?" n + while [ "$want_vlans" = y ]; do + choose vlan_parent "VLAN on which interface" "''${nics[@]}" + while :; do + ask vlan_id "VLAN ID (1-4094)" + [[ $vlan_id =~ ^[0-9]+$ ]] && [ "$vlan_id" -ge 1 ] && [ "$vlan_id" -le 4094 ] && break + echo " a number from 1 to 4094" >&2 + done + devices+=("$vlan_parent.$vlan_id VLAN $vlan_id on $vlan_parent") + yes_no want_vlans "Another VLAN?" n + done + echo echo "Interface towards the internet (WAN):" - choose wan "WAN interface" "''${nics[@]}" + choose wan "WAN interface" "''${devices[@]}" echo echo "Interface for the local network (LAN):" - choose lan "LAN interface" "''${nics[@]}" + choose lan "LAN interface" "''${devices[@]}" + + # A VLAN device is its NIC plus an ID in the settings. + wan_dev=''${wan%.*} wan_vlan="" + [ "$wan_dev" = "$wan" ] || wan_vlan=''${wan##*.} + lan_dev=''${lan%.*} lan_vlan="" + [ "$lan_dev" = "$lan" ] || lan_vlan=''${lan##*.} + if [ "$wan" = "$lan" ]; then + echo "router-setup: WAN and LAN cannot be the same interface" >&2 + exit 1 + fi echo echo "Internet connection:" choose wan_type "Connection" \ "dhcp (behind a cable modem, an ONT or another router)" \ "pppoe (DSL or fibre with a username and password)" - pppoe_user="" pppoe_password="" pppoe_vlan="" + pppoe_user="" pppoe_password="" if [ "$wan_type" = pppoe ]; then while [ -z "$pppoe_user" ]; do ask pppoe_user "PPPoE username"; done while [ -z "$pppoe_password" ]; do ask_secret pppoe_password "PPPoE password"; done - while :; do - ask pppoe_vlan "VLAN ID the provider requires (empty for none)" "" - [ -z "$pppoe_vlan" ] && break - [[ $pppoe_vlan =~ ^[0-9]+$ ]] && [ "$pppoe_vlan" -ge 1 ] && [ "$pppoe_vlan" -le 4094 ] && break - echo " a number from 1 to 4094" >&2 - done fi - if [ "$wan" = "$lan" ] && [ -z "$pppoe_vlan" ]; then - echo "router-setup: WAN and LAN can only share an interface when PPPoE runs on a VLAN" >&2 + if [ -n "$wan_vlan" ] && [ "$wan_type" != pppoe ]; then + echo "router-setup: an uplink on a VLAN is only supported with PPPoE" >&2 + exit 1 + fi + if [ "$wan_dev" = "$lan_dev" ] && [ -z "$wan_vlan$lan_vlan" ]; then + echo "router-setup: WAN and LAN cannot share an interface without a VLAN" >&2 + exit 1 + fi + if [ "$wan_dev" = "$lan_dev" ] && [ -z "$wan_vlan" ]; then + echo "router-setup: with WAN and LAN on one interface, the WAN must be the VLAN" >&2 exit 1 fi + echo + yes_no want_v6 "Enable IPv6 (the provider delegates a prefix to the LAN)?" y echo ask host "Host name" "router" while :; do @@ -302,7 +346,7 @@ let echo echo "Summary" echo " disk: $disk (will be erased)" - echo " WAN: $wan, $wan_type''${pppoe_user:+ as $pppoe_user}''${pppoe_vlan:+ on VLAN $pppoe_vlan}" + echo " WAN: $wan, $wan_type''${pppoe_user:+ as $pppoe_user}, IPv6 $([ "$want_v6" = y ] && echo on || echo off)" echo " LAN: $lan, $lan_address/24, DHCP $net.100 - $net.200" echo " host name: $host" echo " web UI: https://$lan_address" @@ -320,21 +364,25 @@ let jq -n \ --arg host "$host" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \ --argjson schema ${toString cfg.schemaVersion} \ - --arg wan "$wan" --arg type "$wan_type" --arg user "$pppoe_user" --arg vlan "$pppoe_vlan" \ - --arg lan "$lan" --arg addr "$lan_address" --arg net "$net" ' + --arg wan "$wan_dev" --arg type "$wan_type" --arg user "$pppoe_user" --arg vlan "$wan_vlan" \ + --arg lan "$lan_dev" --arg lanvlan "$lan_vlan" --arg v6 "$want_v6" \ + --arg addr "$lan_address" --arg net "$net" ' { version: $schema, system: { hostName: $host, stateVersion: $state, sshKeys: $keys }, router: { wan: ({ interface: $wan, ipv4: { type: $type } } | if $type == "pppoe" then .ipv4.pppoe = ({ username: $user, passwordFile: "/var/lib/router/pppoe-password" } - + (if $vlan == "" then {} else { vlan: ($vlan | tonumber) } end)) else . end), + + (if $vlan == "" then {} else { vlan: ($vlan | tonumber) } end)) else . end + | if $v6 == "y" then .ipv6 = { enable: true, transport: (if $type == "pppoe" then "ppp" else "native" end) } else . end), lans: { - lan: { + lan: ({ interface: $lan, internet: true, ipv4: { address: $addr }, dhcp: { pool: { start: ($net + ".100"), end: ($net + ".200") } } } + + (if $lanvlan == "" then {} else { vlan: ($lanvlan | tonumber) } end) + + (if $v6 == "y" then { ipv6: { pd: true } } else {} end)) } } }' > "$config/settings.json" @@ -346,6 +394,13 @@ let (umask 077; printf '%s\n' "$webui_password" | mkpasswd -s > "$work/secrets/root-password") fi # new router + if [ -n "$config_only" ]; then + mkdir -p "$config_only" + cp -a "$work/secrets/." "$config_only/" + echo "router-setup: wrote the configuration to $config_only" + exit 0 + fi + echo echo ">> building the router system (no internet needed; takes a few minutes)" system=$(nix build --no-link --print-out-paths --out-link "$work/system" \ diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index 6fe6500..72ce1cb 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -445,6 +445,7 @@ type networkData struct { Form Form Errors FormErrors Interfaces []string + Devices []Device } func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session) { @@ -457,7 +458,7 @@ func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session) } f := s.form() f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN - p.Data = networkData{Form: f, Interfaces: Interfaces()} + p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: f.devices(Interfaces())} a.render(w, "network", p) } @@ -468,7 +469,7 @@ func formFromRequest(r *http.Request) Form { Interface: strings.TrimSpace(r.PostFormValue("wan.interface")), Type: r.PostFormValue("wan.type"), PPPoEUser: r.PostFormValue("wan.pppoe.username"), - PPPoEVLAN: r.PostFormValue("wan.pppoe.vlan"), + IPv6: r.PostFormValue("wan.ipv6") == "on", PPPoEPassword: r.PostFormValue("wan.pppoe.password"), }, } @@ -482,6 +483,7 @@ func formFromRequest(r *http.Request) Form { Address: v("address"), Prefix: v("prefix"), Internet: v("internet") == "on", + IPv6PD: v("ipv6pd") == "on", DHCP: v("dhcp") == "on", PoolStart: v("poolStart"), PoolEnd: v("poolEnd"), @@ -507,7 +509,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) { f.WAN.PPPoEPassword = "" f.WAN.PasswordSet = s.form().WAN.PasswordSet p.Error = "Please correct the marked fields." - p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces()} + p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: f.devices(Interfaces())} w.WriteHeader(http.StatusUnprocessableEntity) a.render(w, "network", p) return diff --git a/pkgs/router-webui/settings.go b/pkgs/router-webui/settings.go index e4f7683..03dd0d8 100644 --- a/pkgs/router-webui/settings.go +++ b/pkgs/router-webui/settings.go @@ -131,10 +131,10 @@ type Form struct { } type WANForm struct { - Interface string + Interface string // a device: "enp2s0", or "enp2s0.1001" for a VLAN Type string // "dhcp" or "pppoe" + IPv6 bool PPPoEUser string - PPPoEVLAN string PPPoEPassword string // write-only: empty keeps the stored one PasswordSet bool } @@ -146,6 +146,7 @@ type LANForm struct { Address string Prefix string Internet bool + IPv6PD bool // delegate the ISP's prefix to this LAN DHCP bool PoolStart string PoolEnd string @@ -162,10 +163,13 @@ func (s Settings) form() Form { WAN: WANForm{ Interface: str(router, "wan", "interface"), Type: str(router, "wan", "ipv4", "type"), + IPv6: boolean(router, false, "wan", "ipv6", "enable"), PPPoEUser: str(router, "wan", "ipv4", "pppoe", "username"), - PPPoEVLAN: str(router, "wan", "ipv4", "pppoe", "vlan"), }, } + if vlan := str(router, "wan", "ipv4", "pppoe", "vlan"); vlan != "" && f.WAN.Type == "pppoe" { + f.WAN.Interface += "." + vlan + } if f.WAN.Type == "" { f.WAN.Type = "dhcp" } @@ -188,6 +192,7 @@ func (s Settings) form() Form { Address: str(l, "ipv4", "address"), Prefix: str(l, "ipv4", "prefixLength"), Internet: boolean(l, false, "internet"), + IPv6PD: boolean(l, false, "ipv6", "pd"), DHCP: boolean(l, true, "dhcp", "enable"), PoolStart: str(l, "dhcp", "pool", "start"), PoolEnd: str(l, "dhcp", "pool", "end"), @@ -229,6 +234,64 @@ func optionalInt(v string, lo, hi int) (int, bool, error) { return n, true, nil } +// splitVLAN splits "enp2s0.1001" into the interface and its VLAN ID; a name +// without one has VLAN 0. +func splitVLAN(dev string) (string, int) { + dev = strings.TrimSpace(dev) + i := strings.LastIndex(dev, ".") + if i <= 0 { + return dev, 0 + } + id, err := strconv.Atoi(dev[i+1:]) + if err != nil || id < 1 || id > 4094 { + return dev, 0 + } + return dev[:i], id +} + +// Device is a row of the interface list: a physical NIC or a VLAN on one. +type Device struct { + Name string + Kind string // "physical" or "VLAN" + Role string // "WAN", "LAN lan", or "" +} + +// devices lists the physical interfaces plus the VLAN devices the settings +// use, each with the role it plays. +func (f Form) devices(physical []string) []Device { + roles := map[string][]string{} + add := func(dev, role string) { roles[dev] = append(roles[dev], role) } + add(f.WAN.Interface, "WAN") + for _, l := range f.LANs { + if l.Name == "" || l.Remove { + continue + } + dev := l.Interface + if l.VLAN != "" { + dev += "." + l.VLAN + } + add(dev, "LAN "+l.Name) + } + names := slices.Clone(physical) + var vlans []string + for dev := range roles { + if _, id := splitVLAN(dev); id != 0 && !slices.Contains(names, dev) { + vlans = append(vlans, dev) + } + } + slices.Sort(vlans) + var out []Device + for _, n := range names { + out = append(out, Device{Name: n, Kind: "physical", Role: strings.Join(roles[n], ", ")}) + for _, v := range vlans { + if parent, _ := splitVLAN(v); parent == n { + out = append(out, Device{Name: v, Kind: "VLAN", Role: strings.Join(roles[v], ", ")}) + } + } + } + return out +} + func ipv4(v string) (netip.Addr, bool) { a, err := netip.ParseAddr(strings.TrimSpace(v)) return a, err == nil && a.Is4() @@ -250,11 +313,17 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) { system["hostName"] = f.HostName } + // A VLAN device (enp2s0.1001) is the physical interface plus the + // PPPoE session's VLAN. + wanDev, wanVLAN := splitVLAN(f.WAN.Interface) if !ifaceRe.MatchString(f.WAN.Interface) { errs["wan.interface"] = "an interface name is required" } - wan["interface"] = f.WAN.Interface + wan["interface"] = wanDev ipv4cfg := obj(wan, "ipv4") + if wanVLAN != 0 && f.WAN.Type != "pppoe" { + errs["wan.interface"] = "an uplink on a VLAN needs PPPoE" + } switch f.WAN.Type { case "dhcp": ipv4cfg["type"] = "dhcp" @@ -265,10 +334,8 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) { if pppoe["username"] == "" { errs["wan.pppoe.username"] = "required for PPPoE" } - if vlan, ok, err := optionalInt(f.WAN.PPPoEVLAN, 1, 4094); err != nil { - errs["wan.pppoe.vlan"] = err.Error() - } else if ok { - pppoe["vlan"] = vlan + if wanVLAN != 0 { + pppoe["vlan"] = wanVLAN } else { delete(pppoe, "vlan") } @@ -280,6 +347,18 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) { default: errs["wan.type"] = "must be dhcp or pppoe" } + if f.WAN.IPv6 { + v6 := obj(wan, "ipv6") + v6["enable"] = true + // PPPoE carries IPv6 inside the session; otherwise it is native. + if f.WAN.Type == "pppoe" { + v6["transport"] = "ppp" + } else { + v6["transport"] = "native" + } + } else if v6, ok := wan["ipv6"].(map[string]any); ok { + v6["enable"] = false + } lans := obj(router, "lans") keep := map[string]bool{} @@ -314,6 +393,14 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) { delete(l, "vlan") } l["internet"] = lf.Internet + if lf.IPv6PD { + obj(l, "ipv6")["pd"] = true + } else if v6, ok := l["ipv6"].(map[string]any); ok { + delete(v6, "pd") + if len(v6) == 0 { + delete(l, "ipv6") + } + } v4 := obj(l, "ipv4") if _, ok := ipv4(lf.Address); !ok { errs[field("address")] = "an IPv4 address is required" diff --git a/pkgs/router-webui/settings_test.go b/pkgs/router-webui/settings_test.go index ea9597d..f8ec224 100644 --- a/pkgs/router-webui/settings_test.go +++ b/pkgs/router-webui/settings_test.go @@ -63,7 +63,7 @@ func TestApplyPPPoE(t *testing.T) { f := s.form() f.WAN.Type = "pppoe" f.WAN.PPPoEUser = "user@isp" - f.WAN.PPPoEVLAN = "7" + f.WAN.Interface += ".7" if _, err := s.apply(f, "/pw"); err == nil { t.Fatal("PPPoE without any password was accepted") } @@ -128,3 +128,49 @@ func TestApplyAddsAndRemovesLANs(t *testing.T) { t.Fatal("removing the last LAN was accepted") } } + +func TestVLANUplinkAndIPv6(t *testing.T) { + s := mustParse(t) + f := s.form() + f.WAN.Type = "pppoe" + f.WAN.PPPoEUser = "user@isp" + f.WAN.PPPoEPassword = "secret" + f.WAN.Interface = "enp2s0.1001" + f.WAN.IPv6 = true + f.LANs[0].IPv6PD = true + if _, err := s.apply(f, "/pw"); err != nil { + t.Fatal(err) + } + if got := str(s, "router", "wan", "interface"); got != "enp2s0" { + t.Errorf("interface = %q", got) + } + if got := str(s, "router", "wan", "ipv4", "pppoe", "vlan"); got != "1001" { + t.Errorf("vlan = %q", got) + } + if got := str(s, "router", "wan", "ipv6", "transport"); got != "ppp" { + t.Errorf("transport = %q", got) + } + if !s.form().LANs[0].IPv6PD || s.form().WAN.Interface != "enp2s0.1001" { + t.Errorf("form does not round-trip: %+v", s.form()) + } + devs := s.form().devices([]string{"enp2s0", "wlp3s0"}) + if len(devs) != 3 || devs[1].Name != "enp2s0.1001" || devs[1].Role != "WAN" { + t.Errorf("devices = %+v", devs) + } + + // Without PPPoE a VLAN uplink is not possible; turning IPv6 off keeps the rest. + g := s.form() + g.WAN.Type = "dhcp" + if _, err := s.apply(g, "/pw"); err == nil { + t.Error("a DHCP uplink on a VLAN was accepted") + } + g = s.form() + g.WAN.IPv6 = false + g.LANs[0].IPv6PD = false + if _, err := s.apply(g, "/pw"); err != nil { + t.Fatal(err) + } + if boolean(s, true, "router", "wan", "ipv6", "enable") || s.form().LANs[0].IPv6PD { + t.Error("IPv6 still on") + } +} diff --git a/pkgs/router-webui/templates/network.html b/pkgs/router-webui/templates/network.html index 2163303..5d3b6d1 100644 --- a/pkgs/router-webui/templates/network.html +++ b/pkgs/router-webui/templates/network.html @@ -4,6 +4,7 @@
{{range .Interfaces}} + {{range .Devices}}
System @@ -11,9 +12,18 @@ {{with index $e "hostName"}}{{.}}{{end}}
+
+ Interfaces + + + {{range .Devices}}{{end}} +
InterfaceKindUsed as
{{.Name}}{{.Kind}}{{if .Role}}{{.Role}}{{else}}unused{{end}}
+

A VLAN is written interface.id, for example enp2s0.1001; naming one below creates it.

+
+
Uplink (WAN) - + {{with index $e "wan.interface"}}{{.}}{{end}} +

PPPoE only:

@@ -28,8 +39,6 @@ {{with index $e "wan.pppoe.password"}}{{.}}{{end}} - - {{with index $e "wan.pppoe.vlan"}}{{.}}{{end}}
@@ -38,7 +47,7 @@ {{with index $e "lans"}}

{{.}}

{{end}} - + {{range $i, $l := .Form.LANs}} @@ -47,15 +56,16 @@ + - {{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}{{end}}{{end}} + {{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}{{end}}{{end}} {{end}}
NameInterfaceVLANAddressPrefixInternetDHCPPool startPool endRemove
NameInterfaceVLANAddressPrefixInternet accessIPv6DHCPPool startPool endRemove
{{if $l.Name}}{{end}}
{{$f}}: {{.}}
{{$f}}: {{.}}
-

Fill in the last row to add a LAN. Other LAN settings (IPv6, reservations, zones) are kept; edit them under Advanced.

+

Fill in the last row to add a LAN. Other LAN settings (reservations, zones, a fixed IPv6 address) are kept; edit them under Advanced.

diff --git a/tests/setup.nix b/tests/setup.nix index b68d96c..1700c5b 100644 --- a/tests/setup.nix +++ b/tests/setup.nix @@ -31,9 +31,32 @@ let answers = pkgs.writeText "answers" '' /dev/vda + n eth1 eth2 dhcp + n + gw + 192.168.10.1 + hunter2hunter2 + hunter2hunter2 + + /dev/vda + ''; + + # PPPoE on VLAN 1001 of the NIC that also serves the LAN, with IPv6. + vlanAnswers = pkgs.writeText "vlan-answers" '' + /dev/vda + y + eth1 + 1001 + n + eth1.1001 + eth1 + pppoe + someone@isp + pppoe-secret + gw 192.168.10.1 hunter2hunter2 @@ -105,7 +128,22 @@ pkgs.testers.runNixOSTest { installer.wait_for_unit("multi-user.target") with subtest("router-setup refuses to install without the disk name"): - installer.fail("printf '/dev/vda\\neth1\\neth2\\ndhcp\\n\\n\\nhunter2hunter2\\nhunter2hunter2\\n\\nno\\n' | router-setup") + installer.fail("printf '/dev/vda\\nn\\neth1\\neth2\\ndhcp\\nn\\n\\n\\nhunter2hunter2\\nhunter2hunter2\\n\\nno\\n' | router-setup") + installer.fail("blkid /dev/vda1") + + with subtest("router-setup writes a PPPoE uplink on a VLAN, shared with the LAN, with IPv6"): + installer.succeed("router-setup --config-only /tmp/vlan-config < ${vlanAnswers} >&2") + s = installer.succeed("cat /tmp/vlan-config/config/settings.json") + import json + r = json.loads(s)["router"] + assert r["wan"]["interface"] == "eth1", s + assert r["wan"]["ipv4"]["pppoe"]["vlan"] == 1001, s + assert r["wan"]["ipv4"]["pppoe"]["username"] == "someone@isp", s + assert r["wan"]["ipv6"] == {"enable": True, "transport": "ppp"}, s + assert r["lans"]["lan"]["interface"] == "eth1" and r["lans"]["lan"]["ipv6"] == {"pd": True}, s + assert "vlan" not in r["lans"]["lan"], s + assert "pppoe-secret" not in s + installer.succeed("grep -qx pppoe-secret /tmp/vlan-config/pppoe-password") installer.fail("blkid /dev/vda1") with subtest("router-setup builds the answered configuration offline and installs it"):