summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-08-02 18:42:02 +0300
committerPaul Buetow <paul@buetow.org>2026-08-02 18:42:02 +0300
commit76e151fc9141ef3ccbafc22c62985a30afb54b02 (patch)
tree9570fc8c9f8479e7a43c9a4d5cd21c4d8b56e91b
parentab0953b56309206617be8a4e0669532a5885b619 (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.md2
-rw-r--r--cmd/snonux/main.go2
-rw-r--r--cmd/snonux/sync.go91
-rw-r--r--cmd/snonux/sync_test.go50
-rw-r--r--internal/config/config.go1
5 files changed, 134 insertions, 12 deletions
diff --git a/README.md b/README.md
index 98f47ee..459f57c 100644
--- a/README.md
+++ b/README.md
@@ -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