Add IPv6 and VLAN devices to the web UI and router-setup
The web UI lists physical and VLAN interfaces and takes a VLAN uplink as a device name (enp2s0.1001); it has IPv6 checkboxes for the WAN and each LAN. router-setup asks whether to create VLANs before WAN and LAN are picked, and whether to enable IPv6. --config-only writes the configuration without installing, which the setup test uses for a PPPoE uplink on a VLAN. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
+74
-19
@@ -39,15 +39,17 @@ let
|
|||||||
];
|
];
|
||||||
text = ''
|
text = ''
|
||||||
usage() {
|
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 " 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 " --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
|
exit 2
|
||||||
}
|
}
|
||||||
local_nix=""
|
local_nix="" config_only=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--local) shift; [ $# -gt 0 ] || usage; local_nix=$1 ;;
|
--local) shift; [ $# -gt 0 ] || usage; local_nix=$1 ;;
|
||||||
|
--config-only) shift; [ $# -gt 0 ] || usage; config_only=$1 ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
@@ -88,6 +90,17 @@ let
|
|||||||
echo " please pick one of the above" >&2
|
echo " please pick one of the above" >&2
|
||||||
done
|
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() {
|
ipv4_ok() {
|
||||||
[[ $1 =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
|
[[ $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
|
for o in "''${BASH_REMATCH[@]:1}"; do [ "$o" -le 255 ] || return 1; done
|
||||||
@@ -100,7 +113,7 @@ let
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Set by ask/choose (printf -v).
|
# 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=""
|
webui_password="" webui_password2="" reuse="" reuse_label="" new=""
|
||||||
|
|
||||||
work=$(mktemp -d /run/router-setup.XXXXXX)
|
work=$(mktemp -d /run/router-setup.XXXXXX)
|
||||||
@@ -252,34 +265,65 @@ let
|
|||||||
fi
|
fi
|
||||||
else # a new router
|
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
|
||||||
echo "Interface towards the internet (WAN):"
|
echo "Interface towards the internet (WAN):"
|
||||||
choose wan "WAN interface" "''${nics[@]}"
|
choose wan "WAN interface" "''${devices[@]}"
|
||||||
echo
|
echo
|
||||||
echo "Interface for the local network (LAN):"
|
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
|
||||||
echo "Internet connection:"
|
echo "Internet connection:"
|
||||||
choose wan_type "Connection" \
|
choose wan_type "Connection" \
|
||||||
"dhcp (behind a cable modem, an ONT or another router)" \
|
"dhcp (behind a cable modem, an ONT or another router)" \
|
||||||
"pppoe (DSL or fibre with a username and password)"
|
"pppoe (DSL or fibre with a username and password)"
|
||||||
pppoe_user="" pppoe_password="" pppoe_vlan=""
|
pppoe_user="" pppoe_password=""
|
||||||
if [ "$wan_type" = pppoe ]; then
|
if [ "$wan_type" = pppoe ]; then
|
||||||
while [ -z "$pppoe_user" ]; do ask pppoe_user "PPPoE username"; done
|
while [ -z "$pppoe_user" ]; do ask pppoe_user "PPPoE username"; done
|
||||||
while [ -z "$pppoe_password" ]; do ask_secret pppoe_password "PPPoE password"; 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
|
fi
|
||||||
if [ "$wan" = "$lan" ] && [ -z "$pppoe_vlan" ]; then
|
if [ -n "$wan_vlan" ] && [ "$wan_type" != pppoe ]; then
|
||||||
echo "router-setup: WAN and LAN can only share an interface when PPPoE runs on a VLAN" >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
yes_no want_v6 "Enable IPv6 (the provider delegates a prefix to the LAN)?" y
|
||||||
echo
|
echo
|
||||||
ask host "Host name" "router"
|
ask host "Host name" "router"
|
||||||
while :; do
|
while :; do
|
||||||
@@ -302,7 +346,7 @@ let
|
|||||||
echo
|
echo
|
||||||
echo "Summary"
|
echo "Summary"
|
||||||
echo " disk: $disk (will be erased)"
|
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 " LAN: $lan, $lan_address/24, DHCP $net.100 - $net.200"
|
||||||
echo " host name: $host"
|
echo " host name: $host"
|
||||||
echo " web UI: https://$lan_address"
|
echo " web UI: https://$lan_address"
|
||||||
@@ -320,21 +364,25 @@ let
|
|||||||
jq -n \
|
jq -n \
|
||||||
--arg host "$host" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \
|
--arg host "$host" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \
|
||||||
--argjson schema ${toString cfg.schemaVersion} \
|
--argjson schema ${toString cfg.schemaVersion} \
|
||||||
--arg wan "$wan" --arg type "$wan_type" --arg user "$pppoe_user" --arg vlan "$pppoe_vlan" \
|
--arg wan "$wan_dev" --arg type "$wan_type" --arg user "$pppoe_user" --arg vlan "$wan_vlan" \
|
||||||
--arg lan "$lan" --arg addr "$lan_address" --arg net "$net" '
|
--arg lan "$lan_dev" --arg lanvlan "$lan_vlan" --arg v6 "$want_v6" \
|
||||||
|
--arg addr "$lan_address" --arg net "$net" '
|
||||||
{
|
{
|
||||||
version: $schema,
|
version: $schema,
|
||||||
system: { hostName: $host, stateVersion: $state, sshKeys: $keys },
|
system: { hostName: $host, stateVersion: $state, sshKeys: $keys },
|
||||||
router: {
|
router: {
|
||||||
wan: ({ interface: $wan, ipv4: { type: $type } }
|
wan: ({ interface: $wan, ipv4: { type: $type } }
|
||||||
| if $type == "pppoe" then .ipv4.pppoe = ({ username: $user, passwordFile: "/var/lib/router/pppoe-password" }
|
| 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: {
|
lans: {
|
||||||
lan: {
|
lan: ({
|
||||||
interface: $lan, internet: true,
|
interface: $lan, internet: true,
|
||||||
ipv4: { address: $addr },
|
ipv4: { address: $addr },
|
||||||
dhcp: { pool: { start: ($net + ".100"), end: ($net + ".200") } }
|
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"
|
}' > "$config/settings.json"
|
||||||
@@ -346,6 +394,13 @@ let
|
|||||||
(umask 077; printf '%s\n' "$webui_password" | mkpasswd -s > "$work/secrets/root-password")
|
(umask 077; printf '%s\n' "$webui_password" | mkpasswd -s > "$work/secrets/root-password")
|
||||||
fi # new router
|
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
|
||||||
echo ">> building the router system (no internet needed; takes a few minutes)"
|
echo ">> building the router system (no internet needed; takes a few minutes)"
|
||||||
system=$(nix build --no-link --print-out-paths --out-link "$work/system" \
|
system=$(nix build --no-link --print-out-paths --out-link "$work/system" \
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ type networkData struct {
|
|||||||
Form Form
|
Form Form
|
||||||
Errors FormErrors
|
Errors FormErrors
|
||||||
Interfaces []string
|
Interfaces []string
|
||||||
|
Devices []Device
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session) {
|
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 := s.form()
|
||||||
f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN
|
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)
|
a.render(w, "network", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +469,7 @@ func formFromRequest(r *http.Request) Form {
|
|||||||
Interface: strings.TrimSpace(r.PostFormValue("wan.interface")),
|
Interface: strings.TrimSpace(r.PostFormValue("wan.interface")),
|
||||||
Type: r.PostFormValue("wan.type"),
|
Type: r.PostFormValue("wan.type"),
|
||||||
PPPoEUser: r.PostFormValue("wan.pppoe.username"),
|
PPPoEUser: r.PostFormValue("wan.pppoe.username"),
|
||||||
PPPoEVLAN: r.PostFormValue("wan.pppoe.vlan"),
|
IPv6: r.PostFormValue("wan.ipv6") == "on",
|
||||||
PPPoEPassword: r.PostFormValue("wan.pppoe.password"),
|
PPPoEPassword: r.PostFormValue("wan.pppoe.password"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -482,6 +483,7 @@ func formFromRequest(r *http.Request) Form {
|
|||||||
Address: v("address"),
|
Address: v("address"),
|
||||||
Prefix: v("prefix"),
|
Prefix: v("prefix"),
|
||||||
Internet: v("internet") == "on",
|
Internet: v("internet") == "on",
|
||||||
|
IPv6PD: v("ipv6pd") == "on",
|
||||||
DHCP: v("dhcp") == "on",
|
DHCP: v("dhcp") == "on",
|
||||||
PoolStart: v("poolStart"),
|
PoolStart: v("poolStart"),
|
||||||
PoolEnd: v("poolEnd"),
|
PoolEnd: v("poolEnd"),
|
||||||
@@ -507,7 +509,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
|
|||||||
f.WAN.PPPoEPassword = ""
|
f.WAN.PPPoEPassword = ""
|
||||||
f.WAN.PasswordSet = s.form().WAN.PasswordSet
|
f.WAN.PasswordSet = s.form().WAN.PasswordSet
|
||||||
p.Error = "Please correct the marked fields."
|
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)
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
a.render(w, "network", p)
|
a.render(w, "network", p)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -131,10 +131,10 @@ type Form struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WANForm struct {
|
type WANForm struct {
|
||||||
Interface string
|
Interface string // a device: "enp2s0", or "enp2s0.1001" for a VLAN
|
||||||
Type string // "dhcp" or "pppoe"
|
Type string // "dhcp" or "pppoe"
|
||||||
|
IPv6 bool
|
||||||
PPPoEUser string
|
PPPoEUser string
|
||||||
PPPoEVLAN string
|
|
||||||
PPPoEPassword string // write-only: empty keeps the stored one
|
PPPoEPassword string // write-only: empty keeps the stored one
|
||||||
PasswordSet bool
|
PasswordSet bool
|
||||||
}
|
}
|
||||||
@@ -146,6 +146,7 @@ type LANForm struct {
|
|||||||
Address string
|
Address string
|
||||||
Prefix string
|
Prefix string
|
||||||
Internet bool
|
Internet bool
|
||||||
|
IPv6PD bool // delegate the ISP's prefix to this LAN
|
||||||
DHCP bool
|
DHCP bool
|
||||||
PoolStart string
|
PoolStart string
|
||||||
PoolEnd string
|
PoolEnd string
|
||||||
@@ -162,10 +163,13 @@ func (s Settings) form() Form {
|
|||||||
WAN: WANForm{
|
WAN: WANForm{
|
||||||
Interface: str(router, "wan", "interface"),
|
Interface: str(router, "wan", "interface"),
|
||||||
Type: str(router, "wan", "ipv4", "type"),
|
Type: str(router, "wan", "ipv4", "type"),
|
||||||
|
IPv6: boolean(router, false, "wan", "ipv6", "enable"),
|
||||||
PPPoEUser: str(router, "wan", "ipv4", "pppoe", "username"),
|
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 == "" {
|
if f.WAN.Type == "" {
|
||||||
f.WAN.Type = "dhcp"
|
f.WAN.Type = "dhcp"
|
||||||
}
|
}
|
||||||
@@ -188,6 +192,7 @@ func (s Settings) form() Form {
|
|||||||
Address: str(l, "ipv4", "address"),
|
Address: str(l, "ipv4", "address"),
|
||||||
Prefix: str(l, "ipv4", "prefixLength"),
|
Prefix: str(l, "ipv4", "prefixLength"),
|
||||||
Internet: boolean(l, false, "internet"),
|
Internet: boolean(l, false, "internet"),
|
||||||
|
IPv6PD: boolean(l, false, "ipv6", "pd"),
|
||||||
DHCP: boolean(l, true, "dhcp", "enable"),
|
DHCP: boolean(l, true, "dhcp", "enable"),
|
||||||
PoolStart: str(l, "dhcp", "pool", "start"),
|
PoolStart: str(l, "dhcp", "pool", "start"),
|
||||||
PoolEnd: str(l, "dhcp", "pool", "end"),
|
PoolEnd: str(l, "dhcp", "pool", "end"),
|
||||||
@@ -229,6 +234,64 @@ func optionalInt(v string, lo, hi int) (int, bool, error) {
|
|||||||
return n, true, nil
|
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) {
|
func ipv4(v string) (netip.Addr, bool) {
|
||||||
a, err := netip.ParseAddr(strings.TrimSpace(v))
|
a, err := netip.ParseAddr(strings.TrimSpace(v))
|
||||||
return a, err == nil && a.Is4()
|
return a, err == nil && a.Is4()
|
||||||
@@ -250,11 +313,17 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
|
|||||||
system["hostName"] = f.HostName
|
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) {
|
if !ifaceRe.MatchString(f.WAN.Interface) {
|
||||||
errs["wan.interface"] = "an interface name is required"
|
errs["wan.interface"] = "an interface name is required"
|
||||||
}
|
}
|
||||||
wan["interface"] = f.WAN.Interface
|
wan["interface"] = wanDev
|
||||||
ipv4cfg := obj(wan, "ipv4")
|
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 {
|
switch f.WAN.Type {
|
||||||
case "dhcp":
|
case "dhcp":
|
||||||
ipv4cfg["type"] = "dhcp"
|
ipv4cfg["type"] = "dhcp"
|
||||||
@@ -265,10 +334,8 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
|
|||||||
if pppoe["username"] == "" {
|
if pppoe["username"] == "" {
|
||||||
errs["wan.pppoe.username"] = "required for PPPoE"
|
errs["wan.pppoe.username"] = "required for PPPoE"
|
||||||
}
|
}
|
||||||
if vlan, ok, err := optionalInt(f.WAN.PPPoEVLAN, 1, 4094); err != nil {
|
if wanVLAN != 0 {
|
||||||
errs["wan.pppoe.vlan"] = err.Error()
|
pppoe["vlan"] = wanVLAN
|
||||||
} else if ok {
|
|
||||||
pppoe["vlan"] = vlan
|
|
||||||
} else {
|
} else {
|
||||||
delete(pppoe, "vlan")
|
delete(pppoe, "vlan")
|
||||||
}
|
}
|
||||||
@@ -280,6 +347,18 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
|
|||||||
default:
|
default:
|
||||||
errs["wan.type"] = "must be dhcp or pppoe"
|
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")
|
lans := obj(router, "lans")
|
||||||
keep := map[string]bool{}
|
keep := map[string]bool{}
|
||||||
@@ -314,6 +393,14 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
|
|||||||
delete(l, "vlan")
|
delete(l, "vlan")
|
||||||
}
|
}
|
||||||
l["internet"] = lf.Internet
|
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")
|
v4 := obj(l, "ipv4")
|
||||||
if _, ok := ipv4(lf.Address); !ok {
|
if _, ok := ipv4(lf.Address); !ok {
|
||||||
errs[field("address")] = "an IPv4 address is required"
|
errs[field("address")] = "an IPv4 address is required"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestApplyPPPoE(t *testing.T) {
|
|||||||
f := s.form()
|
f := s.form()
|
||||||
f.WAN.Type = "pppoe"
|
f.WAN.Type = "pppoe"
|
||||||
f.WAN.PPPoEUser = "user@isp"
|
f.WAN.PPPoEUser = "user@isp"
|
||||||
f.WAN.PPPoEVLAN = "7"
|
f.WAN.Interface += ".7"
|
||||||
if _, err := s.apply(f, "/pw"); err == nil {
|
if _, err := s.apply(f, "/pw"); err == nil {
|
||||||
t.Fatal("PPPoE without any password was accepted")
|
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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<form method="post" action="/network">
|
<form method="post" action="/network">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<datalist id="ifaces">{{range .Interfaces}}<option value="{{.}}">{{end}}</datalist>
|
<datalist id="ifaces">{{range .Interfaces}}<option value="{{.}}">{{end}}</datalist>
|
||||||
|
<datalist id="devices">{{range .Devices}}<option value="{{.Name}}">{{end}}</datalist>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>System</legend>
|
<legend>System</legend>
|
||||||
@@ -11,9 +12,18 @@
|
|||||||
{{with index $e "hostName"}}<span class="field-error">{{.}}</span>{{end}}
|
{{with index $e "hostName"}}<span class="field-error">{{.}}</span>{{end}}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Interfaces</legend>
|
||||||
|
<table>
|
||||||
|
<tr><th>Interface</th><th>Kind</th><th>Used as</th></tr>
|
||||||
|
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td></tr>{{end}}
|
||||||
|
</table>
|
||||||
|
<p class="hint">A VLAN is written <code>interface.id</code>, for example <code>enp2s0.1001</code>; naming one below creates it.</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Uplink (WAN)</legend>
|
<legend>Uplink (WAN)</legend>
|
||||||
<label>Interface <input name="wan.interface" list="ifaces" value="{{.Form.WAN.Interface}}" required></label>
|
<label>Interface <input name="wan.interface" list="devices" value="{{.Form.WAN.Interface}}" required></label>
|
||||||
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
|
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
|
||||||
<label>Type
|
<label>Type
|
||||||
<select name="wan.type">
|
<select name="wan.type">
|
||||||
@@ -21,6 +31,7 @@
|
|||||||
<option value="pppoe" {{if eq .Form.WAN.Type "pppoe"}}selected{{end}}>PPPoE</option>
|
<option value="pppoe" {{if eq .Form.WAN.Type "pppoe"}}selected{{end}}>PPPoE</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label><input type="checkbox" name="wan.ipv6" {{if .Form.WAN.IPv6}}checked{{end}}> IPv6 (prefix delegation from the provider; also tick it on the LANs below)</label>
|
||||||
<div class="sub">
|
<div class="sub">
|
||||||
<p class="hint">PPPoE only:</p>
|
<p class="hint">PPPoE only:</p>
|
||||||
<label>Username <input name="wan.pppoe.username" value="{{.Form.WAN.PPPoEUser}}" autocomplete="off"></label>
|
<label>Username <input name="wan.pppoe.username" value="{{.Form.WAN.PPPoEUser}}" autocomplete="off"></label>
|
||||||
@@ -28,8 +39,6 @@
|
|||||||
<label>Password <input type="password" name="wan.pppoe.password" autocomplete="new-password"
|
<label>Password <input type="password" name="wan.pppoe.password" autocomplete="new-password"
|
||||||
placeholder="{{if .Form.WAN.PasswordSet}}stored — leave empty to keep{{end}}"></label>
|
placeholder="{{if .Form.WAN.PasswordSet}}stored — leave empty to keep{{end}}"></label>
|
||||||
{{with index $e "wan.pppoe.password"}}<span class="field-error">{{.}}</span>{{end}}
|
{{with index $e "wan.pppoe.password"}}<span class="field-error">{{.}}</span>{{end}}
|
||||||
<label>VLAN <input name="wan.pppoe.vlan" value="{{.Form.WAN.PPPoEVLAN}}" inputmode="numeric" placeholder="untagged"></label>
|
|
||||||
{{with index $e "wan.pppoe.vlan"}}<span class="field-error">{{.}}</span>{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -38,7 +47,7 @@
|
|||||||
{{with index $e "lans"}}<p class="field-error">{{.}}</p>{{end}}
|
{{with index $e "lans"}}<p class="field-error">{{.}}</p>{{end}}
|
||||||
<input type="hidden" name="lans" value="{{len .Form.LANs}}">
|
<input type="hidden" name="lans" value="{{len .Form.LANs}}">
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Name</th><th>Interface</th><th>VLAN</th><th>Address</th><th>Prefix</th><th>Internet</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
|
<tr><th>Name</th><th>Interface</th><th>VLAN</th><th>Address</th><th>Prefix</th><th>Internet access</th><th>IPv6</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
|
||||||
{{range $i, $l := .Form.LANs}}
|
{{range $i, $l := .Form.LANs}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
|
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
|
||||||
@@ -47,15 +56,16 @@
|
|||||||
<td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td>
|
<td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td>
|
||||||
<td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td>
|
<td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td>
|
||||||
<td><input type="checkbox" name="lan.{{$i}}.internet" {{if $l.Internet}}checked{{end}}></td>
|
<td><input type="checkbox" name="lan.{{$i}}.internet" {{if $l.Internet}}checked{{end}}></td>
|
||||||
|
<td><input type="checkbox" name="lan.{{$i}}.ipv6pd" {{if $l.IPv6PD}}checked{{end}}></td>
|
||||||
<td><input type="checkbox" name="lan.{{$i}}.dhcp" {{if $l.DHCP}}checked{{end}}></td>
|
<td><input type="checkbox" name="lan.{{$i}}.dhcp" {{if $l.DHCP}}checked{{end}}></td>
|
||||||
<td><input name="lan.{{$i}}.poolStart" value="{{$l.PoolStart}}" size="13"></td>
|
<td><input name="lan.{{$i}}.poolStart" value="{{$l.PoolStart}}" size="13"></td>
|
||||||
<td><input name="lan.{{$i}}.poolEnd" value="{{$l.PoolEnd}}" size="13"></td>
|
<td><input name="lan.{{$i}}.poolEnd" value="{{$l.PoolEnd}}" size="13"></td>
|
||||||
<td>{{if $l.Name}}<input type="checkbox" name="lan.{{$i}}.remove">{{end}}</td>
|
<td>{{if $l.Name}}<input type="checkbox" name="lan.{{$i}}.remove">{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="10" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</table>
|
</table>
|
||||||
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (IPv6, reservations, zones) are kept; edit them under Advanced.</p>
|
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (reservations, zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<button>Save</button>
|
<button>Save</button>
|
||||||
|
|||||||
+39
-1
@@ -31,9 +31,32 @@ let
|
|||||||
|
|
||||||
answers = pkgs.writeText "answers" ''
|
answers = pkgs.writeText "answers" ''
|
||||||
/dev/vda
|
/dev/vda
|
||||||
|
n
|
||||||
eth1
|
eth1
|
||||||
eth2
|
eth2
|
||||||
dhcp
|
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
|
gw
|
||||||
192.168.10.1
|
192.168.10.1
|
||||||
hunter2hunter2
|
hunter2hunter2
|
||||||
@@ -105,7 +128,22 @@ pkgs.testers.runNixOSTest {
|
|||||||
installer.wait_for_unit("multi-user.target")
|
installer.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
with subtest("router-setup refuses to install without the disk name"):
|
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")
|
installer.fail("blkid /dev/vda1")
|
||||||
|
|
||||||
with subtest("router-setup builds the answered configuration offline and installs it"):
|
with subtest("router-setup builds the answered configuration offline and installs it"):
|
||||||
|
|||||||
Reference in New Issue
Block a user