Bring an administratively down interface up in AddAddress before writing its addresses and routes. The kernel installs an address's connected route only while its link is up, so adding a gateway to a down interface was rejected as unreachable. A link raised this way is part of the pre-change state AddAddress restores, so it is returned to down on any path that does not complete. Also fix the rollback masking its own error: deleting the address it added takes the default route with it, since that route resolved its nexthop through the address's connected subnet. The following RouteDel then answered ESRCH and was reported in place of "aborted operation due to loss of internet".
461 lines
11 KiB
Go
461 lines
11 KiB
Go
package netconfig
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/prometheus-community/pro-bing"
|
|
)
|
|
|
|
// Handle values that can be an integer or a string.
|
|
type intString struct {
|
|
Int *int
|
|
String *string
|
|
}
|
|
|
|
func (i *intString) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
var asInt int
|
|
var err error
|
|
if err = unmarshal(&asInt); err == nil {
|
|
i.Int = &asInt
|
|
return nil
|
|
}
|
|
var asString string
|
|
if err = unmarshal(&asString); err == nil {
|
|
i.String = &asString
|
|
return nil
|
|
}
|
|
return fmt.Errorf("not valid as an int or a string: %v", err)
|
|
}
|
|
|
|
func (i intString) MarshalYAML() (interface{}, error) {
|
|
if i.Int != nil {
|
|
return *i.Int, nil
|
|
} else if i.String != nil {
|
|
return *i.String, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (i *intString) UnmarshalJSON(data []byte) error {
|
|
var asInt int
|
|
var err error
|
|
if err = json.Unmarshal(data, &asInt); err == nil {
|
|
i.Int = &asInt
|
|
return nil
|
|
}
|
|
var asString string
|
|
if err = json.Unmarshal(data, &asString); err == nil {
|
|
i.String = &asString
|
|
return nil
|
|
}
|
|
return fmt.Errorf("not valid as an int or a string: %v", err)
|
|
}
|
|
|
|
func (i intString) MarshalJSON() ([]byte, error) {
|
|
if i.Int != nil {
|
|
return json.Marshal(*i.Int)
|
|
} else if i.String != nil {
|
|
return json.Marshal(*i.String)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Custom type to sort an directory listing.
|
|
type sortableDirEntries []os.DirEntry
|
|
|
|
func (fil sortableDirEntries) Len() int {
|
|
return len(fil)
|
|
}
|
|
|
|
func (fil sortableDirEntries) Less(i, j int) bool {
|
|
return fil[i].Name() < fil[j].Name()
|
|
}
|
|
|
|
func (fil sortableDirEntries) Swap(i, j int) {
|
|
fil[i], fil[j] = fil[j], fil[i]
|
|
}
|
|
|
|
// Convert an uint32 to net.IP.
|
|
func uint2IP(u uint32) net.IP {
|
|
bs := []byte{0, 0, 0, 0}
|
|
binary.LittleEndian.PutUint32(bs, u)
|
|
return net.IP(bs)
|
|
}
|
|
|
|
// Convert an net.IP to uint32 format.
|
|
func ip2Uint(ip net.IP) uint32 {
|
|
if len(ip) == 16 {
|
|
ip = ip[12:]
|
|
}
|
|
return binary.LittleEndian.Uint32(ip)
|
|
}
|
|
|
|
// Run command and return errors and stdout. The command is bound to ctx, so a
|
|
// cancelled or expired context terminates it.
|
|
func runCommand(ctx context.Context, command string, args ...string) (out []string, err error) {
|
|
cmd := exec.CommandContext(ctx, command, args...)
|
|
hideCommandWindow(cmd)
|
|
|
|
// Get output pipes.
|
|
var stdout, stderr io.ReadCloser
|
|
stdout, err = cmd.StdoutPipe()
|
|
if err != nil {
|
|
return
|
|
}
|
|
stderr, err = cmd.StderrPipe()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Start the command.
|
|
err = cmd.Start()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Setup wait group to wait for buffers to fully read.
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
|
|
// Read stdout.
|
|
stdoutScanner := bufio.NewScanner(stdout)
|
|
go func() {
|
|
for stdoutScanner.Scan() {
|
|
out = append(out, stdoutScanner.Text())
|
|
}
|
|
wg.Done()
|
|
}()
|
|
|
|
// Read stderr.
|
|
var stderrData strings.Builder
|
|
stderrScanner := bufio.NewScanner(stderr)
|
|
go func() {
|
|
for stderrScanner.Scan() {
|
|
line := stderrScanner.Text()
|
|
stderrData.WriteString(line)
|
|
}
|
|
wg.Done()
|
|
}()
|
|
|
|
// Wait for file reads to finish before.
|
|
wg.Wait()
|
|
|
|
// Wait for the command to finish.
|
|
err = cmd.Wait()
|
|
if err != nil {
|
|
stderr := stderrData.String()
|
|
if stderr != "" {
|
|
err = fmt.Errorf("%v (stdout %s)", stderr, strings.Join(out, "\n"))
|
|
}
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
// naturalLess compares two strings the way a person reads them, comparing a run
|
|
// of digits by the number it spells rather than character by character, so eth2
|
|
// sorts before eth10 and enp0s3 before enp0s10. Digit runs are compared without
|
|
// being parsed, so a name carrying a number too large for an integer still
|
|
// orders sensibly.
|
|
func naturalLess(a, b string) bool {
|
|
for a != "" && b != "" {
|
|
if isDigit(a[0]) && isDigit(b[0]) {
|
|
var aNum, bNum string
|
|
aNum, a = leadingDigits(a)
|
|
bNum, b = leadingDigits(b)
|
|
// Leading zeros change the text but not the value.
|
|
aNum = strings.TrimLeft(aNum, "0")
|
|
bNum = strings.TrimLeft(bNum, "0")
|
|
if len(aNum) != len(bNum) {
|
|
return len(aNum) < len(bNum)
|
|
}
|
|
if aNum != bNum {
|
|
return aNum < bNum
|
|
}
|
|
continue
|
|
}
|
|
if a[0] != b[0] {
|
|
return a[0] < b[0]
|
|
}
|
|
a, b = a[1:], b[1:]
|
|
}
|
|
return len(a) < len(b)
|
|
}
|
|
|
|
// isDigit reports whether c is an ASCII digit.
|
|
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
|
|
|
// leadingDigits splits s into its leading run of digits and the rest.
|
|
func leadingDigits(s string) (digits, rest string) {
|
|
i := 0
|
|
for i < len(s) && isDigit(s[i]) {
|
|
i++
|
|
}
|
|
return s[:i], s[i:]
|
|
}
|
|
|
|
// ipStrings converts a list of IPs to their string form, skipping nil entries.
|
|
func ipStrings(ips []net.IP) []string {
|
|
var out []string
|
|
for _, ip := range ips {
|
|
if ip == nil {
|
|
continue
|
|
}
|
|
out = append(out, ip.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// IPv6 has the option to store IPv4 addresses, this is a prefix to determine that.
|
|
var v4InV6Prefix = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}
|
|
|
|
// Confirm that an byte array is all FF.
|
|
func allFF(b []byte) bool {
|
|
for _, c := range b {
|
|
if c != 0xff {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Get broadcast address.
|
|
func getBroadcast(network *net.IPNet) net.IP {
|
|
mask := network.Mask
|
|
ip := network.IP
|
|
|
|
// Ensure mask and IP are same family.
|
|
if len(mask) == net.IPv6len && len(ip) == net.IPv4len && allFF(mask[:12]) {
|
|
mask = mask[12:]
|
|
}
|
|
if len(mask) == net.IPv4len && len(ip) == net.IPv6len && bytes.Equal(ip[:12], v4InV6Prefix) {
|
|
ip = ip[12:]
|
|
}
|
|
n := len(ip)
|
|
|
|
// Make byte array and make broadcast.
|
|
broadCast := make(net.IP, n)
|
|
for i := 0; i < n; i++ {
|
|
invertedMask := mask[i] ^ 0xff
|
|
broadCast[i] = (ip[i] & mask[i]) | invertedMask
|
|
}
|
|
return broadCast
|
|
}
|
|
|
|
// Confirm ping results in packets received. The ping run is bound to ctx, so a
|
|
// cancelled context ends it early. count is the number of probes; timeout is
|
|
// the overall bound on the run.
|
|
func pingTest(ctx context.Context, dest string, count int, timeout time.Duration) bool {
|
|
pinger, err := probing.NewPinger(dest)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if count <= 0 {
|
|
count = defaultPingCount
|
|
}
|
|
pinger.Count = count
|
|
if timeout <= 0 {
|
|
timeout = defaultPingTimeout
|
|
}
|
|
pinger.Timeout = timeout
|
|
err = pinger.RunWithContext(ctx)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
stats := pinger.Statistics()
|
|
if stats.PacketsRecv == 0 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Perform an HTTP request to confirm internet connection. The request is bound
|
|
// to ctx (in addition to a hard timeout), so a cancelled context aborts it. A
|
|
// response is only treated as success when its status code indicates the
|
|
// request was actually served (2xx or 3xx), so a captive portal or error page
|
|
// does not read as connectivity.
|
|
func testInternet(ctx context.Context, testAddress string, timeout time.Duration) bool {
|
|
if testAddress == "test_success" {
|
|
return true
|
|
} else if testAddress == "test_fail" {
|
|
return false
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = defaultConnectivityTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, testAddress, nil)
|
|
if err != nil {
|
|
logger.Printf("internet connectivity test failed: %v", err)
|
|
return false
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
logger.Printf("internet connectivity test failed: %v", err)
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
|
logger.Printf("internet connectivity test failed: status %s", resp.Status)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// A file move wrapper that handles cross device moving.
|
|
func fileMove(source, destination string) error {
|
|
// Attempt to move using normal rename.
|
|
err := os.Rename(source, destination)
|
|
|
|
// If we get a cross device move error, try copying instead.
|
|
if err != nil {
|
|
if le, ok := err.(*os.LinkError); ok && errors.Is(le.Err, syscall.EXDEV) {
|
|
return fileMoveCopy(source, destination)
|
|
}
|
|
// Fallback for implementations that wrap EXDEV differently.
|
|
if errors.Is(err, syscall.EXDEV) {
|
|
return fileMoveCopy(source, destination)
|
|
}
|
|
}
|
|
|
|
// Otherwise return error.
|
|
return err
|
|
}
|
|
|
|
// Copy a file.
|
|
func fileCopy(source, destination string) error {
|
|
// Open the source for reading.
|
|
src, err := os.Open(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Open destination for wrtiting.
|
|
dst, err := os.Create(destination)
|
|
if err != nil {
|
|
src.Close()
|
|
return err
|
|
}
|
|
|
|
// Copy file contents.
|
|
_, err = io.Copy(dst, src)
|
|
|
|
// Close both files.
|
|
src.Close()
|
|
dst.Close()
|
|
|
|
// If copying returned an error, return that error.
|
|
if err != nil {
|
|
os.Remove(destination)
|
|
return err
|
|
}
|
|
|
|
// Get original file permissions.
|
|
fi, err := os.Stat(source)
|
|
if err != nil {
|
|
os.Remove(destination)
|
|
return err
|
|
}
|
|
|
|
// Correct the destination file mode.
|
|
err = os.Chmod(destination, fi.Mode())
|
|
if err != nil {
|
|
os.Remove(destination)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// This moves by copying a file instead of renaming.
|
|
func fileMoveCopy(source, destination string) error {
|
|
// Copy the file.
|
|
err := fileCopy(source, destination)
|
|
if err != nil {
|
|
os.Remove(destination)
|
|
return err
|
|
}
|
|
|
|
// Remove original source.
|
|
os.Remove(source)
|
|
return nil
|
|
}
|
|
|
|
// pruneBackups removes all but the keep most-recently-modified files in dir for
|
|
// which match returns true. A non-positive keep disables pruning (every backup
|
|
// is kept). Errors are logged and otherwise ignored, since leftover backup
|
|
// files are not fatal. Backups are ordered by modification time so the keep
|
|
// newest survive regardless of how the timestamp is encoded in the file name.
|
|
func pruneBackups(dir string, match func(name string) bool, keep int) {
|
|
if keep <= 0 {
|
|
return
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
type backup struct {
|
|
name string
|
|
mtime time.Time
|
|
}
|
|
var backups []backup
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
if match != nil && !match(e.Name()) {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
backups = append(backups, backup{e.Name(), info.ModTime()})
|
|
}
|
|
if len(backups) <= keep {
|
|
return
|
|
}
|
|
sort.Slice(backups, func(i, j int) bool {
|
|
if !backups[i].mtime.Equal(backups[j].mtime) {
|
|
return backups[i].mtime.After(backups[j].mtime)
|
|
}
|
|
return backups[i].name > backups[j].name
|
|
})
|
|
for _, b := range backups[keep:] {
|
|
if err := os.Remove(filepath.Join(dir, b.name)); err != nil {
|
|
logger.Printf("pruneBackups: remove %s: %v", b.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// suffixBackupMatcher matches backups named "base.bak.<ts>" (the netplan,
|
|
// networkd, and cloud-init naming).
|
|
func suffixBackupMatcher(base string) func(string) bool {
|
|
prefix := base + ".bak."
|
|
return func(name string) bool { return strings.HasPrefix(name, prefix) }
|
|
}
|
|
|
|
// prefixBackupMatcher matches backups named ".bak.<ts>.base" (the network-scripts
|
|
// and ifupdown naming).
|
|
func prefixBackupMatcher(base string) func(string) bool {
|
|
return func(name string) bool {
|
|
return strings.HasPrefix(name, ".bak.") && strings.HasSuffix(name, "."+base)
|
|
}
|
|
}
|