From aba1e2bc760a59d80a3b75b2c8d530ee8aedc2c3 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sat, 6 Sep 2025 18:29:47 -0400 Subject: [PATCH 01/33] inital push of some walt stuff (stubbed readme, main entry) --- .gitignore | 21 +++++++++++++++++++++ walt/README.md | 33 +++++++++++++++++++++++++++++++++ walt/go.mod | 3 +++ walt/main.go | 31 +++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 walt/README.md create mode 100644 walt/go.mod create mode 100644 walt/main.go diff --git a/.gitignore b/.gitignore index d5c5853f..275a1480 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,24 @@ bin/ ### Mac OS ### .DS_Store + +### Go ### + +# Ignore only build artifacts inside walt/, not the source code +/walt/walt +/walt/walt-* +/walt/*.test +/walt/*.out +/walt/*.log +/walt/*.prof +/walt/*.pprof + +*.exe +*.exe~ +*.dll +*.so +*.dylib + +bin/ +pkg/ + diff --git a/walt/README.md b/walt/README.md new file mode 100644 index 00000000..b720f578 --- /dev/null +++ b/walt/README.md @@ -0,0 +1,33 @@ +# World Aliasing Loopback Tool (`WALT`, macOS Only) + +This module contains a simple utility for macOS users that enables the RSProx loopback mechanism to function properly. If you are **not** a macOS user - thanks for stopping by. If you are, keep reading! + + +## Motivation +In legacy releases, macOS users would have to manually manage and run this script: + +```bash +#!/bin/bash +set -euo pipefail + +# === Config ==================================================== +MIN_WORLD_ID=300 # Minimum world id to whitelist, inclusive. +MAX_WORLD_ID=650 # Maximum world id to whitelist, inclusive. +GROUP_ID=2 # Proxy target (2 is Oldschool, 3 is first custom, etc). +MODE=+ # "+" to whitelist, "-" to un-whitelist +# =============================================================== + +# === some sudo ifconfig stuff ... ============================== +... + +echo "Alias IPs added for worlds $MIN_WORLD_ID..$MAX_WORLD_ID (group $GROUP_ID)." +``` + +`WALT` puts some formality around this script and exposes it through an easy-to-use and well-documented +CLI. + +## Installation + +`WALT` can be installed via homebrew. + +> homebrew instructions later. \ No newline at end of file diff --git a/walt/go.mod b/walt/go.mod new file mode 100644 index 00000000..39b5abf5 --- /dev/null +++ b/walt/go.mod @@ -0,0 +1,3 @@ +module rsprox/walt + +go 1.25.1 diff --git a/walt/main.go b/walt/main.go new file mode 100644 index 00000000..c0ca34e1 --- /dev/null +++ b/walt/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "os" + "runtime" +) + +/* +The default parameters from the legacy shell script. + +Note that we intentionally constrain the world range to be a single world by default, +so as not to bog down any networking by default on the host. +*/ +const ( + defaultMinWorld = 255 + defaultMaxWorld = 255 + deafultGroup = 2 +) + +func checkOs() { + if runtime.GOOS != "darwin" { + fmt.Fprintln(os.Stderr, "WALT is only supported on (and required by) macOS devices.") + os.Exit(1) + } +} + +func main() { + checkOs() + fmt.Fprintln(os.Stdout, "All good.") +} From 9b40c4ab479d952203193b6ebdc417756e0a72bc Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sat, 6 Sep 2025 22:28:15 -0400 Subject: [PATCH 02/33] use cobra for cli development + cobra-cli for scaffolding --- walt/LICENSE | 21 +++++++++++ walt/cmd/root.go | 97 ++++++++++++++++++++++++++++++++++++++++++++++++ walt/go.mod | 7 ++++ walt/go.sum | 11 ++++++ walt/main.go | 28 +------------- 5 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 walt/LICENSE create mode 100644 walt/cmd/root.go create mode 100644 walt/go.sum diff --git a/walt/LICENSE b/walt/LICENSE new file mode 100644 index 00000000..a732de7c --- /dev/null +++ b/walt/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright © 2025 David O'Neill + +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. diff --git a/walt/cmd/root.go b/walt/cmd/root.go new file mode 100644 index 00000000..4c0e8825 --- /dev/null +++ b/walt/cmd/root.go @@ -0,0 +1,97 @@ +/* +Copyright © 2025 David O'Neill + +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. +*/ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +// default parameters from the legacy shell script. +// Note that we intentionally constrain the world range to be a single world by default, +// so as not to bog down networking on the host. +const ( + defaultMinWorld = 255 + defaultMaxWorld = 255 + defaultGroup = 2 +) + +var ( + minWorld int + maxWorld int + group int +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "walt", + Short: "Manage loopback address aliases for RSProx on macOS.", + Long: `An easy-to-use CLI tool for managing loopback address aliases on macOS. Intended +to be used in parallel with RSProx.`, + PersistentPreRun: rootPreRun, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().IntVarP( + &minWorld, + "min", + "m", + defaultMinWorld, + "world number lower bound (inclusive)", + ) + rootCmd.PersistentFlags().IntVarP( + &maxWorld, + "max", + "M", + defaultMaxWorld, + "world number upper bound (inclusive)", + ) + rootCmd.PersistentFlags().IntVarP( + &maxWorld, + "max", + "M", + defaultMaxWorld, + "world number upper bound (inclusive)", + ) + rootCmd.PersistentFlags().IntVarP( + &group, + "group", + "g", + defaultGroup, + "group ID (2 => OSRS, 3+ => custom targets)", + ) +} + +func rootPreRun(command *cobra.Command, args []string) { + // Need to do setup checks - ensure macOS, parameter validation + // Will declare and implement these functions in the internals. +} diff --git a/walt/go.mod b/walt/go.mod index 39b5abf5..e83a12ae 100644 --- a/walt/go.mod +++ b/walt/go.mod @@ -1,3 +1,10 @@ module rsprox/walt go 1.25.1 + +require github.com/spf13/cobra v1.10.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) diff --git a/walt/go.sum b/walt/go.sum new file mode 100644 index 00000000..989827e1 --- /dev/null +++ b/walt/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/walt/main.go b/walt/main.go index c0ca34e1..9bb169f5 100644 --- a/walt/main.go +++ b/walt/main.go @@ -1,31 +1,7 @@ package main -import ( - "fmt" - "os" - "runtime" -) - -/* -The default parameters from the legacy shell script. - -Note that we intentionally constrain the world range to be a single world by default, -so as not to bog down any networking by default on the host. -*/ -const ( - defaultMinWorld = 255 - defaultMaxWorld = 255 - deafultGroup = 2 -) - -func checkOs() { - if runtime.GOOS != "darwin" { - fmt.Fprintln(os.Stderr, "WALT is only supported on (and required by) macOS devices.") - os.Exit(1) - } -} +import "rsprox/walt/cmd" func main() { - checkOs() - fmt.Fprintln(os.Stdout, "All good.") + cmd.Execute() } From ce0b00e5ec36fc7c1a38c77be432591a3ef2cdc7 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sat, 6 Sep 2025 22:32:05 -0400 Subject: [PATCH 03/33] duplicate persistent flag removed --- walt/cmd/root.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/walt/cmd/root.go b/walt/cmd/root.go index 4c0e8825..9296bb08 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -75,13 +75,6 @@ func init() { defaultMaxWorld, "world number upper bound (inclusive)", ) - rootCmd.PersistentFlags().IntVarP( - &maxWorld, - "max", - "M", - defaultMaxWorld, - "world number upper bound (inclusive)", - ) rootCmd.PersistentFlags().IntVarP( &group, "group", From bd421606e243f1a8159253f0b1e43c47cb5b4f39 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sat, 6 Sep 2025 23:30:02 -0400 Subject: [PATCH 04/33] stub out some internals stuff for networking and elevated command execution (sudo) --- walt/internal/networking.go | 28 ++++++++++++++++++++++++++++ walt/internal/sudo.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 walt/internal/networking.go create mode 100644 walt/internal/sudo.go diff --git a/walt/internal/networking.go b/walt/internal/networking.go new file mode 100644 index 00000000..b4843444 --- /dev/null +++ b/walt/internal/networking.go @@ -0,0 +1,28 @@ +package internal + +import ( + "runtime" + "strconv" + "strings" +) + +func EnsureMac() { + if runtime.GOOS != "Darwin" { + panic("walt is only intended to be use on macOS") + } +} + +func IPForWorld(world, group int) string { + a := world / 256 + b := world % 256 + return strings.Join([]string{ + "127", + strconv.Itoa(a), + strconv.Itoa(b), + strconv.Itoa(group), + }, ".") +} + +func Alias(ip string) error { + return RunElevated("ifconfig", true, "lo0", "alias", ip) +} diff --git a/walt/internal/sudo.go b/walt/internal/sudo.go new file mode 100644 index 00000000..965c44ba --- /dev/null +++ b/walt/internal/sudo.go @@ -0,0 +1,35 @@ +package internal + +import ( + "bytes" + "errors" + "os" + "os/exec" + "strings" +) + +// Runs a command (name args...) as sudo. If interactive, will re-prompt for sudo password if required. +func RunElevated(name string, interactive bool, args ...string) error { + var all []string + if interactive { + all = []string{"-p", "[walt] sudo password for %u:", name} + } else { + all = []string{"-n", name} + } + all = append(all, args...) + + cmd := exec.Command("sudo", all...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if msg := strings.TrimSpace(stderr.String()); msg == "" { + return errors.New(msg) + } + return err + } + return nil +} From 2ac7a12bbd9fa39efb8e52a084fcabf1307ae609 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 10:44:24 -0400 Subject: [PATCH 05/33] registry definition (registry => file that tracks aliased loopback addresses) --- walt/internal/networking.go | 36 ++++++++++++++++++++- walt/internal/registry.go | 63 +++++++++++++++++++++++++++++++++++++ walt/internal/sudo.go | 4 +-- 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 walt/internal/registry.go diff --git a/walt/internal/networking.go b/walt/internal/networking.go index b4843444..03b8a927 100644 --- a/walt/internal/networking.go +++ b/walt/internal/networking.go @@ -1,7 +1,9 @@ package internal import ( + "os/exec" "runtime" + "sort" "strconv" "strings" ) @@ -24,5 +26,37 @@ func IPForWorld(world, group int) string { } func Alias(ip string) error { - return RunElevated("ifconfig", true, "lo0", "alias", ip) + return RunElevated(true, "ifconfig", "lo0", "alias", ip) +} + +func Unalias(ip string) error { + err := RunElevated(true, "ifconfig", "l0", "-alias", ip) + // if the address isn't aliased, an error will be thrown with this message - non-fatal. + if err != nil && strings.Contains(err.Error(), "cannot assign requested address") { + return nil + } + return err +} + +// Incrementally sorts and fetches all the currently aliased local addresses, not including the default 127.0.0.1 +func GetLiveAliases() ([]string, error) { + out, err := exec.Command("ifconfig", "l0").CombinedOutput() + if err != nil { + return nil, err + } + var ips []string + for ln := range strings.SplitSeq(string(out), "\n") { + ln = strings.TrimSpace(ln) + if strings.HasPrefix(ln, "inet") { + fields := strings.Fields(ln) + if len(fields) >= 2 { + ip := fields[1] + if strings.HasPrefix(ip, "127.") && ip != "127.0.0.1" { + ips = append(ips, ip) + } + } + } + } + sort.Strings(ips) + return ips, nil } diff --git a/walt/internal/registry.go b/walt/internal/registry.go new file mode 100644 index 00000000..2863bac4 --- /dev/null +++ b/walt/internal/registry.go @@ -0,0 +1,63 @@ +package internal + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" +) + +type _void struct{} + +var void _void + +func Load() ([]string, error) { + path, err := checkRegistryPath() + if err != nil { + return nil, err + } + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + if err != nil { + return nil, err + } + seen := make(map[string]_void) + for ln := range strings.SplitSeq(string(b), "\n") { + ip := strings.TrimSpace(ln) + if ip != "" { + seen[ip] = void + } + } + out := make([]string, 0, len(seen)) + for ip := range seen { + out = append(out, ip) + } + sort.Strings(out) + return out, nil +} + +func getRegistryPath() (string, error) { + if stateHome := os.Getenv("XDG_STATE_HOME"); stateHome != "" { + return filepath.Join(stateHome, "walt", "aliases.txt"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "state", "walt", "aliases.txt"), nil +} + +func checkRegistryPath() (string, error) { + path, err := getRegistryPath() + baseMessage := "Failed to create alias list file in local state directory: " + if err != nil { + return "", errors.New(baseMessage + err.Error()) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", errors.New(baseMessage + err.Error()) + } + return path, nil +} diff --git a/walt/internal/sudo.go b/walt/internal/sudo.go index 965c44ba..a680dd7a 100644 --- a/walt/internal/sudo.go +++ b/walt/internal/sudo.go @@ -8,8 +8,8 @@ import ( "strings" ) -// Runs a command (name args...) as sudo. If interactive, will re-prompt for sudo password if required. -func RunElevated(name string, interactive bool, args ...string) error { +// Runs a command (name args...) as sudo. If interactive, will re-prompt for sudo password if required (very unlikely). +func RunElevated(interactive bool, name string, args ...string) error { var all []string if interactive { all = []string{"-p", "[walt] sudo password for %u:", name} From 6fdae78c1b6b59040c4377c1e7531ebd06050e1a Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 15:21:48 -0400 Subject: [PATCH 06/33] completed registry (load, save, append, remove, clear) --- walt/internal/registry.go | 117 ++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/walt/internal/registry.go b/walt/internal/registry.go index 2863bac4..6a6182ec 100644 --- a/walt/internal/registry.go +++ b/walt/internal/registry.go @@ -1,22 +1,22 @@ package internal import ( + "bufio" "errors" "os" "path/filepath" + "slices" "sort" "strings" ) -type _void struct{} - -var void _void - +// Reads from the loopback alias registry file and returns all the entries func Load() ([]string, error) { - path, err := checkRegistryPath() + path, err := registryPath() if err != nil { return nil, err } + b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return []string{}, nil @@ -24,13 +24,15 @@ func Load() ([]string, error) { if err != nil { return nil, err } - seen := make(map[string]_void) + + seen := make(map[string]struct{}) for ln := range strings.SplitSeq(string(b), "\n") { ip := strings.TrimSpace(ln) if ip != "" { - seen[ip] = void + seen[ip] = struct{}{} } } + out := make([]string, 0, len(seen)) for ip := range seen { out = append(out, ip) @@ -39,7 +41,101 @@ func Load() ([]string, error) { return out, nil } -func getRegistryPath() (string, error) { +// Saves all supplied loopback IPs to the loopback alias registry file +func Save(ips []string) error { + path, err := ensureRegistryPath() + if err != nil { + return err + } + + seen := make(map[string]struct{}, len(ips)) + for _, ip := range ips { + if ip = strings.TrimSpace(ip); ip != "" { + seen[ip] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for ip := range seen { + out = append(out, ip) + } + sort.Strings(out) + + data := strings.Join(out, "\n") + if data != "" { + data += "\n" + } + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + return err + } + return nil +} + +// Appends a single IP to the loopback alias registry file +func Append(ip string) error { + path, err := ensureRegistryPath() + if err != nil { + return err + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return err + } + + defer f.Close() + + s := bufio.NewScanner(f) + for s.Scan() { + if strings.TrimSpace(s.Text()) == ip { + // already present + return nil + } + } + + _, err = f.WriteString(ip + "\n") + return err +} + +// Removes an entry from the loopback alias registry file. If empty after removal, torch the file. +func Remove(ip string) error { + ips, err := Load() + if err != nil { + return err + } + + ips = slices.DeleteFunc(ips, func(a string) bool { + return a == ip + }) + if len(ips) == 0 { + // already called Load(), no need to explicit error handle + path, _ := registryPath() + if path != "" { + os.Remove(path) + } + return nil + } + + return Save(ips) +} + +// Deletes the registry file entirely. +func Clear() error { + path, err := registryPath() + if err != nil { + return err + } + + if path != "" { + if err := os.Remove(path); err != nil { + return err + } + } + return nil +} + +// Gets the theoretically correct path to the registry file. Does not ensure existence. +func registryPath() (string, error) { if stateHome := os.Getenv("XDG_STATE_HOME"); stateHome != "" { return filepath.Join(stateHome, "walt", "aliases.txt"), nil } @@ -50,8 +146,9 @@ func getRegistryPath() (string, error) { return filepath.Join(home, ".local", "state", "walt", "aliases.txt"), nil } -func checkRegistryPath() (string, error) { - path, err := getRegistryPath() +// Ensures existence of registry file. +func ensureRegistryPath() (string, error) { + path, err := registryPath() baseMessage := "Failed to create alias list file in local state directory: " if err != nil { return "", errors.New(baseMessage + err.Error()) From 346898cad47a801fea39216867327fa91a5acc24 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 16:29:36 -0400 Subject: [PATCH 07/33] add, remove, and sync command implementations. however - will need to backtrack. networking and registry internals do not operate bidrectionally.. need to have better syncing implementation. --- walt/cmd/add.go | 40 +++++++++++++++++++++++++++++ walt/cmd/remove.go | 51 +++++++++++++++++++++++++++++++++++++ walt/cmd/root.go | 17 +++++++++---- walt/cmd/sync.go | 13 ++++++++++ walt/internal/networking.go | 18 +++++++------ 5 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 walt/cmd/add.go create mode 100644 walt/cmd/remove.go create mode 100644 walt/cmd/sync.go diff --git a/walt/cmd/add.go b/walt/cmd/add.go new file mode 100644 index 00000000..e153f2ec --- /dev/null +++ b/walt/cmd/add.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + "rsprox/walt/internal" + + "github.com/spf13/cobra" +) + +var addCmd = &cobra.Command{ + Use: "add", + Short: "Add a whitelisted loopback alias", + Run: func(cmd *cobra.Command, args []string) { + added, warnCount, errCount := 0, 0, 0 + syncNeeded := false + for w := minWorld; w <= maxWorld; w++ { + ip := internal.IPForWorld(w, group) + if err := internal.Alias(ip); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error: alias create for %s failed: %v\n", ip, err) + errCount++ + continue + } + if err := internal.Append(ip); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error: alias %s was added but the registry failed to update - use the sync command. error: %v\n", ip, err) + warnCount++ + } + added++ + } + fmt.Fprintf(cmd.OutOrStdout(), "Added %d aliases for %d..%d (group %d).", added, minWorld, maxWorld, group) + fmt.Fprintf(cmd.OutOrStdout(), "(%d) warnings\n", warnCount) + fmt.Fprintf(cmd.ErrOrStderr(), "(%d) errors\n", errCount) + if syncNeeded { + fmt.Fprintln(cmd.OutOrStdout(), "One or more of your warnings were related to registry synchronization. Use the sync command to fix.") + } + }, +} + +func init() { + rootCmd.AddCommand(addCmd) +} diff --git a/walt/cmd/remove.go b/walt/cmd/remove.go new file mode 100644 index 00000000..c966f188 --- /dev/null +++ b/walt/cmd/remove.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + "rsprox/walt/internal" + + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a whitelisted loopback alias", + Run: func(cmd *cobra.Command, args []string) { + removed, warnCount, errCount := 0, 0, 0 + syncNeeded := false + for w := minWorld; w <= maxWorld; w++ { + ip := internal.IPForWorld(w, group) + status, err := internal.Unalias(ip) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error: alias %s failed: %v\n", ip, err) + errCount++ + continue + } + if !status { + fmt.Fprintf(cmd.ErrOrStderr(), "warn: alias %s not currently set - nothing to remove\n", ip) + warnCount++ + continue + } + if err := internal.Remove(ip); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warn: alias %s was removed but the registry failed to update - use the sync command. error: %v\n", ip, err) + warnCount++ + syncNeeded = true + } + removed++ + } + if removed > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Successfully removed %d aliases for %d..%d (group %d)\n", removed, minWorld, maxWorld, group) + } else { + fmt.Fprintln(cmd.OutOrStdout(), "No aliases removed") + } + fmt.Fprintf(cmd.OutOrStdout(), "(%d) warnings\n", warnCount) + fmt.Fprintf(cmd.ErrOrStderr(), "(%d) errors\n", errCount) + if syncNeeded { + fmt.Fprintln(cmd.OutOrStdout(), "One or more of your warnings were related to registry synchronization. Use the sync command to fix.") + } + }, +} + +func init() { + rootCmd.AddCommand(removeCmd) +} diff --git a/walt/cmd/root.go b/walt/cmd/root.go index 9296bb08..dfdacb92 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -22,7 +22,9 @@ THE SOFTWARE. package cmd import ( + "fmt" "os" + "rsprox/walt/internal" "github.com/spf13/cobra" ) @@ -54,8 +56,8 @@ to be used in parallel with RSProx.`, // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - err := rootCmd.Execute() - if err != nil { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } @@ -80,11 +82,16 @@ func init() { "group", "g", defaultGroup, - "group ID (2 => OSRS, 3+ => custom targets)", + "group ID (2 => OSRS (DO NOT USE! Will fail), 3+ => custom targets)", ) } func rootPreRun(command *cobra.Command, args []string) { - // Need to do setup checks - ensure macOS, parameter validation - // Will declare and implement these functions in the internals. + internal.EnsureMac() + if minWorld > maxWorld { + minWorld, maxWorld = maxWorld, minWorld + } + if group < 3 { + panic(fmt.Errorf("macOS requires group >= 3, got %d", group)) + } } diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go new file mode 100644 index 00000000..ee72016f --- /dev/null +++ b/walt/cmd/sync.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Ensure all registered loopback aliases exist on lo0", + Run: func(cmd *cobra.Command, args []string) { + + }, +} diff --git a/walt/internal/networking.go b/walt/internal/networking.go index 03b8a927..7b86939e 100644 --- a/walt/internal/networking.go +++ b/walt/internal/networking.go @@ -9,7 +9,7 @@ import ( ) func EnsureMac() { - if runtime.GOOS != "Darwin" { + if runtime.GOOS != "darwin" { panic("walt is only intended to be use on macOS") } } @@ -29,18 +29,20 @@ func Alias(ip string) error { return RunElevated(true, "ifconfig", "lo0", "alias", ip) } -func Unalias(ip string) error { - err := RunElevated(true, "ifconfig", "l0", "-alias", ip) - // if the address isn't aliased, an error will be thrown with this message - non-fatal. - if err != nil && strings.Contains(err.Error(), "cannot assign requested address") { - return nil +func Unalias(ip string) (bool, error) { + if err := RunElevated(true, "ifconfig", "lo0", "-alias", ip); err != nil { + // if the address isn't aliased, an error will be thrown with this message - non-fatal. + if strings.Contains(err.Error(), "cannot assign requested address") { + return false, nil + } + return false, err } - return err + return true, nil } // Incrementally sorts and fetches all the currently aliased local addresses, not including the default 127.0.0.1 func GetLiveAliases() ([]string, error) { - out, err := exec.Command("ifconfig", "l0").CombinedOutput() + out, err := exec.Command("ifconfig", "lo0").CombinedOutput() if err != nil { return nil, err } From ddca0dff610ce94e5ce6f86e37664b067f28ce99 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 16:57:57 -0400 Subject: [PATCH 08/33] atomic registry writes using tmp file + rename, similar to like a lock --- walt/cmd/add.go | 15 +++++++++---- walt/cmd/remove.go | 10 +++++---- walt/internal/networking.go | 11 +++++++-- walt/internal/registry.go | 45 ++++++++++++++++++++----------------- 4 files changed, 51 insertions(+), 30 deletions(-) diff --git a/walt/cmd/add.go b/walt/cmd/add.go index e153f2ec..20a69f23 100644 --- a/walt/cmd/add.go +++ b/walt/cmd/add.go @@ -15,14 +15,21 @@ var addCmd = &cobra.Command{ syncNeeded := false for w := minWorld; w <= maxWorld; w++ { ip := internal.IPForWorld(w, group) - if err := internal.Alias(ip); err != nil { + status, err := internal.Alias(ip) + if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "error: alias create for %s failed: %v\n", ip, err) errCount++ continue } - if err := internal.Append(ip); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "error: alias %s was added but the registry failed to update - use the sync command. error: %v\n", ip, err) + if !status { + fmt.Fprintf(cmd.ErrOrStderr(), "warn: alias %s already set\n", ip) warnCount++ + continue + } + if err := internal.Append(ip); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error: alias %s was added but the registry failed to update. error: %v\n", ip, err) + syncNeeded = true + errCount++ } added++ } @@ -30,7 +37,7 @@ var addCmd = &cobra.Command{ fmt.Fprintf(cmd.OutOrStdout(), "(%d) warnings\n", warnCount) fmt.Fprintf(cmd.ErrOrStderr(), "(%d) errors\n", errCount) if syncNeeded { - fmt.Fprintln(cmd.OutOrStdout(), "One or more of your warnings were related to registry synchronization. Use the sync command to fix.") + fmt.Fprintln(cmd.OutOrStdout(), "One or more of your errors were related to registry synchronization; Try `walt sync --mode=merge` to fix.") } }, } diff --git a/walt/cmd/remove.go b/walt/cmd/remove.go index c966f188..c9ccd93e 100644 --- a/walt/cmd/remove.go +++ b/walt/cmd/remove.go @@ -17,18 +17,20 @@ var removeCmd = &cobra.Command{ ip := internal.IPForWorld(w, group) status, err := internal.Unalias(ip) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "error: alias %s failed: %v\n", ip, err) + fmt.Fprintf(cmd.ErrOrStderr(), "error: unalias %s failed: %v\n", ip, err) errCount++ continue } if !status { fmt.Fprintf(cmd.ErrOrStderr(), "warn: alias %s not currently set - nothing to remove\n", ip) warnCount++ + // still attempt to remove from registry to reduce potential drift + internal.Remove(ip) continue } if err := internal.Remove(ip); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "warn: alias %s was removed but the registry failed to update - use the sync command. error: %v\n", ip, err) - warnCount++ + fmt.Fprintf(cmd.ErrOrStderr(), "error: alias %s was removed but the registry failed to update - use the sync command. error: %v\n", ip, err) + errCount++ syncNeeded = true } removed++ @@ -41,7 +43,7 @@ var removeCmd = &cobra.Command{ fmt.Fprintf(cmd.OutOrStdout(), "(%d) warnings\n", warnCount) fmt.Fprintf(cmd.ErrOrStderr(), "(%d) errors\n", errCount) if syncNeeded { - fmt.Fprintln(cmd.OutOrStdout(), "One or more of your warnings were related to registry synchronization. Use the sync command to fix.") + fmt.Fprintln(cmd.OutOrStdout(), "One or more of your errors were related to registry synchronization; Try `walt sync --mode=merge` to fix.") } }, } diff --git a/walt/internal/networking.go b/walt/internal/networking.go index 7b86939e..de7c359c 100644 --- a/walt/internal/networking.go +++ b/walt/internal/networking.go @@ -25,8 +25,15 @@ func IPForWorld(world, group int) string { }, ".") } -func Alias(ip string) error { - return RunElevated(true, "ifconfig", "lo0", "alias", ip) +func Alias(ip string) (bool, error) { + if err := RunElevated(true, "ifconfig", "lo0", "alias", ip); err != nil { + // if the address is already aliased, an error will be thrown with this message - non-fatal + if strings.Contains(err.Error(), "File exists") { + return false, nil + } + return false, err + } + return true, nil } func Unalias(ip string) (bool, error) { diff --git a/walt/internal/registry.go b/walt/internal/registry.go index 6a6182ec..ec45f68f 100644 --- a/walt/internal/registry.go +++ b/walt/internal/registry.go @@ -1,7 +1,6 @@ package internal import ( - "bufio" "errors" "os" "path/filepath" @@ -65,36 +64,41 @@ func Save(ips []string) error { if data != "" { data += "\n" } - if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + + // atomic write - do a tmp file and then rename + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "walt-registry-*.tmp") + if err != nil { return err } - return nil + tmpName := tmp.Name() + _, werr := tmp.WriteString(data) + cerr := tmp.Close() + if werr != nil { + os.Remove(tmpName) + return werr + } + if cerr != nil { + os.Remove(tmpName) + return cerr + } + + return os.Rename(tmpName, path) } // Appends a single IP to the loopback alias registry file func Append(ip string) error { - path, err := ensureRegistryPath() - if err != nil { - return err - } - - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + ips, err := Load() if err != nil { return err } - - defer f.Close() - - s := bufio.NewScanner(f) - for s.Scan() { - if strings.TrimSpace(s.Text()) == ip { - // already present + for _, x := range ips { + if x == ip { return nil } } - - _, err = f.WriteString(ip + "\n") - return err + ips = append(ips, ip) + return Save(ips) } // Removes an entry from the loopback alias registry file. If empty after removal, torch the file. @@ -127,7 +131,8 @@ func Clear() error { } if path != "" { - if err := os.Remove(path); err != nil { + // don't create error if the registry file doesn't exist yet + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return err } } From cf975579bad6423fa80688e30bb9a9b79a7ac21b Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 19:34:11 -0400 Subject: [PATCH 09/33] merge implementation. up & down left to do. --- walt/cmd/root.go | 1 + walt/cmd/sync.go | 189 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/walt/cmd/root.go b/walt/cmd/root.go index dfdacb92..fd757106 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -32,6 +32,7 @@ import ( // default parameters from the legacy shell script. // Note that we intentionally constrain the world range to be a single world by default, // so as not to bog down networking on the host. + const ( defaultMinWorld = 255 defaultMaxWorld = 255 diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go index ee72016f..854ab9b7 100644 --- a/walt/cmd/sync.go +++ b/walt/cmd/sync.go @@ -1,13 +1,196 @@ package cmd import ( + "errors" + "fmt" + "io" + "rsprox/walt/internal" + "sort" + "github.com/spf13/cobra" ) +type IPSets struct { + // Set of loopback aliases currently found in the registry + reg map[string]struct{} + // Set of loopback aliases currently live on lo0 + live map[string]struct{} +} + +type SyncConflicts struct { + // All Loopback IPs in the registry file + reg []string + // Loopback IPs which are tracked in the registry file, but not currently live on lo0 netiface + rml []string + // Loopback IPs which are live on the netiface, but not tracked in the registry file + lmr []string +} + +type SyncResult struct { + // Number of loopback IPs that were added to lo0 from the registry during sync + added int + // Number of loopback IPs that were missing from, and as a result added to the registry from lo0 + adopted int + // Number of loopback IPs that were live on lo0 but unaliased due to not being in the registry + pruned int + // Number of errors encountered during sync + errCount int +} + +// R\L = tracked in the registry but not present on lo0 +// L\R = live on lo0 but untracked (not found in reigstry file) +// adopt only = push live lo0 back to registry if needed - don't push any non-live loopbacks in the registry onto lo0. + +var ( + syncMode string + adoptOnly bool + preview bool +) + var syncCmd = &cobra.Command{ Use: "sync", - Short: "Ensure all registered loopback aliases exist on lo0", - Run: func(cmd *cobra.Command, args []string) { - + Short: "Bidirectional synchronization of lo0 & loopback registry file", + Long: `Sync definitions: + R\L: set of relevant loopback IPs which are tracked in the registry file, but not currently live on lo0 netiface + L\R: set of relevant loopback IPs which are live on the netiface, but not tracked in the registry file +Sync policies: + merge (default): adopt L\R into the registry, and add R\L to lo0 (unless --adopt-only flag present) + up: add R\L to lo0 only + down: remove L\R from lo0 - prune with no adds/appends +`, + RunE: func(cmd *cobra.Command, args []string) error { + conflicts, err := findSyncConflicts() + if err != nil { + return err + } + var result *SyncResult + switch syncMode { + case "merge": + res, err := merge(cmd, conflicts) + if err != nil { + return err + } + result = &res + case "up": + res, err := up(cmd, &conflicts.rml) + if err != nil { + return err + } + result = &res + case "down": + res, err := down(cmd, &conflicts.lmr) + if err != nil { + return err + } + result = &res + default: + return fmt.Errorf("invalid --mode %q (use merge|up|down)", syncMode) + } + fmt.Fprintf( + cmd.OutOrStdout(), + "Sync mode=%s added=%d adopted=%d pruned=%d", + syncMode, result.added, result.adopted, result.pruned, + ) + var w io.Writer + if result.errCount > 0 { + w = cmd.ErrOrStderr() + } else { + w = cmd.OutOrStdout() + } + fmt.Fprintf(w, "(%d) errors", result.errCount) + return nil }, } + +func merge(cmd *cobra.Command, conflicts *SyncConflicts) (SyncResult, error) { + added, adopted, errCount := 0, 0, 0 + if !adoptOnly { + for _, ip := range conflicts.rml { + if preview { + fmt.Fprintf(cmd.OutOrStdout(), "[preview] add %s\n", ip) + added++ + continue + } + if status, err := internal.Alias(ip); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error: alias create for %s failed: %v\n", ip, err) + errCount++ + } else if status { + added++ + } + } + } + if len(conflicts.lmr) > 0 { + union := append(append([]string{}, conflicts.reg...), conflicts.lmr...) + if preview { + for _, ip := range conflicts.lmr { + fmt.Fprintf(cmd.OutOrStdout(), "[preview] adopt into registry %s\n", ip) + adopted++ + } + } else { + if err := internal.Save(union); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error: registry adopt failed: %v\n", err) + errCount++ + } else { + adopted = len(conflicts.lmr) + } + } + } + return SyncResult{ + added: added, + adopted: adopted, + pruned: 0, + errCount: errCount, + }, nil +} + +func up(cmd *cobra.Command, rml *[]string) (SyncResult, error) { + return SyncResult{}, nil +} + +func down(cmd *cobra.Command, lmr *[]string) (SyncResult, error) { + return SyncResult{}, nil +} + +func findSyncConflicts() (*SyncConflicts, error) { + curReg, err := internal.Load() + if err != nil { + return &SyncConflicts{}, errors.New("error: failed to load the registry file: error: " + err.Error()) + } + sets, err := makeSets(&curReg) + if err != nil { + return &SyncConflicts{}, err + } + var rml, lmr []string + for _, ip := range curReg { + if _, ok := sets.live[ip]; !ok { + rml = append(rml, ip) + } + } + for ip := range sets.live { + if _, ok := sets.reg[ip]; !ok { + lmr = append(lmr, ip) + } + } + sort.Strings(rml) + sort.Strings(lmr) + return &SyncConflicts{curReg, rml, lmr}, nil +} + +func makeSets(ips *[]string) (IPSets, error) { + l, err := internal.GetLiveAliases() + if err != nil { + return IPSets{}, errors.New("error: failed to fetch live loopback aliases on lo0: error: " + err.Error()) + } + reg := make(map[string]struct{}, len(*ips)) + for _, ip := range *ips { + reg[ip] = struct{}{} + } + live := make(map[string]struct{}, len(l)) + for _, ip := range l { + live[ip] = struct{}{} + } + return IPSets{ + reg, + live, + }, nil +} From 643425db3d7a6d6afccaaaf133abc0ab3245d6a0 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 20:24:35 -0400 Subject: [PATCH 10/33] other sync methods implemented, idiomatic go fixes (no need to pass slice pointers) --- walt/cmd/sync.go | 156 ++++++++++++++++++++++++++++------------------- 1 file changed, 93 insertions(+), 63 deletions(-) diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go index 854ab9b7..45dac4e5 100644 --- a/walt/cmd/sync.go +++ b/walt/cmd/sync.go @@ -3,7 +3,6 @@ package cmd import ( "errors" "fmt" - "io" "rsprox/walt/internal" "sort" @@ -11,30 +10,21 @@ import ( ) type IPSets struct { - // Set of loopback aliases currently found in the registry - reg map[string]struct{} - // Set of loopback aliases currently live on lo0 - live map[string]struct{} + reg map[string]struct{} // Set of loopback aliases currently found in the registry + live map[string]struct{} // Set of loopback aliases currently live on lo0 } type SyncConflicts struct { - // All Loopback IPs in the registry file - reg []string - // Loopback IPs which are tracked in the registry file, but not currently live on lo0 netiface - rml []string - // Loopback IPs which are live on the netiface, but not tracked in the registry file - lmr []string + reg []string // All Loopback IPs in the registry file + rml []string // Loopback IPs which are tracked in the registry file, but not currently live on lo0 netiface + lmr []string // Loopback IPs which are live on the netiface, but not tracked in the registry file } type SyncResult struct { - // Number of loopback IPs that were added to lo0 from the registry during sync - added int - // Number of loopback IPs that were missing from, and as a result added to the registry from lo0 - adopted int - // Number of loopback IPs that were live on lo0 but unaliased due to not being in the registry - pruned int - // Number of errors encountered during sync - errCount int + added int // Number of loopback IPs that were added to lo0 from the registry during sync + adopted int // Number of loopback IPs that were missing from, and as a result added to the registry from lo0 + pruned int // Number of loopback IPs that were live on lo0 but unaliased due to not being in the registry + errCount int // Number of errors encountered during sync } // R\L = tracked in the registry but not present on lo0 @@ -44,7 +34,7 @@ type SyncResult struct { var ( syncMode string adoptOnly bool - preview bool + dryRun bool ) var syncCmd = &cobra.Command{ @@ -63,70 +53,60 @@ Sync policies: if err != nil { return err } - var result *SyncResult + var result SyncResult switch syncMode { case "merge": - res, err := merge(cmd, conflicts) - if err != nil { - return err - } - result = &res + result = merge(cmd, conflicts) case "up": - res, err := up(cmd, &conflicts.rml) - if err != nil { - return err - } - result = &res + result = up(cmd, &conflicts.rml) case "down": - res, err := down(cmd, &conflicts.lmr) - if err != nil { - return err - } - result = &res + result = down(cmd, &conflicts.lmr) default: return fmt.Errorf("invalid --mode %q (use merge|up|down)", syncMode) } + fmt.Fprintf( cmd.OutOrStdout(), - "Sync mode=%s added=%d adopted=%d pruned=%d", + "Sync mode=%s added=%d adopted=%d pruned=%d\n", syncMode, result.added, result.adopted, result.pruned, ) - var w io.Writer if result.errCount > 0 { - w = cmd.ErrOrStderr() - } else { - w = cmd.OutOrStdout() + return fmt.Errorf("(%d) errors", result.errCount) } - fmt.Fprintf(w, "(%d) errors", result.errCount) + fmt.Fprintf(cmd.OutOrStdout(), "(%d) errors", result.errCount) return nil }, } -func merge(cmd *cobra.Command, conflicts *SyncConflicts) (SyncResult, error) { +func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { added, adopted, errCount := 0, 0, 0 if !adoptOnly { for _, ip := range conflicts.rml { - if preview { - fmt.Fprintf(cmd.OutOrStdout(), "[preview] add %s\n", ip) + if dryRun { + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] add %s\n", ip) added++ continue } - if status, err := internal.Alias(ip); err != nil { + status, err := internal.Alias(ip) + if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "error: alias create for %s failed: %v\n", ip, err) errCount++ - } else if status { + continue + } + if status { added++ } } } if len(conflicts.lmr) > 0 { - union := append(append([]string{}, conflicts.reg...), conflicts.lmr...) - if preview { + + if dryRun { for _, ip := range conflicts.lmr { - fmt.Fprintf(cmd.OutOrStdout(), "[preview] adopt into registry %s\n", ip) + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] adopt into registry %s\n", ip) adopted++ } } else { + union := append(append([]string{}, conflicts.reg...), conflicts.lmr...) if err := internal.Save(union); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "error: registry adopt failed: %v\n", err) errCount++ @@ -140,15 +120,61 @@ func merge(cmd *cobra.Command, conflicts *SyncConflicts) (SyncResult, error) { adopted: adopted, pruned: 0, errCount: errCount, - }, nil + } } -func up(cmd *cobra.Command, rml *[]string) (SyncResult, error) { - return SyncResult{}, nil +func up(cmd *cobra.Command, rml *[]string) SyncResult { + added, errCount := 0, 0 + for _, ip := range *rml { + if dryRun { + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] add %s\n", ip) + added++ + continue + } + status, err := internal.Alias(ip) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error: alias create for %s failed: %v\n", ip, err) + errCount++ + continue + } + if status { + added++ + } + } + return SyncResult{ + added: added, + adopted: 0, + pruned: 0, + errCount: errCount, + } } -func down(cmd *cobra.Command, lmr *[]string) (SyncResult, error) { - return SyncResult{}, nil +func down(cmd *cobra.Command, lmr *[]string) SyncResult { + pruned, errCount := 0, 0 + for _, ip := range *lmr { + if dryRun { + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] remove %s\n", ip) + pruned++ + continue + } + status, err := internal.Unalias(ip) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error: unalias %s failed: %v\n", ip, err) + errCount++ + continue + } + if status { + pruned++ + } + // Also remove from registry if its still somehow in there + internal.Remove(ip) + } + return SyncResult{ + added: 0, + adopted: 0, + pruned: pruned, + errCount: errCount, + } } func findSyncConflicts() (*SyncConflicts, error) { @@ -156,7 +182,7 @@ func findSyncConflicts() (*SyncConflicts, error) { if err != nil { return &SyncConflicts{}, errors.New("error: failed to load the registry file: error: " + err.Error()) } - sets, err := makeSets(&curReg) + sets, err := makeSets(curReg) if err != nil { return &SyncConflicts{}, err } @@ -176,21 +202,25 @@ func findSyncConflicts() (*SyncConflicts, error) { return &SyncConflicts{curReg, rml, lmr}, nil } -func makeSets(ips *[]string) (IPSets, error) { +func makeSets(ips []string) (IPSets, error) { l, err := internal.GetLiveAliases() if err != nil { return IPSets{}, errors.New("error: failed to fetch live loopback aliases on lo0: error: " + err.Error()) } - reg := make(map[string]struct{}, len(*ips)) - for _, ip := range *ips { + reg := make(map[string]struct{}, len(ips)) + for _, ip := range ips { reg[ip] = struct{}{} } live := make(map[string]struct{}, len(l)) for _, ip := range l { live[ip] = struct{}{} } - return IPSets{ - reg, - live, - }, nil + return IPSets{reg, live}, nil +} + +func init() { + rootCmd.AddCommand(syncCmd) + syncCmd.Flags().StringVar(&syncMode, "mode", "merge", "sync policy: merge|up|down") + syncCmd.Flags().BoolVar(&adoptOnly, "adopt-only", false, "merge mode: adopt live-but-untracked into registry, but do not add missing live aliases") + syncCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview actions without making changes") } From e04a2876fe1a54d842f57c41f93e4a39d15b108b Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 20:32:27 -0400 Subject: [PATCH 11/33] missed a couple of slice pointer args - improved error logging via fmt --- walt/cmd/sync.go | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go index 45dac4e5..ddfe877d 100644 --- a/walt/cmd/sync.go +++ b/walt/cmd/sync.go @@ -1,7 +1,6 @@ package cmd import ( - "errors" "fmt" "rsprox/walt/internal" "sort" @@ -58,9 +57,9 @@ Sync policies: case "merge": result = merge(cmd, conflicts) case "up": - result = up(cmd, &conflicts.rml) + result = up(cmd, conflicts.rml) case "down": - result = down(cmd, &conflicts.lmr) + result = down(cmd, conflicts.lmr) default: return fmt.Errorf("invalid --mode %q (use merge|up|down)", syncMode) } @@ -89,7 +88,7 @@ func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { } status, err := internal.Alias(ip) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "error: alias create for %s failed: %v\n", ip, err) + fmt.Fprintf(cmd.ErrOrStderr(), "alias create for %s failed: %v\n", ip, err) errCount++ continue } @@ -99,7 +98,6 @@ func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { } } if len(conflicts.lmr) > 0 { - if dryRun { for _, ip := range conflicts.lmr { fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] adopt into registry %s\n", ip) @@ -108,7 +106,7 @@ func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { } else { union := append(append([]string{}, conflicts.reg...), conflicts.lmr...) if err := internal.Save(union); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "error: registry adopt failed: %v\n", err) + fmt.Fprintf(cmd.ErrOrStderr(), "registry adopt failed: %v\n", err) errCount++ } else { adopted = len(conflicts.lmr) @@ -123,9 +121,9 @@ func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { } } -func up(cmd *cobra.Command, rml *[]string) SyncResult { +func up(cmd *cobra.Command, rml []string) SyncResult { added, errCount := 0, 0 - for _, ip := range *rml { + for _, ip := range rml { if dryRun { fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] add %s\n", ip) added++ @@ -133,7 +131,7 @@ func up(cmd *cobra.Command, rml *[]string) SyncResult { } status, err := internal.Alias(ip) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "error: alias create for %s failed: %v\n", ip, err) + fmt.Fprintf(cmd.ErrOrStderr(), "alias create for %s failed: %v\n", ip, err) errCount++ continue } @@ -149,9 +147,9 @@ func up(cmd *cobra.Command, rml *[]string) SyncResult { } } -func down(cmd *cobra.Command, lmr *[]string) SyncResult { +func down(cmd *cobra.Command, lmr []string) SyncResult { pruned, errCount := 0, 0 - for _, ip := range *lmr { + for _, ip := range lmr { if dryRun { fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] remove %s\n", ip) pruned++ @@ -159,7 +157,7 @@ func down(cmd *cobra.Command, lmr *[]string) SyncResult { } status, err := internal.Unalias(ip) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "error: unalias %s failed: %v\n", ip, err) + fmt.Fprintf(cmd.ErrOrStderr(), "unalias %s failed: %v\n", ip, err) errCount++ continue } @@ -180,7 +178,7 @@ func down(cmd *cobra.Command, lmr *[]string) SyncResult { func findSyncConflicts() (*SyncConflicts, error) { curReg, err := internal.Load() if err != nil { - return &SyncConflicts{}, errors.New("error: failed to load the registry file: error: " + err.Error()) + return &SyncConflicts{}, fmt.Errorf("load registry: %w", err) } sets, err := makeSets(curReg) if err != nil { @@ -205,7 +203,7 @@ func findSyncConflicts() (*SyncConflicts, error) { func makeSets(ips []string) (IPSets, error) { l, err := internal.GetLiveAliases() if err != nil { - return IPSets{}, errors.New("error: failed to fetch live loopback aliases on lo0: error: " + err.Error()) + return IPSets{}, fmt.Errorf("fetch live loopback aliases on lo0: %w", err) } reg := make(map[string]struct{}, len(ips)) for _, ip := range ips { From 6401de34571f2529950dba8e08515d74c040a7d8 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 20:37:27 -0400 Subject: [PATCH 12/33] move OS check to root command --- walt/cmd/root.go | 6 ++++-- walt/internal/networking.go | 7 ------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/walt/cmd/root.go b/walt/cmd/root.go index fd757106..5d1bd4ff 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -24,7 +24,7 @@ package cmd import ( "fmt" "os" - "rsprox/walt/internal" + "runtime" "github.com/spf13/cobra" ) @@ -88,7 +88,9 @@ func init() { } func rootPreRun(command *cobra.Command, args []string) { - internal.EnsureMac() + if runtime.GOOS != "darwin" { + panic("walt is only intended to be use on macOS") + } if minWorld > maxWorld { minWorld, maxWorld = maxWorld, minWorld } diff --git a/walt/internal/networking.go b/walt/internal/networking.go index de7c359c..94fda63a 100644 --- a/walt/internal/networking.go +++ b/walt/internal/networking.go @@ -2,18 +2,11 @@ package internal import ( "os/exec" - "runtime" "sort" "strconv" "strings" ) -func EnsureMac() { - if runtime.GOOS != "darwin" { - panic("walt is only intended to be use on macOS") - } -} - func IPForWorld(world, group int) string { a := world / 256 b := world % 256 From 0eb7a8c990c909ba35c1b9e0cee7b835895f4d97 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 20:51:46 -0400 Subject: [PATCH 13/33] status command --- walt/cmd/root.go | 2 +- walt/cmd/status.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 walt/cmd/status.go diff --git a/walt/cmd/root.go b/walt/cmd/root.go index 5d1bd4ff..89a194ba 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -36,7 +36,7 @@ import ( const ( defaultMinWorld = 255 defaultMaxWorld = 255 - defaultGroup = 2 + defaultGroup = 3 ) var ( diff --git a/walt/cmd/status.go b/walt/cmd/status.go new file mode 100644 index 00000000..1ab4f545 --- /dev/null +++ b/walt/cmd/status.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + "rsprox/walt/internal" + "sort" + + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Compare registry to live lo0 aliases", + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := internal.Load() + if err != nil { + return err + } + live, err := internal.GetLiveAliases() + if err != nil { + return err + } + regSet := map[string]struct{}{} + liveSet := map[string]struct{}{} + for _, ip := range reg { + regSet[ip] = struct{}{} + } + for _, ip := range live { + liveSet[ip] = struct{}{} + } + + var regOnly, liveOnly []string + for _, ip := range reg { + if _, ok := liveSet[ip]; !ok { + regOnly = append(regOnly, ip) + } + } + for _, ip := range live { + if _, ok := regSet[ip]; !ok { + liveOnly = append(liveOnly, ip) + } + } + sort.Strings(regOnly) + sort.Strings(liveOnly) + + fmt.Fprintf(cmd.OutOrStdout(), "Registry (%d):\n", len(reg)) + for _, ip := range reg { + fmt.Fprintf(cmd.OutOrStdout(), "\t%s\n", ip) + } + fmt.Fprintf(cmd.OutOrStdout(), "Live on lo0 (%d):\n", len(live)) + for _, ip := range live { + fmt.Fprintf(cmd.OutOrStdout(), "\t%s\n", ip) + } + + fmt.Fprintln(cmd.OutOrStdout(), "Registry but NOT live:") + if len(regOnly) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "\t(none)") + } else { + for _, ip := range regOnly { + fmt.Fprintf(cmd.OutOrStdout(), "\t%s\n", ip) + } + } + + fmt.Fprintln(cmd.OutOrStdout(), "Live but NOT in registry:") + if len(liveOnly) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "\t(none)") + } else { + for _, ip := range liveOnly { + fmt.Fprintf(cmd.OutOrStdout(), "\t%s\n", ip) + } + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} From 6f3ab3e2acf304060c9746147d999e9e6a1d7640 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Sun, 7 Sep 2025 21:11:13 -0400 Subject: [PATCH 14/33] clear command implemented, plus some tidying of sync (makeSets was weird - consolidated into findConflicts) --- walt/cmd/clear.go | 98 +++++++++++++++++++++++++++++++++++++++++++++++ walt/cmd/sync.go | 41 +++++++------------- 2 files changed, 112 insertions(+), 27 deletions(-) create mode 100644 walt/cmd/clear.go diff --git a/walt/cmd/clear.go b/walt/cmd/clear.go new file mode 100644 index 00000000..dd37370d --- /dev/null +++ b/walt/cmd/clear.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "fmt" + + "rsprox/walt/internal" + + "github.com/spf13/cobra" +) + +var ( + clearDryRun bool + clearPruneOrphan bool // remove L\R as well +) + +var clearCmd = &cobra.Command{ + Use: "clear", + Short: "Remove aliases and wipe the registry file", + Long: `Clear removes all aliases tracked in the registry. With --prune-orphans (default), +it also removes any live aliases not present in the registry (L\R), so the system +and registry end up in a clean state.`, + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := internal.Load() + if err != nil { + return fmt.Errorf("load registry: %w", err) + } + live, err := internal.GetLiveAliases() + if err != nil { + return fmt.Errorf("fetch live loopback aliases on lo0: %w", err) + } + + // This is repeated in sync command implementation... can probably migrate to a utility in the future. + regSet := make(map[string]struct{}, len(reg)) + for _, ip := range reg { + regSet[ip] = struct{}{} + } + liveSet := make(map[string]struct{}, len(live)) + for _, ip := range live { + liveSet[ip] = struct{}{} + } + + var toRemove []string + toRemove = append(toRemove, reg...) + if clearPruneOrphan { + for _, ip := range live { + if _, ok := regSet[ip]; !ok { + toRemove = append(toRemove, ip) + } + } + } + + removed, skipped, errCount := 0, 0, 0 + + for _, ip := range toRemove { + if clearDryRun { + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] remove %s\n", ip) + removed++ + continue + } + status, err := internal.Unalias(ip) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "unalias %s failed: %v\n", ip, err) + errCount++ + continue + } + if status { + removed++ + } else { + // not present on lo0 + skipped++ + } + // Also remove from registry if its still somehow in there + internal.Remove(ip) + } + + if clearDryRun { + fmt.Fprintln(cmd.OutOrStdout(), "[dry-run] delete registry file") + } else if err := internal.Clear(); err != nil { + return fmt.Errorf("delete registry: %w", err) + } + + fmt.Fprintf( + cmd.OutOrStdout(), + "Clear summary: removed=%d skipped=%d\n", + removed, skipped, + ) + if errCount > 0 { + return fmt.Errorf("(%d) errors", errCount) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(clearCmd) + clearCmd.Flags().BoolVar(&clearDryRun, "dry-run", false, "preview actions without making changes") + clearCmd.Flags().BoolVar(&clearPruneOrphan, "prune-orphans", true, "also remove live aliases that are not in the registry (L\\R)") +} diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go index ddfe877d..25f2bf13 100644 --- a/walt/cmd/sync.go +++ b/walt/cmd/sync.go @@ -8,11 +8,6 @@ import ( "github.com/spf13/cobra" ) -type IPSets struct { - reg map[string]struct{} // Set of loopback aliases currently found in the registry - live map[string]struct{} // Set of loopback aliases currently live on lo0 -} - type SyncConflicts struct { reg []string // All Loopback IPs in the registry file rml []string // Loopback IPs which are tracked in the registry file, but not currently live on lo0 netiface @@ -66,7 +61,7 @@ Sync policies: fmt.Fprintf( cmd.OutOrStdout(), - "Sync mode=%s added=%d adopted=%d pruned=%d\n", + "Sync mode=%s added=%d adopted=%d pruned=%d\n", syncMode, result.added, result.adopted, result.pruned, ) if result.errCount > 0 { @@ -180,18 +175,26 @@ func findSyncConflicts() (*SyncConflicts, error) { if err != nil { return &SyncConflicts{}, fmt.Errorf("load registry: %w", err) } - sets, err := makeSets(curReg) + l, err := internal.GetLiveAliases() if err != nil { - return &SyncConflicts{}, err + return &SyncConflicts{}, fmt.Errorf("fetch live loopback aliases on lo0: %w", err) + } + reg := make(map[string]struct{}, len(curReg)) + for _, ip := range curReg { + reg[ip] = struct{}{} + } + live := make(map[string]struct{}, len(l)) + for _, ip := range l { + live[ip] = struct{}{} } var rml, lmr []string for _, ip := range curReg { - if _, ok := sets.live[ip]; !ok { + if _, ok := live[ip]; !ok { rml = append(rml, ip) } } - for ip := range sets.live { - if _, ok := sets.reg[ip]; !ok { + for ip := range live { + if _, ok := reg[ip]; !ok { lmr = append(lmr, ip) } } @@ -200,22 +203,6 @@ func findSyncConflicts() (*SyncConflicts, error) { return &SyncConflicts{curReg, rml, lmr}, nil } -func makeSets(ips []string) (IPSets, error) { - l, err := internal.GetLiveAliases() - if err != nil { - return IPSets{}, fmt.Errorf("fetch live loopback aliases on lo0: %w", err) - } - reg := make(map[string]struct{}, len(ips)) - for _, ip := range ips { - reg[ip] = struct{}{} - } - live := make(map[string]struct{}, len(l)) - for _, ip := range l { - live[ip] = struct{}{} - } - return IPSets{reg, live}, nil -} - func init() { rootCmd.AddCommand(syncCmd) syncCmd.Flags().StringVar(&syncMode, "mode", "merge", "sync policy: merge|up|down") From 5e29f9aa6f808e3dd9a44ef2ca8e06a6e2f31cb4 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 09:44:11 -0400 Subject: [PATCH 15/33] small abstraction of an action plan - such that sync logic can be unit tested in absence of networking --- walt/cmd/sync_plan.go | 63 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 walt/cmd/sync_plan.go diff --git a/walt/cmd/sync_plan.go b/walt/cmd/sync_plan.go new file mode 100644 index 00000000..fe3dab19 --- /dev/null +++ b/walt/cmd/sync_plan.go @@ -0,0 +1,63 @@ +package cmd + +import "fmt" + +type ActionType int + +const ( + Add ActionType = iota // Add an IP present in the registry file to lo0 + Adopt // Add an IP present on lo0 to the registry file (due to missing) + Prune // Remove an IP from lo0 due to not being present in registry file +) + +// Encapsulation of an action taken on a loopabck IP address +type Action struct { + Type ActionType + Ip string +} + +type SyncPlan struct { + Actions []Action // Collection of actions required to synchronize registry file with lo0 +} + +func NewSyncPlan(mode string, adoptOnly bool, rml, lmr []string) (SyncPlan, error) { + rml = unique(rml) + lmr = unique(lmr) + var plan SyncPlan + switch mode { + case "merge": + if !adoptOnly { + for _, ip := range rml { + plan.Actions = append(plan.Actions, Action{Type: Add, Ip: ip}) + } + } + for _, ip := range lmr { + plan.Actions = append(plan.Actions, Action{Type: Adopt, Ip: ip}) + } + case "up": + for _, ip := range rml { + plan.Actions = append(plan.Actions, Action{Type: Add, Ip: ip}) + } + case "down": + for _, ip := range lmr { + plan.Actions = append(plan.Actions, Action{Type: Prune, Ip: ip}) + } + default: + return plan, fmt.Errorf("invalid mode %q (use merge|up|down)", mode) + } + return plan, nil +} + +// Safety de-duplication of a list of loopback IPs +func unique(ips []string) []string { + seen := make(map[string]struct{}, len(ips)) + out := make([]string, 0, len(ips)) + for _, ip := range ips { + if _, in := seen[ip]; in { + continue + } + seen[ip] = struct{}{} + out = append(out, ip) + } + return out +} From 34a129f8adeb950dad4097d96809fb609057aa30 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 10:07:27 -0400 Subject: [PATCH 16/33] remove up/down modes.. realizing that we should just use merge as the default-and-only sync strategy. --- walt/cmd/sync.go | 81 +++----------------------------------- walt/cmd/sync_plan.go | 26 +++--------- walt/cmd/sync_plan_test.go | 28 +++++++++++++ 3 files changed, 39 insertions(+), 96 deletions(-) create mode 100644 walt/cmd/sync_plan_test.go diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go index 25f2bf13..39bef2af 100644 --- a/walt/cmd/sync.go +++ b/walt/cmd/sync.go @@ -17,7 +17,6 @@ type SyncConflicts struct { type SyncResult struct { added int // Number of loopback IPs that were added to lo0 from the registry during sync adopted int // Number of loopback IPs that were missing from, and as a result added to the registry from lo0 - pruned int // Number of loopback IPs that were live on lo0 but unaliased due to not being in the registry errCount int // Number of errors encountered during sync } @@ -26,9 +25,7 @@ type SyncResult struct { // adopt only = push live lo0 back to registry if needed - don't push any non-live loopbacks in the registry onto lo0. var ( - syncMode string - adoptOnly bool - dryRun bool + dryRun bool ) var syncCmd = &cobra.Command{ @@ -47,22 +44,11 @@ Sync policies: if err != nil { return err } - var result SyncResult - switch syncMode { - case "merge": - result = merge(cmd, conflicts) - case "up": - result = up(cmd, conflicts.rml) - case "down": - result = down(cmd, conflicts.lmr) - default: - return fmt.Errorf("invalid --mode %q (use merge|up|down)", syncMode) - } - + result := merge(cmd, conflicts) fmt.Fprintf( cmd.OutOrStdout(), - "Sync mode=%s added=%d adopted=%d pruned=%d\n", - syncMode, result.added, result.adopted, result.pruned, + "Sync mode=merge added=%d adopted=%d\n", + result.added, result.adopted, ) if result.errCount > 0 { return fmt.Errorf("(%d) errors", result.errCount) @@ -74,7 +60,7 @@ Sync policies: func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { added, adopted, errCount := 0, 0, 0 - if !adoptOnly { + if len(conflicts.rml) > 0 { for _, ip := range conflicts.rml { if dryRun { fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] add %s\n", ip) @@ -111,61 +97,6 @@ func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { return SyncResult{ added: added, adopted: adopted, - pruned: 0, - errCount: errCount, - } -} - -func up(cmd *cobra.Command, rml []string) SyncResult { - added, errCount := 0, 0 - for _, ip := range rml { - if dryRun { - fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] add %s\n", ip) - added++ - continue - } - status, err := internal.Alias(ip) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "alias create for %s failed: %v\n", ip, err) - errCount++ - continue - } - if status { - added++ - } - } - return SyncResult{ - added: added, - adopted: 0, - pruned: 0, - errCount: errCount, - } -} - -func down(cmd *cobra.Command, lmr []string) SyncResult { - pruned, errCount := 0, 0 - for _, ip := range lmr { - if dryRun { - fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] remove %s\n", ip) - pruned++ - continue - } - status, err := internal.Unalias(ip) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "unalias %s failed: %v\n", ip, err) - errCount++ - continue - } - if status { - pruned++ - } - // Also remove from registry if its still somehow in there - internal.Remove(ip) - } - return SyncResult{ - added: 0, - adopted: 0, - pruned: pruned, errCount: errCount, } } @@ -205,7 +136,5 @@ func findSyncConflicts() (*SyncConflicts, error) { func init() { rootCmd.AddCommand(syncCmd) - syncCmd.Flags().StringVar(&syncMode, "mode", "merge", "sync policy: merge|up|down") - syncCmd.Flags().BoolVar(&adoptOnly, "adopt-only", false, "merge mode: adopt live-but-untracked into registry, but do not add missing live aliases") syncCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview actions without making changes") } diff --git a/walt/cmd/sync_plan.go b/walt/cmd/sync_plan.go index fe3dab19..5f733f3a 100644 --- a/walt/cmd/sync_plan.go +++ b/walt/cmd/sync_plan.go @@ -1,13 +1,10 @@ package cmd -import "fmt" - type ActionType int const ( Add ActionType = iota // Add an IP present in the registry file to lo0 Adopt // Add an IP present on lo0 to the registry file (due to missing) - Prune // Remove an IP from lo0 due to not being present in registry file ) // Encapsulation of an action taken on a loopabck IP address @@ -20,32 +17,21 @@ type SyncPlan struct { Actions []Action // Collection of actions required to synchronize registry file with lo0 } -func NewSyncPlan(mode string, adoptOnly bool, rml, lmr []string) (SyncPlan, error) { +func NewSyncPlan(rml, lmr []string) SyncPlan { rml = unique(rml) lmr = unique(lmr) var plan SyncPlan - switch mode { - case "merge": - if !adoptOnly { - for _, ip := range rml { - plan.Actions = append(plan.Actions, Action{Type: Add, Ip: ip}) - } - } - for _, ip := range lmr { - plan.Actions = append(plan.Actions, Action{Type: Adopt, Ip: ip}) - } - case "up": + if len(rml) > 0 { for _, ip := range rml { plan.Actions = append(plan.Actions, Action{Type: Add, Ip: ip}) } - case "down": + } + if len(lmr) > 0 { for _, ip := range lmr { - plan.Actions = append(plan.Actions, Action{Type: Prune, Ip: ip}) + plan.Actions = append(plan.Actions, Action{Type: Adopt, Ip: ip}) } - default: - return plan, fmt.Errorf("invalid mode %q (use merge|up|down)", mode) } - return plan, nil + return plan } // Safety de-duplication of a list of loopback IPs diff --git a/walt/cmd/sync_plan_test.go b/walt/cmd/sync_plan_test.go new file mode 100644 index 00000000..f0076636 --- /dev/null +++ b/walt/cmd/sync_plan_test.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "reflect" + "testing" +) + +func TestBuildPlan(t *testing.T) { + // 127.1.1.2 present in registry file but not on lo0 + rml := []string{"127.1.1.2"} + // 127.1.3.2 present on lo0 but not on the registry file + lmr := []string{"127.1.3.2"} + plan := NewSyncPlan(rml, lmr) + want := SyncPlan{ + Actions: []Action{ + {Type: Add, Ip: "127.1.1.2"}, + {Type: Adopt, Ip: "127.1.3.2"}, + }, + } + assertEqualPlan(t, want, plan) +} + +func assertEqualPlan(t *testing.T, want, got SyncPlan) { + t.Helper() + if !reflect.DeepEqual(want, got) { + t.Fatalf("plan mismatch\nwant: %#v\ngot: %#v", want, got) + } +} From 467a371ef7d57ffc2252d08b0bf28b2456f6bacd Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 10:32:44 -0400 Subject: [PATCH 17/33] refactor merge to use sync plan struct --- walt/cmd/sync.go | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go index 39bef2af..5b7c961d 100644 --- a/walt/cmd/sync.go +++ b/walt/cmd/sync.go @@ -59,41 +59,49 @@ Sync policies: } func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { + plan := NewSyncPlan(conflicts.rml, conflicts.lmr) + added, adopted, errCount := 0, 0, 0 - if len(conflicts.rml) > 0 { - for _, ip := range conflicts.rml { + var toAdopt []string + + for _, action := range plan.Actions { + switch action.Type { + case Add: if dryRun { - fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] add %s\n", ip) + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] add %s\n", action.Ip) added++ continue } - status, err := internal.Alias(ip) + status, err := internal.Alias(action.Ip) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "alias create for %s failed: %v\n", ip, err) + fmt.Fprintf(cmd.ErrOrStderr(), "alias create for %s failed: %v\n", action.Ip, err) errCount++ continue } if status { added++ } - } - } - if len(conflicts.lmr) > 0 { - if dryRun { - for _, ip := range conflicts.lmr { - fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] adopt into registry %s\n", ip) + + case Adopt: + if dryRun { + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] adopt into registry %s\n", action.Ip) adopted++ - } - } else { - union := append(append([]string{}, conflicts.reg...), conflicts.lmr...) - if err := internal.Save(union); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "registry adopt failed: %v\n", err) - errCount++ } else { - adopted = len(conflicts.lmr) + toAdopt = append(toAdopt, action.Ip) } } } + + if !dryRun && len(toAdopt) > 0 { + union := append(append([]string{}, conflicts.reg...), toAdopt...) + if err := internal.Save(union); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "registry adopt failed: %v\n", err) + errCount++ + } else { + adopted = len(toAdopt) + } + } + return SyncResult{ added: added, adopted: adopted, From a1144ce97600099f275238e281adf6477ee4b612 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 11:38:27 -0400 Subject: [PATCH 18/33] registry unit tests --- walt/cmd/sync.go | 6 +-- walt/internal/networking.go | 7 ++- walt/internal/registry_test.go | 82 ++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 walt/internal/registry_test.go diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go index 5b7c961d..45530eca 100644 --- a/walt/cmd/sync.go +++ b/walt/cmd/sync.go @@ -34,10 +34,8 @@ var syncCmd = &cobra.Command{ Long: `Sync definitions: R\L: set of relevant loopback IPs which are tracked in the registry file, but not currently live on lo0 netiface L\R: set of relevant loopback IPs which are live on the netiface, but not tracked in the registry file -Sync policies: - merge (default): adopt L\R into the registry, and add R\L to lo0 (unless --adopt-only flag present) - up: add R\L to lo0 only - down: remove L\R from lo0 - prune with no adds/appends +Sync strategy: + merge (default): adopt L\R into the registry, and add R\L to lo0 `, RunE: func(cmd *cobra.Command, args []string) error { conflicts, err := findSyncConflicts() diff --git a/walt/internal/networking.go b/walt/internal/networking.go index 94fda63a..2242891d 100644 --- a/walt/internal/networking.go +++ b/walt/internal/networking.go @@ -46,6 +46,11 @@ func GetLiveAliases() ([]string, error) { if err != nil { return nil, err } + ips := parseAliases(string(out)) + return ips, nil +} + +func parseAliases(out string) []string { var ips []string for ln := range strings.SplitSeq(string(out), "\n") { ln = strings.TrimSpace(ln) @@ -60,5 +65,5 @@ func GetLiveAliases() ([]string, error) { } } sort.Strings(ips) - return ips, nil + return ips } diff --git a/walt/internal/registry_test.go b/walt/internal/registry_test.go new file mode 100644 index 00000000..2db2e41f --- /dev/null +++ b/walt/internal/registry_test.go @@ -0,0 +1,82 @@ +package internal + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func testRegistry(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_STATE_HOME", dir) + return filepath.Join(dir, "walt", "aliases.txt") +} + +func TestSaveLoad(t *testing.T) { + testRegistry(t) + in := []string{"127.1.0.2", "127.1.0.2", "127.1.0.1"} + if err := Save(in); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + want := []string{"127.1.0.1", "127.1.0.2"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestAppend(t *testing.T) { + path := testRegistry(t) + + if err := Append("127.1.2.3"); err != nil { + t.Fatalf("Append: %v", err) + } + // second time no-op + if err := Append("127.1.2.3"); err != nil { + t.Fatalf("Append: %v", err) + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + // file should contain the IP once (with trailing newline) + if string(b) != "127.1.2.3\n" { + t.Fatalf("file content = %q", string(b)) + } +} + +func TestClear(t *testing.T) { + path := testRegistry(t) + + if err := Save([]string{"127.1.2.3", "127.1.2.4"}); err != nil { + t.Fatalf("Save: %v", err) + } + if err := Remove("127.1.2.3"); err != nil { + t.Fatalf("Remove: %v", err) + } + got, _ := Load() + want := []string{"127.1.2.4"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("after remove got %v want %v", got, want) + } + + // remove last, file should be deleted + if err := Remove("127.1.2.4"); err != nil { + t.Fatalf("Remove last: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("registry file should be gone, err=%v", err) + } + + // clear on non-existent file should be no-op + if err := Clear(); err != nil { + t.Fatalf("Clear on missing file: %v", err) + } +} From 7f8dd1e8c8e7b628aa1172723fc0904e569228bf Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 11:53:09 -0400 Subject: [PATCH 19/33] networking tests --- walt/internal/networking_test.go | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 walt/internal/networking_test.go diff --git a/walt/internal/networking_test.go b/walt/internal/networking_test.go new file mode 100644 index 00000000..1e5dc333 --- /dev/null +++ b/walt/internal/networking_test.go @@ -0,0 +1,65 @@ +package internal + +import ( + "reflect" + "runtime" + "testing" +) + +func TestIPForWorld(t *testing.T) { + type tc struct { + world int + group int + ip string + } + + cases := []tc{ + {0, 2, "127.0.0.2"}, + {1, 2, "127.0.1.2"}, + {256, 2, "127.1.0.2"}, + {300, 2, "127.1.44.2"}, + } + for _, c := range cases { + if got := IPForWorld(c.world, c.group); got != c.ip { + t.Fatalf("IPForWorld(%d,%d) = %s want %s", c.world, c.group, got, c.ip) + } + } +} + +func TestEnsureMac(t *testing.T) { + // very likely unnecessary but... + if runtime.GOOS != "darwin" { + t.Skip("not macOS") + } + defer func() { + if r := recover(); r != nil { + t.Fatalf("panicked on darwin: %v", r) + } + }() + ensureMac() +} + +// parse test using captured ifconfig output from my local machine (with added inet) +func TestParseLo0Aliases(t *testing.T) { + fixture := ` +lo0: flags=8049 mtu 16384 + options=1203 + inet 127.0.0.1 netmask 0xff000000 + inet 127.1.2.3 netmask 0xff000000 + inet 127.9.8.7 netmask 0xff000000 + inet6 ::1 prefixlen 128 + inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1 + nd6 options=201 +` + got := parseAliases(fixture) + want := []string{"127.1.2.3", "127.9.8.7"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseLo0Aliases got %v want %v", got, want) + } +} + +func ensureMac() { + if runtime.GOOS != "darwin" { + panic("walt is only intended to be use on macOS") + } +} From 44ec21c7de52a79515e8baa088401161304e351c Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 12:28:22 -0400 Subject: [PATCH 20/33] integration test (e2e) --- walt/cmd/root.go | 4 +- walt/cmd/root_test.go | 188 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 walt/cmd/root_test.go diff --git a/walt/cmd/root.go b/walt/cmd/root.go index 89a194ba..87d03162 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -94,7 +94,7 @@ func rootPreRun(command *cobra.Command, args []string) { if minWorld > maxWorld { minWorld, maxWorld = maxWorld, minWorld } - if group < 3 { - panic(fmt.Errorf("macOS requires group >= 3, got %d", group)) + if group < 2 { + panic(fmt.Errorf("macOS requires group >= 2, got %d", group)) } } diff --git a/walt/cmd/root_test.go b/walt/cmd/root_test.go new file mode 100644 index 00000000..0d7ea2c1 --- /dev/null +++ b/walt/cmd/root_test.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "bytes" + "os" + "os/exec" + "runtime" + "slices" + "strings" + "testing" + + "rsprox/walt/internal" +) + +func requireDarwin(t *testing.T) { + t.Helper() + if runtime.GOOS != "darwin" { + t.Skip("e2e: macOS only") + } +} + +func requireE2EEnabled(t *testing.T) { + t.Helper() + if os.Getenv("WALT_E2E") != "1" { + t.Skip("e2e: set WALT_E2E=1 to run CLI integration tests") + } +} + +func requireSudo(t *testing.T) { + t.Helper() + // CI on macOS runners via GH actions supports passwordless sudo + // locally this will pass only if cahced ticket present or passwordless sudo. + if err := exec.Command("sudo", "-n", "-v").Run(); err != nil { + t.Skip("e2e: sudo -n -v failed (no non-interactive sudo). Run locally with WALT_E2E=1 after elevating or configure CI on macOS.") + } +} + +func setTempRegistry(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_STATE_HOME", dir) +} + +func runRootCmd(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + var out, errb bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&errb) + rootCmd.SetArgs(args) + err = rootCmd.Execute() + return out.String(), errb.String(), err +} + +func TestE2E(t *testing.T) { + requireDarwin(t) + requireE2EEnabled(t) + requireSudo(t) + setTempRegistry(t) + + const ( + world = 300 + group = 2 + ) + ip := internal.IPForWorld(world, group) + + // ensure a clean slate for this IP + internal.Unalias(ip) // ignore if absent + internal.Remove(ip) // ignore if absent + + // add one world + stdout, stderr, err := runRootCmd(t, "add", "--min=300", "--max=300", "--group=2") + if err != nil { + t.Fatalf("add failed: err=%v, out=%q, errout=%q", err, stdout, stderr) + } + + // verify live contains ip + live, err := internal.GetLiveAliases() + if err != nil { + t.Fatalf("GetLiveAliases: %v", err) + } + if !slices.Contains(live, ip) { + t.Fatalf("expected %s live after add; live=%v", ip, live) + } + // verify registry contains ip + reg, err := internal.Load() + if err != nil { + t.Fatalf("Load registry: %v", err) + } + if !slices.Contains(reg, ip) { + t.Fatalf("expected %s in registry after add; reg=%v", ip, reg) + } + + // remove it + stdout, stderr, err = runRootCmd(t, "remove", "-m", "300", "-M", "300", "-g", "2") + if err != nil { + t.Fatalf("remove failed: err=%v, out=%q, errout=%q", err, stdout, stderr) + } + + live, _ = internal.GetLiveAliases() + if slices.Contains(live, ip) { + t.Fatalf("expected %s not live after remove; live=%v", ip, live) + } + reg, _ = internal.Load() + if slices.Contains(reg, ip) { + t.Fatalf("expected %s not in registry after remove; reg=%v", ip, reg) + } + + // create drift: alias live but DO NOT add to registry + if status, err := internal.Alias(ip); err != nil || !status { + t.Fatalf("drift setup alias: changed=%v err=%v", status, err) + } + // ensure not in registry + internal.Remove(ip) + + // run sync: should adopt ip into registry + stdout, stderr, err = runRootCmd(t, "sync") + if err != nil { + t.Fatalf("sync failed: err=%v, out=%q, errout=%q", err, stdout, stderr) + } + if !strings.Contains(stdout+stderr, "adopted=") { + t.Logf("sync output: %s%s", stdout, stderr) + } + + reg, _ = internal.Load() + if !slices.Contains(reg, ip) { + t.Fatalf("expected %s adopted into registry; reg=%v", ip, reg) + } + + // cleanup + if _, err := internal.Unalias(ip); err != nil { + t.Fatalf("cleanup unalias: %v", err) + } + _ = internal.Remove(ip) +} + +func TestE2EDryRun(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("e2e: macOS only") + } + if os.Getenv("WALT_E2E") != "1" { + t.Skip("e2e: set WALT_E2E=1 to run") + } + setTempRegistry(t) + + const ( + world = 301 + group = 2 + ) + ip := internal.IPForWorld(world, group) + + internal.Unalias(ip) + internal.Remove(ip) + + if err := internal.Save([]string{ip}); err != nil { + t.Fatalf("seed registry: %v", err) + } + + // run sync --dry-run (should NOT add to lo0) + var out, errb bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&errb) + rootCmd.SetArgs([]string{"sync", "--dry-run"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("sync --dry-run failed: %v, out=%q err=%q", err, out.String(), errb.String()) + } + + // verify still NOT live + live, err := internal.GetLiveAliases() + if err != nil { + t.Fatalf("GetLiveAliases: %v", err) + } + for _, v := range live { + if v == ip { + t.Fatalf("dry-run mutated live state: %s became live", ip) + } + } + // registry should still contain ip (dry-run doesn't modify registry) + reg, err := internal.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + found := slices.Contains(reg, ip) + if !found { + t.Fatalf("dry-run mutated registry: %s missing", ip) + } + + internal.Remove(ip) +} From fcbfc9eded58dc571beb01c7eafac4dcd75b3f9f Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 12:35:20 -0400 Subject: [PATCH 21/33] formatting fix, osrs = 2 fix for mode --- walt/cmd/root.go | 4 ++-- walt/cmd/root_test.go | 8 ++++---- walt/cmd/status.go | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/walt/cmd/root.go b/walt/cmd/root.go index 87d03162..89a194ba 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -94,7 +94,7 @@ func rootPreRun(command *cobra.Command, args []string) { if minWorld > maxWorld { minWorld, maxWorld = maxWorld, minWorld } - if group < 2 { - panic(fmt.Errorf("macOS requires group >= 2, got %d", group)) + if group < 3 { + panic(fmt.Errorf("macOS requires group >= 3, got %d", group)) } } diff --git a/walt/cmd/root_test.go b/walt/cmd/root_test.go index 0d7ea2c1..b2d69cb5 100644 --- a/walt/cmd/root_test.go +++ b/walt/cmd/root_test.go @@ -59,7 +59,7 @@ func TestE2E(t *testing.T) { const ( world = 300 - group = 2 + group = 3 ) ip := internal.IPForWorld(world, group) @@ -68,7 +68,7 @@ func TestE2E(t *testing.T) { internal.Remove(ip) // ignore if absent // add one world - stdout, stderr, err := runRootCmd(t, "add", "--min=300", "--max=300", "--group=2") + stdout, stderr, err := runRootCmd(t, "add", "--min=300", "--max=300", "--group=3") if err != nil { t.Fatalf("add failed: err=%v, out=%q, errout=%q", err, stdout, stderr) } @@ -91,7 +91,7 @@ func TestE2E(t *testing.T) { } // remove it - stdout, stderr, err = runRootCmd(t, "remove", "-m", "300", "-M", "300", "-g", "2") + stdout, stderr, err = runRootCmd(t, "remove", "-m", "300", "-M", "300", "-g", "3") if err != nil { t.Fatalf("remove failed: err=%v, out=%q, errout=%q", err, stdout, stderr) } @@ -144,7 +144,7 @@ func TestE2EDryRun(t *testing.T) { const ( world = 301 - group = 2 + group = 3 ) ip := internal.IPForWorld(world, group) diff --git a/walt/cmd/status.go b/walt/cmd/status.go index 1ab4f545..75616c59 100644 --- a/walt/cmd/status.go +++ b/walt/cmd/status.go @@ -45,28 +45,28 @@ var statusCmd = &cobra.Command{ fmt.Fprintf(cmd.OutOrStdout(), "Registry (%d):\n", len(reg)) for _, ip := range reg { - fmt.Fprintf(cmd.OutOrStdout(), "\t%s\n", ip) + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", ip) } fmt.Fprintf(cmd.OutOrStdout(), "Live on lo0 (%d):\n", len(live)) for _, ip := range live { - fmt.Fprintf(cmd.OutOrStdout(), "\t%s\n", ip) + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", ip) } fmt.Fprintln(cmd.OutOrStdout(), "Registry but NOT live:") if len(regOnly) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "\t(none)") + fmt.Fprintln(cmd.OutOrStdout(), " (none)") } else { for _, ip := range regOnly { - fmt.Fprintf(cmd.OutOrStdout(), "\t%s\n", ip) + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", ip) } } fmt.Fprintln(cmd.OutOrStdout(), "Live but NOT in registry:") if len(liveOnly) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "\t(none)") + fmt.Fprintln(cmd.OutOrStdout(), " (none)") } else { for _, ip := range liveOnly { - fmt.Fprintf(cmd.OutOrStdout(), "\t%s\n", ip) + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", ip) } } return nil From c103c7477bab6db5e213c90ac60acc90f9685600 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 12:43:21 -0400 Subject: [PATCH 22/33] ascii art at root --- walt/cmd/root.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/walt/cmd/root.go b/walt/cmd/root.go index 89a194ba..1e034125 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -49,7 +49,14 @@ var ( var rootCmd = &cobra.Command{ Use: "walt", Short: "Manage loopback address aliases for RSProx on macOS.", - Long: `An easy-to-use CLI tool for managing loopback address aliases on macOS. Intended + Long: ` + __ ___ _ _____ + \ \ / / \ | | |_ _| + \ \ /\ / / _ \ | | | | + \ V V / ___ \| |___| | + \_/\_/_/ \_\_____|_| + +An easy-to-use CLI tool for managing loopback address aliases on macOS. Intended to be used in parallel with RSProx.`, PersistentPreRun: rootPreRun, } From 784b25c065704caa9bfa922ad5189314509422cb Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 12:53:22 -0400 Subject: [PATCH 23/33] Homebrew formula --- HomebrewFormula/walt.rb | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 HomebrewFormula/walt.rb diff --git a/HomebrewFormula/walt.rb b/HomebrewFormula/walt.rb new file mode 100644 index 00000000..a2ebbdd7 --- /dev/null +++ b/HomebrewFormula/walt.rb @@ -0,0 +1,24 @@ +# HomebrewFormula/walt.rb +class Walt < Formula + desc "Manage loopback address aliases for RSProx on macOS" + homepage "https://github.com/blurite/rsprox" + # update these once tag present + url "https://github.com/blurite/rsprox/archive/refs/tags/v0.1.0.tar.gz" + sha256 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + license "MIT" + + # dev-only: point head to fork + head "https://github.com/doneill612/rsprox.git", branch: "feature/mac-os-world-aliasing-hb" + + depends_on "go" => :build + + def install + # The main package lives in ./walt + system "go", "build", *std_go_args(ldflags: "-s -w"), "./walt" + end + + test do + out = shell_output("#{bin}/walt --help") + assert_match "Manage loopback", out + end +end From d887c83571aa4bd570ee8ecbcc3119dbd941f983 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 13:02:54 -0400 Subject: [PATCH 24/33] stash --- HomebrewFormula/walt.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/HomebrewFormula/walt.rb b/HomebrewFormula/walt.rb index a2ebbdd7..97fa4e5d 100644 --- a/HomebrewFormula/walt.rb +++ b/HomebrewFormula/walt.rb @@ -1,4 +1,3 @@ -# HomebrewFormula/walt.rb class Walt < Formula desc "Manage loopback address aliases for RSProx on macOS" homepage "https://github.com/blurite/rsprox" From 9075510097e3a8f2a72135ba8a2f92a0312b0563 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 13:24:10 -0400 Subject: [PATCH 25/33] wrong directory cited in formula --- HomebrewFormula/walt.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/HomebrewFormula/walt.rb b/HomebrewFormula/walt.rb index 97fa4e5d..dfeb9a3a 100644 --- a/HomebrewFormula/walt.rb +++ b/HomebrewFormula/walt.rb @@ -13,7 +13,9 @@ class Walt < Formula def install # The main package lives in ./walt - system "go", "build", *std_go_args(ldflags: "-s -w"), "./walt" + cd "walt" do + system "go", "build", *std_go_args(ldflags: "-s -w") + end end test do From 3bf01c914e9ea302ccdb6c080474aab25f59be35 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 13:39:23 -0400 Subject: [PATCH 26/33] rename go mod to github.com/blurite/rsprox/walt/cmd --- walt/cmd/add.go | 2 +- walt/cmd/clear.go | 3 +-- walt/cmd/remove.go | 2 +- walt/cmd/root_test.go | 2 +- walt/cmd/status.go | 2 +- walt/cmd/sync.go | 2 +- walt/go.mod | 2 +- walt/main.go | 2 +- 8 files changed, 8 insertions(+), 9 deletions(-) diff --git a/walt/cmd/add.go b/walt/cmd/add.go index 20a69f23..249cdc2b 100644 --- a/walt/cmd/add.go +++ b/walt/cmd/add.go @@ -2,8 +2,8 @@ package cmd import ( "fmt" - "rsprox/walt/internal" + "github.com/blurite/rsprox/walt/internal" "github.com/spf13/cobra" ) diff --git a/walt/cmd/clear.go b/walt/cmd/clear.go index dd37370d..95253340 100644 --- a/walt/cmd/clear.go +++ b/walt/cmd/clear.go @@ -3,8 +3,7 @@ package cmd import ( "fmt" - "rsprox/walt/internal" - + "github.com/blurite/rsprox/walt/internal" "github.com/spf13/cobra" ) diff --git a/walt/cmd/remove.go b/walt/cmd/remove.go index c9ccd93e..88688a06 100644 --- a/walt/cmd/remove.go +++ b/walt/cmd/remove.go @@ -2,8 +2,8 @@ package cmd import ( "fmt" - "rsprox/walt/internal" + "github.com/blurite/rsprox/walt/internal" "github.com/spf13/cobra" ) diff --git a/walt/cmd/root_test.go b/walt/cmd/root_test.go index b2d69cb5..8e14ca21 100644 --- a/walt/cmd/root_test.go +++ b/walt/cmd/root_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "rsprox/walt/internal" + "github.com/blurite/rsprox/walt/internal" ) func requireDarwin(t *testing.T) { diff --git a/walt/cmd/status.go b/walt/cmd/status.go index 75616c59..6f4cf866 100644 --- a/walt/cmd/status.go +++ b/walt/cmd/status.go @@ -2,9 +2,9 @@ package cmd import ( "fmt" - "rsprox/walt/internal" "sort" + "github.com/blurite/rsprox/walt/internal" "github.com/spf13/cobra" ) diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go index 45530eca..ea06570d 100644 --- a/walt/cmd/sync.go +++ b/walt/cmd/sync.go @@ -2,9 +2,9 @@ package cmd import ( "fmt" - "rsprox/walt/internal" "sort" + "github.com/blurite/rsprox/walt/internal" "github.com/spf13/cobra" ) diff --git a/walt/go.mod b/walt/go.mod index e83a12ae..d7653c8c 100644 --- a/walt/go.mod +++ b/walt/go.mod @@ -1,4 +1,4 @@ -module rsprox/walt +module github.com/blurite/rsprox/walt go 1.25.1 diff --git a/walt/main.go b/walt/main.go index 9bb169f5..31642296 100644 --- a/walt/main.go +++ b/walt/main.go @@ -1,6 +1,6 @@ package main -import "rsprox/walt/cmd" +import "github.com/blurite/rsprox/walt/cmd" func main() { cmd.Execute() From 34716e7628adb5d25cb8c0291ebfb686a411b6ff Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 14:06:32 -0400 Subject: [PATCH 27/33] formula update to point to main, command docs update (remove --mode flag in sync) --- HomebrewFormula/walt.rb | 11 ++++++----- walt/cmd/add.go | 2 +- walt/cmd/remove.go | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/HomebrewFormula/walt.rb b/HomebrewFormula/walt.rb index dfeb9a3a..85f9d564 100644 --- a/HomebrewFormula/walt.rb +++ b/HomebrewFormula/walt.rb @@ -1,13 +1,14 @@ class Walt < Formula desc "Manage loopback address aliases for RSProx on macOS" homepage "https://github.com/blurite/rsprox" - # update these once tag present - url "https://github.com/blurite/rsprox/archive/refs/tags/v0.1.0.tar.gz" - sha256 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + + # update these on next release - can be automated via GH action + url "https://github.com/blurite/rsprox/archive/refs/tags/v1.0.tar.gz" + sha256 "be0a466572daa88ee6308da5bafe7d2072948097536bfc35a5b1eff5e5f1550a" + license "MIT" - # dev-only: point head to fork - head "https://github.com/doneill612/rsprox.git", branch: "feature/mac-os-world-aliasing-hb" + head "https://github.com/blurite/rsprox.git", branch: "main" depends_on "go" => :build diff --git a/walt/cmd/add.go b/walt/cmd/add.go index 249cdc2b..3b7b51cf 100644 --- a/walt/cmd/add.go +++ b/walt/cmd/add.go @@ -37,7 +37,7 @@ var addCmd = &cobra.Command{ fmt.Fprintf(cmd.OutOrStdout(), "(%d) warnings\n", warnCount) fmt.Fprintf(cmd.ErrOrStderr(), "(%d) errors\n", errCount) if syncNeeded { - fmt.Fprintln(cmd.OutOrStdout(), "One or more of your errors were related to registry synchronization; Try `walt sync --mode=merge` to fix.") + fmt.Fprintln(cmd.OutOrStdout(), "One or more of your errors were related to registry synchronization; Try `walt sync` to fix.") } }, } diff --git a/walt/cmd/remove.go b/walt/cmd/remove.go index 88688a06..d6aa1869 100644 --- a/walt/cmd/remove.go +++ b/walt/cmd/remove.go @@ -43,7 +43,7 @@ var removeCmd = &cobra.Command{ fmt.Fprintf(cmd.OutOrStdout(), "(%d) warnings\n", warnCount) fmt.Fprintf(cmd.ErrOrStderr(), "(%d) errors\n", errCount) if syncNeeded { - fmt.Fprintln(cmd.OutOrStdout(), "One or more of your errors were related to registry synchronization; Try `walt sync --mode=merge` to fix.") + fmt.Fprintln(cmd.OutOrStdout(), "One or more of your errors were related to registry synchronization; Try `walt sync` to fix.") } }, } From ed77539dc612dbd0c00c6c063527f60bc730d1e9 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 14:35:53 -0400 Subject: [PATCH 28/33] README updates --- walt/README.md | 59 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/walt/README.md b/walt/README.md index b720f578..85fb9f63 100644 --- a/walt/README.md +++ b/walt/README.md @@ -1,10 +1,12 @@ # World Aliasing Loopback Tool (`WALT`, macOS Only) -This module contains a simple utility for macOS users that enables the RSProx loopback mechanism to function properly. If you are **not** a macOS user - thanks for stopping by. If you are, keep reading! +This module contains a simple utility for macOS users that enables the RSProx loopback mechanism to function properly. +If you are **not** a macOS user — thanks for stopping by. If you are, keep reading! ## Motivation -In legacy releases, macOS users would have to manually manage and run this script: + +In the current release, macOS users have to manually manage and run a shell script to alias ranges of loopback addresses: ```bash #!/bin/bash @@ -23,11 +25,54 @@ MODE=+ # "+" to whitelist, "-" to un-whitelist echo "Alias IPs added for worlds $MIN_WORLD_ID..$MAX_WORLD_ID (group $GROUP_ID)." ``` -`WALT` puts some formality around this script and exposes it through an easy-to-use and well-documented -CLI. +`WALT` puts formality around this script and exposes it through a well-documented CLI with +support for add/remove, status, sync, and clear operations. + +It also maintains a **registry file** which lives +at your `XDG_STATE_HOME` location if you have this environment variable set, or at `~/.local/state/walt/aliases.txt`, and tracks loopback aliases you want to have set. Upon reboot, you can sync your `lo0` network interface with the registry file (see [Usage section](#usage)). + +## Requirements and Installation + +Make sure you have [Xcode 16.4 or higher](https://xcodereleases.com/) and [Homebrew](https://brew.sh/) installed. + +Since `WALT` is not part of the v1.0 release, you will need to tap this repo and install the development build. + +```bash +brew tap blurite/rsprox https://github.com/blurite/rsprox.git +brew install --HEAD blurite/rsprox/walt +``` + +## Usage + +**NOTE**: `walt` requires `sudo` to run - most of these commands will prompt you for your `sudo` password if you have one. + +```bash +walt --help +``` + +#### Common commands: + +```bash +# alias a single world loopback address... +walt add --min=300 --max=300 --group=2 +# ... or a world range +walt add --min=255 --max=258 --group=3 + +# remove those aliases +walt remove --min=255 --max=258 --group=3 + +# sync your registry file with lo0 (useful after system reboots) +walt sync -## Installation +# show all tracked loopbacks (in the registry and on lo0) +# those two should be equivalent! if they're not, use `walt sync` +walt status + +# clear all aliases entirely +walt clear +``` -`WALT` can be installed via homebrew. +## Reporting Issues -> homebrew instructions later. \ No newline at end of file +If you find issues or have ideas for improvement, open a PR or discussion. Bug reports +specific to Homebrew installation should include your `brew doctor` output. \ No newline at end of file From 3113f8e5cee1806caf8feddbea1361167ae0cbc2 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 14:52:37 -0400 Subject: [PATCH 29/33] relax group constraint (>=2), fix e2e test to NOT use -n flag on sudo check. --- walt/cmd/root.go | 6 +++--- walt/cmd/root_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/walt/cmd/root.go b/walt/cmd/root.go index 1e034125..d90e62ab 100644 --- a/walt/cmd/root.go +++ b/walt/cmd/root.go @@ -90,7 +90,7 @@ func init() { "group", "g", defaultGroup, - "group ID (2 => OSRS (DO NOT USE! Will fail), 3+ => custom targets)", + "group ID (2 => OSRS, 3+ => custom targets)", ) } @@ -101,7 +101,7 @@ func rootPreRun(command *cobra.Command, args []string) { if minWorld > maxWorld { minWorld, maxWorld = maxWorld, minWorld } - if group < 3 { - panic(fmt.Errorf("macOS requires group >= 3, got %d", group)) + if group < 2 { + panic(fmt.Errorf("macOS requires group >= 2, got %d", group)) } } diff --git a/walt/cmd/root_test.go b/walt/cmd/root_test.go index 8e14ca21..4b7d2530 100644 --- a/walt/cmd/root_test.go +++ b/walt/cmd/root_test.go @@ -30,8 +30,8 @@ func requireSudo(t *testing.T) { t.Helper() // CI on macOS runners via GH actions supports passwordless sudo // locally this will pass only if cahced ticket present or passwordless sudo. - if err := exec.Command("sudo", "-n", "-v").Run(); err != nil { - t.Skip("e2e: sudo -n -v failed (no non-interactive sudo). Run locally with WALT_E2E=1 after elevating or configure CI on macOS.") + if err := exec.Command("sudo", "-v").Run(); err != nil { + t.Skip("e2e: sudo -v failed. Run locally with WALT_E2E=1 after elevating or configure CI on macOS.") } } From a0c2e7e259a73b3818ebb4effbd4ab3e816419ac Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 14:54:07 -0400 Subject: [PATCH 30/33] README update --- walt/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/walt/README.md b/walt/README.md index 85fb9f63..f9d796cb 100644 --- a/walt/README.md +++ b/walt/README.md @@ -42,6 +42,8 @@ brew tap blurite/rsprox https://github.com/blurite/rsprox.git brew install --HEAD blurite/rsprox/walt ``` +If `WALT` is incorporated into future releases, the `--HEAD` flag will not be required. + ## Usage **NOTE**: `walt` requires `sudo` to run - most of these commands will prompt you for your `sudo` password if you have one. From 2c540002232e9c73c282fa5a08b73b461a20af23 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 15:53:00 -0400 Subject: [PATCH 31/33] workflow for unit tests --- .github/workflows/walt.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .github/workflows/walt.yml diff --git a/.github/workflows/walt.yml b/.github/workflows/walt.yml new file mode 100644 index 00000000..b3e3d279 --- /dev/null +++ b/.github/workflows/walt.yml @@ -0,0 +1,10 @@ +name: e2e +on: [push, pull_request] +jobs: + mac-e2e: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: "1.25.1" } + - run: WALT_E2E=1 go test ./... -v From e324cf448c9b26cd42b3c403fbdad55f98c592bb Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 15:55:19 -0400 Subject: [PATCH 32/33] forgot to change working directory --- .github/workflows/walt.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/walt.yml b/.github/workflows/walt.yml index b3e3d279..3ff2ff14 100644 --- a/.github/workflows/walt.yml +++ b/.github/workflows/walt.yml @@ -1,10 +1,25 @@ name: e2e on: [push, pull_request] + jobs: mac-e2e: runs-on: macos-latest + defaults: + run: + working-directory: walt steps: - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 - with: { go-version: "1.25.1" } - - run: WALT_E2E=1 go test ./... -v + with: + go-version: '1.25.1' + cache: true + cache-dependency-path: walt/go.sum # cache correct go.sum + + - name: Sudo preflight + run: sudo -n -v + + - name: Run tests (includes E2E when WALT_E2E=1) + env: + WALT_E2E: "1" + run: go test ./... -v From 6062e29531785de34202e8d597fe4ac623f59ff4 Mon Sep 17 00:00:00 2001 From: David O'Neill Date: Mon, 8 Sep 2025 18:04:17 -0400 Subject: [PATCH 33/33] master not main --- HomebrewFormula/walt.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HomebrewFormula/walt.rb b/HomebrewFormula/walt.rb index 85f9d564..1faa52e7 100644 --- a/HomebrewFormula/walt.rb +++ b/HomebrewFormula/walt.rb @@ -8,7 +8,7 @@ class Walt < Formula license "MIT" - head "https://github.com/blurite/rsprox.git", branch: "main" + head "https://github.com/blurite/rsprox.git", branch: "master" depends_on "go" => :build