first commit

This commit is contained in:
James Coleman 2026-07-01 15:17:11 -05:00
commit 50a535bb7f
123 changed files with 15141 additions and 0 deletions

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

244
README.md Normal file
View file

@ -0,0 +1,244 @@
# go-network-configurator
[![Go Reference](https://pkg.go.dev/badge/github.com/grmrgecko/go-network-configurator.svg)](https://pkg.go.dev/github.com/grmrgecko/go-network-configurator)
A Go library for inspecting and changing a host's IP addresses and static
routes at runtime **and** persisting those changes to whatever on-disk network
configuration backend the system actually uses — so the change survives a
reboot.
It is designed for hosting environments where an automated system needs to add,
swap, or remove IPs on a server without knowing in advance whether that server
is running netplan, systemd-networkd, NetworkManager, RHEL network-scripts,
ifupdown, or cloud-init — and without leaving a control panel (cPanel, Plesk,
InterWorx) out of sync.
```go
import "github.com/grmrgecko/go-network-configurator"
```
## How it works
A single `Configurator` applies every change in two layers:
1. **Runtime** — the live kernel state is changed immediately (via netlink on
Linux, the IP Helper API on Windows). Address and gateway changes are
verified against an internet-reachability test and **rolled back
automatically** if connectivity is lost.
2. **Persistence** — the same change is written to every detected configuration
backend so it survives a reboot, and registered control panels are told to
re-read the system's IPs.
Backends are auto-detected at construction time. A host running both netplan and
cloud-init, for example, has both files kept in sync; a failure writing one
backend is logged but does not abort the others.
## Supported backends
**Network configuration (persistence)**
| Backend | Detection |
| ------------------- | ------------------------------------------- |
| netplan | `netplan` binary on `PATH` |
| cloud-init | cloud-init network config present |
| NetworkManager | `NetworkManager.service` active |
| systemd-networkd | `systemd-networkd.service` active |
| RHEL network-scripts| `network.service` active (config dir on legacy hosts) |
| ifupdown | `/etc/network/interfaces` |
**Control panels**
| Panel | Detection |
| --------- | ---------------------------------- |
| cPanel | `/usr/local/cpanel/bin/whmapi1` |
| Plesk | `/usr/sbin/plesk` |
| InterWorx | `/usr/bin/nodeworx` |
Detection of the service-managed backends (NetworkManager, systemd-networkd,
and RHEL network-scripts) uses systemd over D-Bus, with a fallback for legacy
systems that lack a usable systemd D-Bus interface (Ubuntu 14.04 Upstart,
CentOS 5/6). On that fallback path network-scripts is detected by the presence
of its `/etc/sysconfig/network-scripts` directory.
## Platforms
- **Linux** — full support (all backends and panels above).
- **Windows** — runtime changes via the IP Helper API, persisted to cloud-init,
with Plesk panel support.
## API
```go
type Configurator interface {
// Enumerate interfaces with their addresses, gateways, static routes,
// DNS servers, and search domains.
GetInterfaces(ctx context.Context) ([]*Interface, error)
// Add an IP address (optionally setting/replacing the default gateway).
AddAddress(ctx context.Context, iface string, addr *net.IPNet, gateway net.IP) error
// Promote an address already on the interface to be the primary of its family.
SetPrimaryAddress(ctx context.Context, iface string, addr *net.IPNet) error
// Remove an IP address.
RemoveAddress(ctx context.Context, iface string, addr *net.IPNet) error
// Add or remove a static route.
AddRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP, metric int) error
RemoveRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP) error
// Set the DNS servers and search domains for an interface.
SetDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) error
}
// Construct the configurator for the current OS, auto-detecting backends.
// Behaviour is tuned with Option values (see Options below).
func NewConfigurator(opts ...Option) (Configurator, error)
// Resolve an interface by name, or by the special "public-internet" /
// "public-internet-6" selectors.
func FindInterfaceByName(name string, ifaces []*Interface) *Interface
```
Every operation takes a `context.Context`. The slow steps — the ICMP probe of a
new gateway and the internet-reachability test — honour cancellation and
deadlines, so a caller can bound how long a change may block.
### DNS
`SetDNS` applies an interface's resolvers and search domains to the live
resolver so they take effect immediately, and persists them through whichever
network management backends are detected on the host so they survive a reboot.
### Logging
The package uses a single, package-wide logger for non-fatal diagnostics from
backends and control panels. It defaults to the logrus standard logger. Replace
it with `SetLogger` before constructing any `Configurator`:
```go
netconfig.SetLogger(myLogger) // anything with Printf/Println, e.g. *log.Logger
```
### Options
`NewConfigurator` accepts functional options:
```go
c, err := netconfig.NewConfigurator(
netconfig.WithTestAddress("http://my-canary/health"), // connectivity-test URL
netconfig.WithConnectivityCheck(false), // skip the post-change probe + rollback
netconfig.WithSkipConnectivityCheck(), // same as WithConnectivityCheck(false)
netconfig.WithConnectivityTimeout(5 * time.Second), // HTTP reachability timeout
netconfig.WithPingCount(3), // ICMP probes per ping test
netconfig.WithPingTimeout(10 * time.Second), // overall ping-run timeout
netconfig.WithBackupRetention(10), // .bak.* copies kept per config file (0 = keep all)
netconfig.WithAllowPrimaryRemoval(true), // let RemoveAddress remove the primary IP
netconfig.WithSkipPanels(true), // skip all panel interaction (add/primary/remove)
)
```
`WithConnectivityCheck(false)` (or the convenience `WithSkipConnectivityCheck()`) is useful on hosts with no outbound internet
access, where the default reachability test would always fail and roll changes
back. `WithConnectivityTimeout`, `WithPingCount`, and `WithPingTimeout` tune the
probes used after gateway changes. `WithBackupRetention` limits how many
`.bak.<timestamp>` copies each backend keeps per original config file; the
oldest backups beyond this number are pruned on every save.
`WithAllowPrimaryRemoval(true)` overrides the default refusal to remove an
address that is the primary of its family. The intended safe flow is to call
`SetPrimaryAddress` on another address first and then remove the old one; enable
the override only when you are deliberately tearing down the current primary.
`WithSkipPanels(true)` skips all control-panel interaction: `AddAddress`,
`SetPrimaryAddress`, and `RemoveAddress` do not tell cPanel, Plesk, or
InterWorx to reload, set the main IP, or release an IP. Only the running system
and the network-manager configuration files are changed.
`FindInterfaceByName` accepts two well-known selectors in addition to a literal
interface name:
- `"public-internet"` — the interface that carries the IPv4 default gateway.
- `"public-internet-6"` — the interface that carries the IPv6 default gateway.
## Usage
```go
package main
import (
"context"
"log"
"net"
netconfig "github.com/grmrgecko/go-network-configurator"
)
func main() {
ctx := context.Background()
c, err := netconfig.NewConfigurator()
if err != nil {
log.Fatal(err)
}
ifaces, err := c.GetInterfaces(ctx)
if err != nil {
log.Fatal(err)
}
iface := netconfig.FindInterfaceByName(netconfig.Public, ifaces)
if iface == nil {
log.Fatal("no public interface found")
}
// Swap the primary IP without dropping connectivity:
// add a new address, switch the system over to it, then remove the old one.
_, newIP, _ := net.ParseCIDR("203.0.113.20/24")
_, oldIP, _ := net.ParseCIDR("203.0.113.10/24")
if err := c.AddAddress(ctx, iface.Name, newIP, nil); err != nil {
log.Fatal(err)
}
if err := c.SetPrimaryAddress(ctx, iface.Name, newIP); err != nil {
log.Fatal(err)
}
if err := c.RemoveAddress(ctx, iface.Name, oldIP); err != nil {
log.Fatal(err)
}
}
```
The primary address is the first address listed on an interface.
`SetPrimaryAddress` reorders an interface's addresses so the chosen IP is
first within its family, both in the running kernel and in the written
configuration. The address must already be present on the interface
(compose it with `AddAddress`); IPv4 and IPv6 each have their own primary.
## Safety
Operations that can break connectivity are guarded:
- Adding an IP that already answers ping is refused.
- After an address/gateway change, an internet-reachability test runs; if it
fails, the previous runtime state (addresses and default route) is restored
and the operation returns an error.
- Removing an address that is the only route to the default gateway is refused.
- Removing the primary address of its family is refused by default (use
`WithAllowPrimaryRemoval(true)` to override). The safe flow is to promote
another address with `SetPrimaryAddress` first, then remove the old one.
Most operations require elevated privileges (root on Linux, Administrator on
Windows) to modify network state.
## Development
```sh
go test ./...
```
Some tests exercise real kernel networking inside a throwaway network namespace
and are skipped unless run as root:
```sh
sudo go test ./...
```

1255
cloudinit.go Normal file

File diff suppressed because it is too large Load diff

285
cloudinit_test.go Normal file
View file

@ -0,0 +1,285 @@
package netconfig
import (
"context"
"net"
"os"
"path/filepath"
"testing"
)
// Validate the cloud-init configuration parser/writer functions.
func TestCloudinit(t *testing.T) {
// Setup test file.
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
configPath := filepath.Join(tmpDir, "network.yaml")
testDir, err := filepath.Abs("./tests/cloudinit")
if err != nil {
t.Fatal(err)
}
resultsDir := filepath.Join(testDir, "results")
err = fileCopy(filepath.Join(testDir, "network.yaml"), configPath)
if err != nil {
t.Fatal(err)
}
// Setup ifupdown and parse test file.
ci, err := newCloudInitWith(configPath, filepath.Join(tmpDir, "nothing.json"), filepath.Join(tmpDir, "cloud.cfg.d"))
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err := ci.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = ci.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{
{
IP: net.ParseIP("1.2.3.4"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("1.2.3.43"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("fc00::2"),
Mask: net.CIDRMask(64, 128),
},
}, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1"))
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = ci.SetIfaceRoutes(context.Background(), "test_eth3", []*Route{
{
Destination: &net.IPNet{
IP: net.ParseIP("abcd:ef12:3455:10::"),
Mask: net.CIDRMask(64, 128),
},
Gateway: net.ParseIP("abcd:ef12:3456:10::1"),
Metric: 100,
},
{
Destination: &net.IPNet{
IP: net.ParseIP("10.253.2.0"),
Mask: net.CIDRMask(24, 32),
},
Gateway: net.ParseIP("203.0.113.22"),
Metric: 100,
},
})
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = ci.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 2)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = ci.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{
{
IP: net.ParseIP("1.2.10.4"),
Mask: net.CIDRMask(24, 32),
},
}, net.ParseIP("1.2.10.254"), nil)
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = ci.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{})
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = ci.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Cleanup.
err = os.RemoveAll(tmpDir)
if err != nil {
t.Fatal(err)
}
}
// Validate the cloudbase-init configuration parser/writer functions.
func TestCloudbaseinit(t *testing.T) {
// Setup test file.
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
configPath := filepath.Join(tmpDir, "network.json")
testDir, err := filepath.Abs("./tests/cloudinit/cloudbase")
if err != nil {
t.Fatal(err)
}
resultsDir := filepath.Join(testDir, "results")
err = fileCopy(filepath.Join(testDir, "network.json"), configPath)
if err != nil {
t.Fatal(err)
}
// Setup ifupdown and parse test file.
ci, err := newCloudInitWith(filepath.Join(tmpDir, "nothing.yaml"), configPath, filepath.Join(tmpDir, "cloud.cfg.d"))
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err := ci.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = ci.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{
{
IP: net.ParseIP("1.2.3.4"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("1.2.3.43"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("fc00::2"),
Mask: net.CIDRMask(64, 128),
},
}, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1"))
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = ci.SetIfaceRoutes(context.Background(), "test_eth3", []*Route{
{
Destination: &net.IPNet{
IP: net.ParseIP("abcd:ef12:3455:10::"),
Mask: net.CIDRMask(64, 128),
},
Gateway: net.ParseIP("abcd:ef12:3456:10::1"),
Metric: 100,
},
{
Destination: &net.IPNet{
IP: net.ParseIP("10.253.2.0"),
Mask: net.CIDRMask(24, 32),
},
Gateway: net.ParseIP("203.0.113.22"),
Metric: 100,
},
})
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = ci.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 2)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = ci.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{
{
IP: net.ParseIP("1.2.10.4"),
Mask: net.CIDRMask(24, 32),
},
}, net.ParseIP("1.2.10.254"), nil)
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = ci.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{})
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = ci.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Cleanup.
err = os.RemoveAll(tmpDir)
if err != nil {
t.Fatal(err)
}
}

1018
configurator_linux.go Normal file

File diff suppressed because it is too large Load diff

568
configurator_linux_test.go Normal file
View file

@ -0,0 +1,568 @@
package netconfig
import (
"context"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netns"
"golang.org/x/sys/unix"
)
// Function to setup a test namespace for test network interfaces.
func setupNetlinkTest(t testing.TB) func() {
t.Helper()
if os.Getuid() != 0 {
t.Skip("Test requires root privileges.")
}
runtime.LockOSThread()
ns, err := netns.New()
if err != nil {
t.Fatal("Failed to create new netns", err)
}
link, err := netlink.LinkByName("lo")
if err != nil {
t.Fatalf("Failed to find \"lo\" in new netns: %v", err)
}
err = netlink.LinkSetUp(link)
if err != nil {
t.Fatalf("Failed to bring up \"lo\" in new netns: %v", err)
}
return func() {
ns.Close()
runtime.UnlockOSThread()
}
}
// isPrimary reports whether the given IP is currently the primary address of
// its subnet on the link, i.e. the kernel has not flagged it IFA_F_SECONDARY.
// It also confirms the address is present at all.
func isPrimary(t testing.TB, h *netlink.Handle, link netlink.Link, ip net.IP) (present, primary bool) {
t.Helper()
addrs, err := h.AddrList(link, netlink.FAMILY_ALL)
if err != nil {
t.Fatalf("AddrList: %v", err)
}
for _, a := range addrs {
if a.IPNet.IP.Equal(ip) {
return true, a.Flags&unix.IFA_F_SECONDARY == 0
}
}
return false, false
}
// TestLinuxSetPrimaryAddress exercises the netlink reordering performed by
// SetPrimaryAddress: promoting a secondary same-subnet address to primary,
// idempotency when already primary, and the error when the address is absent.
func TestLinuxSetPrimaryAddress(t *testing.T) {
tearDown := setupNetlinkTest(t)
defer tearDown()
h, err := netlink.NewHandle()
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup test link.
name := "test_enp1s0"
link := netlink.Link(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{
Name: name,
HardwareAddr: net.HardwareAddr{0x52, 0x54, 0x00, 0x8b, 0x0d, 0x93},
}})
if err = h.LinkAdd(link); err != nil {
t.Fatal(err)
}
if err = h.LinkSetUp(link); err != nil {
t.Fatal(err)
}
// Add two addresses in the same subnet. The first added is the primary.
primaryIP := &net.IPNet{IP: net.ParseIP("1.2.3.4"), Mask: net.CIDRMask(24, 32)}
secondaryIP := &net.IPNet{IP: net.ParseIP("1.2.3.5"), Mask: net.CIDRMask(24, 32)}
if err = h.AddrAdd(link, &netlink.Addr{IPNet: primaryIP}); err != nil {
t.Fatal(err)
}
if err = h.AddrAdd(link, &netlink.Addr{IPNet: secondaryIP}); err != nil {
t.Fatal(err)
}
// Add a default route via the subnet so connectivity verification has a
// gateway to work with.
gw := net.ParseIP("1.2.3.1")
if err = h.RouteAdd(&netlink.Route{
Family: netlink.FAMILY_V4,
LinkIndex: link.Attrs().Index,
Dst: &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)},
Gw: gw,
}); err != nil {
t.Fatal(err)
}
// Sanity check the initial primary/secondary assignment.
if _, primary := isPrimary(t, h, link, primaryIP.IP); !primary {
t.Fatal("expected 1.2.3.4 to start as primary")
}
if _, primary := isPrimary(t, h, link, secondaryIP.IP); primary {
t.Fatal("expected 1.2.3.5 to start as secondary")
}
// Configurator with the success sentinel and a capturing backend.
backend := &fakeIfaceBackend{}
c := &linuxConfigurator{configOptions: &configOptions{testAddress: "test_success"}}
c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"fake", backend})
// Promote the secondary to primary.
if err = c.SetPrimaryAddress(context.Background(), name, secondaryIP); err != nil {
t.Fatalf("SetPrimaryAddress: %v", err)
}
// The kernel should now treat 1.2.3.5 as primary and 1.2.3.4 as secondary,
// with both addresses still present and the default route intact.
if present, primary := isPrimary(t, h, link, secondaryIP.IP); !present || !primary {
t.Errorf("after promotion 1.2.3.5: present=%v primary=%v, want both true", present, primary)
}
if present, primary := isPrimary(t, h, link, primaryIP.IP); !present || primary {
t.Errorf("after promotion 1.2.3.4: present=%v primary=%v, want present true primary false", present, primary)
}
routes, err := h.RouteList(link, netlink.FAMILY_V4)
if err != nil {
t.Fatal(err)
}
haveDefault := false
for _, r := range routes {
if ones, _ := r.Dst.Mask.Size(); ones == 0 && r.Gw.Equal(gw) {
haveDefault = true
}
}
if !haveDefault {
t.Error("default route missing after promotion")
}
// The persisted config must receive the reordered list, primary first.
if len(backend.lastAddrs) == 0 || !backend.lastAddrs[0].IP.Equal(secondaryIP.IP) {
t.Errorf("backend primary = %v, want 1.2.3.5 first", backend.lastAddrs)
}
// Promoting the already-primary address is a no-op that still succeeds.
if err = c.SetPrimaryAddress(context.Background(), name, secondaryIP); err != nil {
t.Errorf("SetPrimaryAddress (idempotent): %v", err)
}
if _, primary := isPrimary(t, h, link, secondaryIP.IP); !primary {
t.Error("1.2.3.5 should remain primary after idempotent call")
}
// Promoting an address not on the interface must error before any change.
absent := &net.IPNet{IP: net.ParseIP("1.2.3.99"), Mask: net.CIDRMask(24, 32)}
err = c.SetPrimaryAddress(context.Background(), name, absent)
if err == nil || err.Error() != "address not found on interface" {
t.Errorf("absent address error = %v, want \"address not found on interface\"", err)
}
}
// TestLinuxRemovePrimaryRefused verifies that RemoveAddress refuses to remove
// the primary address of its family by default, and that WithAllowPrimaryRemoval
// overrides the refusal.
func TestLinuxRemovePrimaryRefused(t *testing.T) {
tearDown := setupNetlinkTest(t)
defer tearDown()
h, err := netlink.NewHandle()
if err != nil {
t.Fatal(err)
}
defer h.Close()
name := "test_enp1s0"
link := netlink.Link(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: name}})
if err = h.LinkAdd(link); err != nil {
t.Fatal(err)
}
if err = h.LinkSetUp(link); err != nil {
t.Fatal(err)
}
primaryIP := &net.IPNet{IP: net.ParseIP("1.2.3.4"), Mask: net.CIDRMask(24, 32)}
if err = h.AddrAdd(link, &netlink.Addr{IPNet: primaryIP}); err != nil {
t.Fatal(err)
}
c := &linuxConfigurator{configOptions: &configOptions{testAddress: "test_success"}}
c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"fake", &fakeIfaceBackend{}})
// Default: removing the primary is refused.
err = c.RemoveAddress(context.Background(), name, primaryIP)
if err == nil || !contains(err.Error(), "refusing to remove primary address") {
t.Errorf("RemoveAddress primary = %v, want refusal error", err)
}
// With the override enabled, removal succeeds.
c.allowPrimaryRemoval = true
if err = c.RemoveAddress(context.Background(), name, primaryIP); err != nil {
t.Errorf("RemoveAddress with allowPrimaryRemoval: %v", err)
}
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
func TestLinuxConfigurator(t *testing.T) {
tearDown := setupNetlinkTest(t)
defer tearDown()
// Connect to netlink.
h, err := netlink.NewHandle()
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup test link.
eth0Name := "test_enp1s0"
eth0Link := netlink.Link(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{
Name: eth0Name,
HardwareAddr: net.HardwareAddr{0x52, 0x54, 0x00, 0x8b, 0x0d, 0x93},
}})
err = h.LinkAdd(eth0Link)
if err != nil {
t.Fatal(err)
}
// Set test link up.
err = h.LinkSetUp(eth0Link)
if err != nil {
t.Fatal(err)
}
// Add test IP.
testIP1 := &net.IPNet{
IP: net.ParseIP("1.2.3.4"),
Mask: net.CIDRMask(24, 32),
}
err = h.AddrAdd(eth0Link, &netlink.Addr{IPNet: testIP1})
if err != nil {
t.Fatal(err)
}
// Add default route.
testGW1 := net.ParseIP("1.2.3.1")
defaultRoute := &netlink.Route{
Family: netlink.FAMILY_V4,
LinkIndex: eth0Link.Attrs().Index,
Dst: &net.IPNet{
IP: net.IPv4zero,
Mask: net.CIDRMask(0, 32),
},
Gw: testGW1,
}
err = h.RouteAdd(defaultRoute)
if err != nil {
t.Fatal(err)
}
// Setup test link.
eth1Name := "test_enp2s0"
eth1Link := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{
Name: eth1Name,
HardwareAddr: net.HardwareAddr{0x52, 0x54, 0x00, 0x8b, 0xad, 0x93},
}}
err = h.LinkAdd(eth1Link)
if err != nil {
t.Fatal(err)
}
// Set test link up.
err = h.LinkSetUp(eth1Link)
if err != nil {
t.Fatal(err)
}
// Add test IP.
testIP2 := &net.IPNet{
IP: net.ParseIP("fc00:5aa8:7160:d9eb:1:0:1:3"),
Mask: net.CIDRMask(64, 128),
}
err = h.AddrAdd(eth1Link, &netlink.Addr{IPNet: testIP2})
if err != nil {
t.Fatal(err)
}
// Add default route.
testGW2 := net.ParseIP("fe80::1")
ipv6Dest := &net.IPNet{
IP: net.ParseIP("fc00:5aa8:7160:d9eb::1"),
Mask: net.CIDRMask(128, 128),
}
ipv6Route := &netlink.Route{
Family: netlink.FAMILY_V6,
LinkIndex: eth1Link.Attrs().Index,
Dst: ipv6Dest,
Gw: testGW2,
Priority: 200,
}
err = h.RouteAdd(ipv6Route)
if err != nil {
t.Fatal(err)
}
// Setup configurator with test URL to test server.
c := &linuxConfigurator{configOptions: &configOptions{}}
c.testAddress = "test_success"
// The test intentionally removes addresses that are primary at runtime;
// enable the override so the removal calls succeed.
c.allowPrimaryRemoval = true
// Setup test results path.
testDir, err := filepath.Abs("./tests/configurator_linux")
if err != nil {
t.Fatal(err)
}
resultsDir := filepath.Join(testDir, "results")
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
configPath := filepath.Join(tmpDir, "50-cloud-init.yaml")
err = fileCopy(filepath.Join(testDir, "50-cloud-init.yaml"), configPath)
if err != nil {
t.Fatal(err)
}
// Setup ifupdown and parse test file.
ci, err := newCloudInitWith(configPath, filepath.Join(tmpDir, "nothing.json"), filepath.Join(tmpDir, "cloud.cfg.d"))
if err != nil {
t.Fatal(err)
}
c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"cloud-init", ci})
// Get list of interfaces.
interfaces, err := c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 1)
if err != nil {
t.Error(err)
}
// Test add addresses.
testIP3 := &net.IPNet{IP: net.ParseIP("1.2.3.5"), Mask: net.CIDRMask(24, 32)}
err = c.AddAddress(context.Background(), eth0Name, testIP3, nil)
if err != nil {
t.Error(err)
}
testIP4 := &net.IPNet{IP: net.ParseIP("fc00:5aa8:7160:d9eb:1:0:1:5"), Mask: net.CIDRMask(64, 128)}
err = c.AddAddress(context.Background(), eth1Name, testIP4, net.ParseIP("fc00:5aa8:7160:d9eb::"))
if err != nil {
t.Error(err)
}
// Get list of interfaces.
interfaces, err = c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 2)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 1)
if err != nil {
t.Error(err)
}
// Test remove addresses.
err = c.RemoveAddress(context.Background(), eth0Name, testIP1)
if err != nil {
t.Error(err)
}
err = c.RemoveAddress(context.Background(), eth1Name, testIP2)
if err != nil {
t.Error(err)
}
// Get list of interfaces.
interfaces, err = c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Test rollback on failures.
c.testAddress = "test_fail"
err = c.AddAddress(context.Background(), eth0Name, testIP1, nil)
if err == nil || err.Error() != "aborted operation due to loss of internet" {
t.Errorf("Failed rollback test: %v", err)
}
// Get list of interfaces.
interfaces, err = c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Test remove gateway.
err = c.AddAddress(context.Background(), eth0Name, testIP3, make(net.IP, 4))
if err != nil {
t.Error(err)
}
// Get list of interfaces.
interfaces, err = c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 4)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 3)
if err != nil {
t.Error(err)
}
// Test add gateway rollback.
err = c.AddAddress(context.Background(), eth0Name, testIP3, testGW1)
if err == nil || err.Error() != "aborted operation due to loss of internet" {
t.Errorf("Failed rollback test: %v", err)
}
// Get list of interfaces.
interfaces, err = c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 4)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 3)
if err != nil {
t.Error(err)
}
// Test remove gateway.
c.testAddress = "test_success"
err = c.AddAddress(context.Background(), eth0Name, testIP3, testGW1)
if err != nil {
t.Error(err)
}
// Get list of interfaces.
interfaces, err = c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Test removing route.
err = c.RemoveRoute(context.Background(), eth1Name, ipv6Dest, testGW2)
if err != nil {
t.Error(err)
}
// Get list of interfaces.
interfaces, err = c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 5)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 4)
if err != nil {
t.Error(err)
}
// Test add route.
ipv4Dest := &net.IPNet{
IP: net.ParseIP("10.0.10.0"),
Mask: net.CIDRMask(24, 32),
}
testGW3 := net.ParseIP("1.2.3.254")
err = c.AddRoute(context.Background(), eth0Name, ipv4Dest, testGW3, 100)
if err != nil {
t.Error(err)
}
// Get list of interfaces.
interfaces, err = c.GetInterfaces(context.Background())
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 6)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 5)
if err != nil {
t.Error(err)
}
}

1010
configurator_windows.go Normal file

File diff suppressed because it is too large Load diff

410
cpanel.go Normal file
View file

@ -0,0 +1,410 @@
package netconfig
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"strings"
)
const (
cpanelBin = "/usr/local/cpanel/bin/whmapi1"
cpanelJSON = "--output=json"
// Paths and scripts that define cPanel's main shared IPv4: mainip is the
// stored main IP, ADDR in wwwacct.conf is the shared IP, and
// mainipcheck/fixetchosts sync the stored main IP and /etc/hosts. IPv6 is
// handled as ranges (see removeIP).
cpanelWWWAcctConf = "/etc/wwwacct.conf"
cpanelMainIP = "/var/cpanel/mainip"
cpanelMainIPCheck = "/scripts/mainipcheck"
cpanelFixEtcHosts = "/scripts/fixetchosts"
)
type cpanelMetadata struct {
Command string `json:"command"`
Reason string `json:"reason"`
Result int `json:"result"`
}
type cpanelBase struct {
Metadata cpanelMetadata `json:"metadata"`
}
type cpanelIPInfo struct {
Active int `json:"active"`
Dedicated int `json:"dedicated"`
IP string `json:"ip"`
MainIP int `json:"mainaddr"`
PublicIP string `json:"public_ip"`
Removable int `json:"removable"`
Used int `json:"used"`
}
type cpanelListIPs struct {
Data struct {
IPs []cpanelIPInfo `json:"ip"`
} `json:"data"`
Metadata cpanelMetadata `json:"metadata"`
}
type cpanelAccountInfo struct {
User string `json:"user"`
Domain string `json:"domain"`
IP string `json:"ip"`
IPv6 []string `json:"ipv6"`
}
type cpanelListAccounts struct {
Data struct {
Accounts []cpanelAccountInfo `json:"acct"`
} `json:"data"`
Metadata cpanelMetadata `json:"metadata"`
}
type cpanel struct {
}
// Parse cpanel API json output.
func (*cpanel) parseJSON(out []string, v any) error {
if len(out) != 1 {
return fmt.Errorf("no json output.")
}
return json.Unmarshal([]byte(out[0]), v)
}
// Move sites off the old IP to the new IP.
func (p *cpanel) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error {
// Get list of accounts in cPanel using the old IP.
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", oldAddr.String()), "searchtype=ip")
if err != nil {
return err
}
var data cpanelListAccounts
err = p.parseJSON(out, &data)
if err != nil {
return err
}
// On error, log.
if data.Metadata.Result != 1 {
return fmt.Errorf("%s", data.Metadata.Reason)
}
// Move the sites to new IP.
for _, account := range data.Data.Accounts {
// If the IP is not the one we're removing, skip.'
ip := net.ParseIP(account.IP)
if !ip.Equal(oldAddr) {
continue
}
// Update the IP address for the account.
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "setsiteip", fmt.Sprintf("ip=%s", newAddr.String()), fmt.Sprintf("user=%s", account.User))
if err != nil {
return err
}
var data cpanelBase
err = p.parseJSON(out, &data)
if err != nil {
return err
}
logger.Println("cPanel site move response:", data.Metadata.Reason)
}
return nil
}
// Remove an IP address from cpanel. IPv4 and IPv6 are handled separately: the
// IPv4 main-IP pool (listips) is IPv4-only, so it must not be consulted when
// removing an IPv6 address.
func (p *cpanel) removeIP(ctx context.Context, addr net.IP) error {
if addr.To4() == nil {
return p.removeIPv6(ctx, addr)
}
return p.removeIPv4(ctx, addr)
}
// removeIPv4 moves accounts off addr onto cPanel's main shared IPv4, then
// releases addr.
func (p *cpanel) removeIPv4(ctx context.Context, addr net.IP) error {
// Get list of accounts in cPanel using the old IP.
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", addr.String()), "searchtype=ip")
if err != nil {
return err
}
var accounts cpanelListAccounts
err = p.parseJSON(out, &accounts)
if err != nil {
return err
}
if accounts.Metadata.Result != 1 {
return fmt.Errorf("%s", accounts.Metadata.Reason)
}
// Get list of IP addresses in cPanel.
out, err = runCommand(ctx, cpanelBin, cpanelJSON, "listips")
if err != nil {
return err
}
var data cpanelListIPs
err = p.parseJSON(out, &data)
if err != nil {
return err
}
// On error, log.
if data.Metadata.Result != 1 {
return fmt.Errorf("%s", data.Metadata.Reason)
}
// Information to extract from IP list.
var mainIP net.IP
isMainIP := false
var publicIPs []net.IP
var privateIPs []net.IP
// Parse IP list info.
for _, ipInfo := range data.Data.IPs {
ip := net.ParseIP(ipInfo.IP)
// Depending on the ip, if the IP is one being removed we capture if its the main IP.
// If the IP is the main IP, but no the one being removed, capture that fact.
// If its an public IP, associate with public IP list, and if its private add as needed.
if ip.Equal(addr) {
isMainIP = ipInfo.MainIP == 1 && mainIP == nil
} else if ipInfo.MainIP == 1 {
mainIP = ip
isMainIP = false
} else if !ip.IsPrivate() {
publicIPs = append(publicIPs, ip)
} else {
pubIP := net.ParseIP(ipInfo.PublicIP)
if pubIP != nil && !pubIP.IsPrivate() {
publicIPs = append(publicIPs, ip)
} else {
privateIPs = append(privateIPs, ip)
}
}
}
// If no main IP found, we should find a new main IP.
noMainIP := mainIP == nil
if noMainIP {
if len(publicIPs) != 0 {
mainIP = publicIPs[0]
} else if len(privateIPs) != 0 {
mainIP = privateIPs[0]
} else {
return fmt.Errorf("unable to find the main IP to move ip addresses to")
}
}
// Move sites to the main IP from the old IP.
err = p.moveSites(ctx, addr, mainIP)
if err != nil {
return err
}
// Remove the IP, incase there is an IP alias for it. We don't really care about the output for this,
// it may fail if cPanel already picked up that the IP was removed from the system interface.
runCommand(ctx, cpanelBin, cpanelJSON, "delip", fmt.Sprintf("ip=%s", addr.String()))
// Rebuild the IP pool.
err = p.reload(ctx)
if err != nil {
return err
}
// If the main IP did not exist, or if the IP being removed was the main IP, tell cPanel to check.
if isMainIP || noMainIP {
out, err := runCommand(ctx, cpanelMainIPCheck)
if err != nil {
return err
}
logger.Println("cPanel main IP output:", strings.Join(out, "\n"))
}
return nil
}
// removeIPv6 moves accounts off addr onto a replacement IPv6 address, then
// releases addr. cPanel's listips pool is IPv4-only, so the replacement is
// drawn from the other IPv6 addresses the affected accounts already hold; it is
// never an IPv4 address. When no other IPv6 is available the address is dropped
// from each account rather than substituted.
func (p *cpanel) removeIPv6(ctx context.Context, addr net.IP) error {
// cPanel tracks IPv6 as ranges; search accounts by the IPv6 address.
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", addr.String()), "searchtype=ipv6")
if err != nil {
return err
}
var accounts cpanelListAccounts
err = p.parseJSON(out, &accounts)
if err != nil {
return err
}
if accounts.Metadata.Result != 1 {
return fmt.Errorf("%s", accounts.Metadata.Reason)
}
// Choose a replacement IPv6 from the addresses the affected accounts already
// use, excluding the one being removed. Never fall back to an IPv4 address.
var replacement net.IP
for _, account := range accounts.Data.Accounts {
for _, ipStr := range account.IPv6 {
ip := net.ParseIP(ipStr)
if ip == nil || ip.To4() != nil || ip.Equal(addr) {
continue
}
replacement = ip
break
}
if replacement != nil {
break
}
}
// Move the sites off the address (onto the replacement, or drop it when
// there is no other IPv6 to move to).
err = p.moveIPv6Sites(ctx, accounts, addr, replacement)
if err != nil {
return err
}
// Remove the IP, incase there is an IP alias for it. We don't really care about the output for this,
// it may fail if cPanel already picked up that the IP was removed from the system interface.
runCommand(ctx, cpanelBin, cpanelJSON, "delip", fmt.Sprintf("ip=%s", addr.String()), "ipv6=1")
// Rebuild the IP pool.
return p.reload(ctx)
}
// moveIPv6Sites moves any accounts using oldAddr as an IPv6 address over to
// newAddr, or drops oldAddr from the account when newAddr is nil. cPanel
// exposes account IPv6 via listaccts, so we use that rather than the IPv4
// listips path.
func (p *cpanel) moveIPv6Sites(ctx context.Context, accounts cpanelListAccounts, oldAddr, newAddr net.IP) error {
for _, account := range accounts.Data.Accounts {
// Confirm the account actually has the address being removed.
found := false
for _, ip := range account.IPv6 {
if net.ParseIP(ip).Equal(oldAddr) {
found = true
break
}
}
if !found {
continue
}
// Build the new IPv6 list, replacing oldAddr with newAddr. When there is
// no replacement (newAddr is nil), drop oldAddr from the list entirely
// rather than substituting an address of the wrong family.
var newIPv6 []string
for _, ip := range account.IPv6 {
if net.ParseIP(ip).Equal(oldAddr) {
if newAddr != nil {
newIPv6 = append(newIPv6, newAddr.String())
}
} else {
newIPv6 = append(newIPv6, ip)
}
}
out, err := runCommand(ctx, cpanelBin, cpanelJSON, "setsiteip", fmt.Sprintf("ip=%s", account.IP), fmt.Sprintf("user=%s", account.User), fmt.Sprintf("ipv6=%s", strings.Join(newIPv6, ",")))
if err != nil {
return err
}
var data cpanelBase
err = p.parseJSON(out, &data)
if err != nil {
return err
}
logger.Println("cPanel IPv6 site move response:", data.Metadata.Reason)
}
return nil
}
// setWWWAcctAddr rewrites an address directive in a wwwacct.conf-style file to
// ip in place, preserving every other line and the file's permissions. If the
// directive is absent it is appended. directive is "ADDR" for the shared IPv4 or
// "ADDR6" for the shared IPv6; the two are distinct, so setting one leaves the
// other untouched.
func setWWWAcctAddr(path, directive, ip string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
replaced := false
for i, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 1 && fields[0] == directive {
lines[i] = directive + " " + ip
replaced = true
}
}
if !replaced {
// Insert before a trailing empty element (from a final newline) so the
// file keeps a single trailing newline rather than gaining a blank line.
if n := len(lines); n > 0 && lines[n-1] == "" {
lines = append(lines[:n-1], directive+" "+ip, "")
} else {
lines = append(lines, directive+" "+ip)
}
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm())
}
// setMainIP repoints cPanel's main shared IP to addr: set the shared-IP
// directive in wwwacct.conf (ADDR for IPv4, ADDR6 for IPv6), then run
// mainipcheck and fixetchosts to sync the stored main IP and /etc/hosts. Only
// IPv4 has a stored-main-IP file (/var/cpanel/mainip); IPv6 is tracked as ranges
// and has no such file. The IP pool (/etc/ipaddrpool) is not written here
// because reload() runs /scripts/rebuildippool, which rebuilds the pool from the
// system's bound IPs.
func (p *cpanel) setMainIP(ctx context.Context, addr net.IP) error {
ip := addr.String()
// Point the shared IP at the new main address (ADDR6 for IPv6).
directive := "ADDR"
if addr.To4() == nil {
directive = "ADDR6"
}
if err := setWWWAcctAddr(cpanelWWWAcctConf, directive, ip); err != nil {
return err
}
// Only IPv4 has a stored main IP file; record it without a trailing newline.
if addr.To4() != nil {
if err := os.WriteFile(cpanelMainIP, []byte(ip), 0644); err != nil {
return err
}
}
// Sync cPanel's stored main IP (/var/cpanel/mainip) and /etc/hosts.
out, err := runCommand(ctx, cpanelMainIPCheck)
if err != nil {
return err
}
logger.Println("cPanel main IP output:", strings.Join(out, "\n"))
runCommand(ctx, cpanelFixEtcHosts)
return nil
}
// Tell cpanel to reload the available IP addresses.
func (*cpanel) reload(ctx context.Context) error {
out, err := runCommand(ctx, "/scripts/rebuildippool")
if err != nil {
return err
}
logger.Println("cPanel IP pool output:", strings.Join(out, "\n"))
return nil
}

143
cpanel_test.go Normal file
View file

@ -0,0 +1,143 @@
package netconfig
import (
"os"
"path/filepath"
"testing"
)
// parseJSON unmarshals a single line of whmapi1 output; verify its guard on
// the expected single-line shape and that valid/invalid JSON behave correctly.
func TestCpanelParseJSON(t *testing.T) {
c := &cpanel{}
t.Run("valid single line", func(t *testing.T) {
var v cpanelBase
err := c.parseJSON([]string{`{"metadata":{"command":"listips","result":1}}`}, &v)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v.Metadata.Command != "listips" || v.Metadata.Result != 1 {
t.Errorf("decoded = %+v, want command=listips result=1", v.Metadata)
}
})
t.Run("no output errors", func(t *testing.T) {
var v cpanelBase
if err := c.parseJSON(nil, &v); err == nil {
t.Error("expected error for empty output")
}
})
t.Run("multiple lines errors", func(t *testing.T) {
var v cpanelBase
if err := c.parseJSON([]string{"{}", "{}"}, &v); err == nil {
t.Error("expected error for multi-line output")
}
})
t.Run("invalid json errors", func(t *testing.T) {
var v cpanelBase
if err := c.parseJSON([]string{"not json"}, &v); err == nil {
t.Error("expected error for invalid json")
}
})
}
// setWWWAcctAddr repoints an address directive (ADDR / ADDR6) in wwwacct.conf;
// verify it replaces the value in place, appends when absent, and that ADDR and
// ADDR6 are set independently without disturbing each other.
func TestSetWWWAcctAddr(t *testing.T) {
t.Run("replaces existing ADDR and preserves other lines", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "wwwacct.conf")
content := "HOST server.example.com\nADDR 203.0.113.10\nNS ns1.example.com\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := setWWWAcctAddr(path, "ADDR", "203.0.113.20"); err != nil {
t.Fatalf("setWWWAcctAddr: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "HOST server.example.com\nADDR 203.0.113.20\nNS ns1.example.com\n"
if string(got) != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
})
t.Run("appends ADDR when absent", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "wwwacct.conf")
if err := os.WriteFile(path, []byte("HOST server.example.com\n"), 0644); err != nil {
t.Fatal(err)
}
if err := setWWWAcctAddr(path, "ADDR", "203.0.113.20"); err != nil {
t.Fatalf("setWWWAcctAddr: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "HOST server.example.com\nADDR 203.0.113.20\n"
if string(got) != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
})
t.Run("setting ADDR does not touch ADDR6", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "wwwacct.conf")
content := "ADDR 203.0.113.10\nADDR6 2001:db8::1\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := setWWWAcctAddr(path, "ADDR", "203.0.113.20"); err != nil {
t.Fatalf("setWWWAcctAddr: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "ADDR 203.0.113.20\nADDR6 2001:db8::1\n"
if string(got) != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
})
t.Run("setting ADDR6 replaces it and leaves ADDR alone", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "wwwacct.conf")
content := "ADDR 203.0.113.10\nADDR6 2001:db8::1\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := setWWWAcctAddr(path, "ADDR6", "2001:db8::20"); err != nil {
t.Fatalf("setWWWAcctAddr: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "ADDR 203.0.113.10\nADDR6 2001:db8::20\n"
if string(got) != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
})
t.Run("appends ADDR6 when absent", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "wwwacct.conf")
if err := os.WriteFile(path, []byte("ADDR 203.0.113.10\n"), 0644); err != nil {
t.Fatal(err)
}
if err := setWWWAcctAddr(path, "ADDR6", "2001:db8::20"); err != nil {
t.Fatalf("setWWWAcctAddr: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "ADDR 203.0.113.10\nADDR6 2001:db8::20\n"
if string(got) != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
})
}

145
dns_backends_test.go Normal file
View file

@ -0,0 +1,145 @@
package netconfig
import (
"context"
"net"
"os"
"path/filepath"
"testing"
)
// readIfcfg parses a written ifcfg-<iface> file back into a key/value map so
// tests can assert on individual directives (PEERDNS, DNS1, ...).
func readIfcfg(t *testing.T, dir, iface string) map[string]string {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, "ifcfg-"+iface))
if err != nil {
t.Fatalf("read ifcfg-%s: %v", iface, err)
}
out := make(map[string]string)
for _, line := range splitLines(string(data)) {
k, v, ok := cutKV(line)
if ok {
out[k] = v
}
}
return out
}
func splitLines(s string) []string {
var lines []string
cur := ""
for _, r := range s {
if r == '\n' {
lines = append(lines, cur)
cur = ""
continue
}
cur += string(r)
}
if cur != "" {
lines = append(lines, cur)
}
return lines
}
func cutKV(line string) (string, string, bool) {
for i := 0; i < len(line); i++ {
if line[i] == '=' {
v := line[i+1:]
// Strip surrounding quotes written for values with whitespace.
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
v = v[1 : len(v)-1]
}
return line[:i], v, true
}
}
return "", "", false
}
// TestNetworkScriptsDNSPeerDNS verifies that setting DNS on a network-scripts
// interface writes PEERDNS=no (so dhclient does not override the static
// resolvers) and that clearing DNS removes PEERDNS again.
func TestNetworkScriptsDNSPeerDNS(t *testing.T) {
dir := t.TempDir()
ns := &networkScripts{ConfigDir: dir}
// Set DNS servers.
err := ns.SetIfaceDNS(context.Background(), "eth0",
[]net.IP{net.ParseIP("1.1.1.1"), net.ParseIP("8.8.8.8")},
[]string{"example.com"})
if err != nil {
t.Fatalf("SetIfaceDNS: %v", err)
}
cfg := readIfcfg(t, dir, "eth0")
if cfg["PEERDNS"] != "no" {
t.Errorf("PEERDNS = %q, want \"no\"", cfg["PEERDNS"])
}
if cfg["DNS1"] != "1.1.1.1" || cfg["DNS2"] != "8.8.8.8" {
t.Errorf("DNS1/DNS2 = %q/%q, want 1.1.1.1/8.8.8.8", cfg["DNS1"], cfg["DNS2"])
}
if cfg["DOMAIN"] != "example.com" {
t.Errorf("DOMAIN = %q, want example.com", cfg["DOMAIN"])
}
// Clearing DNS should drop PEERDNS so DHCP-provided resolvers resume.
err = ns.SetIfaceDNS(context.Background(), "eth0", nil, nil)
if err != nil {
t.Fatalf("SetIfaceDNS clear: %v", err)
}
cfg = readIfcfg(t, dir, "eth0")
if _, ok := cfg["PEERDNS"]; ok {
t.Errorf("PEERDNS still present after clear: %q", cfg["PEERDNS"])
}
if _, ok := cfg["DNS1"]; ok {
t.Errorf("DNS1 still present after clear: %q", cfg["DNS1"])
}
}
// TestIfUpDownDNSEnsuresInterface verifies that SetIfaceDNS creates the
// interface block when it has not been seen before, rather than silently
// no-opping and reporting success with nothing persisted.
func TestIfUpDownDNSEnsuresInterface(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "interfaces")
if err := os.WriteFile(cfgPath, []byte("auto lo\niface lo inet loopback\n"), 0644); err != nil {
t.Fatal(err)
}
i := &ifUpDown{BaseConfig: cfgPath, BackupDir: cfgPath + ".backup"}
if err := i.ReadFile(cfgPath); err != nil {
t.Fatal(err)
}
// eth0 is not present in the file; SetIfaceDNS must create it.
err := i.SetIfaceDNS(context.Background(), "eth0",
[]net.IP{net.ParseIP("1.1.1.1")}, []string{"example.com"})
if err != nil {
t.Fatalf("SetIfaceDNS: %v", err)
}
// Re-read from disk and confirm the stanzas were persisted.
i.Interfaces = nil
if err := i.ReadFile(cfgPath); err != nil {
t.Fatal(err)
}
interfaces, err := i.GetInterfaces()
if err != nil {
t.Fatal(err)
}
var found *Interface
for _, iface := range interfaces {
if iface.Name == "eth0" {
found = iface
break
}
}
if found == nil {
t.Fatal("eth0 was not persisted; SetIfaceDNS silently no-opped")
}
if len(found.DNS) != 1 || !found.DNS[0].Equal(net.ParseIP("1.1.1.1")) {
t.Errorf("DNS = %v, want [1.1.1.1]", found.DNS)
}
if len(found.SearchDomains) != 1 || found.SearchDomains[0] != "example.com" {
t.Errorf("SearchDomains = %v, want [example.com]", found.SearchDomains)
}
}

154
dns_linux.go Normal file
View file

@ -0,0 +1,154 @@
package netconfig
import (
"bufio"
"context"
"fmt"
"net"
"os"
"os/exec"
"strings"
)
const (
resolvectlBin = "resolvectl"
resolvConf = "/etc/resolv.conf"
)
// applyLiveDNS applies the DNS servers and search domains to the running
// resolver in addition to whatever the configuration backends persist. It is
// best-effort: errors are logged but never returned, because a failure to
// update the live resolver does not undo the (already-written) persistent
// configuration.
//
// Two mechanisms are used, in order of preference:
// 1. systemd-resolved via resolvectl, which keeps DNS per-interface and is the
// correct mechanism when resolved manages /etc/resolv.conf.
// 2. A direct rewrite of /etc/resolv.conf, used only when resolvectl is absent
// and the file is a regular file (not a symlink to the resolved stub). This
// is a global resolver change rather than a per-interface one.
func applyLiveDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) {
serverStrs := ipStrings(servers)
if _, err := exec.LookPath(resolvectlBin); err == nil {
if applyResolvectlDNS(ctx, iface, serverStrs, searchDomains) {
return
}
logger.Println("live DNS: resolvectl failed, falling back to /etc/resolv.conf")
}
// The /etc/resolv.conf fallback is a global change with no per-interface
// concept, so an empty server list would strip every nameserver and take
// down all resolution on the host. Clearing DNS is only meaningful
// per-interface (handled above by resolvectl), so skip the file rewrite
// rather than wipe the global resolver.
if len(serverStrs) == 0 {
return
}
if err := writeResolvConf(serverStrs, searchDomains); err != nil {
logger.Printf("live DNS: %v", err)
}
}
// applyResolvectlDNS sets per-interface DNS servers and search domains via
// resolvectl. An empty server/domain list clears that field for the interface.
// It returns true if the resolvectl calls succeeded.
func applyResolvectlDNS(ctx context.Context, iface string, servers, searchDomains []string) bool {
// When both lists are empty the intent is to drop this interface's DNS
// entirely; "resolvectl revert LINK" is the documented reset and avoids
// relying on the version-sensitive behaviour of passing an empty string.
if len(servers) == 0 && len(searchDomains) == 0 {
if _, err := runCommand(ctx, resolvectlBin, "revert", iface); err != nil {
logger.Printf("live DNS: resolvectl revert: %v", err)
return false
}
return true
}
ok := true
// resolvectl dns LINK [SERVER...]; passing a single empty string clears the
// per-link server list.
dnsArgs := []string{"dns", iface}
if len(servers) > 0 {
dnsArgs = append(dnsArgs, servers...)
} else {
dnsArgs = append(dnsArgs, "")
}
if _, err := runCommand(ctx, resolvectlBin, dnsArgs...); err != nil {
logger.Printf("live DNS: resolvectl dns: %v", err)
ok = false
}
// resolvectl domain LINK [DOMAIN...]; passing a single empty string clears
// the per-link search list.
domainArgs := []string{"domain", iface}
if len(searchDomains) > 0 {
domainArgs = append(domainArgs, searchDomains...)
} else {
domainArgs = append(domainArgs, "")
}
if _, err := runCommand(ctx, resolvectlBin, domainArgs...); err != nil {
logger.Printf("live DNS: resolvectl domain: %v", err)
ok = false
}
return ok
}
// writeResolvConf rewrites /etc/resolv.conf with the given servers and search
// domains, preserving any non-nameserver/non-search lines (comments, options,
// etc.). It only writes a regular file: when /etc/resolv.conf is a symlink (as
// it is when managed by systemd-resolved or resolvconf), it is left untouched
// so the manager keeps ownership.
func writeResolvConf(servers, searchDomains []string) error {
fi, err := os.Lstat(resolvConf)
if err != nil {
return fmt.Errorf("stat %s: %w", resolvConf, err)
}
if fi.Mode()&os.ModeSymlink != 0 {
// resolv.conf is a symlink to a manager-owned target; do not clobber it.
return nil
}
perm := fi.Mode().Perm()
data, err := os.ReadFile(resolvConf)
if err != nil {
return fmt.Errorf("read %s: %w", resolvConf, err)
}
var kept []string
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
field := strings.Fields(scanner.Text())
if len(field) > 0 && (field[0] == "nameserver" || field[0] == "search") {
continue
}
kept = append(kept, scanner.Text())
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan %s: %w", resolvConf, err)
}
var b strings.Builder
for _, line := range kept {
b.WriteString(line)
b.WriteByte('\n')
}
if len(searchDomains) > 0 {
b.WriteString("search ")
b.WriteString(strings.Join(searchDomains, " "))
b.WriteByte('\n')
}
for _, s := range servers {
b.WriteString("nameserver ")
b.WriteString(s)
b.WriteByte('\n')
}
tmp := resolvConf + ".tmp"
if err := os.WriteFile(tmp, []byte(b.String()), perm); err != nil {
return fmt.Errorf("write %s: %w", tmp, err)
}
if err := fileMove(tmp, resolvConf); err != nil {
os.Remove(tmp)
return fmt.Errorf("commit %s: %w", resolvConf, err)
}
return nil
}

58
dns_windows.go Normal file
View file

@ -0,0 +1,58 @@
package netconfig
import (
"context"
"net"
"net/netip"
"golang.org/x/sys/windows"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
// applyLiveDNSWindows applies the DNS servers and search domains to the live
// adapter via winipcfg, in addition to whatever the configuration backends
// persist. SetDNS on the adapter is per-family, so it is called for both IPv4
// and IPv6; winipcfg filters the server list to the matching family. Errors
// are logged but never returned, since a failure to update the live resolver
// does not undo the already-written persistent configuration.
func applyLiveDNSWindows(_ context.Context, iface string, servers []net.IP, searchDomains []string) {
ipAdapters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludePrefix|winipcfg.GAAFlagSkipAnycast|winipcfg.GAAFlagSkipMulticast|winipcfg.GAAFlagSkipDNSServer|winipcfg.GAAFlagSkipFriendlyName|winipcfg.GAAFlagSkipDNSInfo)
if err != nil {
logger.Printf("live DNS: list adapters: %v", err)
return
}
var ipAdapter *winipcfg.IPAdapterAddresses
for _, adap := range ipAdapters {
if adap.FriendlyName() == iface {
ipAdapter = adap
break
}
}
if ipAdapter == nil {
logger.Printf("live DNS: no adapter found with name: %s", iface)
return
}
var serverAddrs []netip.Addr
for _, ip := range servers {
if ip == nil {
continue
}
if v4 := ip.To4(); v4 != nil {
serverAddrs = append(serverAddrs, netip.AddrFrom4([4]byte(v4)))
} else if v16 := ip.To16(); v16 != nil {
serverAddrs = append(serverAddrs, netip.AddrFrom16([16]byte(v16)))
}
// A net.IP that is neither a valid IPv4 nor IPv6 address is skipped
// rather than converted, since [16]byte() on a short slice panics.
}
// SetDNS is per-family and filters the server list to the matching family,
// so pass the full list to both calls. An empty list clears that family.
if err := ipAdapter.LUID.SetDNS(winipcfg.AddressFamily(windows.AF_INET), serverAddrs, searchDomains); err != nil {
logger.Printf("live DNS: set IPv4: %v", err)
}
if err := ipAdapter.LUID.SetDNS(winipcfg.AddressFamily(windows.AF_INET6), serverAddrs, searchDomains); err != nil {
logger.Printf("live DNS: set IPv6: %v", err)
}
}

28
go.mod Normal file
View file

@ -0,0 +1,28 @@
module github.com/grmrgecko/go-network-configurator
go 1.24.2
replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0
require (
dario.cat/mergo v1.0.2
github.com/Wifx/gonetworkmanager/v3 v3.2.0
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/godbus/dbus/v5 v5.1.0
github.com/kylelemons/godebug v1.1.0
github.com/prometheus-community/pro-bing v0.7.0
github.com/sirupsen/logrus v1.9.3
github.com/vishvananda/netlink v1.3.1
github.com/vishvananda/netns v0.0.5
golang.org/x/sys v0.37.0
golang.zx2c4.com/wireguard/windows v0.5.3
gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
)

48
go.sum Normal file
View file

@ -0,0 +1,48 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Wifx/gonetworkmanager/v3 v3.2.0 h1:qYBNHTSCRg+cwXRFlAYrlAGyPid9MPy9vdfeQNSMs8U=
github.com/Wifx/gonetworkmanager/v3 v3.2.0/go.mod h1:JPAftRDTjp5o37GNOYytCzerFU8ZjudV/jC/g41kvHE=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1095
ifupdown.go Normal file

File diff suppressed because it is too large Load diff

164
ifupdown_test.go Normal file
View file

@ -0,0 +1,164 @@
package netconfig
import (
"context"
"net"
"os"
"path/filepath"
"testing"
)
// Validate the netplan configuration parser/writer functions.
func TestIfUpDown(t *testing.T) {
// Setup test file.
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
configPath := filepath.Join(tmpDir, "interfaces")
testDir, err := filepath.Abs("./tests/ifupdown")
if err != nil {
t.Fatal(err)
}
resultsDir := filepath.Join(testDir, "results")
err = fileCopy(filepath.Join(testDir, "interfaces"), configPath)
if err != nil {
t.Fatal(err)
}
// Setup ifupdown and parse test file.
i := new(ifUpDown)
i.BaseConfig = configPath
i.BackupDir = configPath + ".backup"
err = i.ReadFile(i.BaseConfig)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err := i.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = i.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{
{
IP: net.ParseIP("203.0.113.2"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("203.0.113.3"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("fc00::2"),
Mask: net.CIDRMask(64, 128),
},
}, net.ParseIP("203.0.113.6"), net.ParseIP("fc00::1"))
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = i.SetIfaceRoutes(context.Background(), "test_eth1", []*Route{
{
Destination: &net.IPNet{
IP: net.ParseIP("abcd:ef12:3455:3::"),
Mask: net.CIDRMask(64, 128),
},
Gateway: net.ParseIP("abcd:ef12:3456:3::1"),
Metric: 100,
},
{
Destination: &net.IPNet{
IP: net.ParseIP("10.253.2.0"),
Mask: net.CIDRMask(24, 32),
},
Gateway: net.ParseIP("1.2.3.1"),
Metric: 100,
},
})
if err != nil {
t.Fatal(err)
}
// Verify we can read the modified files.
i.Interfaces = nil
err = i.ReadFile(i.BaseConfig)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = i.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 2)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = i.SetIfaceAddresses(context.Background(), "test_backend", []*net.IPNet{
{
IP: net.ParseIP("1.2.10.4"),
Mask: net.CIDRMask(24, 32),
},
}, net.ParseIP("1.2.10.254"), nil)
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = i.SetIfaceRoutes(context.Background(), "test_eth0", []*Route{})
if err != nil {
t.Fatal(err)
}
// Verify we can read the modified files.
i.Interfaces = nil
err = i.ReadFile(i.BaseConfig)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = i.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Cleanup.
err = os.RemoveAll(tmpDir)
if err != nil {
t.Fatal(err)
}
}

220
interworx.go Normal file
View file

@ -0,0 +1,220 @@
package netconfig
import (
"bufio"
"context"
"fmt"
"net"
"os/exec"
"regexp"
"strings"
"sync"
"time"
)
const nodeworxBin = "/usr/bin/nodeworx"
type interworx struct {
}
// Move sites off the old IP to a new IP.
func (*interworx) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error {
cmd := exec.CommandContext(ctx, "/usr/local/interworx/bin/php", "/usr/local/interworx/bin/migrate-ip.php")
// Get output pipes.
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
// Send the IP addresses.
// Start the command.
err = cmd.Start()
if err != nil {
return err
}
// Setup wait group and buffer.
var wg sync.WaitGroup
wg.Add(2)
// Setup expected line matching.
oldMatch := regexp.MustCompile(`(?i)OLD IP Address`)
newMatch := regexp.MustCompile(`(?i)NEW IP Address`)
revokeMatch := regexp.MustCompile(`(?i)\[y/N\]`)
// Read stdout, and provide answers to expected lines.
var line strings.Builder
go func() {
for {
// Read one byte to avoid lock up when the migrator asks for info.
buf := make([]byte, 1)
n, err := stdout.Read(buf)
if err != nil || n != 1 {
break
}
// If this is a new line, write the log and reset the line.
if buf[0] == '\n' || buf[0] == '\r' {
logger.Printf("Interworx migrate <stdout>: %s\n", line.String())
line.Reset()
continue
}
// Write the read byte to the buffer.
line.Write(buf)
// If the byte matches expected questions, answer them.
if buf[0] == ']' {
if revokeMatch.MatchString(line.String()) {
fmt.Fprintln(stdin, "y")
}
} else if buf[0] == ':' {
if oldMatch.MatchString(line.String()) {
fmt.Fprintln(stdin, oldAddr.String())
} else if newMatch.MatchString(line.String()) {
fmt.Fprintln(stdin, newAddr.String())
}
}
}
wg.Done()
}()
// Read stderr.
stderrScanner := bufio.NewScanner(stderr)
go func() {
for stderrScanner.Scan() {
line := stderrScanner.Text()
logger.Printf("Interworx migrate <stderr>: %s\n", line)
}
wg.Done()
}()
// Wait for file reads to finish before.
wg.Wait()
// Wait for the command to finish.
err = cmd.Wait()
if err != nil {
return err
}
return nil
}
// Remove an IP address from interworx.
func (p *interworx) removeIP(ctx context.Context, addr net.IP) error {
// We need the IP type.
isIPv6 := addr.To4() == nil
// Get list of IP addresses in nodeworx.
out, err := runCommand(ctx, nodeworxBin, "-u", "-n", "-c", "Ip", "-a", "listIpAddresses")
if err != nil {
return err
}
// Data to capture from IP list.
var sharedIP net.IP
var publicIPs []net.IP
var privateIPs []net.IP
// Parse the IP list.
for _, line := range out {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
// Parse the IP field.
ip := net.ParseIP(fields[0])
// Skip IP addresses of a different type.
if ip.To4() == nil && !isIPv6 {
continue
} else if ip.To4() != nil && isIPv6 {
continue
}
// Depending on IP match, apply to fields. When we find the address we are removing,
// we should determine how may sites are hosted by the IP. If the type is an shared iP,
// set the shared IP to it. Otherwise, associate other IP types to public/private IP
// lists to allow us to select an IP where a shared IP doesn't exist.
if ip.Equal(addr) {
continue
} else if fields[3] == "shared" && !ip.IsPrivate() {
sharedIP = ip
} else if !ip.IsPrivate() {
publicIPs = append(publicIPs, ip)
} else {
pubIP := net.ParseIP(fields[1])
if pubIP != nil && !pubIP.IsPrivate() {
publicIPs = append(publicIPs, ip)
} else {
privateIPs = append(privateIPs, ip)
}
}
}
// If no shared IP, try public IP and private IP association instead.
if sharedIP == nil {
if len(publicIPs) != 0 {
sharedIP = publicIPs[0]
} else if len(privateIPs) != 0 {
sharedIP = privateIPs[0]
} else {
return fmt.Errorf("unable to find a shared IP to move ip addresses to")
}
}
// Migrate sites to new shared IP.
err = p.moveSites(ctx, addr, sharedIP)
if err != nil {
return err
}
// Wait briefly for the migration to propagate before removing the address.
// The wait honours ctx cancellation so the caller can abort a stuck removal.
select {
case <-time.After(30 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
// Tell nodeworx to remove the IP address.
out, err = runCommand(ctx, nodeworxBin, "-u", "-n", "-c", "Ip", "-a", "delete", "--confirm_action", "all", "--ip", addr.String())
if err != nil {
return err
}
if len(out) != 0 {
logger.Println("Interworx output:", strings.Join(out, "\n"))
}
return nil
}
// setMainIP is a no-op for InterWorx. InterWorx changes the primary IP with the
// migrate-ip tool, which moves sites between IPs rather than repointing a stored
// main IP. That migration is performed by removeIP (via moveSites) when the old
// primary is removed, so promoting an address needs no separate action here.
func (*interworx) setMainIP(ctx context.Context, addr net.IP) error {
return nil
}
// Tell interworx to reload the available IP addresses.
func (*interworx) reload(ctx context.Context) error {
out, err := runCommand(ctx, nodeworxBin, "-u", "-n", "-c", "Ip", "-a", "import", "--ip", "all")
if err != nil {
return err
}
if len(out) != 0 {
logger.Println("Interworx output:", strings.Join(out, "\n"))
}
return nil
}

999
netplan.go Normal file
View file

@ -0,0 +1,999 @@
package netconfig
import (
"context"
"fmt"
"net"
"os"
"path"
"sort"
"strings"
"time"
"dario.cat/mergo"
"github.com/vishvananda/netlink"
"gopkg.in/yaml.v3"
)
// Name server cvonfigurations.
type npNameservers struct {
Search []string `yaml:"search,omitempty,flow"`
Addresses []string `yaml:"addresses,omitempty,flow"`
}
// Route configurations.
type npRoute struct {
From string `yaml:"from,omitempty"`
OnLink *bool `yaml:"on-link,omitempty"`
Scope string `yaml:"scope,omitempty"`
Table *int `yaml:"table,omitempty"`
To string `yaml:"to,omitempty"`
Type string `yaml:"type,omitempty"`
Via string `yaml:"via,omitempty"`
Metric *int `yaml:"metric,omitempty"`
}
type npRoutePolicy struct {
From string `yaml:"from,omitempty"`
Mark *int `yaml:"mark,omitempty"`
Priority *int `yaml:"priority,omitempty"`
Table *int `yaml:"table,omitempty"`
To string `yaml:"to,omitempty"`
TypeOfService *int `yaml:"type-of-service,omitempty"`
}
// Match options.
type npMatch struct {
Name string `yaml:"name,omitempty"`
MACAddress string `yaml:"macaddress,omitempty"`
Driver string `yaml:"driver,omitempty"`
}
// Common configurations on physical interfaces.
type npPhysical struct {
Match npMatch `yaml:"match,omitempty"`
SetName string `yaml:"set-name,omitempty"`
Wakeonlan bool `yaml:"wakeonlan,omitempty"`
}
// Common netplan interface configurations.
type npInterface struct {
AcceptRA *bool `yaml:"accept-ra,omitempty"`
Addresses []string `yaml:"addresses,omitempty"`
Critical bool `yaml:"critical,omitempty"`
DHCP4 *bool `yaml:"dhcp4,omitempty"`
DHCP6 *bool `yaml:"dhcp6,omitempty"`
DHCP4Overrides map[string]any `yaml:"dhcp4-overrides,omitempty"`
DHCP6Overrides map[string]any `yaml:"dhcp6-overrides,omitempty"`
DHCPIdentifier string `yaml:"dhcp-identifier,omitempty"`
Gateway4 string `yaml:"gateway4,omitempty"`
Gateway6 string `yaml:"gateway6,omitempty"`
Nameservers npNameservers `yaml:"nameservers,omitempty"`
MACAddress string `yaml:"macaddress,omitempty"`
MTU int `yaml:"mtu,omitempty"`
Renderer string `yaml:"renderer,omitempty"`
Routes []npRoute `yaml:"routes,omitempty"`
RoutingPolicy []npRoutePolicy `yaml:"routing-policy,omitempty"`
Optional bool `yaml:"optional,omitempty"`
// Configure the link-local addresses to bring up. Valid options are
// "ipv4" and "ipv6". According to the netplan reference, netplan will
// only bring up ipv6 addresses if *no* link-local attribute is
// specified. On the other hand, if an empty link-local attribute is
// specified, this instructs netplan not to bring any ipv4/ipv6 address
// up.
LinkLocal *[]string `yaml:"link-local,omitempty"`
// According to the netplan examples, this section typically includes
// some OVS-specific configuration bits. However, MAAS may just
// include an empty block to indicate the presence of an OVS-managed
// bridge (LP1942328). As a workaround, we make this an optional map
// so we can tell whether it is present (but empty) vs not being
// present.
//
// See: https://github.com/canonical/netplan/blob/main/examples/openvswitch.yaml
OVSParameters *map[string]interface{} `yaml:"openvswitch,omitempty"`
}
// Physical ethernet configurations.
type npEthernet struct {
npPhysical `yaml:",inline"`
npInterface `yaml:",inline"`
}
// Wifi configurations.
type npAccessPoint struct {
Password string `yaml:"password,omitempty"`
Mode string `yaml:"mode,omitempty"`
Channel int `yaml:"channel,omitempty"`
}
type npWifi struct {
npPhysical `yaml:",inline"`
AccessPoints map[string]npAccessPoint `yaml:"access-points,omitempty"`
npInterface `yaml:",inline"`
}
// Bridge interface configurations.
type npBridgeParameters struct {
AgeingTime *int `yaml:"ageing-time,omitempty"`
ForwardDelay intString `yaml:"forward-delay,omitempty"`
HelloTime *int `yaml:"hello-time,omitempty"`
MaxAge *int `yaml:"max-age,omitempty"`
PathCost map[string]int `yaml:"path-cost,omitempty"`
PortPriority map[string]int `yaml:"port-priority,omitempty"`
Priority *int `yaml:"priority,omitempty"`
STP *bool `yaml:"stp,omitempty"`
}
type npBridge struct {
Interfaces []string `yaml:"interfaces,omitempty,flow"`
npInterface `yaml:",inline"`
Parameters npBridgeParameters `yaml:"parameters,omitempty"`
}
// Bond interface configurations.
type npBondParameters struct {
Mode intString `yaml:"mode,omitempty"`
LACPRate intString `yaml:"lacp-rate,omitempty"`
MIIMonitorInterval intString `yaml:"mii-monitor-interval,omitempty"`
MinLinks *int `yaml:"min-links,omitempty"`
TransmitHashPolicy string `yaml:"transmit-hash-policy,omitempty"`
ADSelect intString `yaml:"ad-select,omitempty"`
AllSlavesActive *bool `yaml:"all-slaves-active,omitempty"`
ARPInterval *int `yaml:"arp-interval,omitempty"`
ARPIPTargets []string `yaml:"arp-ip-targets,omitempty"`
ARPValidate intString `yaml:"arp-validate,omitempty"`
ARPAllTargets intString `yaml:"arp-all-targets,omitempty"`
UpDelay intString `yaml:"up-delay,omitempty"`
DownDelay intString `yaml:"down-delay,omitempty"`
FailOverMACPolicy intString `yaml:"fail-over-mac-policy,omitempty"`
// Netplan misspelled this and retained it along with the corrected spelling.
GratuitousARPLegacy *int `yaml:"gratuitious-arp,omitempty"` // nolint: misspell
GratuitousARP *int `yaml:"gratuitous-arp,omitempty"`
PacketsPerSlave *int `yaml:"packets-per-slave,omitempty"`
PrimaryReselectPolicy intString `yaml:"primary-reselect-policy,omitempty"`
ResendIGMP *int `yaml:"resend-igmp,omitempty"`
// bonding.txt says that this can be a value from 1-0x7fffffff, should we be forcing it to be a hex value?
LearnPacketInterval *int `yaml:"learn-packet-interval,omitempty"`
Primary string `yaml:"primary,omitempty"`
}
type npBond struct {
Interfaces []string `yaml:"interfaces,omitempty,flow"`
npInterface `yaml:",inline"`
Parameters npBondParameters `yaml:"parameters,omitempty"`
}
// VLAN interface configurations.
type npVLAN struct {
Id *int `yaml:"id,omitempty"`
Link string `yaml:"link,omitempty"`
npInterface `yaml:",inline"`
}
// Common netplan network configs.
type npNetwork struct {
Version intString `yaml:"version"`
Renderer string `yaml:"renderer,omitempty"`
Ethernets map[string]npEthernet `yaml:"ethernets,omitempty"`
Wifis map[string]npWifi `yaml:"wifis,omitempty"`
Bridges map[string]npBridge `yaml:"bridges,omitempty"`
Bonds map[string]npBond `yaml:"bonds,omitempty"`
VLANs map[string]npVLAN `yaml:"vlans,omitempty"`
Routes []npRoute `yaml:"routes,omitempty"`
}
// The default netplan configuration structure and interface to configuring netplan.
type npConfig struct {
Network npNetwork `yaml:"network"`
file string `yaml:"-"`
empty bool `yaml:"-"`
// dirty marks that a Set* call actually modified this file's in-memory
// config during the current operation. Save only rewrites/backs up dirty
// files, so files nobody touched (including legitimately empty netplan
// fragments that were never merged away) are left untouched on disk.
dirty bool `yaml:"-"`
}
type netplan struct {
configs []*npConfig `yaml:"-"`
configDir string `yaml:"-"`
backupRetention int
}
// Verify netplan exists, and try parsing its configurations.
func newNetplan(backupRetention int) (np *netplan, err error) {
// Calling netplan info will verify netplan is on the machine.
_, err = runCommand(context.Background(), "netplan", "info")
if err != nil {
return
}
// Try to parse the netplan configurations. /etc/netplan is the durable,
// admin-facing source of truth and is tried first so persisted changes
// actually survive a reboot; /run/netplan is a volatile tmpfs directory
// that netplan also honors (e.g. "netplan try") and is only used as a
// fallback, otherwise a host with any file in /run/netplan would have its
// real /etc/netplan configuration silently ignored.
np, err = readNetplanConfigDirectory("/etc/netplan")
if err != nil {
np, err = readNetplanConfigDirectory("/run/netplan")
if err != nil {
np, err = readNetplanConfigDirectory("/lib/netplan")
}
}
if np != nil {
np.backupRetention = backupRetention
}
return
}
// Read an netplan configuration directory for netplan configuration files.
func readNetplanConfigDirectory(dirPath string) (np *netplan, err error) {
// Try to read the directory files.
unsortedEntries, err := os.ReadDir(dirPath)
if err != nil {
return
}
// Sort by name.
entries := sortableDirEntries(unsortedEntries)
sort.Sort(entries)
// Temporary struct to store unparsed configs.
type npConfigFile struct {
data map[string]any
file string
empty bool
// dirty marks that this file's in-memory data no longer matches what
// is on disk because a duplicate key was merged into (or out of) it
// during parsing, so it must be rewritten on the next Save even if no
// Set* call ends up touching an interface still contained in it.
dirty bool
}
var configs []*npConfigFile
configTypes := []string{"ethernets", "wifis", "bridges", "bonds", "vlans", "routes"}
// Read each yaml file and merge into a single config.
for _, entry := range entries {
// If not an yaml file, skip.
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
continue
}
config := &npConfigFile{
file: entry.Name(),
empty: false,
}
// Read the bytes from the file.
configPath := path.Join(dirPath, entry.Name())
var data []byte
data, err = os.ReadFile(configPath)
if err != nil {
return
}
// Parse the yaml to an map.
err = yaml.Unmarshal(data, &config.data)
if err != nil {
return
}
// Check if prior configs have same network configs and merge.
configCount := 0
network, ok := config.data["network"].(map[string]any)
if !ok {
goto addConfig
}
// Merge types with prior configs.
for _, t := range configTypes {
// First get the config type.
c, ok := network[t].(map[string]any)
if !ok {
continue
}
// Loop through all prior parsed configs,
// and find ones that has same type.
for _, thisConfig := range configs {
// If empty, there is no config so skip.
if thisConfig.empty {
continue
}
// Try and get the config type from this config.
thisC, ok := thisConfig.data["network"].(map[string]any)[t].(map[string]any)
if !ok {
continue
}
// Find entries in this config which match the current config.
for key, _ := range thisC {
// Find and if not found, skip.
k, ok := c[key].(map[string]any)
if !ok {
continue
}
thisK := thisC[key].(map[string]any)
// This config contains a key from the current.
// We need to merge.
err = mergo.Merge(&thisK, k)
if err != nil {
return
}
// Update the previously parsed data with merged data.
thisConfig.data["network"].(map[string]any)[t].(map[string]any)[key] = thisK
thisConfig.dirty = true
// Delete the config from the current config.
delete(config.data["network"].(map[string]any)[t].(map[string]any), key)
delete(network[t].(map[string]any), key)
config.dirty = true
}
}
}
// Count number of configs to determine if file is empty.
for _, t := range configTypes {
// First get the config type.
c, ok := network[t].(map[string]any)
if !ok {
continue
}
configCount += len(c)
}
addConfig:
routes, ok := config.data["routes"].([]any)
if ok {
configCount += len(routes)
}
config.empty = configCount == 0
// Add config to slice of configs.
configs = append(configs, config)
}
// No config when no configs.
if len(configs) == 0 {
return nil, fmt.Errorf("no netplan configurations found")
}
err = nil
// Build the netplan data.
np = new(netplan)
np.configDir = dirPath
for _, config := range configs {
// Convert config data back into yaml so we can re-parse into a struct.
data, err := yaml.Marshal(config.data)
if err != nil {
return nil, err
}
// Parse the netplan config.
cfg := new(npConfig)
err = yaml.Unmarshal(data, cfg)
if err != nil {
return nil, err
}
// Successfully parsed, append to the configs slice.
cfg.file = config.file
cfg.dirty = config.dirty
cfg.empty = config.empty
np.configs = append(np.configs, cfg)
}
return
}
// Convert netplan interface to a netconfig Interface.
func (*netplan) ConvertInterface(name string, config npInterface, links []netlink.Link) *Interface {
// Find the MAC address, starting with the list of interfaces
// on the machine.
var mac net.HardwareAddr
var foundLink netlink.Link
for _, link := range links {
if link.Attrs().Name == name {
mac = link.Attrs().HardwareAddr
foundLink = link
}
}
// If the MAC was not found, try parsing the config.
if mac == nil && config.MACAddress != "" {
mac, _ = net.ParseMAC(config.MACAddress)
}
// Setup interface with the name and MAC.
i := new(Interface)
i.Name = name
i.MAC = mac
i.Link = foundLink
// Parse addresses.
for _, addr := range config.Addresses {
ip, ipnet, err := net.ParseCIDR(addr)
if err != nil {
continue
}
ipnet.IP = ip
i.Addresses = append(i.Addresses, ipnet)
}
// Add gateways.
if config.Gateway4 != "" {
i.Gateway4 = net.ParseIP(config.Gateway4)
}
if config.Gateway6 != "" {
i.Gateway6 = net.ParseIP(config.Gateway6)
}
// Parse nameservers.
for _, addr := range config.Nameservers.Addresses {
if ip := net.ParseIP(addr); ip != nil {
i.DNS = append(i.DNS, ip)
}
}
i.SearchDomains = config.Nameservers.Search
// Parse routes.
for _, route := range config.Routes {
dstIP, dst, err := net.ParseCIDR(route.To)
if err != nil {
continue
}
dst.IP = dstIP
ip := net.ParseIP(route.Via)
r := new(Route)
r.Destination = dst
r.Gateway = ip
if route.Metric != nil {
r.Metric = *route.Metric
} else {
r.Metric = 256
}
i.Routes = append(i.Routes, r)
}
return i
}
// Get interfaces configured.
func (np *netplan) GetInterfaces() (interfaces []*Interface, err error) {
// Connect to netlink.
var h *netlink.Handle
h, err = netlink.NewHandle()
if err != nil {
return
}
defer h.Close()
// Get list of interfaces to match with MAC address.
var links []netlink.Link
links, err = h.LinkList()
if err != nil {
return
}
// Add each config.
for _, c := range np.configs {
if c.empty {
continue
}
// Add ethernet interfaces to the list.
var keys []string
for key := range c.Network.Ethernets {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range keys {
config := c.Network.Ethernets[name]
// If the name is being changed in the config, use that
// as the name.
if config.SetName != "" {
name = config.SetName
}
// Update the config MAC to the match MAC.
if config.MACAddress == "" {
config.MACAddress = config.Match.MACAddress
}
// Get interface.
i := np.ConvertInterface(name, config.npInterface, links)
// Add the interface.
interfaces = append(interfaces, i)
}
// Add Wifi interfaces to the list.
keys = nil
for key := range c.Network.Wifis {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range keys {
config := c.Network.Wifis[name]
// If the name is being changed in the config, use that
// as the name.
if config.SetName != "" {
name = config.SetName
}
// Update the config MAC to the match MAC.
if config.MACAddress == "" {
config.MACAddress = config.Match.MACAddress
}
// Get interface.
i := np.ConvertInterface(name, config.npInterface, links)
// Add the interface.
interfaces = append(interfaces, i)
}
// Add Bridge interfaces to the list.
keys = nil
for key := range c.Network.Bridges {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range keys {
config := c.Network.Bridges[name]
// Get interface.
i := np.ConvertInterface(name, config.npInterface, links)
// Add the interface.
interfaces = append(interfaces, i)
}
// Add Bond interfaces to the list.
keys = nil
for key := range c.Network.Bonds {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range keys {
config := c.Network.Bonds[name]
// Get interface.
i := np.ConvertInterface(name, config.npInterface, links)
// Add the interface.
interfaces = append(interfaces, i)
}
// Add VLAN interfaces to the list.
keys = nil
for key := range c.Network.VLANs {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range keys {
config := c.Network.VLANs[name]
// Get interface.
i := np.ConvertInterface(name, config.npInterface, links)
// Add the interface.
interfaces = append(interfaces, i)
}
}
return
}
// Save configurations.
func (np *netplan) Save() error {
// Update each configuration file that was actually touched by this
// operation. Configs nobody modified are left alone on disk, so an
// unrelated legitimately-empty netplan fragment is never backed up and
// removed as a side effect of changing a different interface.
for _, config := range np.configs {
if !config.dirty {
continue
}
// Get config paths.
configPath := path.Join(np.configDir, config.file)
tmpName := fmt.Sprintf("%s.tmp.%d", config.file, time.Now().UnixNano())
tmpPath := path.Join(np.configDir, tmpName)
bakupName := fmt.Sprintf("%s.bak.%d", config.file, time.Now().UnixNano())
bakupPath := path.Join(np.configDir, bakupName)
// Only save if not empty.
if !config.empty {
// Try to encode/save the config.
data, err := yaml.Marshal(config)
if err != nil {
return err
}
err = os.WriteFile(tmpPath, data, 0644)
if err != nil {
return err
}
}
// Backup old config.
err := fileMove(configPath, bakupPath)
if err != nil {
return err
}
// Move new config in place.
if !config.empty {
// Move new config in place.
err = fileMove(tmpPath, configPath)
if err != nil {
return err
}
}
// Prune old backups of this config file beyond the retention count.
pruneBackups(np.configDir, suffixBackupMatcher(config.file), np.backupRetention)
// The file on disk now matches this config's in-memory state, so it
// does not need rewriting again until something else marks it dirty.
// Configs that turned out empty are left dirty so the cleanup pass
// below still notices they were just removed from disk.
if !config.empty {
config.dirty = false
}
}
// Now that touched configurations were updated, drop the ones we just
// emptied out and removed from disk above. Configs that were already
// empty but untouched this call were left on disk, so they stay tracked.
// Rebuild the slice rather than deleting in place while ranging over it,
// which would shift the backing array under the loop and skip or drop the
// wrong entries when more than one config was emptied.
kept := np.configs[:0]
for _, config := range np.configs {
if config.dirty && config.empty {
continue
}
kept = append(kept, config)
}
np.configs = kept
return nil
}
// Set addresses to interface.
func (np *netplan) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) {
// Update each config.
for _, c := range np.configs {
if c.empty {
continue
}
// Check ethernet interfaces.
for name, config := range c.Network.Ethernets {
// If the name is being changed in the config, use that
// as the name.
ifName := name
if config.SetName != "" {
ifName = config.SetName
}
// If this is not the interface we're updating addresses on.
if ifName != iface {
continue
}
c.dirty = true
// Re-configure with specified addresses.
config.Addresses = nil
for _, addr := range addrs {
config.Addresses = append(config.Addresses, addr.String())
}
if gateway4 == nil {
config.Gateway4 = ""
} else {
config.Gateway4 = gateway4.String()
}
if gateway6 == nil {
config.Gateway6 = ""
} else {
config.Gateway6 = gateway6.String()
}
c.Network.Ethernets[name] = config
}
// Check Wifi interfaces.
for name, config := range c.Network.Wifis {
// If the name is being changed in the config, use that
// as the name.
ifName := name
if config.SetName != "" {
ifName = config.SetName
}
// If this is the interface we're updating addresses on.
if ifName != iface {
continue
}
c.dirty = true
// Re-configure with specified addresses.
config.Addresses = nil
for _, addr := range addrs {
config.Addresses = append(config.Addresses, addr.String())
}
if gateway4 == nil {
config.Gateway4 = ""
} else {
config.Gateway4 = gateway4.String()
}
if gateway6 == nil {
config.Gateway6 = ""
} else {
config.Gateway6 = gateway6.String()
}
c.Network.Wifis[name] = config
}
// Check bridges.
{
config, ok := c.Network.Bridges[iface]
if ok {
c.dirty = true
// Re-configure with specified addresses.
config.Addresses = nil
for _, addr := range addrs {
config.Addresses = append(config.Addresses, addr.String())
}
if gateway4 == nil {
config.Gateway4 = ""
} else {
config.Gateway4 = gateway4.String()
}
if gateway6 == nil {
config.Gateway6 = ""
} else {
config.Gateway6 = gateway6.String()
}
c.Network.Bridges[iface] = config
}
}
// Check bonds.
{
config, ok := c.Network.Bonds[iface]
if ok {
c.dirty = true
// Re-configure with specified addresses.
config.Addresses = nil
for _, addr := range addrs {
config.Addresses = append(config.Addresses, addr.String())
}
if gateway4 == nil {
config.Gateway4 = ""
} else {
config.Gateway4 = gateway4.String()
}
if gateway6 == nil {
config.Gateway6 = ""
} else {
config.Gateway6 = gateway6.String()
}
c.Network.Bonds[iface] = config
}
}
// Check VLAN interfaces.
{
config, ok := c.Network.VLANs[iface]
if ok {
c.dirty = true
// Re-configure with specified addresses.
config.Addresses = nil
for _, addr := range addrs {
config.Addresses = append(config.Addresses, addr.String())
}
if gateway4 == nil {
config.Gateway4 = ""
} else {
config.Gateway4 = gateway4.String()
}
if gateway6 == nil {
config.Gateway6 = ""
} else {
config.Gateway6 = gateway6.String()
}
c.Network.VLANs[iface] = config
}
}
}
// Try to save changes.
err = np.Save()
return
}
// Set static routes to interface.
func (np *netplan) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) (err error) {
// Update each config.
for _, c := range np.configs {
if c.empty {
continue
}
// Check ethernet interfaces.
for name, config := range c.Network.Ethernets {
// If the name is being changed in the config, use that
// as the name.
ifName := name
if config.SetName != "" {
ifName = config.SetName
}
// If this is not the interface we're updating routes on.
if ifName != iface {
continue
}
c.dirty = true
// Re-configure with specified routes.
config.Routes = nil
for _, route := range routes {
config.Routes = append(config.Routes, npRoute{
To: route.Destination.String(),
Via: route.Gateway.String(),
Metric: &route.Metric,
})
}
c.Network.Ethernets[name] = config
}
// Check Wifi interfaces.
for name, config := range c.Network.Wifis {
// If the name is being changed in the config, use that
// as the name.
ifName := name
if config.SetName != "" {
ifName = config.SetName
}
// If this is the interface we're updating routes on.
if ifName != iface {
continue
}
c.dirty = true
// Re-configure with specified routes.
config.Routes = nil
for _, route := range routes {
config.Routes = append(config.Routes, npRoute{
To: route.Destination.String(),
Via: route.Gateway.String(),
Metric: &route.Metric,
})
}
c.Network.Wifis[name] = config
}
// Check bridges.
{
config, ok := c.Network.Bridges[iface]
if ok {
c.dirty = true
// Re-configure with specified routes.
config.Routes = nil
for _, route := range routes {
config.Routes = append(config.Routes, npRoute{
To: route.Destination.String(),
Via: route.Gateway.String(),
Metric: &route.Metric,
})
}
c.Network.Bridges[iface] = config
}
}
// Check bonds.
{
config, ok := c.Network.Bonds[iface]
if ok {
c.dirty = true
// Re-configure with specified routes.
config.Routes = nil
for _, route := range routes {
config.Routes = append(config.Routes, npRoute{
To: route.Destination.String(),
Via: route.Gateway.String(),
Metric: &route.Metric,
})
}
c.Network.Bonds[iface] = config
}
}
// Check VLAN interfaces.
{
config, ok := c.Network.VLANs[iface]
if ok {
c.dirty = true
// Re-configure with specified routes.
config.Routes = nil
for _, route := range routes {
config.Routes = append(config.Routes, npRoute{
To: route.Destination.String(),
Via: route.Gateway.String(),
Metric: &route.Metric,
})
}
c.Network.VLANs[iface] = config
}
}
}
// Try to save changes.
err = np.Save()
return
}
// Set DNS servers and search domains on interface.
func (np *netplan) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) (err error) {
nameservers := npNameservers{
Search: searchDomains,
Addresses: ipStrings(servers),
}
// Update each config.
for _, c := range np.configs {
if c.empty {
continue
}
// Check ethernet interfaces.
for name, config := range c.Network.Ethernets {
ifName := name
if config.SetName != "" {
ifName = config.SetName
}
if ifName != iface {
continue
}
c.dirty = true
config.Nameservers = nameservers
c.Network.Ethernets[name] = config
}
// Check Wifi interfaces.
for name, config := range c.Network.Wifis {
ifName := name
if config.SetName != "" {
ifName = config.SetName
}
if ifName != iface {
continue
}
c.dirty = true
config.Nameservers = nameservers
c.Network.Wifis[name] = config
}
// Check bridges.
if config, ok := c.Network.Bridges[iface]; ok {
c.dirty = true
config.Nameservers = nameservers
c.Network.Bridges[iface] = config
}
// Check bonds.
if config, ok := c.Network.Bonds[iface]; ok {
c.dirty = true
config.Nameservers = nameservers
c.Network.Bonds[iface] = config
}
// Check VLAN interfaces.
if config, ok := c.Network.VLANs[iface]; ok {
c.dirty = true
config.Nameservers = nameservers
c.Network.VLANs[iface] = config
}
}
// Try to save changes.
err = np.Save()
return
}

151
netplan_test.go Normal file
View file

@ -0,0 +1,151 @@
package netconfig
import (
"context"
"net"
"os"
"path/filepath"
"testing"
)
// Validate the netplan configuration parser/writer functions.
func TestNetplan(t *testing.T) {
// Setup test file.
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
configPath := filepath.Join(tmpDir, "netplan.yaml")
testDir, err := filepath.Abs("./tests/netplan")
if err != nil {
t.Fatal(err)
}
resultsDir := filepath.Join(testDir, "results")
err = fileCopy(filepath.Join(testDir, "netplan.yaml"), configPath)
if err != nil {
t.Fatal(err)
}
err = fileCopy(filepath.Join(testDir, "netplan2.yaml"), filepath.Join(tmpDir, "netplan2.yaml"))
if err != nil {
t.Fatal(err)
}
// Setup ifupdown and parse test file.
np, err := readNetplanConfigDirectory(tmpDir)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err := np.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = np.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{
{
IP: net.ParseIP("1.2.3.4"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("1.2.3.43"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("fc00::2"),
Mask: net.CIDRMask(64, 128),
},
}, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1"))
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = np.SetIfaceRoutes(context.Background(), "test_eth3", []*Route{
{
Destination: &net.IPNet{
IP: net.ParseIP("abcd:ef12:3455:10::"),
Mask: net.CIDRMask(64, 128),
},
Gateway: net.ParseIP("abcd:ef12:3456:10::1"),
Metric: 100,
},
{
Destination: &net.IPNet{
IP: net.ParseIP("10.253.2.0"),
Mask: net.CIDRMask(24, 32),
},
Gateway: net.ParseIP("203.0.113.22"),
Metric: 100,
},
})
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = np.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 2)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = np.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{
{
IP: net.ParseIP("1.2.10.4"),
Mask: net.CIDRMask(24, 32),
},
}, net.ParseIP("1.2.10.254"), nil)
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = np.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{})
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = np.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Cleanup.
err = os.RemoveAll(tmpDir)
if err != nil {
t.Fatal(err)
}
}

272
networkConfigurator.go Normal file
View file

@ -0,0 +1,272 @@
package netconfig
import (
"context"
"fmt"
"net"
"strings"
)
const (
defaultInternetTestAddress = "http://clients3.google.com/generate_204"
Public = "public-internet"
Public6 = "public-internet-6"
)
type Route struct {
Destination *net.IPNet
Gateway net.IP
Metric int
}
func (i *Route) String() string {
return fmt.Sprintf("%s via %s metric %d", i.Destination.String(), i.Gateway.String(), i.Metric)
}
type Interface struct {
Name string
MAC net.HardwareAddr
Addresses []*net.IPNet
Gateway4 net.IP
Gateway6 net.IP
Routes []*Route
DNS []net.IP
SearchDomains []string
Link any
}
func (i *Interface) String() string {
var routes []string
for _, route := range i.Routes {
routes = append(routes, route.String())
}
return fmt.Sprintf(
"Name: %s MAC: %s Addresses: %v Gateway4: %s Gateway6: %s Routes: [%s]",
i.Name,
i.MAC.String(),
i.Addresses,
i.Gateway4.String(),
i.Gateway6.String(),
strings.Join(routes, ", "),
)
}
type Configurator interface {
GetInterfaces(ctx context.Context) ([]*Interface, error)
AddAddress(ctx context.Context, iface string, addr *net.IPNet, gateway net.IP) error
SetPrimaryAddress(ctx context.Context, iface string, addr *net.IPNet) error
RemoveAddress(ctx context.Context, iface string, addr *net.IPNet) error
AddRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP, metric int) error
RemoveRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP) error
SetDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) error
}
// ifaceBackend persists interface address, route, and DNS changes to an on-disk
// network configuration backend such as netplan, cloud-init, networkd,
// NetworkManager, RHEL network-scripts, or ifupdown.
type ifaceBackend interface {
SetIfaceAddresses(ctx context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) error
SetIfaceRoutes(ctx context.Context, iface string, routes []*Route) error
SetIfaceDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) error
}
// panelBackend persists IP changes to a hosting control panel such as
// cPanel, Plesk, or InterWorx.
type panelBackend interface {
reload(ctx context.Context) error
setMainIP(ctx context.Context, addr net.IP) error
removeIP(ctx context.Context, addr net.IP) error
}
// namedIfaceBackend pairs an ifaceBackend with a label used for logging.
type namedIfaceBackend struct {
name string
backend ifaceBackend
}
// namedPanelBackend pairs a panelBackend with a label used for logging.
type namedPanelBackend struct {
name string
backend panelBackend
}
// applyIfaceAddresses pushes the interface's address configuration to every
// registered file backend. Individual failures are logged but do not abort
// the others, since each backend writes an independent configuration file.
func applyIfaceAddresses(ctx context.Context, backends []namedIfaceBackend, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) {
for _, b := range backends {
if err := b.backend.SetIfaceAddresses(ctx, iface, addrs, gateway4, gateway6); err != nil {
logger.Printf("%s error: %v", b.name, err)
}
}
}
// applyIfaceRoutes pushes the interface's static routes to every registered
// file backend, logging but not aborting on individual errors.
func applyIfaceRoutes(ctx context.Context, backends []namedIfaceBackend, iface string, routes []*Route) {
for _, b := range backends {
if err := b.backend.SetIfaceRoutes(ctx, iface, routes); err != nil {
logger.Printf("%s error: %v", b.name, err)
}
}
}
// ifaceDNSReader is an optional capability of a file backend: reading back the
// interfaces — including their DNS servers and search domains — from the
// backend's persisted configuration. Every on-disk backend implements this via
// its existing GetInterfaces method.
type ifaceDNSReader interface {
GetInterfaces() ([]*Interface, error)
}
// ipIsIn reports whether ip is already present in addrs.
func ipIsIn(addrs []net.IP, ip net.IP) bool {
for _, a := range addrs {
if a.Equal(ip) {
return true
}
}
return false
}
// stringInSlice reports whether s is already present in list.
func stringInSlice(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// mergeDNSFromBackends reads the DNS servers and search domains each backend
// has persisted and merges them into the matching runtime Interface (matched by
// name). The kernel/netlink layer does not track per-interface DNS, so the
// backends are the source of truth for these fields. Entries are de-duplicated
// so a host running several backends (e.g. netplan and cloud-init) does not
// list each resolver more than once. Per-backend errors are logged and do not
// abort the merge.
func mergeDNSFromBackends(backends []namedIfaceBackend, ifaces []*Interface) {
byName := make(map[string]*Interface, len(ifaces))
for _, i := range ifaces {
byName[i.Name] = i
}
for _, nb := range backends {
reader, ok := nb.backend.(ifaceDNSReader)
if !ok {
continue
}
backendIfaces, err := reader.GetInterfaces()
if err != nil {
logger.Printf("%s: error reading DNS: %v", nb.name, err)
continue
}
for _, bi := range backendIfaces {
target := byName[bi.Name]
if target == nil {
continue
}
for _, ip := range bi.DNS {
if ip == nil || ipIsIn(target.DNS, ip) {
continue
}
target.DNS = append(target.DNS, ip)
}
for _, d := range bi.SearchDomains {
if d == "" || stringInSlice(target.SearchDomains, d) {
continue
}
target.SearchDomains = append(target.SearchDomains, d)
}
}
}
}
// applyIfaceDNS pushes the interface's DNS servers and search domains to every
// registered file backend, logging but not aborting on individual errors.
func applyIfaceDNS(ctx context.Context, backends []namedIfaceBackend, iface string, servers []net.IP, searchDomains []string) {
for _, b := range backends {
if err := b.backend.SetIfaceDNS(ctx, iface, servers, searchDomains); err != nil {
logger.Printf("%s error: %v", b.name, err)
}
}
}
// reloadPanels tells every registered control panel to re-read the system's
// IP addresses, logging but not aborting on individual errors.
func reloadPanels(ctx context.Context, backends []namedPanelBackend) {
for _, b := range backends {
if err := b.backend.reload(ctx); err != nil {
logger.Printf("%s error: %v", b.name, err)
}
}
}
// setMainIPOnPanels tells every registered control panel to repoint its main
// IP to the given address, logging but not aborting on individual errors.
func setMainIPOnPanels(ctx context.Context, backends []namedPanelBackend, ip net.IP) {
for _, b := range backends {
if err := b.backend.setMainIP(ctx, ip); err != nil {
logger.Printf("%s error: %v", b.name, err)
}
}
}
// removeIPFromPanels tells every registered control panel to release an IP
// address, logging but not aborting on individual errors.
func removeIPFromPanels(ctx context.Context, backends []namedPanelBackend, ip net.IP) {
for _, b := range backends {
if err := b.backend.removeIP(ctx, ip); err != nil {
logger.Printf("%s error: %v", b.name, err)
}
}
}
// reorderPrimaryAddress returns addrs with target moved to the front, making it
// the first address of its family and therefore the primary in every backend
// (IPADDR0 / IPV6ADDR) and at runtime. The relative order of all other
// addresses is preserved, so the other address family is left untouched.
// Returns false if target is not present in addrs.
func reorderPrimaryAddress(addrs []*net.IPNet, target *net.IPNet) ([]*net.IPNet, bool) {
found := false
reordered := make([]*net.IPNet, 0, len(addrs))
for _, addr := range addrs {
if addr.IP.Equal(target.IP) {
found = true
continue
}
reordered = append(reordered, addr)
}
if !found {
return addrs, false
}
return append([]*net.IPNet{target}, reordered...), true
}
// Take a name and a list of interfaces and finds an interface by its name.
func FindInterfaceByName(name string, ifaces []*Interface) *Interface {
switch name {
case Public:
// The interface that carries the IPv4 default gateway.
for _, iface := range ifaces {
if iface.Gateway4 != nil {
return iface
}
}
case Public6:
// The interface that carries the IPv6 default gateway.
for _, iface := range ifaces {
if iface.Gateway6 != nil {
return iface
}
}
default:
// An interface with the specified name.
for _, iface := range ifaces {
if iface.Name == name {
return iface
}
}
}
return nil
}

349
networkConfigurator_test.go Normal file
View file

@ -0,0 +1,349 @@
package netconfig
import (
"context"
"errors"
"net"
"testing"
)
func mustCIDR(t *testing.T, s string) *net.IPNet {
t.Helper()
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("ParseCIDR(%q): %v", s, err)
}
return n
}
func TestRouteString(t *testing.T) {
r := &Route{
Destination: mustCIDR(t, "10.0.0.0/24"),
Gateway: net.ParseIP("10.0.0.1"),
Metric: 100,
}
if got, want := r.String(), "10.0.0.0/24 via 10.0.0.1 metric 100"; got != want {
t.Errorf("Route.String() = %q, want %q", got, want)
}
}
func TestInterfaceString(t *testing.T) {
mac, _ := net.ParseMAC("00:11:22:33:44:55")
iface := &Interface{
Name: "eth0",
MAC: mac,
Addresses: []*net.IPNet{mustCIDR(t, "192.168.1.10/24")},
Gateway4: net.ParseIP("192.168.1.1"),
Routes: []*Route{
{Destination: mustCIDR(t, "10.0.0.0/24"), Gateway: net.ParseIP("10.0.0.1"), Metric: 5},
},
}
got := iface.String()
want := "Name: eth0 MAC: 00:11:22:33:44:55 Addresses: [192.168.1.0/24] Gateway4: 192.168.1.1 Gateway6: <nil> Routes: [10.0.0.0/24 via 10.0.0.1 metric 5]"
if got != want {
t.Errorf("Interface.String()\n got %q\nwant %q", got, want)
}
}
func TestFindInterfaceByName(t *testing.T) {
eth0 := &Interface{Name: "eth0", Gateway4: net.ParseIP("203.0.113.1")}
eth1 := &Interface{Name: "eth1"}
pub6 := &Interface{Name: "eth2", Gateway6: net.ParseIP("2001:db8::1")}
ifaces := []*Interface{eth1, eth0, pub6}
t.Run("by name", func(t *testing.T) {
if got := FindInterfaceByName("eth1", ifaces); got != eth1 {
t.Errorf("got %v, want eth1", got)
}
})
t.Run("public picks gateway4 interface", func(t *testing.T) {
if got := FindInterfaceByName(Public, ifaces); got != eth0 {
t.Errorf("got %v, want eth0", got)
}
})
t.Run("public6 picks gateway6 interface", func(t *testing.T) {
if got := FindInterfaceByName(Public6, ifaces); got != pub6 {
t.Errorf("got %v, want pub6", got)
}
})
t.Run("not found", func(t *testing.T) {
if got := FindInterfaceByName("missing", ifaces); got != nil {
t.Errorf("got %v, want nil", got)
}
})
t.Run("public with no gateway returns nil", func(t *testing.T) {
if got := FindInterfaceByName(Public, []*Interface{eth1}); got != nil {
t.Errorf("got %v, want nil", got)
}
})
t.Run("public6 with no gateway returns nil", func(t *testing.T) {
if got := FindInterfaceByName(Public6, []*Interface{eth1}); got != nil {
t.Errorf("got %v, want nil", got)
}
})
}
func TestReorderPrimaryAddress(t *testing.T) {
// addrStrings renders a list of addresses for stable comparison.
addrStrings := func(addrs []*net.IPNet) []string {
out := make([]string, len(addrs))
for i, a := range addrs {
out[i] = a.IP.String()
}
return out
}
equal := func(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// hostIP keeps the host bits so IP.Equal matching is exercised, unlike the
// network address returned by mustCIDR.
hostIP := func(ip, cidr string) *net.IPNet {
n := mustCIDR(t, cidr)
return &net.IPNet{IP: net.ParseIP(ip), Mask: n.Mask}
}
a := hostIP("192.168.1.10", "192.168.1.10/24")
b := hostIP("192.168.1.11", "192.168.1.11/24")
c := hostIP("192.168.1.12", "192.168.1.12/24")
v6 := hostIP("2001:db8::5", "2001:db8::5/64")
t.Run("middle moves to front", func(t *testing.T) {
got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, c}, b)
if !ok {
t.Fatal("ok = false, want true")
}
if want := []string{"192.168.1.11", "192.168.1.10", "192.168.1.12"}; !equal(addrStrings(got), want) {
t.Errorf("got %v, want %v", addrStrings(got), want)
}
})
t.Run("already first is unchanged", func(t *testing.T) {
got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, c}, a)
if !ok {
t.Fatal("ok = false, want true")
}
if want := []string{"192.168.1.10", "192.168.1.11", "192.168.1.12"}; !equal(addrStrings(got), want) {
t.Errorf("got %v, want %v", addrStrings(got), want)
}
})
t.Run("v6 target preserves v4 relative order", func(t *testing.T) {
got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, v6}, v6)
if !ok {
t.Fatal("ok = false, want true")
}
if want := []string{"2001:db8::5", "192.168.1.10", "192.168.1.11"}; !equal(addrStrings(got), want) {
t.Errorf("got %v, want %v", addrStrings(got), want)
}
})
t.Run("absent target returns false", func(t *testing.T) {
got, ok := reorderPrimaryAddress([]*net.IPNet{a, b}, c)
if ok {
t.Error("ok = true, want false")
}
// The original slice is returned unchanged.
if want := []string{"192.168.1.10", "192.168.1.11"}; !equal(addrStrings(got), want) {
t.Errorf("got %v, want %v", addrStrings(got), want)
}
})
}
// fakeIfaceBackend records the calls made to it and optionally returns an error.
type fakeIfaceBackend struct {
addrCalls int
routeCalls int
dnsCalls int
lastAddrs []*net.IPNet
err error
}
func (f *fakeIfaceBackend) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) error {
f.addrCalls++
f.lastAddrs = addrs
return f.err
}
func (f *fakeIfaceBackend) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) error {
f.routeCalls++
return f.err
}
func (f *fakeIfaceBackend) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) error {
f.dnsCalls++
return f.err
}
// fakePanelBackend records the calls made to it and optionally returns an error.
type fakePanelBackend struct {
reloadCalls int
setMainIPCalls int
removeIPCalls int
err error
}
func (f *fakePanelBackend) reload(_ context.Context) error {
f.reloadCalls++
return f.err
}
func (f *fakePanelBackend) setMainIP(_ context.Context, addr net.IP) error {
f.setMainIPCalls++
return f.err
}
func (f *fakePanelBackend) removeIP(_ context.Context, addr net.IP) error {
f.removeIPCalls++
return f.err
}
// Every registered backend must be invoked even when an earlier backend
// returns an error, since each writes an independent configuration.
func TestApplyIfaceAddresses(t *testing.T) {
failing := &fakeIfaceBackend{err: errors.New("boom")}
ok := &fakeIfaceBackend{}
backends := []namedIfaceBackend{
{name: "failing", backend: failing},
{name: "ok", backend: ok},
}
applyIfaceAddresses(context.Background(), backends, "eth0", nil, nil, nil)
if failing.addrCalls != 1 || ok.addrCalls != 1 {
t.Errorf("addr calls: failing=%d ok=%d, want 1 each", failing.addrCalls, ok.addrCalls)
}
}
func TestApplyIfaceRoutes(t *testing.T) {
failing := &fakeIfaceBackend{err: errors.New("boom")}
ok := &fakeIfaceBackend{}
backends := []namedIfaceBackend{
{name: "failing", backend: failing},
{name: "ok", backend: ok},
}
applyIfaceRoutes(context.Background(), backends, "eth0", nil)
if failing.routeCalls != 1 || ok.routeCalls != 1 {
t.Errorf("route calls: failing=%d ok=%d, want 1 each", failing.routeCalls, ok.routeCalls)
}
}
func TestReloadPanels(t *testing.T) {
failing := &fakePanelBackend{err: errors.New("boom")}
ok := &fakePanelBackend{}
backends := []namedPanelBackend{
{name: "failing", backend: failing},
{name: "ok", backend: ok},
}
reloadPanels(context.Background(), backends)
if failing.reloadCalls != 1 || ok.reloadCalls != 1 {
t.Errorf("reload calls: failing=%d ok=%d, want 1 each", failing.reloadCalls, ok.reloadCalls)
}
}
func TestSetMainIPOnPanels(t *testing.T) {
failing := &fakePanelBackend{err: errors.New("boom")}
ok := &fakePanelBackend{}
backends := []namedPanelBackend{
{name: "failing", backend: failing},
{name: "ok", backend: ok},
}
setMainIPOnPanels(context.Background(), backends, net.ParseIP("192.0.2.5"))
if failing.setMainIPCalls != 1 || ok.setMainIPCalls != 1 {
t.Errorf("setMainIP calls: failing=%d ok=%d, want 1 each", failing.setMainIPCalls, ok.setMainIPCalls)
}
}
func TestRemoveIPFromPanels(t *testing.T) {
failing := &fakePanelBackend{err: errors.New("boom")}
ok := &fakePanelBackend{}
backends := []namedPanelBackend{
{name: "failing", backend: failing},
{name: "ok", backend: ok},
}
removeIPFromPanels(context.Background(), backends, net.ParseIP("192.0.2.5"))
if failing.removeIPCalls != 1 || ok.removeIPCalls != 1 {
t.Errorf("removeIP calls: failing=%d ok=%d, want 1 each", failing.removeIPCalls, ok.removeIPCalls)
}
}
// fakeDNSReaderBackend is a file backend that also reports back persisted
// interfaces (including DNS), satisfying both ifaceBackend and ifaceDNSReader.
type fakeDNSReaderBackend struct {
ifaces []*Interface
err error
}
func (f *fakeDNSReaderBackend) SetIfaceAddresses(context.Context, string, []*net.IPNet, net.IP, net.IP) error {
return nil
}
func (f *fakeDNSReaderBackend) SetIfaceRoutes(context.Context, string, []*Route) error { return nil }
func (f *fakeDNSReaderBackend) SetIfaceDNS(context.Context, string, []net.IP, []string) error {
return nil
}
func (f *fakeDNSReaderBackend) GetInterfaces() ([]*Interface, error) { return f.ifaces, f.err }
// mergeDNSFromBackends must union DNS from every backend into the matching
// runtime interface (by name), de-duplicate entries so multi-backend hosts do
// not list a resolver twice, skip backends with no matching interface, and keep
// going when one backend errors.
func TestMergeDNSFromBackends(t *testing.T) {
runtime := []*Interface{{Name: "eth0"}, {Name: "eth1"}}
backends := []namedIfaceBackend{
{name: "broken", backend: &fakeDNSReaderBackend{err: errors.New("boom")}},
{name: "netplan", backend: &fakeDNSReaderBackend{ifaces: []*Interface{
{Name: "eth0", DNS: []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888")}, SearchDomains: []string{"example.com"}},
}}},
{name: "cloud-init", backend: &fakeDNSReaderBackend{ifaces: []*Interface{
// Overlaps netplan (must dedupe) and adds one new entry.
{Name: "eth0", DNS: []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("1.1.1.1")}, SearchDomains: []string{"example.com", "corp.example"}},
}}},
{name: "ifupdown", backend: &fakeDNSReaderBackend{ifaces: []*Interface{
{Name: "eth1", DNS: []net.IP{net.ParseIP("9.9.9.9")}, SearchDomains: []string{"a.test"}},
// No runtime match; must be ignored.
{Name: "eth9", DNS: []net.IP{net.ParseIP("10.0.0.1")}},
}}},
}
mergeDNSFromBackends(backends, runtime)
eth0 := runtime[0]
wantDNS := []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888"), net.ParseIP("1.1.1.1")}
if !equalIPs(eth0.DNS, wantDNS) {
t.Errorf("eth0 DNS = %v, want %v", eth0.DNS, wantDNS)
}
wantSearch := []string{"example.com", "corp.example"}
if len(eth0.SearchDomains) != len(wantSearch) || eth0.SearchDomains[0] != wantSearch[0] || eth0.SearchDomains[1] != wantSearch[1] {
t.Errorf("eth0 SearchDomains = %v, want %v", eth0.SearchDomains, wantSearch)
}
eth1 := runtime[1]
if !equalIPs(eth1.DNS, []net.IP{net.ParseIP("9.9.9.9")}) {
t.Errorf("eth1 DNS = %v, want [9.9.9.9]", eth1.DNS)
}
if len(eth1.SearchDomains) != 1 || eth1.SearchDomains[0] != "a.test" {
t.Errorf("eth1 SearchDomains = %v, want [a.test]", eth1.SearchDomains)
}
}
func equalIPs(a, b []net.IP) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if !a[i].Equal(b[i]) {
return false
}
}
return true
}

575
networkManager.go Normal file
View file

@ -0,0 +1,575 @@
package netconfig
import (
"context"
"errors"
"fmt"
"net"
"strings"
"github.com/Wifx/gonetworkmanager/v3"
"github.com/vishvananda/netlink"
)
type nmConnection struct {
ID string
Name string
UsingData bool
Addresses4 []*net.IPNet
Addresses6 []*net.IPNet
Gateway4 net.IP
Gateway6 net.IP
Routes4 []*Route
Routes6 []*Route
DNS []net.IP
DNSSearch []string
}
type networkManager struct {
config gonetworkmanager.Settings
}
// nmNameservers extracts DNS server IPs from a NetworkManager ipv4/ipv6
// settings group. It prefers the modern "dns-data" property (array of strings)
// and falls back to the legacy "dns" property, which encodes IPv4 servers as an
// array of uint32 and IPv6 servers as an array of byte arrays.
func nmNameservers(group map[string]any) []net.IP {
var servers []net.IP
if data, ok := group["dns-data"].([]string); ok {
for _, s := range data {
if ip := net.ParseIP(s); ip != nil {
servers = append(servers, ip)
}
}
return servers
}
if data, ok := group["dns"].([]uint32); ok {
for _, u := range data {
if ip := uint2IP(u); len(ip) > 0 {
servers = append(servers, ip)
}
}
return servers
}
if data, ok := group["dns"].([][]byte); ok {
for _, b := range data {
if ip := net.IP(b); ip != nil {
servers = append(servers, ip)
}
}
}
return servers
}
// nmSearchDomains extracts the DNS search list from a NetworkManager ipv4/ipv6
// settings group.
func nmSearchDomains(group map[string]any) []string {
if data, ok := group["dns-search"].([]string); ok {
return append([]string(nil), data...)
}
return nil
}
// Parse a network manager connection settings map to get network configurations.
func (*networkManager) ParseConnection(settings gonetworkmanager.ConnectionSettings) (conn *nmConnection, err error) {
conn = new(nmConnection)
// Get the interface id.
id, ok := settings["connection"]["id"].(string)
if !ok {
err = fmt.Errorf("failed to get interface id")
return
}
conn.ID = id
// Get the interface name.
name, ok := settings["connection"]["interface-name"].(string)
if !ok {
err = fmt.Errorf("failed to get interface name")
return
}
conn.Name = name
// Get the IPv4 address map, and confirm the newer configuration style is used.
addrMap, ok := settings["ipv4"]["address-data"]
if ok {
// Update the information to show the newer configuration is used.
conn.UsingData = true
// Parse the IPv4 address data into the address list.
if addrMap != nil {
addrSlice := addrMap.([]map[string]any)
for _, addr := range addrSlice {
ip := net.ParseIP(addr["address"].(string))
prefix := addr["prefix"].(uint32)
conn.Addresses4 = append(conn.Addresses4, &net.IPNet{
IP: ip,
Mask: net.CIDRMask(int(prefix), 32),
})
}
}
// Parse the IPv4 gateway.
gateway4S, ok := settings["ipv4"]["gateway"].(string)
if ok {
conn.Gateway4 = net.ParseIP(gateway4S)
}
// Parse the IPv6 addresses.
addr6Map, ok := settings["ipv6"]["address-data"]
if ok && addr6Map != nil {
addrSlice := addr6Map.([]map[string]any)
for _, addr := range addrSlice {
ip := net.ParseIP(addr["address"].(string))
prefix := addr["prefix"].(uint32)
conn.Addresses6 = append(conn.Addresses6, &net.IPNet{
IP: ip,
Mask: net.CIDRMask(int(prefix), 128),
})
}
}
// Parse the IPv6 gateway.
gateway6S, ok := settings["ipv6"]["gateway"].(string)
if ok {
conn.Gateway6 = net.ParseIP(gateway6S)
}
// Parse the IPv4 static route data.
routeMap, ok := settings["ipv4"]["route-data"]
if ok && routeMap != nil {
routeSlice := routeMap.([]map[string]any)
for _, route := range routeSlice {
dstIP := net.ParseIP(route["dest"].(string))
prefix := route["prefix"].(uint32)
r := new(Route)
r.Destination = &net.IPNet{
IP: dstIP,
Mask: net.CIDRMask(int(prefix), 32),
}
r.Gateway = net.ParseIP(route["next-hop"].(string))
r.Metric = int(route["metric"].(uint32))
conn.Routes4 = append(conn.Routes4, r)
}
}
// Parse the IPv6 static route data.
route6Map, ok := settings["ipv6"]["route-data"]
if ok && route6Map != nil {
routeSlice := route6Map.([]map[string]any)
for _, route := range routeSlice {
dstIP := net.ParseIP(route["dest"].(string))
prefix := route["prefix"].(uint32)
r := new(Route)
r.Destination = &net.IPNet{
IP: dstIP,
Mask: net.CIDRMask(int(prefix), 128),
}
r.Gateway = net.ParseIP(route["next-hop"].(string))
r.Metric = int(route["metric"].(uint32))
conn.Routes6 = append(conn.Routes6, r)
}
}
} else {
// This is the old style configuration, we do not parse
// these unless the new style is missing.
// Get the zero IP assignment so we can ignore them for
// gateway addresses.
zeroIP := make(net.IP, 4)
zeroIP6 := make(net.IP, 16)
// Parse IPv4 address slices.
addrSlice, ok := settings["ipv4"]["addresses"].([][]uint32)
if ok {
for _, addr := range addrSlice {
gateway := uint2IP(addr[2])
if gateway != nil && !gateway.Equal(zeroIP) {
conn.Gateway4 = gateway
}
conn.Addresses4 = append(conn.Addresses4, &net.IPNet{
IP: uint2IP(addr[0]),
Mask: net.CIDRMask(int(addr[1]), 32),
})
}
}
// Parse IPv6 address slices.
addr6Slice, ok := settings["ipv6"]["addresses"].([][]any)
if ok {
for _, addr := range addr6Slice {
gateway := net.IP(addr[2].([]byte))
if gateway != nil && !gateway.Equal(zeroIP6) {
conn.Gateway6 = gateway
}
conn.Addresses6 = append(conn.Addresses6, &net.IPNet{
IP: net.IP(addr[0].([]byte)),
Mask: net.CIDRMask(int(addr[1].(uint32)), 128),
})
}
}
// Parse IPv4 static routes.
routeSlice, ok := settings["ipv4"]["routes"].([][]uint32)
if ok {
for _, route := range routeSlice {
r := new(Route)
r.Destination = &net.IPNet{
IP: uint2IP(route[0]),
Mask: net.CIDRMask(int(route[1]), 32),
}
r.Gateway = uint2IP(route[2])
r.Metric = int(route[3])
conn.Routes4 = append(conn.Routes4, r)
}
}
// Parse IPv6 static routes.
route6Slice, ok := settings["ipv6"]["routes"].([][]any)
if ok {
for _, route := range route6Slice {
r := new(Route)
r.Destination = &net.IPNet{
IP: net.IP(route[0].([]byte)),
Mask: net.CIDRMask(int(route[1].(uint32)), 128),
}
r.Gateway = net.IP(route[2].([]byte))
r.Metric = int(route[3].(uint32))
conn.Routes6 = append(conn.Routes6, r)
}
}
}
// Parse DNS servers and search domains from both families. DNS is stored
// independently of the address style, so it is read for both new and old
// configurations.
if ipv4, ok := settings["ipv4"]; ok {
conn.DNS = append(conn.DNS, nmNameservers(ipv4)...)
conn.DNSSearch = append(conn.DNSSearch, nmSearchDomains(ipv4)...)
}
if ipv6, ok := settings["ipv6"]; ok {
conn.DNS = append(conn.DNS, nmNameservers(ipv6)...)
conn.DNSSearch = append(conn.DNSSearch, nmSearchDomains(ipv6)...)
}
return
}
// Verify netplan exists, and try parsing its configurations.
func newNetworkManager() (nm *networkManager, err error) {
config, err := gonetworkmanager.NewSettings()
if err != nil {
return
}
nm = &networkManager{
config: config,
}
return
}
// Get interfaces configured.
func (nm *networkManager) GetInterfaces() (interfaces []*Interface, err error) {
// Connect to netlink.
var h *netlink.Handle
h, err = netlink.NewHandle()
if err != nil {
return
}
defer h.Close()
// Get list of interfaces to match with MAC address.
var links []netlink.Link
links, err = h.LinkList()
if err != nil {
return
}
// Get connections from NM.
connections, err := nm.config.ListConnections()
// Add devices to the list.
for _, c := range connections {
// Get the settings of the connection.
var settings gonetworkmanager.ConnectionSettings
settings, err = c.GetSettings()
if err != nil {
return
}
// Parse the connection. A connection that fails to parse (for example
// a VPN or bridge-slave profile with no interface-name) is skipped,
// not fatal, so this must not touch the named err return: doing so
// would let the last connection processed decide whether the whole
// call reports an error, discarding an otherwise fully-populated
// interfaces slice depending purely on D-Bus connection ordering.
connection, parseErr := nm.ParseConnection(settings)
if parseErr != nil {
continue
}
// Find the MAC address and link.
var mac net.HardwareAddr
var foundLink netlink.Link
for _, link := range links {
if link.Attrs().Name == connection.Name {
mac = link.Attrs().HardwareAddr
foundLink = link
}
}
// Setup new interface.
i := new(Interface)
i.Name = connection.Name
i.MAC = mac
i.Link = foundLink
// Append addresses.
for _, addr := range connection.Addresses4 {
i.Addresses = append(i.Addresses, addr)
}
for _, addr := range connection.Addresses6 {
i.Addresses = append(i.Addresses, addr)
}
// Add gateways.
if connection.Gateway4 != nil {
i.Gateway4 = connection.Gateway4
}
if connection.Gateway6 != nil {
i.Gateway6 = connection.Gateway6
}
// Append routes.
for _, route := range connection.Routes4 {
i.Routes = append(i.Routes, route)
}
for _, route := range connection.Routes6 {
i.Routes = append(i.Routes, route)
}
// Add DNS servers and search domains.
i.DNS = append(i.DNS, connection.DNS...)
i.SearchDomains = append(i.SearchDomains, connection.DNSSearch...)
// Add the interface.
interfaces = append(interfaces, i)
}
return
}
// Set the IP addresses on an interface.
func (nm *networkManager) SetIfaceAddresses(ctx context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) {
// Separate addresses by addr4 and addr6.
var addrs4 []string
var addrs6 []string
for _, addr := range addrs {
if addr.IP.To4() == nil {
addrs6 = append(addrs6, addr.String())
} else {
addrs4 = append(addrs4, addr.String())
}
}
// Get connections from NM.
connections, err := nm.config.ListConnections()
if err != nil {
return err
}
var errs []error
// Find the connections that has the interfaces.
for _, c := range connections {
// Get the settings of the connection.
var settings gonetworkmanager.ConnectionSettings
settings, err = c.GetSettings()
if err != nil {
return err
}
// Get the interface name.
name, ok := settings["connection"]["interface-name"].(string)
if !ok {
continue
}
if name != iface {
continue
}
// Get the interface id.
id, ok := settings["connection"]["id"].(string)
if !ok {
err = fmt.Errorf("failed to get interface id")
return
}
// Update address list for IPv4.
if len(addrs4) == 0 {
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.method", "disabled", "ipv4.addresses", "", "ipv4.gateway", "")
if err != nil {
errs = append(errs, fmt.Errorf("failed to disable ipv4 on %s: %w", id, err))
}
} else {
gateway4S := ""
if gateway4 != nil {
gateway4S = gateway4.String()
}
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.method", "manual", "ipv4.addresses", strings.Join(addrs4, ","), "ipv4.gateway", gateway4S)
if err != nil {
errs = append(errs, fmt.Errorf("failed to set ipv4.addresses on %s: %w", id, err))
}
}
// Update address list for IPv6.
if len(addrs6) == 0 {
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.method", "link-local", "ipv6.addresses", "", "ipv6.gateway", "")
if err != nil {
errs = append(errs, fmt.Errorf("failed to set ipv6 link-local on %s: %w", id, err))
}
} else {
gateway6S := ""
if gateway6 != nil {
gateway6S = gateway6.String()
}
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.method", "manual", "ipv6.addresses", strings.Join(addrs6, ","), "ipv6.gateway", gateway6S)
if err != nil {
errs = append(errs, fmt.Errorf("failed to set ipv6.addresses on %s: %w", id, err))
}
}
}
return errors.Join(errs...)
}
// Set static routes to interface.
func (nm *networkManager) SetIfaceRoutes(ctx context.Context, iface string, routes []*Route) (err error) {
// Build route slices.
var routes4 []string
var routes6 []string
for _, route := range routes {
r := fmt.Sprintf("%s %s %d", route.Destination.String(), route.Gateway, route.Metric)
if route.Destination.IP.To4() == nil {
routes6 = append(routes6, r)
} else {
routes4 = append(routes4, r)
}
}
// Get connections from NM.
connections, err := nm.config.ListConnections()
if err != nil {
return err
}
var errs []error
// Find the connections that has the interfaces.
for _, c := range connections {
// Get the settings of the connection.
var settings gonetworkmanager.ConnectionSettings
settings, err = c.GetSettings()
if err != nil {
return err
}
// Get the interface name.
name, ok := settings["connection"]["interface-name"].(string)
if !ok {
continue
}
if name != iface {
continue
}
// Get the interface id.
id, ok := settings["connection"]["id"].(string)
if !ok {
err = fmt.Errorf("failed to get interface id")
return
}
// Update routes.
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.routes", strings.Join(routes4, ","))
if err != nil {
errs = append(errs, fmt.Errorf("failed to set ipv4.routes on %s: %w", id, err))
}
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.routes", strings.Join(routes6, ","))
if err != nil {
errs = append(errs, fmt.Errorf("failed to set ipv6.routes on %s: %w", id, err))
}
}
return errors.Join(errs...)
}
// Set DNS servers and search domains on interface.
func (nm *networkManager) SetIfaceDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) (err error) {
// Separate DNS servers by family; NetworkManager keeps them under the
// ipv4 and ipv6 settings.
var dns4 []string
var dns6 []string
for _, ip := range servers {
if ip == nil {
continue
}
if ip.To4() == nil {
dns6 = append(dns6, ip.String())
} else {
dns4 = append(dns4, ip.String())
}
}
search := strings.Join(searchDomains, ",")
// Get connections from NM.
connections, err := nm.config.ListConnections()
if err != nil {
return err
}
var errs []error
// Find the connections that has the interfaces.
for _, c := range connections {
// Get the settings of the connection.
var settings gonetworkmanager.ConnectionSettings
settings, err = c.GetSettings()
if err != nil {
return err
}
// Get the interface name.
name, ok := settings["connection"]["interface-name"].(string)
if !ok {
continue
}
if name != iface {
continue
}
// Get the interface id.
id, ok := settings["connection"]["id"].(string)
if !ok {
err = fmt.Errorf("failed to get interface id")
return
}
// Update DNS servers and search domains for each family. Also disable
// automatic DNS from DHCP/RA so the static servers are the only
// resolvers used.
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.dns", strings.Join(dns4, ","), "ipv4.dns-search", search, "ipv4.ignore-auto-dns", "yes")
if err != nil {
errs = append(errs, fmt.Errorf("failed to set ipv4.dns on %s: %w", id, err))
}
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.dns", strings.Join(dns6, ","), "ipv6.dns-search", search, "ipv6.ignore-auto-dns", "yes")
if err != nil {
errs = append(errs, fmt.Errorf("failed to set ipv6.dns on %s: %w", id, err))
}
}
return errors.Join(errs...)
}

474
networkManager_test.go Normal file
View file

@ -0,0 +1,474 @@
package netconfig
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"testing"
"github.com/Wifx/gonetworkmanager/v3"
"github.com/godbus/dbus/v5"
)
type mockNMConnection struct {
settings gonetworkmanager.ConnectionSettings
}
func (c *mockNMConnection) GetPath() dbus.ObjectPath {
return ""
}
func (c *mockNMConnection) Update(settings gonetworkmanager.ConnectionSettings) error {
return nil
}
func (c *mockNMConnection) UpdateUnsaved(settings gonetworkmanager.ConnectionSettings) error {
return nil
}
func (c *mockNMConnection) Delete() error {
return nil
}
func (c *mockNMConnection) GetSettings() (gonetworkmanager.ConnectionSettings, error) {
return c.settings, nil
}
func (c *mockNMConnection) GetSecrets(settingName string) (gonetworkmanager.ConnectionSettings, error) {
return nil, nil
}
func (c *mockNMConnection) ClearSecrets() error {
return nil
}
func (c *mockNMConnection) Save() error {
return nil
}
func (c *mockNMConnection) GetPropertyUnsaved() (bool, error) {
return false, nil
}
func (c *mockNMConnection) GetPropertyFlags() (uint32, error) {
return 0, nil
}
func (c *mockNMConnection) GetPropertyFilename() (string, error) {
return "", nil
}
func (c *mockNMConnection) MarshalJSON() ([]byte, error) {
s, _ := c.GetSettings()
return json.Marshal(s)
}
type mockNMSettings struct {
}
func (s *mockNMSettings) ListConnections() ([]gonetworkmanager.Connection, error) {
var connections []gonetworkmanager.Connection
connection := &mockNMConnection{
settings: gonetworkmanager.ConnectionSettings{
"ipv4": {
"address-data": []map[string]interface{}(nil),
"dns-search": []string{},
"method": "auto",
"route-data": []map[string]interface{}(nil),
"routes": [][]uint32{},
"addresses": [][]uint32{},
"gateway": "1.2.3.1",
"route-metric": 100,
"dhcp-timeout": 45,
},
"ipv6": {
"addr-gen-mode": 3,
"address-data": []map[string]interface{}(nil),
"routes": [][]interface{}{},
"dns-search": []string{},
"method": "auto",
"route-data": []map[string]interface{}(nil),
"dhcp-timeout": 45,
"route-metric": 100,
"addresses": [][]interface{}{},
},
"proxy": {},
"connection": {
"uuid": "843f71c2-161d-46e1-9533-195bfdc8ae56",
"autoconnect-priority": 1,
"autoconnect-retries": 0,
"id": "test_eth0",
"interface-name": "test_eth0",
"permissions": []string{},
"timestamp": 1669049774,
"type": "802-3-ethernet",
},
"802-3-ethernet": {
"auto-negotiate": false,
"mac-address-blacklist": []string{},
"s390-options": map[string]string{},
},
},
}
connections = append(connections, connection)
connection = &mockNMConnection{
settings: gonetworkmanager.ConnectionSettings{
"ipv4": {
"address-data": []map[string]interface{}{
{
"address": "1.2.3.4",
"prefix": uint32(24),
},
},
"dns-search": []string{},
"method": "manual",
"route-data": []map[string]interface{}{
{
"dest": "10.0.0.0",
"prefix": uint32(24),
"next-hop": "1.2.3.5",
"metric": uint32(100),
},
},
"routes": [][]uint32{},
"addresses": [][]uint32{},
"gateway": "1.2.3.1",
"route-metric": 100,
"dhcp-timeout": 45,
},
"ipv6": {
"addr-gen-mode": 3,
"address-data": []map[string]interface{}(nil),
"routes": [][]interface{}{},
"dns-search": []string{},
"method": "auto",
"route-data": []map[string]interface{}(nil),
"dhcp-timeout": 45,
"route-metric": 100,
"addresses": [][]interface{}{},
},
"proxy": {},
"connection": {
"uuid": "843f71c2-161d-46e1-9533-195bfdc8ae56",
"autoconnect-priority": 1,
"autoconnect-retries": 0,
"id": "main",
"interface-name": "test_eth0.1556",
"permissions": []string{},
"timestamp": 1669049774,
"type": "802-3-ethernet",
},
"802-3-ethernet": {
"auto-negotiate": false,
"mac-address-blacklist": []string{},
"s390-options": map[string]string{},
},
},
}
connections = append(connections, connection)
connection = &mockNMConnection{
settings: gonetworkmanager.ConnectionSettings{
"ipv4": {
"address-data": []map[string]interface{}(nil),
"dns-search": []string{},
"method": "auto",
"route-data": []map[string]interface{}(nil),
"routes": [][]uint32{},
"addresses": [][]uint32{},
"route-metric": 100,
"dhcp-timeout": 45,
},
"ipv6": {
"addr-gen-mode": 3,
"address-data": []map[string]interface{}{
{
"address": "fc00:aa8:7160:d9eb:1:0:1:3",
"prefix": uint32(64),
},
},
"routes": [][]interface{}{},
"dns-search": []string{},
"method": "manual",
"route-data": []map[string]interface{}{
{
"dest": "fc00:5aa8:7160:d9eb::1",
"prefix": uint32(128),
"next-hop": "fe80::1",
"metric": uint32(200),
},
},
"dhcp-timeout": 45,
"route-metric": 100,
"addresses": [][]interface{}{},
},
"proxy": {},
"connection": {
"uuid": "41f45675-787c-491e-a034-0363732908f7",
"autoconnect-priority": 1,
"autoconnect-retries": 0,
"id": "vxlan",
"interface-name": "test_eth1.1557",
"permissions": []string{},
"timestamp": 1669049774,
"type": "802-3-ethernet",
},
"802-3-ethernet": {
"auto-negotiate": false,
"mac-address-blacklist": []string{},
"s390-options": map[string]string{},
},
},
}
connections = append(connections, connection)
connection = &mockNMConnection{
settings: gonetworkmanager.ConnectionSettings{
"ipv4": {
"address-data": []map[string]interface{}{
{
"address": "203.0.113.2",
"prefix": uint32(24),
},
{
"address": "203.0.113.3",
"prefix": uint32(24),
},
},
"dns-search": []string{},
"method": "manual",
"gateway": "203.0.113.1",
"route-data": []map[string]interface{}(nil),
"routes": [][]uint32{},
"addresses": [][]uint32{},
"route-metric": 100,
"dhcp-timeout": 45,
},
"ipv6": {
"addr-gen-mode": 3,
"address-data": []map[string]interface{}{
{
"address": "abcd:ef12:3456:10::4",
"prefix": uint32(64),
},
},
"routes": [][]interface{}{},
"dns-search": []string{},
"method": "manual",
"gateway": "abcd:ef12:3456:10::1",
"route-data": []map[string]interface{}(nil),
"dhcp-timeout": 45,
"route-metric": 100,
"addresses": [][]interface{}{},
},
"proxy": {},
"connection": {
"uuid": "2f2e2453-4f68-41b1-95c5-817276bbce9f",
"autoconnect-priority": 1,
"autoconnect-retries": 0,
"id": "test",
"interface-name": "test_eth2",
"permissions": []string{},
"timestamp": 1669049774,
"type": "802-3-ethernet",
},
"802-3-ethernet": {
"auto-negotiate": false,
"mac-address-blacklist": []string{},
"s390-options": map[string]string{},
},
},
}
connections = append(connections, connection)
return connections, nil
}
func (s *mockNMSettings) ReloadConnections() error {
return nil
}
func (s *mockNMSettings) GetConnectionByUUID(uuid string) (gonetworkmanager.Connection, error) {
return nil, nil
}
func (s *mockNMSettings) AddConnection(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) {
return nil, nil
}
func (s *mockNMSettings) AddConnectionUnsaved(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) {
return nil, nil
}
func (s *mockNMSettings) SaveHostname(hostname string) error {
return nil
}
func (s *mockNMSettings) GetPropertyHostname() (string, error) {
return "", nil
}
func (s *mockNMSettings) GetPropertyCanModify() (bool, error) {
return false, nil
}
// Validate the ifupdown configuration parser/writer functions.
func TestNetworkManager(t *testing.T) {
// Setup test file.
tmpDir := "/tmp/networkManager-netconfig-Test"
testDir, err := filepath.Abs("./tests/networkManager")
if err != nil {
t.Fatal(err)
}
resultsDir := filepath.Join(testDir, "results")
// Update the PATH environment variable so that our nmcli is used instead of the systems.
pathEnv := os.Getenv("PATH")
os.Setenv("PATH", testDir+":"+pathEnv)
// Setup ifupdown and parse test file.
n := new(networkManager)
n.config = new(mockNMSettings)
// Get the interfaces state.
interfaces, err := n.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = n.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{
{
IP: net.ParseIP("1.2.3.4"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("1.2.3.43"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("fc00::2"),
Mask: net.CIDRMask(64, 128),
},
}, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1"))
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = n.SetIfaceRoutes(context.Background(), "test_eth2", []*Route{
{
Destination: &net.IPNet{
IP: net.ParseIP("abcd:ef12:3455:10::"),
Mask: net.CIDRMask(64, 128),
},
Gateway: net.ParseIP("abcd:ef12:3456:10::1"),
Metric: 100,
},
{
Destination: &net.IPNet{
IP: net.ParseIP("10.253.2.0"),
Mask: net.CIDRMask(24, 32),
},
Gateway: net.ParseIP("203.0.113.22"),
Metric: 100,
},
})
if err != nil {
t.Fatal(err)
}
// Test setting DNS on an interface; static DNS should disable automatic DNS.
err = n.SetIfaceDNS(context.Background(), "test_eth0", []net.IP{
net.ParseIP("8.8.8.8"),
net.ParseIP("1.1.1.1"),
net.ParseIP("2001:4860:4860::8888"),
}, []string{"example.com"})
if err != nil {
t.Fatal(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = n.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{
{
IP: net.ParseIP("1.2.10.4"),
Mask: net.CIDRMask(24, 32),
},
}, net.ParseIP("1.2.10.254"), nil)
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = n.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{})
if err != nil {
t.Fatal(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Cleanup.
err = os.RemoveAll(tmpDir)
if err != nil {
t.Fatal(err)
}
}
// nmNameservers must read the modern dns-data (strings) as well as the legacy
// "dns" property (ipv4 uint32 array, ipv6 byte-array array), and nmSearchDomains
// must read dns-search. Empty groups return nothing without error.
func TestNMDNSParsing(t *testing.T) {
// Modern dns-data + dns-search, applies to either family.
group := map[string]any{
"dns-data": []string{"8.8.8.8", "1.1.1.1"},
"dns-search": []string{"a.test", "b.test"},
}
got := nmNameservers(group)
want := []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("1.1.1.1")}
if !equalIPs(got, want) {
t.Errorf("dns-data = %v, want %v", got, want)
}
if search := nmSearchDomains(group); len(search) != 2 || search[0] != "a.test" || search[1] != "b.test" {
t.Errorf("dns-search = %v, want [a.test b.test]", search)
}
// Legacy ipv4 "dns" as []uint32 (decoded via uint2IP, matching addresses).
ipv4Legacy := map[string]any{"dns": []uint32{ip2Uint(net.ParseIP("1.2.3.4"))}}
if got := nmNameservers(ipv4Legacy); len(got) != 1 || !got[0].Equal(net.ParseIP("1.2.3.4")) {
t.Errorf("legacy ipv4 dns = %v, want [1.2.3.4]", got)
}
// Legacy ipv6 "dns" as [][]byte.
ipv6Legacy := map[string]any{"dns": [][]byte{net.ParseIP("2001:4860:4860::8888").To16()}}
if got := nmNameservers(ipv6Legacy); len(got) != 1 || !got[0].Equal(net.ParseIP("2001:4860:4860::8888")) {
t.Errorf("legacy ipv6 dns = %v, want [2001:4860:4860::8888]", got)
}
// Empty group yields nothing.
if v := nmNameservers(map[string]any{}); len(v) != 0 {
t.Errorf("empty group dns = %v, want empty", v)
}
if v := nmSearchDomains(map[string]any{}); len(v) != 0 {
t.Errorf("empty group dns-search = %v, want empty", v)
}
}

919
networkScripts.go Normal file
View file

@ -0,0 +1,919 @@
package netconfig
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
"dario.cat/mergo"
"github.com/vishvananda/netlink"
)
const networkScriptsPath = "/etc/sysconfig/network-scripts"
type nsFile struct {
Name string
Config map[string]string
}
type nsRouteFile struct {
Name string
Routes [][]string
}
type nsInterface struct {
Name string
IFFiles []nsFile
Route4File nsRouteFile
Route6File nsRouteFile
}
type networkScripts struct {
Interfaces []*nsInterface
ConfigDir string
backupRetention int
}
// Either retreives an existing interface, or makes a new one.
func (ns *networkScripts) EnsureInterface(name string) *nsInterface {
// Find existing interface and return it.
for _, iface := range ns.Interfaces {
if iface.Name == name {
return iface
}
}
// Make a new interface as one wasn't found.
iface := &nsInterface{
Name: name,
}
ns.Interfaces = append(ns.Interfaces, iface)
return iface
}
// Parse a file that should only contain key=value data.
func parseKeyValueFile(fd *os.File) (map[string]string, error) {
// Setup the response.
resp := make(map[string]string)
var builder strings.Builder
readKey := false
inSingleQuote := false
inDoubleQuote := false
var key string
var value string
scanner := bufio.NewScanner(fd)
lineLoop:
for scanner.Scan() {
line := scanner.Bytes()
// Trim whitespace.
line = bytes.TrimFunc(line, unicode.IsSpace)
// Ignore blank lines.
if len(line) == 0 {
continue
}
// Ignore comments.
if line[0] == '#' {
continue
}
lineLen := len(line)
readLoop:
for i := 0; i < lineLen; i++ {
b := line[i]
// Handle single quotes.
if b == '\'' && !inDoubleQuote {
if inSingleQuote {
inSingleQuote = false
} else {
inSingleQuote = true
}
continue
}
// Handle double quotes.
if b == '"' && !inSingleQuote {
if inDoubleQuote {
inDoubleQuote = false
} else {
inDoubleQuote = true
}
continue
}
// Handle escapes.
if b == '\\' {
if i+1 >= lineLen {
continue lineLoop
}
if inDoubleQuote && line[i+1] == '"' {
i++
builder.WriteByte('"')
continue
}
}
// If equal sign and not in a quote, handle key logic.
if b == '=' && !inDoubleQuote && !inSingleQuote {
if readKey {
return nil, fmt.Errorf("invalid file syntax in %s", fd.Name())
}
key = builder.String()
builder.Reset()
readKey = true
continue
}
// Handle spaces outside of quotes.
if unicode.IsSpace(rune(b)) && !inDoubleQuote && !inSingleQuote {
// If there is an space outside of an quote,
// and its not leading to an comment or
// the end of line, it is invalid.
for ; i < lineLen; i++ {
b := line[i]
if unicode.IsSpace(rune(b)) {
continue
} else if b == '#' {
break readLoop
} else {
return nil, fmt.Errorf("invalid file syntax in %s", fd.Name())
}
}
break
}
// Write data.
builder.WriteByte(b)
}
// If we ended reading line in a quote, continue to next line.
if inDoubleQuote || inSingleQuote {
continue lineLoop
}
// If we didn't read a key, that's invalid.
if !readKey {
return nil, fmt.Errorf("invalid file syntax in %s", fd.Name())
}
// At this point, we have fully read the key/value.
value = builder.String()
builder.Reset()
readKey = false
// Assign key/value.
resp[key] = value
}
return resp, nil
}
// Read list of IP route formatted fields and return list of routes.
func parseIPRoutes(routes [][]string) (resp []*Route) {
// Loop through each route.
routeLoop:
for _, route := range routes {
// If the length of fields is 0, no route to parse.
routeLen := len(route)
if routeLen == 0 {
continue
}
// Try and parse the network field.
ip, network, err := net.ParseCIDR(route[0])
if err != nil {
continue
}
network.IP = ip
// Read the fields.
var gateway net.IP
metric := int(256)
for i := 1; i < routeLen; i++ {
field := route[i]
if field == "via" && i+1 < routeLen {
i++
gateway = net.ParseIP(route[i])
if gateway == nil {
continue routeLoop
}
}
if field == "metric" && i+1 < routeLen {
i++
metric, err = strconv.Atoi(route[i])
if err != nil || metric == 0 {
metric = 256
}
}
}
// Add the route.
r := new(Route)
r.Destination = network
r.Gateway = gateway
r.Metric = metric
resp = append(resp, r)
}
return
}
func newNetworkScripts(backupRetention int) (ns *networkScripts, err error) {
ns, err = newNetworkScriptsWithConfig(networkScriptsPath)
if ns != nil {
ns.backupRetention = backupRetention
}
return
}
func newNetworkScriptsWithConfig(configPath string) (ns *networkScripts, err error) {
// Try to read the directory files.
unsortedEntries, err := os.ReadDir(configPath)
if err != nil {
return
}
// Sort by name.
entries := sortableDirEntries(unsortedEntries)
sort.Sort(entries)
// Setup response.
ns = new(networkScripts)
ns.ConfigDir = configPath
// Setup regex match for route network/netmask style.
routeRe := regexp.MustCompile(`^\s*ADDRESS[0-9]+=`)
for _, entry := range entries {
// If not an network file, skip.
if entry.IsDir() {
continue
}
// If the file is a interface config file, read it.
if strings.HasPrefix(entry.Name(), "ifcfg-") {
// Open the file.
configFile := path.Join(configPath, entry.Name())
fd, err := os.Open(configFile)
if err != nil {
return nil, err
}
// Parse the file.
resp, err := parseKeyValueFile(fd)
fd.Close()
if err != nil {
return nil, err
}
// Find the device name.
devName, ok := resp["DEVICE"]
if !ok || devName == "" {
devName = entry.Name()[6:]
}
idx := strings.IndexByte(devName, ':')
if idx != -1 {
devName = devName[:idx]
}
// In the rare event the device name isn't found with the above,
// try hardware address matching.
if devName == "" {
hwAddr, ok := resp["HWADDR"]
mac, err := net.ParseMAC(hwAddr)
if !ok || err != nil {
return nil, fmt.Errorf("unable to find interface name in %s", configFile)
}
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range ifaces {
if bytes.Equal(iface.HardwareAddr, mac) {
devName = iface.Name
break
}
}
if devName == "" {
return nil, fmt.Errorf("unable to find interface name in %s", configFile)
}
}
// Setup file.
file := nsFile{
Name: entry.Name(),
Config: resp,
}
// Get the interface.
iface := ns.EnsureInterface(devName)
// Add the file to the config.
iface.IFFiles = append(iface.IFFiles, file)
}
// If the file is a route file.
if strings.HasPrefix(entry.Name(), "route-") {
// Get the device name for the network/netmask format.
devName := entry.Name()[6:]
if devName == "" {
return nil, fmt.Errorf("invalid route file name")
}
// Open the file.
configFile := path.Join(configPath, entry.Name())
fd, err := os.Open(configFile)
if err != nil {
return nil, err
}
// Confirm file type.
file := nsRouteFile{
Name: entry.Name(),
}
isNetworkMas := false
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
if routeRe.MatchString(line) {
isNetworkMas = true
break
}
}
fd.Seek(0, io.SeekStart)
// Parse depending on type.
if isNetworkMas {
// Parse key value file.
resp, err := parseKeyValueFile(fd)
fd.Close()
if err != nil {
return nil, err
}
// Parse network/netmask configuration.
for i := 0; ; i++ {
var route []string
addr, ok := resp[fmt.Sprintf("ADDRESS%d", i)]
if !ok {
break
}
netmask, ok := resp[fmt.Sprintf("NETMASK%d", i)]
if !ok {
return nil, fmt.Errorf("invalid file format, missing netmask %s", configFile)
}
gateway, ok := resp[fmt.Sprintf("GATEWAY%d", i)]
if !ok {
return nil, fmt.Errorf("invalid file format, missing gateway %s", configFile)
}
ipMask := net.ParseIP(netmask).To4()
if ipMask == nil {
return nil, fmt.Errorf("invalid file format %s", configFile)
}
mask := net.IPMask(ipMask)
prefix, _ := mask.Size()
route = append(route, fmt.Sprintf("%s/%d", addr, prefix), "via", gateway, "dev", devName)
file.Routes = append(file.Routes, route)
}
} else {
// Read ip command format routes.
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
// Ignore blank lines and comments.
if line == "" || line[0] == '#' {
continue
}
// Parse route.
route := strings.Fields(line)
file.Routes = append(file.Routes, route)
}
fd.Close()
}
// Get the interface.
iface := ns.EnsureInterface(devName)
// Add the route to the iface.
iface.Route4File = file
}
// If the file is a route file.
if strings.HasPrefix(entry.Name(), "route6-") {
// Get the device name for use in assigning to interface.
devName := entry.Name()[7:]
if devName == "" {
return nil, fmt.Errorf("invalid route file name")
}
// Open the file.
configFile := path.Join(configPath, entry.Name())
fd, err := os.Open(configFile)
if err != nil {
return nil, err
}
// Read ip command format routes.
file := nsRouteFile{
Name: entry.Name(),
}
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
// Ignore blank lines and comments.
if line == "" || line[0] == '#' {
continue
}
// Parse route.
route := strings.Fields(line)
file.Routes = append(file.Routes, route)
}
fd.Close()
// Get the interface.
iface := ns.EnsureInterface(devName)
// Add the route to the iface.
iface.Route6File = file
}
}
return
}
// Reads the environment variables at an IP index, and returns an ipnet.
func (ns *networkScripts) ParseIP(cfg map[string]string, idx string) *net.IPNet {
// Get the IPADDR environment, non-existing means no IP.
addr, ok := cfg["IPADDR"+idx]
if !ok || addr == "" {
return nil
}
// Get the prefix and netmask to get network size.
prefix := cfg["PREFIX"+idx]
netmask := cfg["NETMASK"+idx]
// If the prefix isn't found, try parsing it from the netmask.
if prefix == "" {
// Get the prefix from netmask, defaulting to 16.
ipMask := net.ParseIP(netmask).To4()
if ipMask != nil {
mask := net.IPMask(ipMask)
ones, _ := mask.Size()
prefix = strconv.Itoa(ones)
} else {
prefix = "16"
}
}
// Parse the CIDR.
ip, ipnet, err := net.ParseCIDR(fmt.Sprintf("%s/%s", addr, prefix))
if err != nil {
return nil
}
ipnet.IP = ip
return ipnet
}
// Get interfaces configured.
func (ns *networkScripts) GetInterfaces() (interfaces []*Interface, err error) {
// Connect to netlink.
var h *netlink.Handle
h, err = netlink.NewHandle()
if err != nil {
return
}
defer h.Close()
// Get list of interfaces to match with MAC address.
var links []netlink.Link
links, err = h.LinkList()
if err != nil {
return
}
for _, iface := range ns.Interfaces {
i := new(Interface)
i.Name = iface.Name
// Discover the interface name and mac address.
for _, link := range links {
if link.Attrs().Name == iface.Name {
i.MAC = link.Attrs().HardwareAddr
i.Link = link
}
}
// Parse interface files.
for _, file := range iface.IFFiles {
// Parse IPv4 addresses in the file.
ipAddr := ns.ParseIP(file.Config, "")
if ipAddr != nil {
i.Addresses = append(i.Addresses, ipAddr)
}
for idx := 0; idx <= 255; idx++ {
ipAddr = ns.ParseIP(file.Config, strconv.Itoa(idx))
if ipAddr != nil {
i.Addresses = append(i.Addresses, ipAddr)
} else if idx >= 2 {
break
}
}
gateway4 := file.Config["GATEWAY"]
if gateway4 != "" {
i.Gateway4 = net.ParseIP(gateway4)
}
gateway6 := file.Config["IPV6_DEFAULTGW"]
if gateway6 != "" {
i.Gateway6 = net.ParseIP(gateway6)
}
// Parse DNS servers (DNS1, DNS2, ...) and search domains.
for idx := 1; ; idx++ {
dns, ok := file.Config[fmt.Sprintf("DNS%d", idx)]
if !ok {
break
}
if ip := net.ParseIP(dns); ip != nil {
i.DNS = append(i.DNS, ip)
}
}
if domain := file.Config["DOMAIN"]; domain != "" {
i.SearchDomains = append(i.SearchDomains, strings.Fields(domain)...)
}
// Parse IPv6 addresses in the file.
addr := file.Config["IPV6ADDR"]
if addr != "" {
ip, ipnet, err := net.ParseCIDR(addr)
if err == nil {
ipnet.IP = ip
i.Addresses = append(i.Addresses, ipnet)
}
}
addrs := strings.Fields(file.Config["IPV6ADDR_SECONDARIES"])
for _, addr := range addrs {
ip, ipnet, err := net.ParseCIDR(addr)
if err == nil {
ipnet.IP = ip
i.Addresses = append(i.Addresses, ipnet)
}
}
}
// Parse static routes.
i.Routes = parseIPRoutes(iface.Route4File.Routes)
route6 := parseIPRoutes(iface.Route6File.Routes)
i.Routes = append(i.Routes, route6...)
// Add interface.
interfaces = append(interfaces, i)
}
return
}
// Save an interface to script.
func (n *nsInterface) Save(config map[string]string, configDir string, backupRetention int) error {
// Get config paths.
configName := fmt.Sprintf("ifcfg-%s", n.Name)
configPath := path.Join(configDir, configName)
tmpName := fmt.Sprintf(".tmp.%d.%s", time.Now().UnixNano(), configName)
tmpPath := path.Join(configDir, tmpName)
// Try to encode/save the config.
fd, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return err
}
var keys []string
for key := range config {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
val := config[key]
if strings.ContainsFunc(val, unicode.IsSpace) {
fmt.Fprintf(fd, "%s=\"%s\"\n", key, val)
} else {
fmt.Fprintf(fd, "%s=%s\n", key, val)
}
}
fd.Close()
// Backup existing configs.
prefix := fmt.Sprintf(".bak.%d.", time.Now().UnixNano())
for _, file := range n.IFFiles {
newName := prefix + file.Name
oldFile := path.Join(configDir, file.Name)
newFile := path.Join(configDir, newName)
err := fileMove(oldFile, newFile)
if err != nil {
return err
}
}
// Move new config in place.
err = fileMove(tmpPath, configPath)
if err != nil {
return err
}
// Update the config in the structure.
n.IFFiles = []nsFile{{
Name: configName,
Config: config,
}}
// Prune old backups beyond the retention count.
for _, file := range n.IFFiles {
pruneBackups(configDir, prefixBackupMatcher(file.Name), backupRetention)
}
return nil
}
// Save an interface to script.
func (n *nsInterface) SaveRouteFiles(routes4 []string, routes6 []string, configDir string, backupRetention int) error {
// Get config paths for routes IPv4.
config4Name := fmt.Sprintf("route-%s", n.Name)
config4Path := path.Join(configDir, config4Name)
tmp4Name := fmt.Sprintf(".tmp.%d.%s", time.Now().UnixNano(), config4Name)
tmp4Path := path.Join(configDir, tmp4Name)
// Try to encode/save the config.
if len(routes4) != 0 {
fd, err := os.OpenFile(tmp4Path, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return err
}
for _, route := range routes4 {
fmt.Fprintln(fd, route)
}
fd.Close()
}
// Get config paths for routes IPv6
config6Name := fmt.Sprintf("route6-%s", n.Name)
config6Path := path.Join(configDir, config6Name)
tmp6Name := fmt.Sprintf(".tmp.%d.%s", time.Now().UnixNano(), config6Name)
tmp6Path := path.Join(configDir, tmp6Name)
// Try to encode/save the config.
if len(routes6) != 0 {
fd, err := os.OpenFile(tmp6Path, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return err
}
for _, route := range routes6 {
fmt.Fprintf(fd, "%s\n", route)
}
fd.Close()
}
// Backup existing configs.
prefix := fmt.Sprintf(".bak.%d.", time.Now().UnixNano())
if n.Route4File.Name != "" {
newName := prefix + n.Route4File.Name
oldFile := path.Join(configDir, n.Route4File.Name)
newFile := path.Join(configDir, newName)
err := fileMove(oldFile, newFile)
if err != nil {
return err
}
}
if n.Route6File.Name != "" {
newName := prefix + n.Route6File.Name
oldFile := path.Join(configDir, n.Route6File.Name)
newFile := path.Join(configDir, newName)
err := fileMove(oldFile, newFile)
if err != nil {
return err
}
}
// Move new config in place.
if len(routes4) != 0 {
err := fileMove(tmp4Path, config4Path)
if err != nil {
return err
}
}
if len(routes6) != 0 {
err := fileMove(tmp6Path, config6Path)
if err != nil {
return err
}
}
// Update the config in the structure.
n.Route4File = nsRouteFile{}
if len(routes4) != 0 {
n.Route4File.Name = config4Name
for _, route := range routes4 {
n.Route4File.Routes = append(n.Route4File.Routes, strings.Fields(route))
}
}
n.Route6File = nsRouteFile{}
if len(routes6) != 0 {
n.Route6File.Name = config6Name
for _, route := range routes6 {
n.Route6File.Routes = append(n.Route6File.Routes, strings.Fields(route))
}
}
// Prune old backups beyond the retention count.
if n.Route4File.Name != "" {
pruneBackups(configDir, prefixBackupMatcher(n.Route4File.Name), backupRetention)
}
if n.Route6File.Name != "" {
pruneBackups(configDir, prefixBackupMatcher(n.Route6File.Name), backupRetention)
}
return nil
}
// Set addresses to interface.
func (ns *networkScripts) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) {
// Separate addresses by addr4 and addr6.
var addrs4 []*net.IPNet
var addrs6 []string
for _, addr := range addrs {
if addr.IP.To4() == nil {
addrs6 = append(addrs6, addr.String())
} else {
addrs4 = append(addrs4, addr)
}
}
// Find or create the interface. A backend must not silently no-op just
// because it has not seen this interface before (e.g. a freshly attached
// NIC), otherwise the caller gets a success with no persisted change.
ifc := ns.EnsureInterface(iface)
// Merge configs.
config := make(map[string]string)
for _, file := range ifc.IFFiles {
err = mergo.Merge(&config, file.Config)
if err != nil {
return err
}
}
// Delete existing configs.
delete(config, "IPADDR6")
delete(config, "IPV6ADDR")
delete(config, "IPV6ADDR_SECONDARIES")
delete(config, "IPV6_DEFAULTGW")
delete(config, "GATEWAY")
configMap := []string{""}
for i := 0; i <= 255; i++ {
configMap = append(configMap, strconv.Itoa(i))
}
for _, idx := range configMap {
delete(config, fmt.Sprintf("IPADDR%s", idx))
delete(config, fmt.Sprintf("PREFIX%s", idx))
delete(config, fmt.Sprintf("NETMASK%s", idx))
delete(config, fmt.Sprintf("BROADCAST%s", idx))
delete(config, fmt.Sprintf("ARPCHECK%s", idx))
delete(config, fmt.Sprintf("ARPUPDATE%s", idx))
}
// Add IP addresses.
for idx, ip := range addrs4 {
config[fmt.Sprintf("IPADDR%d", idx)] = ip.IP.String()
prefix, _ := ip.Mask.Size()
config[fmt.Sprintf("PREFIX%d", idx)] = strconv.Itoa(prefix)
config[fmt.Sprintf("NETMASK%d", idx)] = net.IP(ip.Mask).String()
config[fmt.Sprintf("BROADCAST%d", idx)] = getBroadcast(ip).String()
}
// Add IPv6 addresses.
if len(addrs6) != 0 {
config["IPV6ADDR"] = addrs6[0]
addrs6 = addrs6[1:]
if len(addrs6) != 0 {
config["IPV6ADDR_SECONDARIES"] = strings.Join(addrs6, " ")
}
}
// Set gateways.
if gateway4 != nil {
config["GATEWAY"] = gateway4.String()
}
if gateway6 != nil {
config["IPV6_DEFAULTGW"] = gateway6.String()
}
// Try to save.
err = ifc.Save(config, ns.ConfigDir, ns.backupRetention)
if err != nil {
return err
}
return
}
// Set static routes to interface.
func (ns *networkScripts) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) (err error) {
// Build route slices.
var routes4 []string
var routes6 []string
for _, route := range routes {
r := fmt.Sprintf("%s via %s metric %d", route.Destination.String(), route.Gateway, route.Metric)
if route.Destination.IP.To4() == nil {
routes6 = append(routes6, r)
} else {
routes4 = append(routes4, r)
}
}
// Find or create the interface. A backend must not silently no-op just
// because it has not seen this interface before (e.g. a freshly attached
// NIC), otherwise the caller gets a success with no persisted change.
ifc := ns.EnsureInterface(iface)
// Save the routes.
err = ifc.SaveRouteFiles(routes4, routes6, ns.ConfigDir, ns.backupRetention)
if err != nil {
return err
}
return
}
// Set DNS servers and search domains on interface.
func (ns *networkScripts) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) (err error) {
// Find or create the interface. A backend must not silently no-op just
// because it has not seen this interface before (e.g. a freshly attached
// NIC), otherwise the caller gets a success with no persisted change.
ifc := ns.EnsureInterface(iface)
// Merge configs.
config := make(map[string]string)
for _, file := range ifc.IFFiles {
err = mergo.Merge(&config, file.Config)
if err != nil {
return err
}
}
// Delete existing DNS and search-domain configs. network-scripts
// stores resolvers as DNS1, DNS2, ... and the search list in DOMAIN.
for key := range config {
if strings.HasPrefix(key, "DNS") {
delete(config, key)
}
}
delete(config, "DOMAIN")
delete(config, "PEERDNS")
// Add DNS servers, numbered from 1 per the network-scripts format.
idx := 1
for _, ip := range servers {
if ip == nil {
continue
}
config[fmt.Sprintf("DNS%d", idx)] = ip.String()
idx++
}
// On a DHCP interface, dhclient rewrites /etc/resolv.conf from the
// lease on every renewal, which would override the DNS1/DNS2 set above.
// PEERDNS=no tells the initscripts not to accept DHCP-provided resolvers
// so the statically configured servers win. Only set it when we actually
// have servers to enforce; when clearing, leaving PEERDNS unset restores
// the default of honouring DHCP.
if idx > 1 {
config["PEERDNS"] = "no"
}
// Add search domains.
if len(searchDomains) != 0 {
config["DOMAIN"] = strings.Join(searchDomains, " ")
}
// Try to save.
err = ifc.Save(config, ns.ConfigDir, ns.backupRetention)
if err != nil {
return err
}
return
}

173
networkScripts_test.go Normal file
View file

@ -0,0 +1,173 @@
package netconfig
import (
"context"
"net"
"os"
"path/filepath"
"strings"
"testing"
)
// Validate the network scripts configuration parser/writer functions.
func TestNetworkScripts(t *testing.T) {
// Setup test file.
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
testDir, err := filepath.Abs("./tests/networkScripts")
if err != nil {
t.Fatal(err)
}
resultsDir := filepath.Join(testDir, "results")
// Try to read the test directory for files to copy to our temporary directory.
entries, err := os.ReadDir(testDir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "ifcfg-") && !strings.HasPrefix(entry.Name(), "route-") && !strings.HasPrefix(entry.Name(), "route6-") {
continue
}
configPath := filepath.Join(testDir, entry.Name())
tmpPath := filepath.Join(tmpDir, entry.Name())
err = fileCopy(configPath, tmpPath)
if err != nil {
t.Fatal(err)
}
}
// Setup ifupdown and parse test file.
ns, err := newNetworkScriptsWithConfig(tmpDir)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err := ns.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = ns.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{
{
IP: net.ParseIP("1.2.3.4"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("1.2.3.43"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("fc00::2"),
Mask: net.CIDRMask(64, 128),
},
}, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1"))
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = ns.SetIfaceRoutes(context.Background(), "test_eth2", []*Route{
{
Destination: &net.IPNet{
IP: net.ParseIP("abcd:ef12:3455:10::"),
Mask: net.CIDRMask(64, 128),
},
Gateway: net.ParseIP("abcd:ef12:3456:10::1"),
Metric: 100,
},
{
Destination: &net.IPNet{
IP: net.ParseIP("10.253.2.0"),
Mask: net.CIDRMask(24, 32),
},
Gateway: net.ParseIP("203.0.113.22"),
Metric: 100,
},
})
if err != nil {
t.Fatal(err)
}
// Verify we can re-read configurations.
ns, err = newNetworkScriptsWithConfig(tmpDir)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = ns.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 2)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = ns.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{
{
IP: net.ParseIP("1.2.10.4"),
Mask: net.CIDRMask(24, 32),
},
}, net.ParseIP("1.2.10.254"), nil)
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = ns.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{})
if err != nil {
t.Fatal(err)
}
// Verify we can re-read configurations.
ns, err = newNetworkScriptsWithConfig(tmpDir)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = ns.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Cleanup.
err = os.RemoveAll(tmpDir)
if err != nil {
t.Fatal(err)
}
}

135
networkScripts_unit_test.go Normal file
View file

@ -0,0 +1,135 @@
package netconfig
import (
"net"
"testing"
)
// parseIPRoutes is a pure transform from raw whitespace-split route fields to
// Route structs; exercise its branches directly rather than via fixtures.
func TestParseIPRoutes(t *testing.T) {
t.Run("full route", func(t *testing.T) {
routes := parseIPRoutes([][]string{
{"10.0.0.0/24", "via", "10.0.0.1", "metric", "100"},
})
if len(routes) != 1 {
t.Fatalf("got %d routes, want 1", len(routes))
}
r := routes[0]
if got := r.Destination.String(); got != "10.0.0.0/24" {
t.Errorf("destination = %s, want 10.0.0.0/24", got)
}
if !r.Gateway.Equal(net.ParseIP("10.0.0.1")) {
t.Errorf("gateway = %s, want 10.0.0.1", r.Gateway)
}
if r.Metric != 100 {
t.Errorf("metric = %d, want 100", r.Metric)
}
})
t.Run("default metric when absent", func(t *testing.T) {
routes := parseIPRoutes([][]string{{"10.0.0.0/24", "via", "10.0.0.1"}})
if len(routes) != 1 || routes[0].Metric != 256 {
t.Fatalf("expected single route with default metric 256, got %+v", routes)
}
})
t.Run("metric zero falls back to default", func(t *testing.T) {
routes := parseIPRoutes([][]string{{"10.0.0.0/24", "metric", "0"}})
if len(routes) != 1 || routes[0].Metric != 256 {
t.Fatalf("expected default metric 256 for metric 0, got %+v", routes)
}
})
t.Run("invalid metric falls back to default", func(t *testing.T) {
routes := parseIPRoutes([][]string{{"10.0.0.0/24", "metric", "notanumber"}})
if len(routes) != 1 || routes[0].Metric != 256 {
t.Fatalf("expected default metric 256 for bad metric, got %+v", routes)
}
})
t.Run("empty and invalid entries are skipped", func(t *testing.T) {
routes := parseIPRoutes([][]string{
{}, // empty: skipped
{"not-a-cidr"}, // invalid CIDR: skipped
{"10.0.0.0/24", "via", "nope"}, // bad gateway: skipped
{"192.168.0.0/16", "via", "192.168.0.1"}, // valid
})
if len(routes) != 1 {
t.Fatalf("got %d routes, want 1", len(routes))
}
if got := routes[0].Destination.String(); got != "192.168.0.0/16" {
t.Errorf("destination = %s, want 192.168.0.0/16", got)
}
})
}
// ParseIP derives a *net.IPNet from network-scripts style key/value config.
func TestNetworkScriptsParseIP(t *testing.T) {
ns := &networkScripts{}
t.Run("missing IPADDR returns nil", func(t *testing.T) {
if got := ns.ParseIP(map[string]string{}, ""); got != nil {
t.Errorf("got %v, want nil", got)
}
})
t.Run("empty IPADDR returns nil", func(t *testing.T) {
if got := ns.ParseIP(map[string]string{"IPADDR": ""}, ""); got != nil {
t.Errorf("got %v, want nil", got)
}
})
t.Run("explicit prefix", func(t *testing.T) {
got := ns.ParseIP(map[string]string{"IPADDR": "192.168.1.5", "PREFIX": "24"}, "")
if got == nil {
t.Fatal("got nil")
}
if !got.IP.Equal(net.ParseIP("192.168.1.5")) {
t.Errorf("IP = %s, want 192.168.1.5", got.IP)
}
if ones, _ := got.Mask.Size(); ones != 24 {
t.Errorf("prefix = %d, want 24", ones)
}
})
t.Run("prefix derived from netmask", func(t *testing.T) {
got := ns.ParseIP(map[string]string{"IPADDR": "10.1.2.3", "NETMASK": "255.255.255.0"}, "")
if got == nil {
t.Fatal("got nil")
}
if ones, _ := got.Mask.Size(); ones != 24 {
t.Errorf("prefix = %d, want 24 (from netmask)", ones)
}
})
t.Run("defaults to /16 with no prefix or netmask", func(t *testing.T) {
got := ns.ParseIP(map[string]string{"IPADDR": "10.1.2.3"}, "")
if got == nil {
t.Fatal("got nil")
}
if ones, _ := got.Mask.Size(); ones != 16 {
t.Errorf("prefix = %d, want default 16", ones)
}
})
t.Run("indexed keys", func(t *testing.T) {
cfg := map[string]string{"IPADDR0": "172.16.0.9", "PREFIX0": "30"}
got := ns.ParseIP(cfg, "0")
if got == nil {
t.Fatal("got nil")
}
if !got.IP.Equal(net.ParseIP("172.16.0.9")) {
t.Errorf("IP = %s, want 172.16.0.9", got.IP)
}
if ones, _ := got.Mask.Size(); ones != 30 {
t.Errorf("prefix = %d, want 30", ones)
}
})
t.Run("invalid address returns nil", func(t *testing.T) {
if got := ns.ParseIP(map[string]string{"IPADDR": "not-an-ip", "PREFIX": "24"}, ""); got != nil {
t.Errorf("got %v, want nil", got)
}
})
}

587
networkd.go Normal file
View file

@ -0,0 +1,587 @@
package netconfig
import (
"bytes"
"context"
"fmt"
"net"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
"github.com/vishvananda/netlink"
"gopkg.in/ini.v1"
)
type networkdNetwork struct {
Config *ini.File
Name string
MAC net.HardwareAddr
Link netlink.Link
File string
SubFiles []string
}
type networkd struct {
Networks []*networkdNetwork
configDir string
backupRetention int
}
// Verify try parsing networkd configurations.
func newNetworkd(backupRetention int) (n *networkd, err error) {
// Try to parse the networkd configurations. /etc/systemd/network is the
// durable, admin-facing source of truth and is tried first so persisted
// changes actually survive a reboot; /run/systemd/network is a volatile
// tmpfs directory that systemd-networkd also honors and is only used as a
// fallback, otherwise a host with any file in /run/systemd/network would
// have its real /etc/systemd/network configuration silently ignored.
n, err = readNetworkdConfigDirectory("/etc/systemd/network")
if err != nil {
n, err = readNetworkdConfigDirectory("/run/systemd/network")
if err != nil {
n, err = readNetworkdConfigDirectory("/lib/systemd/network")
}
}
if n != nil {
n.backupRetention = backupRetention
}
return
}
// Read an systemd-networkd configuration directory for network configuration files.
func readNetworkdConfigDirectory(dirPath string) (nd *networkd, err error) {
// Try to read the directory files.
unsortedEntries, err := os.ReadDir(dirPath)
if err != nil {
return
}
// Sort by name.
entries := sortableDirEntries(unsortedEntries)
sort.Sort(entries)
// Connect to netlink.
var h *netlink.Handle
h, err = netlink.NewHandle()
if err != nil {
return
}
defer h.Close()
// Get list of interfaces to match with MAC address.
var links []netlink.Link
links, err = h.LinkList()
if err != nil {
return
}
// Read each network file.
var networks []*networkdNetwork
for _, entry := range entries {
// If not an network file, skip.
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".network") {
continue
}
// Get the config path and sub config path.
configPath := path.Join(dirPath, entry.Name())
configSubPath := configPath + ".d"
// Find any sub configuration files.
var subFiles []string
var subPaths []interface{}
if stat, serr := os.Stat(configSubPath); !os.IsNotExist(serr) && stat.IsDir() {
// Try to read the directory files.
unsortedEntries, err = os.ReadDir(configSubPath)
if err != nil {
return
}
// Sort by name.
subEntries := sortableDirEntries(unsortedEntries)
sort.Sort(subEntries)
for _, subEnt := range subEntries {
// If not an network file, skip.
if subEnt.IsDir() || !strings.HasSuffix(subEnt.Name(), ".conf") {
continue
}
subFiles = append(subFiles, subEnt.Name())
subPath := path.Join(configSubPath, subEnt.Name())
subPaths = append(subPaths, subPath)
}
}
// Parse the config files found.
var cfg *ini.File
opts := ini.LoadOptions{
AllowShadows: true,
KeyValueDelimiters: "=",
KeyValueDelimiterOnWrite: "=",
ChildSectionDelimiter: ".",
AllowNonUniqueSections: true,
AllowDuplicateShadowValues: true,
}
cfg, err = ini.LoadSources(opts, configPath, subPaths...)
if err != nil {
return
}
// The config should have an network and match section.
if !cfg.HasSection("Network") || !cfg.HasSection("Match") {
return nil, fmt.Errorf("the configuration is missing key sections: %s", configPath)
}
// Discover the interface name and mac address.
var mac net.HardwareAddr
var foundLink netlink.Link
// Get the match section needed.
match, _ := cfg.GetSection("Match")
// Find the name in the match section.
var name string
nameKey, _ := match.GetKey("Name")
if nameKey != nil {
name = nameKey.String()
}
// If the name wasn't in the match section, try finding
// by MAC address.'
if name == "" {
// Get the MAC address from the match section.
var macAddr string
macKey, _ := match.GetKey("MACAddress")
if macKey != nil {
macAddr = macKey.String()
}
// Try parsing the MAC.
mac, err = net.ParseMAC(macAddr)
if err != nil {
return nil, fmt.Errorf("unable to parse mac address %s: %v", macAddr, err)
}
// Find the matching link.
for _, link := range links {
if bytes.Equal(link.Attrs().HardwareAddr, mac) {
foundLink = link
name = link.Attrs().Name
}
}
}
/// If the link wasn't found above, its likely to be found by name.
if foundLink == nil {
for _, link := range links {
if link.Attrs().Name == name {
foundLink = link
}
}
}
// Add to list of networks.
networks = append(networks, &networkdNetwork{
Config: cfg,
Name: name,
MAC: mac,
Link: foundLink,
File: entry.Name(),
SubFiles: subFiles,
})
}
// If no networks were parsed, this directory has no configurations.
if len(networks) == 0 {
return nil, fmt.Errorf("no configuration files exist")
}
// Return networkd.
nd = new(networkd)
nd.Networks = networks
nd.configDir = dirPath
return
}
// Get interfaces configured.
func (nd *networkd) GetInterfaces() (interfaces []*Interface, err error) {
// Add networks to the list.
for _, n := range nd.Networks {
// Setup the new interface.
i := new(Interface)
i.Name = n.Name
i.MAC = n.MAC
i.Link = n.Link
// Find IP addresses in the network and address sections.
// Also parse any routes.
for _, sec := range n.Config.Sections() {
switch sec.Name() {
// IF this is an network section, try to find addresses.
case "Network":
// Find the addresses in the address key.
addressKey, _ := sec.GetKey("Address")
if addressKey == nil {
continue
}
addresses := addressKey.ValueWithShadows()
// Add addresses.
for _, addr := range addresses {
// Per the spec, clear on empty.
if addr == "" {
i.Addresses = nil
continue
}
ip, ipnet, err := net.ParseCIDR(addr)
if err != nil {
continue
}
ipnet.IP = ip
i.Addresses = append(i.Addresses, ipnet)
}
// Find DNS servers and search domains.
if dnsKey, _ := sec.GetKey("DNS"); dnsKey != nil {
for _, d := range dnsKey.ValueWithShadows() {
if ip := net.ParseIP(d); ip != nil {
i.DNS = append(i.DNS, ip)
}
}
}
if domainsKey, _ := sec.GetKey("Domains"); domainsKey != nil {
i.SearchDomains = append(i.SearchDomains, strings.Fields(domainsKey.String())...)
}
// Find the gateways in the gateway key.
gatewayKey, _ := sec.GetKey("Gateway")
if gatewayKey == nil {
continue
}
gateways := gatewayKey.ValueWithShadows()
// Add gateways.
for _, gatewayIP := range gateways {
gateway := net.ParseIP(gatewayIP)
if gateway == nil {
continue
}
if gateway.To4() == nil {
i.Gateway6 = gateway
} else {
i.Gateway4 = gateway
}
}
// IF this is an address section, try to find addresses.
case "Address":
// Find the addresses in the address key.
addressKey, _ := sec.GetKey("Address")
if addressKey == nil {
continue
}
addresses := addressKey.ValueWithShadows()
// Add addresses.
for _, addr := range addresses {
// Per the spec, clear on empty.
if addr == "" {
i.Addresses = nil
continue
}
ip, ipnet, err := net.ParseCIDR(addr)
if err != nil {
continue
}
ipnet.IP = ip
i.Addresses = append(i.Addresses, ipnet)
}
// Find DNS servers and search domains.
if dnsKey, _ := sec.GetKey("DNS"); dnsKey != nil {
for _, d := range dnsKey.ValueWithShadows() {
if ip := net.ParseIP(d); ip != nil {
i.DNS = append(i.DNS, ip)
}
}
}
if domainsKey, _ := sec.GetKey("Domains"); domainsKey != nil {
i.SearchDomains = append(i.SearchDomains, strings.Fields(domainsKey.String())...)
}
// Find the gateways in the gateway key.
gatewayKey, _ := sec.GetKey("Gateway")
if gatewayKey == nil {
continue
}
gateways := gatewayKey.ValueWithShadows()
// Add gateways.
for _, gatewayIP := range gateways {
gateway := net.ParseIP(gatewayIP)
if gateway == nil {
continue
}
if gateway.To4() == nil {
i.Gateway6 = gateway
} else {
i.Gateway4 = gateway
}
}
// Add any routes specified.
case "Route":
// Parse the destination.
var destination *net.IPNet
destinationKey, _ := sec.GetKey("Destination")
if destinationKey != nil {
ip, ipnet, err := net.ParseCIDR(destinationKey.String())
if err == nil {
ipnet.IP = ip
destination = ipnet
}
}
if destination == nil {
continue
}
// Parse the gateway.
var gateway net.IP
gatewayKey, _ := sec.GetKey("Gateway")
if gatewayKey != nil {
gateway = net.ParseIP(gatewayKey.String())
}
if gateway == nil {
continue
}
// Parse the metric.
var metric int = 256
metricKey, _ := sec.GetKey("Metric")
if metricKey != nil {
metricVal, err := metricKey.Int()
if err == nil {
metric = metricVal
}
}
// Append the route.
i.Routes = append(i.Routes, &Route{
Destination: destination,
Gateway: gateway,
Metric: metric,
})
}
}
// Add the interface.
interfaces = append(interfaces, i)
}
return
}
// Save network config.
func (n *networkdNetwork) Save(configDir string, backupRetention int) error {
// Get config paths.
configPath := path.Join(configDir, n.File)
tmpName := fmt.Sprintf("%s.tmp.%d", n.File, time.Now().UnixNano())
tmpPath := path.Join(configDir, tmpName)
// Try to encode/save the config.
fd, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return err
}
_, err = n.Config.WriteTo(fd)
fd.Close()
if err != nil {
return err
}
// Track the original file names backed up so we can prune per base.
bases := append([]string{}, n.SubFiles...)
// Backup existing configs.
suffix := fmt.Sprintf(".bak.%d", time.Now().UnixNano())
for _, file := range n.SubFiles {
newName := file + suffix
oldFile := path.Join(configDir, file)
newFile := path.Join(configDir, newName)
err := fileMove(oldFile, newFile)
if err != nil {
return err
}
}
bakName := n.File + suffix
bakPath := path.Join(configDir, bakName)
err = fileMove(configPath, bakPath)
if err != nil {
return err
}
// Move new config in place.
err = fileMove(tmpPath, configPath)
if err != nil {
return err
}
// We only have one merged file now.
n.SubFiles = nil
// Prune old backups beyond the retention count, per original file.
bases = append(bases, n.File)
for _, base := range bases {
pruneBackups(configDir, suffixBackupMatcher(base), backupRetention)
}
return nil
}
func (nd *networkd) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) {
for _, n := range nd.Networks {
if n.Name != iface {
continue
}
// Get existing DNS/domain config in-case different configs
// contains them from the main section.
var dns []string
var domains []string
for _, sec := range n.Config.Sections() {
switch sec.Name() {
// IF this is an network section, try to find addresses.
case "Network", "Address":
// Find the DNS.
dnsKey, _ := sec.GetKey("DNS")
if dnsKey != nil {
dnsServers := dnsKey.ValueWithShadows()
// Get the gateway.
for _, d := range dnsServers {
dns = append(dns, d)
}
}
// Find the domains.
domainsKey, _ := sec.GetKey("Domains")
if domainsKey != nil {
theseDoamins := strings.Fields(domainsKey.String())
domains = append(domains, theseDoamins...)
}
}
}
// Delete the address sections as we'll assign add addresses
// under the network section.
n.Config.DeleteSection("Address")
// Get the network section to modify it.
sec, err := n.Config.GetSection("Network")
if err != nil {
return err
}
// Delete existing fields.
sec.DeleteKey("Address")
sec.DeleteKey("Gateway")
sec.DeleteKey("DNS")
sec.DeleteKey("Domains")
// Update section with addresses and network configs discovered.
for _, addr := range addrs {
sec.NewKey("Address", addr.String())
}
if gateway4 != nil {
sec.NewKey("Gateway", gateway4.String())
}
if gateway6 != nil {
sec.NewKey("Gateway", gateway6.String())
}
for _, d := range dns {
sec.NewKey("DNS", d)
}
sec.NewKey("Domains", strings.Join(domains, " "))
// Save the network.
err = n.Save(nd.configDir, nd.backupRetention)
if err != nil {
return err
}
}
return nil
}
// Set static routes to interface.
func (nd *networkd) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) (err error) {
for _, n := range nd.Networks {
if n.Name != iface {
continue
}
// Delete existing routes from the config.
n.Config.DeleteSection("Route")
// Add routes.
for _, route := range routes {
sec, err := n.Config.NewSection("Route")
if err != nil {
return err
}
sec.NewKey("Destination", route.Destination.String())
sec.NewKey("Gateway", route.Gateway.String())
sec.NewKey("Metric", strconv.Itoa(route.Metric))
}
// Save the network.
err = n.Save(nd.configDir, nd.backupRetention)
if err != nil {
return err
}
}
return nil
}
// Set DNS servers and search domains on interface.
func (nd *networkd) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) (err error) {
for _, n := range nd.Networks {
if n.Name != iface {
continue
}
// DNS may be split across the Network and Address sections; the
// canonical place is the Network section, so clear both and rewrite
// the Network section.
for _, sec := range n.Config.Sections() {
switch sec.Name() {
case "Network", "Address":
sec.DeleteKey("DNS")
sec.DeleteKey("Domains")
}
}
// Get the network section to modify it.
sec, serr := n.Config.GetSection("Network")
if serr != nil {
return serr
}
for _, d := range ipStrings(servers) {
sec.NewKey("DNS", d)
}
if len(searchDomains) != 0 {
sec.NewKey("Domains", strings.Join(searchDomains, " "))
}
// Save the network.
err = n.Save(nd.configDir, nd.backupRetention)
if err != nil {
return err
}
}
return nil
}

173
networkd_test.go Normal file
View file

@ -0,0 +1,173 @@
package netconfig
import (
"context"
"net"
"os"
"path/filepath"
"strings"
"testing"
)
// Validate the networkd configuration parser/writer functions.
func TestNetworkd(t *testing.T) {
// Setup test file.
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
testDir, err := filepath.Abs("./tests/networkd")
if err != nil {
t.Fatal(err)
}
resultsDir := filepath.Join(testDir, "results")
// Try to read the test directory for files to copy to our temporary directory.
entries, err := os.ReadDir(testDir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".network") {
continue
}
configPath := filepath.Join(testDir, entry.Name())
tmpPath := filepath.Join(tmpDir, entry.Name())
err = fileCopy(configPath, tmpPath)
if err != nil {
t.Fatal(err)
}
}
// Setup ifupdown and parse test file.
n, err := readNetworkdConfigDirectory(tmpDir)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err := n.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = n.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{
{
IP: net.ParseIP("1.2.3.4"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("1.2.3.43"),
Mask: net.CIDRMask(24, 32),
},
{
IP: net.ParseIP("fc00::2"),
Mask: net.CIDRMask(64, 128),
},
}, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1"))
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = n.SetIfaceRoutes(context.Background(), "test_eth2", []*Route{
{
Destination: &net.IPNet{
IP: net.ParseIP("abcd:ef12:3455:10::"),
Mask: net.CIDRMask(64, 128),
},
Gateway: net.ParseIP("abcd:ef12:3456:10::1"),
Metric: 100,
},
{
Destination: &net.IPNet{
IP: net.ParseIP("10.253.2.0"),
Mask: net.CIDRMask(24, 32),
},
Gateway: net.ParseIP("203.0.113.22"),
Metric: 100,
},
})
if err != nil {
t.Fatal(err)
}
// Verify we can re-read configurations.
n, err = readNetworkdConfigDirectory(tmpDir)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = n.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 2)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 1)
if err != nil {
t.Error(err)
}
// Test setting the IP addresses on an interface.
err = n.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{
{
IP: net.ParseIP("1.2.10.4"),
Mask: net.CIDRMask(24, 32),
},
}, net.ParseIP("1.2.10.254"), nil)
if err != nil {
t.Fatal(err)
}
// Test setting routes on an interface.
err = n.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{})
if err != nil {
t.Fatal(err)
}
// Verify we can re-read configurations.
n, err = readNetworkdConfigDirectory(tmpDir)
if err != nil {
t.Fatal(err)
}
// Get the interfaces state.
interfaces, err = n.GetInterfaces()
if err != nil {
t.Fatal(err)
}
// Verify interfaces read from file.
err = testVerifyInterfaces(interfaces, resultsDir, 3)
if err != nil {
t.Error(err)
}
// Read the current file and expected state.
err = testVerifyResults(resultsDir, tmpDir, 2)
if err != nil {
t.Error(err)
}
// Cleanup.
err = os.RemoveAll(tmpDir)
if err != nil {
t.Fatal(err)
}
}

195
options.go Normal file
View file

@ -0,0 +1,195 @@
package netconfig
import (
"time"
log "github.com/sirupsen/logrus"
)
// Defaults for the tunable settings. They can be overridden with the
// corresponding With* Option when constructing a Configurator.
const (
// defaultConnectivityTimeout bounds the HTTP reachability test run after an
// address or gateway change so it cannot stall the rollback logic.
defaultConnectivityTimeout = 10 * time.Second
// defaultPingCount is the number of ICMP probes used to confirm a gateway or
// candidate address is reachable.
defaultPingCount = 5
// defaultPingTimeout bounds a single ping run.
defaultPingTimeout = 20 * time.Second
// defaultBackupRetention is how many .bak.* copies are kept per original
// configuration file. Older backups beyond this number are pruned on save.
defaultBackupRetention = 5
)
// Logger is the minimal logging surface used across the package. It is
// satisfied by the standard library *log.Logger, logrus, and most other
// logging libraries, so callers can plug in their own logger via SetLogger.
type Logger interface {
Printf(format string, args ...interface{})
Println(args ...interface{})
}
// logger is the package-wide logger. It defaults to the logrus standard
// logger and can be replaced with SetLogger. It is package scoped because the
// configuration backends and control panels log independently of any single
// Configurator instance.
var logger Logger = log.StandardLogger()
// SetLogger replaces the package-wide logger used for non-fatal diagnostics.
// It is package scoped (not a per-Configurator option) because the configuration
// backends and control panels log independently of any single Configurator
// instance; the setting therefore applies to every Configurator in the process.
// A nil logger is ignored.
func SetLogger(l Logger) {
if l != nil {
logger = l
}
}
// configOptions holds the tunable settings applied when constructing a
// Configurator. Use the With* Option helpers to set them.
type configOptions struct {
// testAddress is the URL fetched to confirm connectivity after an
// address or gateway change.
testAddress string
// skipConnectivityCheck disables the post-change internet reachability
// test and its automatic rollback.
skipConnectivityCheck bool
// connectivityTimeout bounds the HTTP reachability test.
connectivityTimeout time.Duration
// pingCount is the number of ICMP probes per ping test.
pingCount int
// pingTimeout bounds a single ping run.
pingTimeout time.Duration
// backupRetention is the number of .bak.* copies kept per original
// configuration file; <= 0 disables pruning (keeps all backups).
backupRetention int
// allowPrimaryRemoval lets RemoveAddress remove an address that is the
// primary of its family, which is otherwise refused.
allowPrimaryRemoval bool
// skipPanels makes RemoveAddress bypass all control-panel backends
// (cPanel/Plesk/InterWorx) and only remove the address from the running
// system and the network-manager configuration files.
skipPanels bool
}
// defaultConfigOptions returns the options used when no Option is supplied.
func defaultConfigOptions() *configOptions {
return &configOptions{
testAddress: defaultInternetTestAddress,
connectivityTimeout: defaultConnectivityTimeout,
pingCount: defaultPingCount,
pingTimeout: defaultPingTimeout,
backupRetention: defaultBackupRetention,
}
}
// newConfigOptions builds a configOptions from the supplied options, starting
// from the package defaults.
func newConfigOptions(opts ...Option) *configOptions {
o := defaultConfigOptions()
for _, opt := range opts {
if opt != nil {
opt(o)
}
}
return o
}
// Option configures a Configurator constructed with NewConfigurator.
type Option func(*configOptions)
// WithTestAddress overrides the URL used for the post-change connectivity
// test. The two sentinel values "test_success" and "test_fail" force the test
// to pass or fail without performing a request, which is useful in tests.
func WithTestAddress(addr string) Option {
return func(o *configOptions) {
if addr != "" {
o.testAddress = addr
}
}
}
// WithConnectivityCheck enables or disables the post-change internet
// reachability test (and the automatic rollback that depends on it). It is
// enabled by default; pass false to skip it, for example on hosts with no
// outbound internet access.
func WithConnectivityCheck(enabled bool) Option {
return func(o *configOptions) {
o.skipConnectivityCheck = !enabled
}
}
// WithSkipConnectivityCheck is a convenience option that disables the post-change
// internet reachability test and its automatic rollback. It is equivalent to
// WithConnectivityCheck(false).
func WithSkipConnectivityCheck() Option {
return WithConnectivityCheck(false)
}
// WithConnectivityTimeout sets the timeout for the HTTP reachability test run
// after an address or gateway change. A smaller value fails faster on a
// black-holed route; a larger value tolerates slower links.
func WithConnectivityTimeout(d time.Duration) Option {
return func(o *configOptions) {
if d > 0 {
o.connectivityTimeout = d
}
}
}
// WithPingCount sets the number of ICMP probes sent when confirming that a
// candidate address or gateway is reachable. More probes are more reliable on
// lossy links but take longer.
func WithPingCount(n int) Option {
return func(o *configOptions) {
if n > 0 {
o.pingCount = n
}
}
}
// WithPingTimeout sets the overall timeout for a single ping run.
func WithPingTimeout(d time.Duration) Option {
return func(o *configOptions) {
if d > 0 {
o.pingTimeout = d
}
}
}
// WithBackupRetention sets how many .bak.* copies are kept per original
// configuration file. The oldest backups beyond this number are pruned each
// time a backend saves. Pass 0 (or a negative value) to disable pruning and
// keep every backup.
func WithBackupRetention(n int) Option {
return func(o *configOptions) {
o.backupRetention = n
}
}
// WithAllowPrimaryRemoval permits RemoveAddress to remove an address that is
// the primary of its family on its interface. By default removing the primary
// is refused so a caller cannot accidentally change the system's source
// address or leave a control panel without a main IP; the intended flow is to
// SetPrimaryAddress on another IP first. Enable this only when you are
// deliberately tearing down the current primary.
func WithAllowPrimaryRemoval(allowed bool) Option {
return func(o *configOptions) {
o.allowPrimaryRemoval = allowed
}
}
// WithSkipPanels makes the configurator skip all control-panel backends
// (cPanel, Plesk, InterWorx) during mutating operations. When enabled,
// AddAddress, SetPrimaryAddress, and RemoveAddress do not tell the panels to
// reload, set the main IP, or release an IP; only the running system and the
// network-manager configuration files are changed. This is intended for
// maintenance paths where the panel must be left untouched, or where the panel
// is expected to be reconciled separately.
func WithSkipPanels(skip bool) Option {
return func(o *configOptions) {
o.skipPanels = skip
}
}

225
options_test.go Normal file
View file

@ -0,0 +1,225 @@
package netconfig
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// captureLogger records the last formatted message so SetLogger can be
// verified.
type captureLogger struct {
printfCalls int
printlnCalls int
}
func (c *captureLogger) Printf(format string, args ...interface{}) { c.printfCalls++ }
func (c *captureLogger) Println(args ...interface{}) { c.printlnCalls++ }
func TestConfigOptionsDefaults(t *testing.T) {
o := newConfigOptions()
if o.testAddress != defaultInternetTestAddress {
t.Errorf("default testAddress = %q, want %q", o.testAddress, defaultInternetTestAddress)
}
if o.skipConnectivityCheck {
t.Error("skipConnectivityCheck = true, want false by default")
}
if o.connectivityTimeout != defaultConnectivityTimeout {
t.Errorf("default connectivityTimeout = %v, want %v", o.connectivityTimeout, defaultConnectivityTimeout)
}
if o.pingCount != defaultPingCount {
t.Errorf("default pingCount = %d, want %d", o.pingCount, defaultPingCount)
}
if o.pingTimeout != defaultPingTimeout {
t.Errorf("default pingTimeout = %v, want %v", o.pingTimeout, defaultPingTimeout)
}
if o.backupRetention != defaultBackupRetention {
t.Errorf("default backupRetention = %d, want %d", o.backupRetention, defaultBackupRetention)
}
if o.allowPrimaryRemoval {
t.Error("allowPrimaryRemoval = true, want false by default")
}
if o.skipPanels {
t.Error("skipPanels = true, want false by default")
}
}
func TestWithTestAddress(t *testing.T) {
o := newConfigOptions(WithTestAddress("http://canary.example/health"))
if o.testAddress != "http://canary.example/health" {
t.Errorf("testAddress = %q, want the overridden value", o.testAddress)
}
// An empty address must not clobber the default.
o = newConfigOptions(WithTestAddress(""))
if o.testAddress != defaultInternetTestAddress {
t.Errorf("empty WithTestAddress changed default to %q", o.testAddress)
}
}
func TestWithConnectivityCheck(t *testing.T) {
if o := newConfigOptions(WithConnectivityCheck(false)); !o.skipConnectivityCheck {
t.Error("WithConnectivityCheck(false) did not set skipConnectivityCheck")
}
if o := newConfigOptions(WithConnectivityCheck(true)); o.skipConnectivityCheck {
t.Error("WithConnectivityCheck(true) set skipConnectivityCheck")
}
}
func TestWithSkipConnectivityCheck(t *testing.T) {
if o := newConfigOptions(WithSkipConnectivityCheck()); !o.skipConnectivityCheck {
t.Error("WithSkipConnectivityCheck did not set skipConnectivityCheck")
}
}
func TestSetLogger(t *testing.T) {
prev := logger
defer func() { logger = prev }()
cap := &captureLogger{}
SetLogger(cap)
if logger != cap {
t.Fatal("SetLogger did not replace the package logger")
}
logger.Printf("hi %d", 1)
logger.Println("hi")
if cap.printfCalls != 1 || cap.printlnCalls != 1 {
t.Errorf("logger not used: printf=%d println=%d", cap.printfCalls, cap.printlnCalls)
}
// A nil logger must be ignored, leaving the current logger in place.
SetLogger(nil)
if logger != cap {
t.Error("SetLogger(nil) replaced the logger")
}
}
func TestWithConnectivityTimeout(t *testing.T) {
if o := newConfigOptions(WithConnectivityTimeout(5 * time.Second)); o.connectivityTimeout != 5*time.Second {
t.Errorf("connectivityTimeout = %v, want 5s", o.connectivityTimeout)
}
// Non-positive values must not clobber the default.
if o := newConfigOptions(WithConnectivityTimeout(0)); o.connectivityTimeout != defaultConnectivityTimeout {
t.Errorf("zero WithConnectivityTimeout changed default to %v", o.connectivityTimeout)
}
}
func TestWithPingCount(t *testing.T) {
if o := newConfigOptions(WithPingCount(3)); o.pingCount != 3 {
t.Errorf("pingCount = %d, want 3", o.pingCount)
}
// Non-positive values must not clobber the default.
if o := newConfigOptions(WithPingCount(0)); o.pingCount != defaultPingCount {
t.Errorf("zero WithPingCount changed default to %d", o.pingCount)
}
}
func TestWithPingTimeout(t *testing.T) {
if o := newConfigOptions(WithPingTimeout(30 * time.Second)); o.pingTimeout != 30*time.Second {
t.Errorf("pingTimeout = %v, want 30s", o.pingTimeout)
}
if o := newConfigOptions(WithPingTimeout(0)); o.pingTimeout != defaultPingTimeout {
t.Errorf("zero WithPingTimeout changed default to %v", o.pingTimeout)
}
}
func TestWithBackupRetention(t *testing.T) {
if o := newConfigOptions(WithBackupRetention(10)); o.backupRetention != 10 {
t.Errorf("backupRetention = %d, want 10", o.backupRetention)
}
// Zero/negative disables pruning (keeps all backups).
if o := newConfigOptions(WithBackupRetention(0)); o.backupRetention != 0 {
t.Errorf("backupRetention = %d, want 0", o.backupRetention)
}
}
func TestWithAllowPrimaryRemoval(t *testing.T) {
if o := newConfigOptions(WithAllowPrimaryRemoval(true)); !o.allowPrimaryRemoval {
t.Error("WithAllowPrimaryRemoval(true) did not set allowPrimaryRemoval")
}
}
func TestWithSkipPanels(t *testing.T) {
if o := newConfigOptions(WithSkipPanels(true)); !o.skipPanels {
t.Error("WithSkipPanels(true) did not set skipPanels")
}
if o := newConfigOptions(WithSkipPanels(false)); o.skipPanels {
t.Error("WithSkipPanels(false) set skipPanels")
}
}
// applyIfaceDNS must invoke every backend even when an earlier one fails.
func TestApplyIfaceDNS(t *testing.T) {
failing := &fakeIfaceBackend{err: errors.New("boom")}
ok := &fakeIfaceBackend{}
backends := []namedIfaceBackend{
{name: "failing", backend: failing},
{name: "ok", backend: ok},
}
applyIfaceDNS(context.Background(), backends, "eth0", []net.IP{net.ParseIP("8.8.8.8")}, []string{"example.com"})
if failing.dnsCalls != 1 || ok.dnsCalls != 1 {
t.Errorf("dns calls: failing=%d ok=%d, want 1 each", failing.dnsCalls, ok.dnsCalls)
}
}
func TestIPStrings(t *testing.T) {
got := ipStrings([]net.IP{net.ParseIP("8.8.8.8"), nil, net.ParseIP("2001:4860:4860::8888")})
want := []string{"8.8.8.8", "2001:4860:4860::8888"}
if len(got) != len(want) {
t.Fatalf("ipStrings = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("ipStrings[%d] = %q, want %q", i, got[i], want[i])
}
}
}
// TestNetplanDNS round-trips DNS through the netplan backend: SetIfaceDNS writes
// the nameservers block and GetInterfaces parses it back.
func TestNetplanDNS(t *testing.T) {
tmpDir := t.TempDir()
config := "network:\n version: 2\n ethernets:\n eth0:\n addresses: [192.0.2.10/24]\n"
if err := os.WriteFile(filepath.Join(tmpDir, "01-test.yaml"), []byte(config), 0644); err != nil {
t.Fatal(err)
}
np, err := readNetplanConfigDirectory(tmpDir)
if err != nil {
t.Fatal(err)
}
servers := []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888")}
if err := np.SetIfaceDNS(context.Background(), "eth0", servers, []string{"example.com", "corp.example"}); err != nil {
t.Fatal(err)
}
// Re-read from disk so we exercise the written config, not in-memory state.
np, err = readNetplanConfigDirectory(tmpDir)
if err != nil {
t.Fatal(err)
}
ifaces, err := np.GetInterfaces()
if err != nil {
t.Fatal(err)
}
var eth0 *Interface
for _, i := range ifaces {
if i.Name == "eth0" {
eth0 = i
}
}
if eth0 == nil {
t.Fatal("eth0 not found after SetIfaceDNS")
}
if len(eth0.DNS) != 2 || !eth0.DNS[0].Equal(servers[0]) || !eth0.DNS[1].Equal(servers[1]) {
t.Errorf("DNS = %v, want %v", eth0.DNS, servers)
}
if len(eth0.SearchDomains) != 2 || eth0.SearchDomains[0] != "example.com" || eth0.SearchDomains[1] != "corp.example" {
t.Errorf("SearchDomains = %v, want [example.com corp.example]", eth0.SearchDomains)
}
}

267
plesk_linux.go Normal file
View file

@ -0,0 +1,267 @@
package netconfig
import (
"context"
"fmt"
"net"
"regexp"
"strconv"
"strings"
)
const pleskBin = "/usr/sbin/plesk"
type plesk struct {
}
// parsePleskIPField extracts the IP address from a field produced by
// `plesk bin ipmanage --ip_list`. The field has the form
// "<interface>:<ip>[/<mask>]"; missing components result in a nil IP so the
// line is skipped rather than crashing the process.
func parsePleskIPField(s string) net.IP {
// Drop an optional "/<mask>".
if idx := strings.Index(s, "/"); idx != -1 {
s = s[:idx]
}
// The field may be just the IP, or "<interface>:<ip>". Try the whole
// string first; if it does not parse, drop the interface prefix up to
// the first colon. IPv6 addresses contain colons, so LastIndex would
// truncate the final hextet.
if ip := net.ParseIP(s); ip != nil {
return ip
}
if idx := strings.Index(s, ":"); idx != -1 {
s = s[idx+1:]
// If the IP was compressed with a leading "::", the interface
// prefix consumed one of the leading colons (e.g. "eth0::1").
if strings.HasPrefix(s, ":") {
s = ":" + s
}
return net.ParseIP(s)
}
return nil
}
// Move sites off the old IP to a new IP.
func (*plesk) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error {
// We need the IP type.
isIPv6 := oldAddr.To4() == nil
// Get list of IP addresses from plesk.
domains, err := runCommand(ctx, pleskBin, "bin", "domain", "--list")
if err != nil {
return err
}
// Domain info parser.
dataParse := regexp.MustCompile(`^([^:]+):\s*([^$]+)$`)
// Check each domain for the old IP, and update to either remove or move to the new IP.
for _, domain := range domains {
// Get the domain info.
out, err := runCommand(ctx, pleskBin, "bin", "domain", "--info", domain)
if err != nil {
return err
}
// Parse the domain info IP addresses.
var domainIPv4 []net.IP
var domainIPv6 []net.IP
for _, line := range out {
// Parse and skip lines that do not match.
match := dataParse.FindAllStringSubmatch(line, 1)
if len(match) == 0 {
continue
}
// Get the key/value from the parsed info.
key := strings.TrimSpace(match[0][1])
value := strings.TrimSpace(match[0][2])
// If this is the IP address configuration, parse the IP addresses assigned.
if key == "IP Address" {
ipAddrs := strings.Split(value, ", ")
for _, ipAddr := range ipAddrs {
ip := net.ParseIP(ipAddr)
if ip.To4() == nil {
domainIPv6 = append(domainIPv6, ip)
} else {
domainIPv4 = append(domainIPv4, ip)
}
}
}
}
// Look for the old IP according to its type, and remove and or replace with the new IP if there are no other
// IP addresses of its type.
foundOld := false
if isIPv6 {
for i, ip := range domainIPv6 {
if ip.Equal(oldAddr) {
foundOld = true
domainIPv6 = append(domainIPv6[:i], domainIPv6[i+1:]...)
}
}
if foundOld && len(domainIPv6) == 0 {
domainIPv6 = append(domainIPv6, newAddr)
}
} else {
for i, ip := range domainIPv4 {
if ip.Equal(oldAddr) {
foundOld = true
domainIPv4 = append(domainIPv4[:i], domainIPv4[i+1:]...)
}
}
if foundOld && len(domainIPv4) == 0 {
domainIPv4 = append(domainIPv4, newAddr)
}
}
// If we found the old IP, run the domain update command to update the sites association.
if foundOld {
var domainIPs []string
for _, ip := range domainIPv4 {
domainIPs = append(domainIPs, ip.String())
}
for _, ip := range domainIPv6 {
domainIPs = append(domainIPs, ip.String())
}
out, err := runCommand(ctx, pleskBin, "bin", "domain", "--update", domain, "-ip", strings.Join(domainIPs, ","))
if err != nil {
return err
}
logger.Println("Plesk domain output:", strings.Join(out, "\n"))
}
}
return nil
}
// Remove an IP address from Plesk.
func (p *plesk) removeIP(ctx context.Context, addr net.IP) error {
// We need the IP type.
isIPv6 := addr.To4() == nil
// Get list of IP addresses from plesk.
out, err := runCommand(ctx, pleskBin, "bin", "ipmanage", "--ip_list")
if err != nil {
return err
}
// We should capture these fields from the list.
ipHosting := 0
var sharedIP net.IP
var publicIPs []net.IP
var privateIPs []net.IP
// Parse each line returned from plesk.
for _, line := range out {
fields := strings.Fields(line)
// Skip blank or malformed lines that lack the fields parsed below.
if len(fields) < 5 {
continue
}
// Skip the header line.
if fields[2] == "IP" {
continue
}
// Parse the IP field.
ip := parsePleskIPField(fields[2])
if ip == nil {
continue
}
// Skip IP addresses of a different type.
if ip.To4() == nil && !isIPv6 {
continue
} else if ip.To4() != nil && isIPv6 {
continue
}
// Depending on IP match, apply to fields. When we find the address we are removing,
// we should determine how may sites are hosted by the IP. If the type is an shared iP,
// set the shared IP to it. Otherwise, associate other IP types to public/private IP
// lists to allow us to select an IP where a shared IP doesn't exist.
if ip.Equal(addr) {
ipHosting, _ = strconv.Atoi(fields[4])
} else if fields[1] == "S" {
sharedIP = ip
} else if !ip.IsPrivate() {
publicIPs = append(publicIPs, ip)
} else {
if len(fields) == 6 {
pubIP := net.ParseIP(fields[5])
if pubIP != nil {
publicIPs = append(publicIPs, ip)
} else {
privateIPs = append(privateIPs, ip)
}
} else {
privateIPs = append(privateIPs, ip)
}
}
}
// If no shared IP, try public IP and private IP association instead.
if sharedIP == nil {
if len(publicIPs) != 0 {
sharedIP = publicIPs[0]
} else if len(privateIPs) != 0 {
sharedIP = privateIPs[0]
} else if ipHosting != 0 {
return fmt.Errorf("unable to find a shared IP to move ip addresses to")
}
}
// If the IP we are removing is hosting sites, move the sites to the shared IP determined above.
if ipHosting != 0 {
err := p.moveSites(ctx, addr, sharedIP)
if err != nil {
return err
}
}
// Remove the IP address from plesk.
out, err = runCommand(ctx, pleskBin, "bin", "ipmanage", "--remove", addr.String())
if err != nil {
return err
}
logger.Println("Plesk output:", strings.Join(out, "\n"))
return nil
}
// setMainIP repoints Plesk's main IP to addr. There is no `plesk bin ipmanage`
// flag for this, so the main flag is set directly in the psa database's
// IP_Addresses table via `plesk db`, which authenticates automatically. The new
// address is promoted first so there is never a window with no main IP, then
// every other address is demoted, leaving exactly one main row.
func (p *plesk) setMainIP(ctx context.Context, addr net.IP) error {
ip := addr.String()
queries := []string{
fmt.Sprintf("UPDATE IP_Addresses SET main='true' WHERE ip_address='%s'", ip),
fmt.Sprintf("UPDATE IP_Addresses SET main='false' WHERE ip_address<>'%s'", ip),
}
for _, query := range queries {
out, err := runCommand(ctx, pleskBin, "db", query)
if err != nil {
return err
}
if len(out) != 0 {
logger.Println("Plesk db output:", strings.Join(out, "\n"))
}
}
return nil
}
// Tell plesk to reload the available IP addresses.
func (*plesk) reload(ctx context.Context) error {
out, err := runCommand(ctx, pleskBin, "bin", "ipmanage", "--reread")
if err != nil {
return err
}
logger.Println("Plesk output:", strings.Join(out, "\n"))
return nil
}

267
plesk_windows.go Normal file
View file

@ -0,0 +1,267 @@
package netconfig
import (
"context"
"fmt"
"net"
"regexp"
"strconv"
"strings"
)
const pleskBin = `C:\Program Files (x86)\Plesk\bin\plesk.exe`
type plesk struct {
}
// parsePleskIPField extracts the IP address from a field produced by
// `plesk bin ipmanage --ip_list`. The field has the form
// "<interface>:<ip>[/<mask>]"; missing components result in a nil IP so the
// line is skipped rather than crashing the process.
func parsePleskIPField(s string) net.IP {
// Drop an optional "/<mask>".
if idx := strings.Index(s, "/"); idx != -1 {
s = s[:idx]
}
// The field may be just the IP, or "<interface>:<ip>". Try the whole
// string first; if it does not parse, drop the interface prefix up to
// the first colon. IPv6 addresses contain colons, so LastIndex would
// truncate the final hextet.
if ip := net.ParseIP(s); ip != nil {
return ip
}
if idx := strings.Index(s, ":"); idx != -1 {
s = s[idx+1:]
// If the IP was compressed with a leading "::", the interface
// prefix consumed one of the leading colons (e.g. "eth0::1").
if strings.HasPrefix(s, ":") {
s = ":" + s
}
return net.ParseIP(s)
}
return nil
}
// Move sites off the old IP to a new IP.
func (*plesk) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error {
// We need the IP type.
isIPv6 := oldAddr.To4() == nil
// Get list of IP addresses from plesk.
domains, err := runCommand(ctx, pleskBin, "bin", "domain", "--list")
if err != nil {
return err
}
// Domain info parser.
dataParse := regexp.MustCompile(`^([^:]+):\s*([^$]+)$`)
// Check each domain for the old IP, and update to either remove or move to the new IP.
for _, domain := range domains {
// Get the domain info.
out, err := runCommand(ctx, pleskBin, "bin", "domain", "--info", domain)
if err != nil {
return err
}
// Parse the domain info IP addresses.
var domainIPv4 []net.IP
var domainIPv6 []net.IP
for _, line := range out {
// Parse and skip lines that do not match.
match := dataParse.FindAllStringSubmatch(line, 1)
if len(match) == 0 {
continue
}
// Get the key/value from the parsed info.
key := strings.TrimSpace(match[0][1])
value := strings.TrimSpace(match[0][2])
// If this is the IP address configuration, parse the IP addresses assigned.
if key == "IP Address" {
ipAddrs := strings.Split(value, ", ")
for _, ipAddr := range ipAddrs {
ip := net.ParseIP(ipAddr)
if ip.To4() == nil {
domainIPv6 = append(domainIPv6, ip)
} else {
domainIPv4 = append(domainIPv4, ip)
}
}
}
}
// Look for the old IP according to its type, and remove and or replace with the new IP if there are no other
// IP addresses of its type.
foundOld := false
if isIPv6 {
for i, ip := range domainIPv6 {
if ip.Equal(oldAddr) {
foundOld = true
domainIPv6 = append(domainIPv6[:i], domainIPv6[i+1:]...)
}
}
if foundOld && len(domainIPv6) == 0 {
domainIPv6 = append(domainIPv6, newAddr)
}
} else {
for i, ip := range domainIPv4 {
if ip.Equal(oldAddr) {
foundOld = true
domainIPv4 = append(domainIPv4[:i], domainIPv4[i+1:]...)
}
}
if foundOld && len(domainIPv4) == 0 {
domainIPv4 = append(domainIPv4, newAddr)
}
}
// If we found the old IP, run the domain update command to update the sites association.
if foundOld {
var domainIPs []string
for _, ip := range domainIPv4 {
domainIPs = append(domainIPs, ip.String())
}
for _, ip := range domainIPv6 {
domainIPs = append(domainIPs, ip.String())
}
out, err := runCommand(ctx, pleskBin, "bin", "domain", "--update", domain, "-ip", strings.Join(domainIPs, ","))
if err != nil {
return err
}
logger.Println("Plesk domain output:", strings.Join(out, "\n"))
}
}
return nil
}
// Remove an IP address from Plesk.
func (p *plesk) removeIP(ctx context.Context, addr net.IP) error {
// We need the IP type.
isIPv6 := addr.To4() == nil
// Get list of IP addresses from plesk.
out, err := runCommand(ctx, pleskBin, "bin", "ipmanage", "--ip_list")
if err != nil {
return err
}
// We should capture these fields from the list.
ipHosting := 0
var sharedIP net.IP
var publicIPs []net.IP
var privateIPs []net.IP
// Parse each line returned from plesk.
for _, line := range out {
fields := strings.Fields(line)
// Skip blank or malformed lines that lack the fields parsed below.
if len(fields) < 5 {
continue
}
// Skip the header line.
if fields[2] == "IP" {
continue
}
// Parse the IP field.
ip := parsePleskIPField(fields[2])
if ip == nil {
continue
}
// Skip IP addresses of a different type.
if ip.To4() == nil && !isIPv6 {
continue
} else if ip.To4() != nil && isIPv6 {
continue
}
// Depending on IP match, apply to fields. When we find the address we are removing,
// we should determine how may sites are hosted by the IP. If the type is an shared iP,
// set the shared IP to it. Otherwise, associate other IP types to public/private IP
// lists to allow us to select an IP where a shared IP doesn't exist.
if ip.Equal(addr) {
ipHosting, _ = strconv.Atoi(fields[4])
} else if fields[1] == "S" {
sharedIP = ip
} else if !ip.IsPrivate() {
publicIPs = append(publicIPs, ip)
} else {
if len(fields) == 6 {
pubIP := net.ParseIP(fields[5])
if pubIP != nil {
publicIPs = append(publicIPs, ip)
} else {
privateIPs = append(privateIPs, ip)
}
} else {
privateIPs = append(privateIPs, ip)
}
}
}
// If no shared IP, try public IP and private IP association instead.
if sharedIP == nil {
if len(publicIPs) != 0 {
sharedIP = publicIPs[0]
} else if len(privateIPs) != 0 {
sharedIP = privateIPs[0]
} else if ipHosting != 0 {
return fmt.Errorf("unable to find a shared IP to move ip addresses to")
}
}
// If the IP we are removing is hosting sites, move the sites to the shared IP determined above.
if ipHosting != 0 {
err := p.moveSites(ctx, addr, sharedIP)
if err != nil {
return err
}
}
// Remove the IP address from plesk.
out, err = runCommand(ctx, pleskBin, "bin", "ipmanage", "--remove", addr.String())
if err != nil {
return err
}
logger.Println("Plesk output:", strings.Join(out, "\n"))
return nil
}
// setMainIP repoints Plesk's main IP to addr. There is no `plesk bin ipmanage`
// flag for this, so the main flag is set directly in the psa database's
// IP_Addresses table via `plesk db`, which authenticates automatically. The new
// address is promoted first so there is never a window with no main IP, then
// every other address is demoted, leaving exactly one main row.
func (p *plesk) setMainIP(ctx context.Context, addr net.IP) error {
ip := addr.String()
queries := []string{
fmt.Sprintf("UPDATE IP_Addresses SET main='true' WHERE ip_address='%s'", ip),
fmt.Sprintf("UPDATE IP_Addresses SET main='false' WHERE ip_address<>'%s'", ip),
}
for _, query := range queries {
out, err := runCommand(ctx, pleskBin, "db", query)
if err != nil {
return err
}
if len(out) != 0 {
logger.Println("Plesk db output:", strings.Join(out, "\n"))
}
}
return nil
}
// Tell plesk to reload the available IP addresses.
func (*plesk) reload(ctx context.Context) error {
out, err := runCommand(ctx, pleskBin, "bin", "ipmanage", "--reread")
if err != nil {
return err
}
logger.Println("Plesk output:", strings.Join(out, "\n"))
return nil
}

View file

@ -0,0 +1,108 @@
{
"config": [
{
"id": "test_eth0",
"mac_address": "52:54:00:e8:53:b5",
"mtu": 1500,
"name": "test_eth0",
"subnets": [
{
"type": "manual"
}
],
"type": "physical"
},
{
"id": "test_eth0.1556",
"mtu": 1500,
"name": "test_eth0.1556",
"subnets": [
{
"address": "1.2.3.4/24",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"gateway": "1.2.3.1",
"type": "static",
"routes": [
{
"destination": "10.0.0.0/24",
"gateway": "1.2.3.5",
"metric": 100
}
]
}
],
"type": "vlan",
"vlan_id": 1556,
"vlan_link": "eth0"
},
{
"id": "test_eth1",
"mac_address": "52:54:00:5a:ff:31",
"mtu": 1500,
"name": "test_eth1",
"subnets": [
{
"type": "manual"
}
],
"type": "physical"
},
{
"id": "test_eth1.1557",
"mtu": 1500,
"name": "test_eth1.1557",
"subnets": [
{
"address": "fc00:aa8:7160:d9eb:1:0:1:3/64",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"type": "static"
}
],
"type": "vlan",
"vlan_id": 1557,
"vlan_link": "eth1"
},
{
"id": "test_eth3",
"mac_address": "52:54:00:e8:54:b5",
"mtu": 1500,
"name": "test_eth3",
"subnets": [
{
"address": "abcd:ef12:3456:10::4/64",
"gateway": "abcd:ef12:3456:10::1",
"type": "static"
},
{
"address": "203.0.113.2/24",
"gateway": "203.0.113.1",
"type": "static"
}
],
"type": "physical"
},
{
"address": [
"10.0.0.1"
],
"search": [
"example.com",
"maas"
],
"type": "nameserver"
}
],
"version": 1
}

View file

@ -0,0 +1,141 @@
{
"config": [
{
"mac_address": "52:54:00:e8:53:b5",
"mtu": 1500,
"name": "test_eth0",
"subnets": [
{
"type": "manual"
}
],
"type": "physical"
},
{
"mtu": 1500,
"name": "test_eth0.1556",
"subnets": [
{
"address": "1.2.3.4/24",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"gateway": "1.2.3.1",
"routes": [
{
"destination": "10.0.0.0/24",
"gateway": "1.2.3.5",
"metric": 100
}
],
"type": "static"
},
{
"address": "1.2.3.43/24",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"gateway": "1.2.3.1",
"type": "static"
},
{
"address": "fc00::2/64",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"gateway": "fc00::1",
"type": "static"
}
],
"type": "vlan",
"vlan_id": 1556,
"vlan_link": "eth0"
},
{
"mac_address": "52:54:00:5a:ff:31",
"mtu": 1500,
"name": "test_eth1",
"subnets": [
{
"type": "manual"
}
],
"type": "physical"
},
{
"mtu": 1500,
"name": "test_eth1.1557",
"subnets": [
{
"address": "fc00:aa8:7160:d9eb:1:0:1:3/64",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"type": "static"
}
],
"type": "vlan",
"vlan_id": 1557,
"vlan_link": "eth1"
},
{
"mac_address": "52:54:00:e8:54:b5",
"mtu": 1500,
"name": "test_eth3",
"subnets": [
{
"address": "abcd:ef12:3456:10::4/64",
"gateway": "abcd:ef12:3456:10::1",
"routes": [
{
"destination": "abcd:ef12:3455:10::/64",
"gateway": "abcd:ef12:3456:10::1",
"metric": 100
}
],
"type": "static"
},
{
"address": "203.0.113.2/24",
"gateway": "203.0.113.1",
"routes": [
{
"destination": "10.253.2.0/24",
"gateway": "203.0.113.22",
"metric": 100
}
],
"type": "static"
}
],
"type": "physical"
},
{
"address": [
"10.0.0.1"
],
"search": [
"example.com",
"maas"
],
"type": "nameserver"
}
],
"version": 1
}

View file

@ -0,0 +1,136 @@
{
"config": [
{
"mac_address": "52:54:00:e8:53:b5",
"mtu": 1500,
"name": "test_eth0",
"subnets": [
{
"address": "1.2.10.4/24",
"gateway": "1.2.10.254",
"type": "static"
}
],
"type": "physical"
},
{
"mtu": 1500,
"name": "test_eth0.1556",
"subnets": [
{
"address": "1.2.3.4/24",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"gateway": "1.2.3.1",
"type": "static"
},
{
"address": "1.2.3.43/24",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"gateway": "1.2.3.1",
"type": "static"
},
{
"address": "fc00::2/64",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"gateway": "fc00::1",
"type": "static"
}
],
"type": "vlan",
"vlan_id": 1556,
"vlan_link": "eth0"
},
{
"mac_address": "52:54:00:5a:ff:31",
"mtu": 1500,
"name": "test_eth1",
"subnets": [
{
"type": "manual"
}
],
"type": "physical"
},
{
"mtu": 1500,
"name": "test_eth1.1557",
"subnets": [
{
"address": "fc00:aa8:7160:d9eb:1:0:1:3/64",
"dns_nameservers": [
"10.0.0.1"
],
"dns_search": [
"example.com",
"maas"
],
"type": "static"
}
],
"type": "vlan",
"vlan_id": 1557,
"vlan_link": "eth1"
},
{
"mac_address": "52:54:00:e8:54:b5",
"mtu": 1500,
"name": "test_eth3",
"subnets": [
{
"address": "abcd:ef12:3456:10::4/64",
"gateway": "abcd:ef12:3456:10::1",
"routes": [
{
"destination": "abcd:ef12:3455:10::/64",
"gateway": "abcd:ef12:3456:10::1",
"metric": 100
}
],
"type": "static"
},
{
"address": "203.0.113.2/24",
"gateway": "203.0.113.1",
"routes": [
{
"destination": "10.253.2.0/24",
"gateway": "203.0.113.22",
"metric": 100
}
],
"type": "static"
}
],
"type": "physical"
},
{
"address": [
"10.0.0.1"
],
"search": [
"example.com",
"maas"
],
"type": "nameserver"
}
],
"version": 1
}

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:e8:53:b5 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1 MAC: 52:54:00:5a:ff:31 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:e8:54:b5 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: []

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:e8:53:b5 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1 MAC: 52:54:00:5a:ff:31 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:e8:54:b5 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100]

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:e8:53:b5 Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: <nil> Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: []
Name: test_eth1 MAC: 52:54:00:5a:ff:31 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:e8:54:b5 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100]

View file

@ -0,0 +1,47 @@
network:
version: 2
ethernets:
test_eth0:
match:
macaddress: 52:54:00:b9:ab:93
set-name: test_eth0
mtu: 1500
test_eth1:
match:
macaddress: 52:54:00:8b:0d:93
set-name: test_eth1
mtu: 1500
test_eth3:
match:
macaddress: 52:54:00:8b:cd:93
set-name: test_eth3
addresses:
- abcd:ef12:3456:10::4/64
- 203.0.113.2/24
gateway6: abcd:ef12:3456:10::1
gateway4: 203.0.113.1
mtu: 1500
vlans:
test_eth0.1556:
id: 1556
link: test_eth0
addresses:
- 1.2.3.4/24
gateway4: 1.2.3.1
routes:
- to: 10.0.0.0/24
via: 1.2.3.5
metric: 100
nameservers:
search: [example.com, maas]
addresses: [10.0.0.1]
mtu: 1500
test_eth1.1557:
id: 1557
link: test_eth1
addresses:
- fc00:aa8:7160:d9eb:1:0:1:3/64
nameservers:
search: [example.com, maas]
addresses: [10.0.0.1]
mtu: 1500

View file

@ -0,0 +1,63 @@
network:
ethernets:
test_eth0:
match:
macaddress: 52:54:00:b9:ab:93
mtu: 1500
set-name: test_eth0
test_eth1:
match:
macaddress: 52:54:00:8b:0d:93
mtu: 1500
set-name: test_eth1
test_eth3:
addresses:
- abcd:ef12:3456:10::4/64
- 203.0.113.2/24
gateway4: 203.0.113.1
gateway6: abcd:ef12:3456:10::1
match:
macaddress: 52:54:00:8b:cd:93
mtu: 1500
routes:
- metric: 100
to: abcd:ef12:3455:10::/64
via: abcd:ef12:3456:10::1
- metric: 100
to: 10.253.2.0/24
via: 203.0.113.22
set-name: test_eth3
version: 2
vlans:
test_eth0.1556:
addresses:
- 1.2.3.4/24
- 1.2.3.43/24
- fc00::2/64
gateway4: 1.2.3.1
gateway6: fc00::1
id: 1556
link: test_eth0
mtu: 1500
nameservers:
addresses:
- 10.0.0.1
search:
- example.com
- maas
routes:
- metric: 100
to: 10.0.0.0/24
via: 1.2.3.5
test_eth1.1557:
addresses:
- fc00:aa8:7160:d9eb:1:0:1:3/64
id: 1557
link: test_eth1
mtu: 1500
nameservers:
addresses:
- 10.0.0.1
search:
- example.com
- maas

View file

@ -0,0 +1,62 @@
network:
ethernets:
test_eth0:
addresses:
- 1.2.10.4/24
gateway4: 1.2.10.254
match:
macaddress: 52:54:00:b9:ab:93
mtu: 1500
set-name: test_eth0
test_eth1:
match:
macaddress: 52:54:00:8b:0d:93
mtu: 1500
set-name: test_eth1
test_eth3:
addresses:
- abcd:ef12:3456:10::4/64
- 203.0.113.2/24
gateway4: 203.0.113.1
gateway6: abcd:ef12:3456:10::1
match:
macaddress: 52:54:00:8b:cd:93
mtu: 1500
routes:
- metric: 100
to: abcd:ef12:3455:10::/64
via: abcd:ef12:3456:10::1
- metric: 100
to: 10.253.2.0/24
via: 203.0.113.22
set-name: test_eth3
version: 2
vlans:
test_eth0.1556:
addresses:
- 1.2.3.4/24
- 1.2.3.43/24
- fc00::2/64
gateway4: 1.2.3.1
gateway6: fc00::1
id: 1556
link: test_eth0
mtu: 1500
nameservers:
addresses:
- 10.0.0.1
search:
- example.com
- maas
test_eth1.1557:
addresses:
- fc00:aa8:7160:d9eb:1:0:1:3/64
id: 1557
link: test_eth1
mtu: 1500
nameservers:
addresses:
- 10.0.0.1
search:
- example.com
- maas

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100]
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: <nil> Routes: []
Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100]
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: []
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []

View file

@ -0,0 +1,22 @@
network:
version: 2
ethernets:
test_enp1s0:
match:
macaddress: 52:54:00:8b:0d:93
set-name: test_enp1s0
addresses:
- 1.2.3.4/24
gateway4: 1.2.3.1
mtu: 1500
test_enp2s0:
match:
macaddress: 52:54:00:8b:ad:93
set-name: test_enp2s0
addresses:
- fc00:5aa8:7160:d9eb:1:0:1:3/64
routes:
- metric: 200
to: fc00:5aa8:7160:d9eb::1/128
via: fe80::1
mtu: 1500

View file

@ -0,0 +1,25 @@
network:
ethernets:
test_enp1s0:
addresses:
- 1.2.3.4/24
- 1.2.3.5/24
gateway4: 1.2.3.1
match:
macaddress: 52:54:00:8b:0d:93
mtu: 1500
set-name: test_enp1s0
test_enp2s0:
addresses:
- fc00:5aa8:7160:d9eb:1:0:1:3/64
- fc00:5aa8:7160:d9eb:1:0:1:5/64
gateway6: 'fc00:5aa8:7160:d9eb::'
match:
macaddress: 52:54:00:8b:ad:93
mtu: 1500
routes:
- metric: 200
to: fc00:5aa8:7160:d9eb::1/128
via: fe80::1
set-name: test_enp2s0
version: 2

View file

@ -0,0 +1,23 @@
network:
ethernets:
test_enp1s0:
addresses:
- 1.2.3.5/24
gateway4: 1.2.3.1
match:
macaddress: 52:54:00:8b:0d:93
mtu: 1500
set-name: test_enp1s0
test_enp2s0:
addresses:
- fc00:5aa8:7160:d9eb:1:0:1:5/64
gateway6: 'fc00:5aa8:7160:d9eb::'
match:
macaddress: 52:54:00:8b:ad:93
mtu: 1500
routes:
- metric: 200
to: fc00:5aa8:7160:d9eb::1/128
via: fe80::1
set-name: test_enp2s0
version: 2

View file

@ -0,0 +1,22 @@
network:
ethernets:
test_enp1s0:
addresses:
- 1.2.3.5/24
match:
macaddress: 52:54:00:8b:0d:93
mtu: 1500
set-name: test_enp1s0
test_enp2s0:
addresses:
- fc00:5aa8:7160:d9eb:1:0:1:5/64
gateway6: 'fc00:5aa8:7160:d9eb::'
match:
macaddress: 52:54:00:8b:ad:93
mtu: 1500
routes:
- metric: 200
to: fc00:5aa8:7160:d9eb::1/128
via: fe80::1
set-name: test_enp2s0
version: 2

View file

@ -0,0 +1,19 @@
network:
ethernets:
test_enp1s0:
addresses:
- 1.2.3.5/24
gateway4: 1.2.3.1
match:
macaddress: 52:54:00:8b:0d:93
mtu: 1500
set-name: test_enp1s0
test_enp2s0:
addresses:
- fc00:5aa8:7160:d9eb:1:0:1:5/64
gateway6: 'fc00:5aa8:7160:d9eb::'
match:
macaddress: 52:54:00:8b:ad:93
mtu: 1500
set-name: test_enp2s0
version: 2

View file

@ -0,0 +1,23 @@
network:
ethernets:
test_enp1s0:
addresses:
- 1.2.3.5/24
gateway4: 1.2.3.1
match:
macaddress: 52:54:00:8b:0d:93
mtu: 1500
routes:
- metric: 100
to: 10.0.10.0/24
via: 1.2.3.254
set-name: test_enp1s0
test_enp2s0:
addresses:
- fc00:5aa8:7160:d9eb:1:0:1:5/64
gateway6: 'fc00:5aa8:7160:d9eb::'
match:
macaddress: 52:54:00:8b:ad:93
mtu: 1500
set-name: test_enp2s0
version: 2

View file

@ -0,0 +1,2 @@
Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: []
Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200]

View file

@ -0,0 +1,2 @@
Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.4/24 1.2.3.5/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: []
Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64 fc00:5aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: fc00:5aa8:7160:d9eb:: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200]

View file

@ -0,0 +1,2 @@
Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.5/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: []
Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64] Gateway4: <nil> Gateway6: fc00:5aa8:7160:d9eb:: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200]

View file

@ -0,0 +1,2 @@
Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.5/24] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64] Gateway4: <nil> Gateway6: fc00:5aa8:7160:d9eb:: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200]

View file

@ -0,0 +1,2 @@
Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.5/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: []
Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64] Gateway4: <nil> Gateway6: fc00:5aa8:7160:d9eb:: Routes: []

View file

@ -0,0 +1,2 @@
Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.5/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: [10.0.10.0/24 via 1.2.3.254 metric 100]
Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64] Gateway4: <nil> Gateway6: fc00:5aa8:7160:d9eb:: Routes: []

57
tests/ifupdown/interfaces Normal file
View file

@ -0,0 +1,57 @@
iface test_eth0
address 203.0.113.2/24
gateway 203.0.113.1
up /usr/local/bin/etho-up.sh
down /usr/local/bin/etho-down.sh
up ip route add 10.23.1.0/24 via 203.0.113.4 dev eth0
down ip route del 10.23.1.0/24 via 203.0.113.4 dev eth0
auto test_wg0
iface test_wg0
use wireguard
address 1.4.3.4/24
requires eth0
iface test_v6
address 2001:470:1f10::1
iface test_v4
address 203.0.115.2
iface test_eth0.8
address 2001:db8:1000:2::2/64
iface test_servers
vlan-raw-device eth0
vlan-id 8
address 2001:db8:1002:2::2/64
iface test_lo inet loopback
auto test_eth1
iface test_eth1
address 1.2.3.4/24
address abcd:ef12:3456:3::4/64
mtu 8000
auto test_backend
iface test_backend
address 1.2.10.4/24
gateway 1.2.10.1
address abcd:ef12:3456:10::4/64
gateway abcd:ef12:3456:10::1
mtu 8000
vlan-raw-device eth0
vlan_id 5
auto test_eth2
iface test_eth2
address 2001:db8:1003:2::2
netmask 112
gateway 2001:db8:1003:2::1
auto test_eth3
iface test_eth3
address 203.0.114.2
netmask 255.255.255.248
gateway 203.0.114.1

View file

@ -0,0 +1,67 @@
auto test_wg0
iface test_eth0
address 203.0.113.2/24
address fc00::2/64
gateway 203.0.113.6
gateway fc00::1
up /usr/local/bin/etho-up.sh
down /usr/local/bin/etho-down.sh
up ip addr add 203.0.113.3/24 dev $IFACE
down ip addr del 203.0.113.3/24 dev $IFACE
up ip route add 10.23.1.0/24 via 203.0.113.4 metric 256 dev $IFACE
down ip route del 10.23.1.0/24 via 203.0.113.4 metric 256 dev $IFACE
iface test_wg0
use wireguard
address 1.4.3.4/24
requires eth0
iface test_v6
address 2001:470:1f10::1
iface test_v4
address 203.0.115.2
iface test_eth0.8
address 2001:db8:1000:2::2/64
iface test_servers
vlan-raw-device eth0
vlan-id 8
address 2001:db8:1002:2::2/64
iface test_lo inet loopback
auto test_eth1
auto test_backend
iface test_eth1
address 1.2.3.4/24
address abcd:ef12:3456:3::4/64
mtu 8000
up ip -6 route add abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100 dev $IFACE
down ip -6 route del abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100 dev $IFACE
up ip route add 10.253.2.0/24 via 1.2.3.1 metric 100 dev $IFACE
down ip route del 10.253.2.0/24 via 1.2.3.1 metric 100 dev $IFACE
iface test_backend
address 1.2.10.4/24
gateway 1.2.10.1
address abcd:ef12:3456:10::4/64
gateway abcd:ef12:3456:10::1
mtu 8000
vlan-raw-device eth0
vlan_id 5
auto test_eth2
iface test_eth2
address 2001:db8:1003:2::2
netmask 112
gateway 2001:db8:1003:2::1
auto test_eth3
iface test_eth3
address 203.0.114.2
netmask 255.255.255.248
gateway 203.0.114.1

View file

@ -0,0 +1,64 @@
auto test_wg0
iface test_eth0
address 203.0.113.2/24
address fc00::2/64
gateway 203.0.113.6
gateway fc00::1
up /usr/local/bin/etho-up.sh
down /usr/local/bin/etho-down.sh
up ip addr add 203.0.113.3/24 dev $IFACE
down ip addr del 203.0.113.3/24 dev $IFACE
iface test_wg0
use wireguard
address 1.4.3.4/24
requires eth0
iface test_v6
address 2001:470:1f10::1
iface test_v4
address 203.0.115.2
iface test_eth0.8
address 2001:db8:1000:2::2/64
iface test_servers
vlan-raw-device eth0
vlan-id 8
address 2001:db8:1002:2::2/64
iface test_lo inet loopback
auto test_eth1
auto test_backend
iface test_eth1
address 1.2.3.4/24
address abcd:ef12:3456:3::4/64
mtu 8000
up ip -6 route add abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100 dev $IFACE
down ip -6 route del abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100 dev $IFACE
up ip route add 10.253.2.0/24 via 1.2.3.1 metric 100 dev $IFACE
down ip route del 10.253.2.0/24 via 1.2.3.1 metric 100 dev $IFACE
auto test_eth2
iface test_backend
address 1.2.10.4/24
gateway 1.2.10.254
mtu 8000
vlan-raw-device eth0
vlan_id 5
iface test_eth2
address 2001:db8:1003:2::2
netmask 112
gateway 2001:db8:1003:2::1
auto test_eth3
iface test_eth3
address 203.0.114.2
netmask 255.255.255.248
gateway 203.0.114.1

View file

@ -0,0 +1,11 @@
Name: test_eth0 MAC: Addresses: [203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: <nil> Routes: [10.23.1.0/24 via 203.0.113.4 metric 256]
Name: test_wg0 MAC: Addresses: [1.4.3.4/24] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_v6 MAC: Addresses: [2001:470:1f10::1/128] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_v4 MAC: Addresses: [203.0.115.2/32] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth0.8 MAC: Addresses: [2001:db8:1000:2::2/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_servers MAC: Addresses: [2001:db8:1002:2::2/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_lo MAC: Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1 MAC: Addresses: [1.2.3.4/24 abcd:ef12:3456:3::4/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_backend MAC: Addresses: [1.2.10.4/24 abcd:ef12:3456:10::4/64] Gateway4: 1.2.10.1 Gateway6: abcd:ef12:3456:10::1 Routes: []
Name: test_eth2 MAC: Addresses: [2001:db8:1003:2::2/112] Gateway4: <nil> Gateway6: 2001:db8:1003:2::1 Routes: []
Name: test_eth3 MAC: Addresses: [203.0.114.2/29] Gateway4: 203.0.114.1 Gateway6: <nil> Routes: []

View file

@ -0,0 +1,11 @@
Name: test_eth0 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 fc00::2/64] Gateway4: 203.0.113.6 Gateway6: fc00::1 Routes: [10.23.1.0/24 via 203.0.113.4 metric 256]
Name: test_wg0 MAC: Addresses: [1.4.3.4/24] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_v6 MAC: Addresses: [2001:470:1f10::1/128] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_v4 MAC: Addresses: [203.0.115.2/32] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth0.8 MAC: Addresses: [2001:db8:1000:2::2/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_servers MAC: Addresses: [2001:db8:1002:2::2/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_lo MAC: Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1 MAC: Addresses: [1.2.3.4/24 abcd:ef12:3456:3::4/64] Gateway4: <nil> Gateway6: <nil> Routes: [abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100, 10.253.2.0/24 via 1.2.3.1 metric 100]
Name: test_backend MAC: Addresses: [1.2.10.4/24 abcd:ef12:3456:10::4/64] Gateway4: 1.2.10.1 Gateway6: abcd:ef12:3456:10::1 Routes: []
Name: test_eth2 MAC: Addresses: [2001:db8:1003:2::2/112] Gateway4: <nil> Gateway6: 2001:db8:1003:2::1 Routes: []
Name: test_eth3 MAC: Addresses: [203.0.114.2/29] Gateway4: 203.0.114.1 Gateway6: <nil> Routes: []

View file

@ -0,0 +1,11 @@
Name: test_eth0 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 fc00::2/64] Gateway4: 203.0.113.6 Gateway6: fc00::1 Routes: []
Name: test_wg0 MAC: Addresses: [1.4.3.4/24] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_v6 MAC: Addresses: [2001:470:1f10::1/128] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_v4 MAC: Addresses: [203.0.115.2/32] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth0.8 MAC: Addresses: [2001:db8:1000:2::2/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_servers MAC: Addresses: [2001:db8:1002:2::2/64] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_lo MAC: Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1 MAC: Addresses: [1.2.3.4/24 abcd:ef12:3456:3::4/64] Gateway4: <nil> Gateway6: <nil> Routes: [abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100, 10.253.2.0/24 via 1.2.3.1 metric 100]
Name: test_backend MAC: Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: <nil> Routes: []
Name: test_eth2 MAC: Addresses: [2001:db8:1003:2::2/112] Gateway4: <nil> Gateway6: 2001:db8:1003:2::1 Routes: []
Name: test_eth3 MAC: Addresses: [203.0.114.2/29] Gateway4: 203.0.114.1 Gateway6: <nil> Routes: []

View file

@ -0,0 +1,31 @@
network:
version: 2
ethernets:
test_eth0:
match:
macaddress: 52:54:00:b9:ab:93
set-name: test_eth0
mtu: 1500
test_eth1:
match:
macaddress: 52:54:00:8b:0d:93
set-name: test_eth1
mtu: 1500
test_eth3:
gateway4: 203.0.113.1
mtu: 1500
vlans:
test_eth0.1556:
id: 1556
link: test_eth0
addresses:
- 1.2.3.4/24
gateway4: 1.2.3.1
routes:
- to: 10.0.0.0/24
via: 1.2.3.5
metric: 100
nameservers:
search: [example.com, maas]
addresses: [10.0.0.1]
mtu: 1500

View file

@ -0,0 +1,22 @@
network:
version: 2
ethernets:
test_eth3:
match:
macaddress: 52:54:00:8b:cd:93
set-name: test_eth3
addresses:
- abcd:ef12:3456:10::4/64
- 203.0.113.2/24
gateway6: abcd:ef12:3456:10::1
mtu: 1500
vlans:
test_eth1.1557:
id: 1557
link: test_eth1
addresses:
- fc00:aa8:7160:d9eb:1:0:1:3/64
nameservers:
search: [example.com, maas]
addresses: [10.0.0.1]
mtu: 1500

View file

@ -0,0 +1,48 @@
network:
version: 2
ethernets:
test_eth0:
match:
macaddress: 52:54:00:b9:ab:93
set-name: test_eth0
mtu: 1500
test_eth1:
match:
macaddress: 52:54:00:8b:0d:93
set-name: test_eth1
mtu: 1500
test_eth3:
match:
macaddress: 52:54:00:8b:cd:93
set-name: test_eth3
addresses:
- abcd:ef12:3456:10::4/64
- 203.0.113.2/24
gateway4: 203.0.113.1
gateway6: abcd:ef12:3456:10::1
mtu: 1500
routes:
- to: abcd:ef12:3455:10::/64
via: abcd:ef12:3456:10::1
metric: 100
- to: 10.253.2.0/24
via: 203.0.113.22
metric: 100
vlans:
test_eth0.1556:
id: 1556
link: test_eth0
addresses:
- 1.2.3.4/24
- 1.2.3.43/24
- fc00::2/64
gateway4: 1.2.3.1
gateway6: fc00::1
nameservers:
search: [example.com, maas]
addresses: [10.0.0.1]
mtu: 1500
routes:
- to: 10.0.0.0/24
via: 1.2.3.5
metric: 100

View file

@ -0,0 +1,12 @@
network:
version: 2
vlans:
test_eth1.1557:
id: 1557
link: test_eth1
addresses:
- fc00:aa8:7160:d9eb:1:0:1:3/64
nameservers:
search: [example.com, maas]
addresses: [10.0.0.1]
mtu: 1500

View file

@ -0,0 +1,47 @@
network:
version: 2
ethernets:
test_eth0:
match:
macaddress: 52:54:00:b9:ab:93
set-name: test_eth0
addresses:
- 1.2.10.4/24
gateway4: 1.2.10.254
mtu: 1500
test_eth1:
match:
macaddress: 52:54:00:8b:0d:93
set-name: test_eth1
mtu: 1500
test_eth3:
match:
macaddress: 52:54:00:8b:cd:93
set-name: test_eth3
addresses:
- abcd:ef12:3456:10::4/64
- 203.0.113.2/24
gateway4: 203.0.113.1
gateway6: abcd:ef12:3456:10::1
mtu: 1500
routes:
- to: abcd:ef12:3455:10::/64
via: abcd:ef12:3456:10::1
metric: 100
- to: 10.253.2.0/24
via: 203.0.113.22
metric: 100
vlans:
test_eth0.1556:
id: 1556
link: test_eth0
addresses:
- 1.2.3.4/24
- 1.2.3.43/24
- fc00::2/64
gateway4: 1.2.3.1
gateway6: fc00::1
nameservers:
search: [example.com, maas]
addresses: [10.0.0.1]
mtu: 1500

View file

@ -0,0 +1,12 @@
network:
version: 2
vlans:
test_eth1.1557:
id: 1557
link: test_eth1
addresses:
- fc00:aa8:7160:d9eb:1:0:1:3/64
nameservers:
search: [example.com, maas]
addresses: [10.0.0.1]
mtu: 1500

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100]
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: <nil> Routes: []
Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100]
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: []
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: []

4
tests/networkManager/nmcli Executable file
View file

@ -0,0 +1,4 @@
#!/bin/bash
mkdir -p /tmp/networkManager-netconfig-Test
echo $@ >> /tmp/networkManager-netconfig-Test/test

View file

@ -0,0 +1,6 @@
connection modify main ipv4.method manual ipv4.addresses 1.2.3.4/24,1.2.3.43/24 ipv4.gateway 1.2.3.1
connection modify main ipv6.method manual ipv6.addresses fc00::2/64 ipv6.gateway fc00::1
connection modify test ipv4.routes 10.253.2.0/24 203.0.113.22 100
connection modify test ipv6.routes abcd:ef12:3455:10::/64 abcd:ef12:3456:10::1 100
connection modify test_eth0 ipv4.dns 8.8.8.8,1.1.1.1 ipv4.dns-search example.com ipv4.ignore-auto-dns yes
connection modify test_eth0 ipv6.dns 2001:4860:4860::8888 ipv6.dns-search example.com ipv6.ignore-auto-dns yes

View file

@ -0,0 +1,10 @@
connection modify main ipv4.method manual ipv4.addresses 1.2.3.4/24,1.2.3.43/24 ipv4.gateway 1.2.3.1
connection modify main ipv6.method manual ipv6.addresses fc00::2/64 ipv6.gateway fc00::1
connection modify test ipv4.routes 10.253.2.0/24 203.0.113.22 100
connection modify test ipv6.routes abcd:ef12:3455:10::/64 abcd:ef12:3456:10::1 100
connection modify test_eth0 ipv4.dns 8.8.8.8,1.1.1.1 ipv4.dns-search example.com ipv4.ignore-auto-dns yes
connection modify test_eth0 ipv6.dns 2001:4860:4860::8888 ipv6.dns-search example.com ipv6.ignore-auto-dns yes
connection modify test_eth0 ipv4.method manual ipv4.addresses 1.2.10.4/24 ipv4.gateway 1.2.10.254
connection modify test_eth0 ipv6.method link-local ipv6.addresses ipv6.gateway
connection modify main ipv4.routes
connection modify main ipv6.routes

View file

@ -0,0 +1,4 @@
Name: test_eth0 MAC: Addresses: [] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200]
Name: test_eth2 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 abcd:ef12:3456:10::4/64] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: []

View file

@ -0,0 +1,8 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEVICE=test_eth0
HWADDR=52:54:00:66:38:8b
MTU=1500
ONBOOT=yes
TYPE=Ethernet
USERCTL=no

View file

@ -0,0 +1,14 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEFROUTE=yes
DEVICE=test_eth0.1556
DNS1=10.0.0.1
DOMAIN="example.com maas"
GATEWAY=1.2.3.1
IPADDR=1.2.3.4
MTU=1500
NETMASK=255.255.255.0
ONBOOT=yes
PHYSDEV=test_eth0
USERCTL=no
VLAN=yes

View file

@ -0,0 +1,8 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEVICE=test_eth1
HWADDR=52:54:00:fb:84:8d
MTU=1500
ONBOOT=yes
TYPE=Ethernet
USERCTL=no

View file

@ -0,0 +1,24 @@
AUTOCONNECT_PRIORITY=120
DEVICE=test_eth1.1557
IPV6ADDR=fc00:aa8:7160:d9eb:1:0:1:3/64
IPV6INIT=yes
IPV6_AUTOCONF=no
IPV6_FORCE_ACCEPT_RA=no
MTU=1500
ONBOOT=yes
PHYSDEV=test_eth1
USERCTL=no
VLAN=yes
TYPE=Vlan
VLAN_ID=1557
REORDER_HDR=yes
GVRP=no
MVRP=no
HWADDR=
PROXY_METHOD=none
BROWSER_ONLY=no
IPV6_DOMAIN="example.com maas"
IPV6_DEFROUTE=yes
IPV6_FAILURE_FATAL=no
NAME="Vlan test_eth1.1557"
UUID=ed754097-1ede-7ce8-2335-0eb8d1d0dca9

View file

@ -0,0 +1,18 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEVICE=test_eth2
HWADDR=52:54:00:66:28:8b
MTU=1500
ONBOOT=yes
TYPE=Ethernet
USERCTL=no
IPV6ADDR=abcd:ef12:3456:10::4/64
IPV6_DEFAULTGW=abcd:ef12:3456:10::1
IPV6INIT=yes
IPV6_AUTOCONF=no
IPV6_FORCE_ACCEPT_RA=no
GATEWAY=203.0.113.1
IPADDR0=203.0.113.2
NETMASK0=255.255.255.0
IPADDR1=203.0.113.3
PREFIX1=24

View file

@ -0,0 +1,8 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEVICE=test_eth0
HWADDR=52:54:00:66:38:8b
MTU=1500
ONBOOT=yes
TYPE=Ethernet
USERCTL=no

View file

@ -0,0 +1,22 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
BROADCAST0=1.2.3.255
BROADCAST1=1.2.3.255
DEFROUTE=yes
DEVICE=test_eth0.1556
DNS1=10.0.0.1
DOMAIN="example.com maas"
GATEWAY=1.2.3.1
IPADDR0=1.2.3.4
IPADDR1=1.2.3.43
IPV6ADDR=fc00::2/64
IPV6_DEFAULTGW=fc00::1
MTU=1500
NETMASK0=255.255.255.0
NETMASK1=255.255.255.0
ONBOOT=yes
PHYSDEV=test_eth0
PREFIX0=24
PREFIX1=24
USERCTL=no
VLAN=yes

View file

@ -0,0 +1,8 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEVICE=test_eth1
HWADDR=52:54:00:fb:84:8d
MTU=1500
ONBOOT=yes
TYPE=Ethernet
USERCTL=no

View file

@ -0,0 +1,24 @@
AUTOCONNECT_PRIORITY=120
DEVICE=test_eth1.1557
IPV6ADDR=fc00:aa8:7160:d9eb:1:0:1:3/64
IPV6INIT=yes
IPV6_AUTOCONF=no
IPV6_FORCE_ACCEPT_RA=no
MTU=1500
ONBOOT=yes
PHYSDEV=test_eth1
USERCTL=no
VLAN=yes
TYPE=Vlan
VLAN_ID=1557
REORDER_HDR=yes
GVRP=no
MVRP=no
HWADDR=
PROXY_METHOD=none
BROWSER_ONLY=no
IPV6_DOMAIN="example.com maas"
IPV6_DEFROUTE=yes
IPV6_FAILURE_FATAL=no
NAME="Vlan test_eth1.1557"
UUID=ed754097-1ede-7ce8-2335-0eb8d1d0dca9

View file

@ -0,0 +1,18 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEVICE=test_eth2
HWADDR=52:54:00:66:28:8b
MTU=1500
ONBOOT=yes
TYPE=Ethernet
USERCTL=no
IPV6ADDR=abcd:ef12:3456:10::4/64
IPV6_DEFAULTGW=abcd:ef12:3456:10::1
IPV6INIT=yes
IPV6_AUTOCONF=no
IPV6_FORCE_ACCEPT_RA=no
GATEWAY=203.0.113.1
IPADDR0=203.0.113.2
NETMASK0=255.255.255.0
IPADDR1=203.0.113.3
PREFIX1=24

View file

@ -0,0 +1 @@
10.0.0.0/24 via 1.2.3.5 metric 100

View file

@ -0,0 +1 @@
10.253.2.0/24 via 203.0.113.22 metric 100

View file

@ -0,0 +1 @@
fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200

View file

@ -0,0 +1 @@
abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100

View file

@ -0,0 +1,13 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
BROADCAST0=1.2.10.255
DEVICE=test_eth0
GATEWAY=1.2.10.254
HWADDR=52:54:00:66:38:8b
IPADDR0=1.2.10.4
MTU=1500
NETMASK0=255.255.255.0
ONBOOT=yes
PREFIX0=24
TYPE=Ethernet
USERCTL=no

View file

@ -0,0 +1,22 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
BROADCAST0=1.2.3.255
BROADCAST1=1.2.3.255
DEFROUTE=yes
DEVICE=test_eth0.1556
DNS1=10.0.0.1
DOMAIN="example.com maas"
GATEWAY=1.2.3.1
IPADDR0=1.2.3.4
IPADDR1=1.2.3.43
IPV6ADDR=fc00::2/64
IPV6_DEFAULTGW=fc00::1
MTU=1500
NETMASK0=255.255.255.0
NETMASK1=255.255.255.0
ONBOOT=yes
PHYSDEV=test_eth0
PREFIX0=24
PREFIX1=24
USERCTL=no
VLAN=yes

View file

@ -0,0 +1,8 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEVICE=test_eth1
HWADDR=52:54:00:fb:84:8d
MTU=1500
ONBOOT=yes
TYPE=Ethernet
USERCTL=no

View file

@ -0,0 +1,24 @@
AUTOCONNECT_PRIORITY=120
DEVICE=test_eth1.1557
IPV6ADDR=fc00:aa8:7160:d9eb:1:0:1:3/64
IPV6INIT=yes
IPV6_AUTOCONF=no
IPV6_FORCE_ACCEPT_RA=no
MTU=1500
ONBOOT=yes
PHYSDEV=test_eth1
USERCTL=no
VLAN=yes
TYPE=Vlan
VLAN_ID=1557
REORDER_HDR=yes
GVRP=no
MVRP=no
HWADDR=
PROXY_METHOD=none
BROWSER_ONLY=no
IPV6_DOMAIN="example.com maas"
IPV6_DEFROUTE=yes
IPV6_FAILURE_FATAL=no
NAME="Vlan test_eth1.1557"
UUID=ed754097-1ede-7ce8-2335-0eb8d1d0dca9

View file

@ -0,0 +1,18 @@
AUTOCONNECT_PRIORITY=120
BOOTPROTO=none
DEVICE=test_eth2
HWADDR=52:54:00:66:28:8b
MTU=1500
ONBOOT=yes
TYPE=Ethernet
USERCTL=no
IPV6ADDR=abcd:ef12:3456:10::4/64
IPV6_DEFAULTGW=abcd:ef12:3456:10::1
IPV6INIT=yes
IPV6_AUTOCONF=no
IPV6_FORCE_ACCEPT_RA=no
GATEWAY=203.0.113.1
IPADDR0=203.0.113.2
NETMASK0=255.255.255.0
IPADDR1=203.0.113.3
PREFIX1=24

View file

@ -0,0 +1 @@
10.253.2.0/24 via 203.0.113.22 metric 100

View file

@ -0,0 +1 @@
fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200

View file

@ -0,0 +1 @@
abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: <nil> Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1 MAC: Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200]
Name: test_eth2 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 abcd:ef12:3456:10::4/64] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: []

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100]
Name: test_eth1 MAC: Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200]
Name: test_eth2 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 abcd:ef12:3456:10::4/64] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [10.253.2.0/24 via 203.0.113.22 metric 100, abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100]

View file

@ -0,0 +1,5 @@
Name: test_eth0 MAC: Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: <nil> Routes: []
Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: []
Name: test_eth1 MAC: Addresses: [] Gateway4: <nil> Gateway6: <nil> Routes: []
Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: <nil> Gateway6: <nil> Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200]
Name: test_eth2 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 abcd:ef12:3456:10::4/64] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [10.253.2.0/24 via 203.0.113.22 metric 100, abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100]

Some files were not shown because too many files have changed in this diff Show more