first commit
This commit is contained in:
commit
50a535bb7f
123 changed files with 15141 additions and 0 deletions
19
LICENSE
Normal file
19
LICENSE
Normal 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
244
README.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# go-network-configurator
|
||||
|
||||
[](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
1255
cloudinit.go
Normal file
File diff suppressed because it is too large
Load diff
285
cloudinit_test.go
Normal file
285
cloudinit_test.go
Normal 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
1018
configurator_linux.go
Normal file
File diff suppressed because it is too large
Load diff
568
configurator_linux_test.go
Normal file
568
configurator_linux_test.go
Normal 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
1010
configurator_windows.go
Normal file
File diff suppressed because it is too large
Load diff
410
cpanel.go
Normal file
410
cpanel.go
Normal 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
143
cpanel_test.go
Normal 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
145
dns_backends_test.go
Normal 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
154
dns_linux.go
Normal 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
58
dns_windows.go
Normal 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
28
go.mod
Normal 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
48
go.sum
Normal 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
1095
ifupdown.go
Normal file
File diff suppressed because it is too large
Load diff
164
ifupdown_test.go
Normal file
164
ifupdown_test.go
Normal 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
220
interworx.go
Normal 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
999
netplan.go
Normal 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
151
netplan_test.go
Normal 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
272
networkConfigurator.go
Normal 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
349
networkConfigurator_test.go
Normal 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
575
networkManager.go
Normal 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
474
networkManager_test.go
Normal 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
919
networkScripts.go
Normal 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
173
networkScripts_test.go
Normal 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
135
networkScripts_unit_test.go
Normal 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
587
networkd.go
Normal 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
173
networkd_test.go
Normal 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
195
options.go
Normal 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
225
options_test.go
Normal 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
267
plesk_linux.go
Normal 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
267
plesk_windows.go
Normal 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
|
||||
}
|
||||
108
tests/cloudinit/cloudbase/network.json
Normal file
108
tests/cloudinit/cloudbase/network.json
Normal 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
|
||||
}
|
||||
141
tests/cloudinit/cloudbase/results/1/network.json
Normal file
141
tests/cloudinit/cloudbase/results/1/network.json
Normal 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
|
||||
}
|
||||
136
tests/cloudinit/cloudbase/results/2/network.json
Normal file
136
tests/cloudinit/cloudbase/results/2/network.json
Normal 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
|
||||
}
|
||||
5
tests/cloudinit/cloudbase/results/interfaces/1.expected
Normal file
5
tests/cloudinit/cloudbase/results/interfaces/1.expected
Normal 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: []
|
||||
5
tests/cloudinit/cloudbase/results/interfaces/2.expected
Normal file
5
tests/cloudinit/cloudbase/results/interfaces/2.expected
Normal 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]
|
||||
5
tests/cloudinit/cloudbase/results/interfaces/3.expected
Normal file
5
tests/cloudinit/cloudbase/results/interfaces/3.expected
Normal 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]
|
||||
47
tests/cloudinit/network.yaml
Normal file
47
tests/cloudinit/network.yaml
Normal 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
|
||||
63
tests/cloudinit/results/1/network.yaml
Normal file
63
tests/cloudinit/results/1/network.yaml
Normal 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
|
||||
62
tests/cloudinit/results/2/network.yaml
Normal file
62
tests/cloudinit/results/2/network.yaml
Normal 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
|
||||
5
tests/cloudinit/results/interfaces/1.expected
Normal file
5
tests/cloudinit/results/interfaces/1.expected
Normal 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: []
|
||||
5
tests/cloudinit/results/interfaces/2.expected
Normal file
5
tests/cloudinit/results/interfaces/2.expected
Normal 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: []
|
||||
5
tests/cloudinit/results/interfaces/3.expected
Normal file
5
tests/cloudinit/results/interfaces/3.expected
Normal 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: []
|
||||
22
tests/configurator_linux/50-cloud-init.yaml
Normal file
22
tests/configurator_linux/50-cloud-init.yaml
Normal 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
|
||||
25
tests/configurator_linux/results/1/50-cloud-init.yaml
Normal file
25
tests/configurator_linux/results/1/50-cloud-init.yaml
Normal 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
|
||||
23
tests/configurator_linux/results/2/50-cloud-init.yaml
Normal file
23
tests/configurator_linux/results/2/50-cloud-init.yaml
Normal 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
|
||||
22
tests/configurator_linux/results/3/50-cloud-init.yaml
Normal file
22
tests/configurator_linux/results/3/50-cloud-init.yaml
Normal 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
|
||||
19
tests/configurator_linux/results/4/50-cloud-init.yaml
Normal file
19
tests/configurator_linux/results/4/50-cloud-init.yaml
Normal 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
|
||||
23
tests/configurator_linux/results/5/50-cloud-init.yaml
Normal file
23
tests/configurator_linux/results/5/50-cloud-init.yaml
Normal 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
|
||||
2
tests/configurator_linux/results/interfaces/1.expected
Normal file
2
tests/configurator_linux/results/interfaces/1.expected
Normal 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]
|
||||
2
tests/configurator_linux/results/interfaces/2.expected
Normal file
2
tests/configurator_linux/results/interfaces/2.expected
Normal 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]
|
||||
2
tests/configurator_linux/results/interfaces/3.expected
Normal file
2
tests/configurator_linux/results/interfaces/3.expected
Normal 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]
|
||||
2
tests/configurator_linux/results/interfaces/4.expected
Normal file
2
tests/configurator_linux/results/interfaces/4.expected
Normal 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]
|
||||
2
tests/configurator_linux/results/interfaces/5.expected
Normal file
2
tests/configurator_linux/results/interfaces/5.expected
Normal 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: []
|
||||
2
tests/configurator_linux/results/interfaces/6.expected
Normal file
2
tests/configurator_linux/results/interfaces/6.expected
Normal 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
57
tests/ifupdown/interfaces
Normal 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
|
||||
67
tests/ifupdown/results/1/interfaces
Normal file
67
tests/ifupdown/results/1/interfaces
Normal 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
|
||||
64
tests/ifupdown/results/2/interfaces
Normal file
64
tests/ifupdown/results/2/interfaces
Normal 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
|
||||
11
tests/ifupdown/results/interfaces/1.expected
Normal file
11
tests/ifupdown/results/interfaces/1.expected
Normal 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: []
|
||||
11
tests/ifupdown/results/interfaces/2.expected
Normal file
11
tests/ifupdown/results/interfaces/2.expected
Normal 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: []
|
||||
11
tests/ifupdown/results/interfaces/3.expected
Normal file
11
tests/ifupdown/results/interfaces/3.expected
Normal 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: []
|
||||
31
tests/netplan/netplan.yaml
Normal file
31
tests/netplan/netplan.yaml
Normal 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
|
||||
22
tests/netplan/netplan2.yaml
Normal file
22
tests/netplan/netplan2.yaml
Normal 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
|
||||
48
tests/netplan/results/1/netplan.yaml
Normal file
48
tests/netplan/results/1/netplan.yaml
Normal 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
|
||||
12
tests/netplan/results/1/netplan2.yaml
Normal file
12
tests/netplan/results/1/netplan2.yaml
Normal 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
|
||||
47
tests/netplan/results/2/netplan.yaml
Normal file
47
tests/netplan/results/2/netplan.yaml
Normal 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
|
||||
12
tests/netplan/results/2/netplan2.yaml
Normal file
12
tests/netplan/results/2/netplan2.yaml
Normal 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
|
||||
5
tests/netplan/results/interfaces/1.expected
Normal file
5
tests/netplan/results/interfaces/1.expected
Normal 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: []
|
||||
5
tests/netplan/results/interfaces/2.expected
Normal file
5
tests/netplan/results/interfaces/2.expected
Normal 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: []
|
||||
5
tests/netplan/results/interfaces/3.expected
Normal file
5
tests/netplan/results/interfaces/3.expected
Normal 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
4
tests/networkManager/nmcli
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
|
||||
mkdir -p /tmp/networkManager-netconfig-Test
|
||||
echo $@ >> /tmp/networkManager-netconfig-Test/test
|
||||
6
tests/networkManager/results/1/test
Normal file
6
tests/networkManager/results/1/test
Normal 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
|
||||
10
tests/networkManager/results/2/test
Normal file
10
tests/networkManager/results/2/test
Normal 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
|
||||
4
tests/networkManager/results/interfaces/1.expected
Normal file
4
tests/networkManager/results/interfaces/1.expected
Normal 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: []
|
||||
8
tests/networkScripts/ifcfg-test_eth0
Normal file
8
tests/networkScripts/ifcfg-test_eth0
Normal 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
|
||||
14
tests/networkScripts/ifcfg-test_eth0.1556
Normal file
14
tests/networkScripts/ifcfg-test_eth0.1556
Normal 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
|
||||
8
tests/networkScripts/ifcfg-test_eth1
Normal file
8
tests/networkScripts/ifcfg-test_eth1
Normal 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
|
||||
24
tests/networkScripts/ifcfg-test_eth1.1557
Normal file
24
tests/networkScripts/ifcfg-test_eth1.1557
Normal 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
|
||||
18
tests/networkScripts/ifcfg-test_eth2
Normal file
18
tests/networkScripts/ifcfg-test_eth2
Normal 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
|
||||
8
tests/networkScripts/results/1/ifcfg-test_eth0
Normal file
8
tests/networkScripts/results/1/ifcfg-test_eth0
Normal 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
|
||||
22
tests/networkScripts/results/1/ifcfg-test_eth0.1556
Normal file
22
tests/networkScripts/results/1/ifcfg-test_eth0.1556
Normal 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
|
||||
8
tests/networkScripts/results/1/ifcfg-test_eth1
Normal file
8
tests/networkScripts/results/1/ifcfg-test_eth1
Normal 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
|
||||
24
tests/networkScripts/results/1/ifcfg-test_eth1.1557
Normal file
24
tests/networkScripts/results/1/ifcfg-test_eth1.1557
Normal 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
|
||||
18
tests/networkScripts/results/1/ifcfg-test_eth2
Normal file
18
tests/networkScripts/results/1/ifcfg-test_eth2
Normal 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
|
||||
1
tests/networkScripts/results/1/route-test_eth0.1556
Normal file
1
tests/networkScripts/results/1/route-test_eth0.1556
Normal file
|
|
@ -0,0 +1 @@
|
|||
10.0.0.0/24 via 1.2.3.5 metric 100
|
||||
1
tests/networkScripts/results/1/route-test_eth2
Normal file
1
tests/networkScripts/results/1/route-test_eth2
Normal file
|
|
@ -0,0 +1 @@
|
|||
10.253.2.0/24 via 203.0.113.22 metric 100
|
||||
1
tests/networkScripts/results/1/route6-test_eth1.1557
Normal file
1
tests/networkScripts/results/1/route6-test_eth1.1557
Normal file
|
|
@ -0,0 +1 @@
|
|||
fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200
|
||||
1
tests/networkScripts/results/1/route6-test_eth2
Normal file
1
tests/networkScripts/results/1/route6-test_eth2
Normal file
|
|
@ -0,0 +1 @@
|
|||
abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100
|
||||
13
tests/networkScripts/results/2/ifcfg-test_eth0
Normal file
13
tests/networkScripts/results/2/ifcfg-test_eth0
Normal 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
|
||||
22
tests/networkScripts/results/2/ifcfg-test_eth0.1556
Normal file
22
tests/networkScripts/results/2/ifcfg-test_eth0.1556
Normal 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
|
||||
8
tests/networkScripts/results/2/ifcfg-test_eth1
Normal file
8
tests/networkScripts/results/2/ifcfg-test_eth1
Normal 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
|
||||
24
tests/networkScripts/results/2/ifcfg-test_eth1.1557
Normal file
24
tests/networkScripts/results/2/ifcfg-test_eth1.1557
Normal 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
|
||||
18
tests/networkScripts/results/2/ifcfg-test_eth2
Normal file
18
tests/networkScripts/results/2/ifcfg-test_eth2
Normal 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
|
||||
1
tests/networkScripts/results/2/route-test_eth2
Normal file
1
tests/networkScripts/results/2/route-test_eth2
Normal file
|
|
@ -0,0 +1 @@
|
|||
10.253.2.0/24 via 203.0.113.22 metric 100
|
||||
1
tests/networkScripts/results/2/route6-test_eth1.1557
Normal file
1
tests/networkScripts/results/2/route6-test_eth1.1557
Normal file
|
|
@ -0,0 +1 @@
|
|||
fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200
|
||||
1
tests/networkScripts/results/2/route6-test_eth2
Normal file
1
tests/networkScripts/results/2/route6-test_eth2
Normal file
|
|
@ -0,0 +1 @@
|
|||
abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100
|
||||
5
tests/networkScripts/results/interfaces/1.expected
Normal file
5
tests/networkScripts/results/interfaces/1.expected
Normal 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: []
|
||||
5
tests/networkScripts/results/interfaces/2.expected
Normal file
5
tests/networkScripts/results/interfaces/2.expected
Normal 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]
|
||||
5
tests/networkScripts/results/interfaces/3.expected
Normal file
5
tests/networkScripts/results/interfaces/3.expected
Normal 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
Loading…
Add table
Reference in a new issue