diff --git a/.github/workflows/walt.yml b/.github/workflows/walt.yml new file mode 100644 index 00000000..3ff2ff14 --- /dev/null +++ b/.github/workflows/walt.yml @@ -0,0 +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' + 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 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/HomebrewFormula/walt.rb b/HomebrewFormula/walt.rb new file mode 100644 index 00000000..1faa52e7 --- /dev/null +++ b/HomebrewFormula/walt.rb @@ -0,0 +1,26 @@ +class Walt < Formula + desc "Manage loopback address aliases for RSProx on macOS" + homepage "https://github.com/blurite/rsprox" + + # 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" + + head "https://github.com/blurite/rsprox.git", branch: "master" + + depends_on "go" => :build + + def install + # The main package lives in ./walt + cd "walt" do + system "go", "build", *std_go_args(ldflags: "-s -w") + end + end + + test do + out = shell_output("#{bin}/walt --help") + assert_match "Manage loopback", out + end +end 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/README.md b/walt/README.md new file mode 100644 index 00000000..f9d796cb --- /dev/null +++ b/walt/README.md @@ -0,0 +1,80 @@ +# 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 the current release, macOS users have to manually manage and run a shell script to alias ranges of loopback addresses: + +```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 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 +``` + +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. + +```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 + +# 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 +``` + +## Reporting Issues + +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 diff --git a/walt/cmd/add.go b/walt/cmd/add.go new file mode 100644 index 00000000..3b7b51cf --- /dev/null +++ b/walt/cmd/add.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + + "github.com/blurite/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) + 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 { + 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++ + } + 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 errors were related to registry synchronization; Try `walt sync` to fix.") + } + }, +} + +func init() { + rootCmd.AddCommand(addCmd) +} diff --git a/walt/cmd/clear.go b/walt/cmd/clear.go new file mode 100644 index 00000000..95253340 --- /dev/null +++ b/walt/cmd/clear.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "fmt" + + "github.com/blurite/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/remove.go b/walt/cmd/remove.go new file mode 100644 index 00000000..d6aa1869 --- /dev/null +++ b/walt/cmd/remove.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + + "github.com/blurite/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: 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(), "error: alias %s was removed but the registry failed to update - use the sync command. error: %v\n", ip, err) + errCount++ + 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 errors were related to registry synchronization; Try `walt sync` to fix.") + } + }, +} + +func init() { + rootCmd.AddCommand(removeCmd) +} diff --git a/walt/cmd/root.go b/walt/cmd/root.go new file mode 100644 index 00000000..d90e62ab --- /dev/null +++ b/walt/cmd/root.go @@ -0,0 +1,107 @@ +/* +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 ( + "fmt" + "os" + "runtime" + + "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 = 3 +) + +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: ` + __ ___ _ _____ + \ \ / / \ | | |_ _| + \ \ /\ / / _ \ | | | | + \ 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, +} + +// 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() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + 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( + &group, + "group", + "g", + defaultGroup, + "group ID (2 => OSRS, 3+ => custom targets)", + ) +} + +func rootPreRun(command *cobra.Command, args []string) { + if runtime.GOOS != "darwin" { + panic("walt is only intended to be use on macOS") + } + if minWorld > maxWorld { + minWorld, maxWorld = maxWorld, minWorld + } + 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..4b7d2530 --- /dev/null +++ b/walt/cmd/root_test.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "bytes" + "os" + "os/exec" + "runtime" + "slices" + "strings" + "testing" + + "github.com/blurite/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", "-v").Run(); err != nil { + t.Skip("e2e: sudo -v failed. 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 = 3 + ) + 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=3") + 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", "3") + 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 = 3 + ) + 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) +} diff --git a/walt/cmd/status.go b/walt/cmd/status.go new file mode 100644 index 00000000..6f4cf866 --- /dev/null +++ b/walt/cmd/status.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + "sort" + + "github.com/blurite/rsprox/walt/internal" + "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(), " %s\n", ip) + } + fmt.Fprintf(cmd.OutOrStdout(), "Live on lo0 (%d):\n", len(live)) + for _, ip := range live { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", ip) + } + + fmt.Fprintln(cmd.OutOrStdout(), "Registry but NOT live:") + if len(regOnly) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), " (none)") + } else { + for _, ip := range regOnly { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", ip) + } + } + + fmt.Fprintln(cmd.OutOrStdout(), "Live but NOT in registry:") + if len(liveOnly) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), " (none)") + } else { + for _, ip := range liveOnly { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", ip) + } + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} diff --git a/walt/cmd/sync.go b/walt/cmd/sync.go new file mode 100644 index 00000000..ea06570d --- /dev/null +++ b/walt/cmd/sync.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "fmt" + "sort" + + "github.com/blurite/rsprox/walt/internal" + "github.com/spf13/cobra" +) + +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 + lmr []string // Loopback IPs which are live on the netiface, but not tracked in the registry file +} + +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 + errCount int // Number of errors encountered during sync +} + +// 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 ( + dryRun bool +) + +var syncCmd = &cobra.Command{ + Use: "sync", + 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 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() + if err != nil { + return err + } + result := merge(cmd, conflicts) + fmt.Fprintf( + cmd.OutOrStdout(), + "Sync mode=merge added=%d adopted=%d\n", + result.added, result.adopted, + ) + if result.errCount > 0 { + return fmt.Errorf("(%d) errors", result.errCount) + } + fmt.Fprintf(cmd.OutOrStdout(), "(%d) errors", result.errCount) + return nil + }, +} + +func merge(cmd *cobra.Command, conflicts *SyncConflicts) SyncResult { + plan := NewSyncPlan(conflicts.rml, conflicts.lmr) + + added, adopted, errCount := 0, 0, 0 + var toAdopt []string + + for _, action := range plan.Actions { + switch action.Type { + case Add: + if dryRun { + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] add %s\n", action.Ip) + added++ + continue + } + status, err := internal.Alias(action.Ip) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "alias create for %s failed: %v\n", action.Ip, err) + errCount++ + continue + } + if status { + added++ + } + + case Adopt: + if dryRun { + fmt.Fprintf(cmd.OutOrStdout(), "[dry-run] adopt into registry %s\n", action.Ip) + adopted++ + } else { + 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, + errCount: errCount, + } +} + +func findSyncConflicts() (*SyncConflicts, error) { + curReg, err := internal.Load() + if err != nil { + return &SyncConflicts{}, fmt.Errorf("load registry: %w", err) + } + l, err := internal.GetLiveAliases() + if err != nil { + 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 := live[ip]; !ok { + rml = append(rml, ip) + } + } + for ip := range live { + if _, ok := reg[ip]; !ok { + lmr = append(lmr, ip) + } + } + sort.Strings(rml) + sort.Strings(lmr) + return &SyncConflicts{curReg, rml, lmr}, nil +} + +func init() { + rootCmd.AddCommand(syncCmd) + 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 new file mode 100644 index 00000000..5f733f3a --- /dev/null +++ b/walt/cmd/sync_plan.go @@ -0,0 +1,49 @@ +package cmd + +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) +) + +// 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(rml, lmr []string) SyncPlan { + rml = unique(rml) + lmr = unique(lmr) + var plan SyncPlan + if len(rml) > 0 { + for _, ip := range rml { + plan.Actions = append(plan.Actions, Action{Type: Add, Ip: ip}) + } + } + if len(lmr) > 0 { + for _, ip := range lmr { + plan.Actions = append(plan.Actions, Action{Type: Adopt, Ip: ip}) + } + } + return plan +} + +// 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 +} 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) + } +} diff --git a/walt/go.mod b/walt/go.mod new file mode 100644 index 00000000..d7653c8c --- /dev/null +++ b/walt/go.mod @@ -0,0 +1,10 @@ +module github.com/blurite/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/internal/networking.go b/walt/internal/networking.go new file mode 100644 index 00000000..2242891d --- /dev/null +++ b/walt/internal/networking.go @@ -0,0 +1,69 @@ +package internal + +import ( + "os/exec" + "sort" + "strconv" + "strings" +) + +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) (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) { + 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 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", "lo0").CombinedOutput() + 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) + 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 +} 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") + } +} diff --git a/walt/internal/registry.go b/walt/internal/registry.go new file mode 100644 index 00000000..ec45f68f --- /dev/null +++ b/walt/internal/registry.go @@ -0,0 +1,165 @@ +package internal + +import ( + "errors" + "os" + "path/filepath" + "slices" + "sort" + "strings" +) + +// Reads from the loopback alias registry file and returns all the entries +func Load() ([]string, error) { + path, err := registryPath() + 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]struct{}) + for ln := range strings.SplitSeq(string(b), "\n") { + ip := strings.TrimSpace(ln) + if ip != "" { + seen[ip] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for ip := range seen { + out = append(out, ip) + } + sort.Strings(out) + return out, nil +} + +// 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" + } + + // 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 + } + 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 { + ips, err := Load() + if err != nil { + return err + } + for _, x := range ips { + if x == ip { + return nil + } + } + ips = append(ips, ip) + return Save(ips) +} + +// 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 != "" { + // 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 + } + } + 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 + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "state", "walt", "aliases.txt"), nil +} + +// 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()) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", errors.New(baseMessage + err.Error()) + } + return path, nil +} 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) + } +} diff --git a/walt/internal/sudo.go b/walt/internal/sudo.go new file mode 100644 index 00000000..a680dd7a --- /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 (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} + } 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 +} diff --git a/walt/main.go b/walt/main.go new file mode 100644 index 00000000..31642296 --- /dev/null +++ b/walt/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/blurite/rsprox/walt/cmd" + +func main() { + cmd.Execute() +}