diff options
| author | Paul Buetow <paul@buetow.org> | 2026-08-02 18:42:02 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-08-02 18:42:02 +0300 |
| commit | 76e151fc9141ef3ccbafc22c62985a30afb54b02 (patch) | |
| tree | 9570fc8c9f8479e7a43c9a4d5cd21c4d8b56e91b | |
| parent | ab0953b56309206617be8a4e0669532a5885b619 (diff) | |
Add WireGuard fallback for mirror sync
Amp-Thread-ID: https://ampcode.com/threads/T-019fc31d-668a-769a-8dc8-554ecbd6a274
Co-authored-by: Amp <amp@ampcode.com>
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | cmd/snonux/main.go | 2 | ||||
| -rw-r--r-- | cmd/snonux/sync.go | 91 | ||||
| -rw-r--r-- | cmd/snonux/sync_test.go | 50 | ||||
| -rw-r--r-- | internal/config/config.go | 1 |
5 files changed, 134 insertions, 12 deletions
@@ -84,7 +84,7 @@ All source files are removed from the input directory once they have been succes --output DIR Output directory for generated site (default: ./dist) --base-url URL Base URL for Atom feed links (default: https://snonux.foo) --theme NAME Visual theme, or "random" (default: random) ---sync Rsync output to pi0/pi1 after generation +--sync Rsync output to pi0/pi1 after generation (LAN, then WireGuard fallback) --list-themes Print available theme names and exit --version Print version and exit ``` diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go index 853d807..1b3271a 100644 --- a/cmd/snonux/main.go +++ b/cmd/snonux/main.go @@ -94,7 +94,7 @@ func parseFlags(args []string) (*config.Config, cliMode, error) { fs.StringVar(&cfg.OutputDir, "output", "./dist", "root directory for generated static site output") fs.StringVar(&cfg.BaseURL, "base-url", "https://snonux.foo", "canonical base URL used in Atom feed links") fs.StringVar(&cfg.Theme, "theme", "random", "visual theme name, or \"random\" to pick one at random") - fs.BoolVar(&cfg.Sync, "sync", false, "after a successful run, rsync -output to mirror hosts when all are pingable (SSH user: SNONUX_SYNC_USER or login name)") + fs.BoolVar(&cfg.Sync, "sync", false, "after a successful run, rsync -output to mirror hosts when all are pingable (LAN defaults fall back to WireGuard; SSH user: SNONUX_SYNC_USER or login name)") var syncTargets string fs.StringVar(&syncTargets, "sync-targets", "", "comma-separated list of rsync target hosts (overrides SNONUX_SYNC_TARGETS and defaults)") fs.StringVar(&cfg.SyncRemoteDir, "sync-remote-dir", "", "remote destination directory on target hosts (overrides SNONUX_SYNC_REMOTE_DIR and default)") diff --git a/cmd/snonux/sync.go b/cmd/snonux/sync.go index 1eb549e..e6311f4 100644 --- a/cmd/snonux/sync.go +++ b/cmd/snonux/sync.go @@ -17,6 +17,8 @@ import ( // SNONUX_SYNC_USER overrides the SSH username for rsync (default: current login name). const envSyncUser = "SNONUX_SYNC_USER" +const wireGuardJumpHost = "rex@fishfinger.buetow.org:2" + // defaultSyncTargets are the built-in mirror hosts used when no configuration overrides them. var defaultSyncTargets = []string{ "pi0.lan.buetow.org", @@ -57,19 +59,13 @@ func splitAndTrim(s string) []string { } // syncOutput rsyncs localOutput (trailing-slash source) to each sync target over SSH -// port 22. It runs only if every target answers ICMP ping (Linux iputils: ping -c 1 -W …). +// port 22. It runs only if every target or its WireGuard fallback answers ICMP +// ping (Linux iputils: ping -c 1 -W …). // The ctx parameter is accepted for cancellation propagation; it is wired into // exec.CommandContext for the rsync subprocesses. func syncOutput(ctx context.Context, cfg *config.Config) error { resolveSyncConfig(cfg) - for _, host := range cfg.SyncTargets { - if !hostPingable(host) { - log.Printf("sync skipped: %q not pingable (all mirror hosts must be reachable)", host) - return nil - } - } - sshUser := os.Getenv(envSyncUser) if sshUser == "" { u, err := user.Current() @@ -79,20 +75,43 @@ func syncOutput(ctx context.Context, cfg *config.Config) error { sshUser = u.Username } + syncTargets := make([]string, 0, len(cfg.SyncTargets)) + for _, host := range cfg.SyncTargets { + target, ok := reachableSyncTarget(host, hostPingable, func(fallback string) bool { + return hostReachableViaWireGuard(fallback, sshUser) + }) + if !ok { + fallback := wireGuardFallback(host) + if fallback != "" { + log.Printf("sync skipped: neither %q nor WireGuard fallback %q via %q is reachable (all mirror hosts must be reachable)", host, fallback, wireGuardJumpHost) + } else { + log.Printf("sync skipped: %q not pingable (all mirror hosts must be reachable)", host) + } + return nil + } + if target != host { + log.Printf("sync target %q not pingable; using WireGuard fallback %q via %q", host, target, wireGuardJumpHost) + } + syncTargets = append(syncTargets, target) + } + absOut, err := filepath.Abs(cfg.OutputDir) if err != nil { return fmt.Errorf("sync output dir: %w", err) } src := filepath.Clean(absOut) + string(filepath.Separator) - ssh := "ssh -p 22 -o BatchMode=yes -o ConnectTimeout=15" - for _, host := range cfg.SyncTargets { + for _, host := range syncTargets { dest := fmt.Sprintf("%s@%s:%s", sshUser, host, cfg.SyncRemoteDir) log.Printf("rsync %s -> %s", src, dest) // --chmod overrides the locally-generated (mode 600) output permissions: // the remote webserver runs as its own unprivileged user (e.g. bozohttpd's // _httpd), not as the SSH login user, so published files must be // world-readable regardless of local perms. + ssh := "ssh -p 22 -o BatchMode=yes -o ConnectTimeout=15" + if wireGuardFallbackHost(host) { + ssh += " -o HostKeyAlias=" + wireGuardHostKeyAlias(host) + " -J " + wireGuardJumpHost + } cmd := exec.CommandContext(ctx, "rsync", "-az", "--chmod=D755,F644", "-e", ssh, src, dest) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -103,6 +122,58 @@ func syncOutput(ctx context.Context, cfg *config.Config) error { return nil } +func reachableSyncTarget(host string, pingable, fallbackReachable func(string) bool) (string, bool) { + if pingable(host) { + return host, true + } + + fallback := wireGuardFallback(host) + if fallback != "" && fallbackReachable(fallback) { + return fallback, true + } + + return "", false +} + +func wireGuardFallback(host string) string { + switch host { + case "pi0.lan.buetow.org": + return "pi0.wg0" + case "pi1.lan.buetow.org": + return "pi1.wg0" + default: + return "" + } +} + +func wireGuardFallbackHost(host string) bool { + return host == "pi0.wg0" || host == "pi1.wg0" +} + +func wireGuardHostKeyAlias(host string) string { + switch host { + case "pi0.wg0": + return "pi0.lan.buetow.org" + case "pi1.wg0": + return "pi1.lan.buetow.org" + default: + return host + } +} + +func hostReachableViaWireGuard(host, sshUser string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, + "ssh", "-p", "22", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", + "-o", "HostKeyAlias="+wireGuardHostKeyAlias(host), + "-J", wireGuardJumpHost, sshUser+"@"+host, "true", + ) + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() == nil +} + func hostPingable(host string) bool { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/cmd/snonux/sync_test.go b/cmd/snonux/sync_test.go index 8fe34e1..4fb5481 100644 --- a/cmd/snonux/sync_test.go +++ b/cmd/snonux/sync_test.go @@ -84,6 +84,56 @@ func TestResolveSyncConfig_flagsOverrideEnv(t *testing.T) { } } +func TestReachableSyncTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + reachable map[string]bool + fallback map[string]bool + want string + wantOK bool + }{ + { + name: "LAN reachable", + host: "pi0.lan.buetow.org", + reachable: map[string]bool{"pi0.lan.buetow.org": true}, + want: "pi0.lan.buetow.org", + wantOK: true, + }, + { + name: "WireGuard fallback reachable", + host: "pi1.lan.buetow.org", + fallback: map[string]bool{"pi1.wg0": true}, + want: "pi1.wg0", + wantOK: true, + }, + { + name: "neither reachable", + host: "pi0.lan.buetow.org", + }, + { + name: "custom target has no fallback", + host: "custom.example.org", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := reachableSyncTarget(tt.host, func(host string) bool { + return tt.reachable[host] + }, func(host string) bool { + return tt.fallback[host] + }) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("reachableSyncTarget(%q) = (%q, %v), want (%q, %v)", tt.host, got, ok, tt.want, tt.wantOK) + } + }) + } +} + func unsetEnv(t *testing.T, key string) { t.Helper() orig, hadOrig := os.LookupEnv(key) diff --git a/internal/config/config.go b/internal/config/config.go index d2042f5..0423be2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { // SyncTargets are the remote hostnames to rsync to when Sync is true. // Defaults to ["pi0.lan.buetow.org", "pi1.lan.buetow.org"]. + // The default targets fall back to pi0.wg0 and pi1.wg0 when unreachable. // Override with SNONUX_SYNC_TARGETS env var (comma-separated). SyncTargets []string |
