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:
2026-10-29 21:11:11 +01:00
co-authored by Claude Sonnet 5.5
parent 5983eadae2
commit 574748806f
6 changed files with 276 additions and 38 deletions
+5 -3
View File
@@ -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
+95 -8
View File
@@ -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"
+47 -1
View File
@@ -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")
}
}
+16 -6
View File
@@ -4,6 +4,7 @@
<form method="post" action="/network">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<datalist id="ifaces">{{range .Interfaces}}<option value="{{.}}">{{end}}</datalist>
<datalist id="devices">{{range .Devices}}<option value="{{.Name}}">{{end}}</datalist>
<fieldset>
<legend>System</legend>
@@ -11,9 +12,18 @@
{{with index $e "hostName"}}<span class="field-error">{{.}}</span>{{end}}
</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>
<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}}
<label>Type
<select name="wan.type">
@@ -21,6 +31,7 @@
<option value="pppoe" {{if eq .Form.WAN.Type "pppoe"}}selected{{end}}>PPPoE</option>
</select>
</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">
<p class="hint">PPPoE only:</p>
<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"
placeholder="{{if .Form.WAN.PasswordSet}}stored — leave empty to keep{{end}}"></label>
{{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>
</fieldset>
@@ -38,7 +47,7 @@
{{with index $e "lans"}}<p class="field-error">{{.}}</p>{{end}}
<input type="hidden" name="lans" value="{{len .Form.LANs}}">
<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}}
<tr>
<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}}.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}}.ipv6pd" {{if $l.IPv6PD}}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}}.poolEnd" value="{{$l.PoolEnd}}" size="13"></td>
<td>{{if $l.Name}}<input type="checkbox" name="lan.{{$i}}.remove">{{end}}</td>
</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}}
</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>
<button>Save</button>