Compare commits

..

10 Commits

Author SHA1 Message Date
Paul Payne
c85d27a5d1 Use default vite port 5173 for Central web (Cloud uses 5174) 2026-07-14 15:51:29 +00:00
Paul Payne
a98557cb55 Simplify .envrc.example, dev env vars moved to parent 2026-07-14 15:49:37 +00:00
Paul Payne
0437494715 Remove dist .envrc, update env var hints to be generic 2026-07-14 15:38:07 +00:00
Paul Payne
e8b6449df3 Bump version to 0.2.0 2026-07-14 15:32:51 +00:00
Paul Payne
245d260a2b Rename wild-cloud → wild-central for all managed config files and nftables table
Completes the naming separation: nftables table, rules file, dnsmasq config,
resolved config, systemd unit, sudoers rule, and temp files now all use
the wild-central name.
2026-07-14 15:31:50 +00:00
Paul Payne
282d255c0a Gitignore .envrc, add .envrc.example
.envrc contains local config (data dir paths) and should not be tracked.
Add .envrc.example documenting available env vars with defaults.
2026-07-14 15:12:57 +00:00
Paul Payne
519df610e8 Use standard ports for dev: API 5055, NATS 4222 (same as prod) 2026-07-14 15:01:32 +00:00
Paul Payne
93700d7956 Separate Central data dir from Cloud — use /var/lib/wild-central (prod default) 2026-07-14 14:33:40 +00:00
Paul Payne
6f85d25362 Add login screen for API token authentication
AuthGate component wraps the app layout:
- On load, checks if saved token is valid by calling a protected endpoint
- If auth not required (dev mode), passes through immediately
- If auth required and no valid token, shows a clean login screen
- Token input with password field, stored in localStorage on success
- Shows guidance: "Find your token in secrets.yaml under api.bearerToken"
- Handles connection errors gracefully (shows app anyway)
2026-07-14 13:21:34 +00:00
Paul Payne
f5a030fd44 Add bearer token API authentication
- Auto-generate random 32-char bearer token on first startup, stored in
  secrets.yaml as api.bearerToken
- BearerAuthMiddleware checks Authorization: Bearer <token> on all /api/
  endpoints except /health, /health/reconcile, /events (SSE), and non-API
  paths (frontend static files)
- Development mode (WILD_CENTRAL_ENV=development) skips auth entirely
- Web app ApiClient: add setToken/clearToken/hasToken methods, persist
  token in localStorage, automatically include Authorization header on
  all API requests
- Token can be found in secrets.yaml for CLI/automation use
2026-07-14 12:45:22 +00:00
22 changed files with 327 additions and 91 deletions

7
.envrc
View File

@@ -1,7 +0,0 @@
# Wild Central dev environment
# Shares data dir with Wild Cloud for development
export WILD_CENTRAL_ENV=development
export WILD_CENTRAL_DATA_DIR=$HOME/repos/wild-cloud-dev/wild-cloud-redmond-data
export WILD_CENTRAL_PORT=15055
export WILD_CENTRAL_NATS_PORT=14222
export WILD_CENTRAL_VITE_URL=http://localhost:5174

8
.envrc.example Normal file
View File

@@ -0,0 +1,8 @@
# Wild Central dev environment
# Most dev env vars are set in the parent wild-cloud-dev/.envrc
# Only create a local .envrc if you need to override defaults:
# export WILD_CENTRAL_DATA_DIR=/var/lib/wild-central
# export WILD_CENTRAL_PORT=5055
# export WILD_CENTRAL_NATS_PORT=4222
# export WILD_CENTRAL_VITE_URL=http://localhost:5173

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ coverage.out
coverage.html coverage.html
.claude/ .claude/
CLAUDE.md CLAUDE.md
.envrc

View File

@@ -1 +1 @@
0.1.0 0.2.0

8
dist/Makefile vendored
View File

@@ -65,7 +65,7 @@ help:
@echo " GITEA_TOKEN - Gitea API token (required for release)" @echo " GITEA_TOKEN - Gitea API token (required for release)"
@echo " APTLY_URL - Aptly instance URL (required for APT deployment)" @echo " APTLY_URL - Aptly instance URL (required for APT deployment)"
@echo " APTLY_PASS - Aptly API password" @echo " APTLY_PASS - Aptly API password"
@echo " Run: source .envrc" @echo " These must be set in your environment"
clean: clean:
@echo "Cleaning build artifacts..." @echo "Cleaning build artifacts..."
@@ -119,7 +119,7 @@ deploy-repo: $(ARM64_DEB) $(AMD64_DEB)
@echo "Deploying APT repository..." @echo "Deploying APT repository..."
@if [ -z "$(APTLY_URL)" ] || [ -z "$(APTLY_PASS)" ]; then \ @if [ -z "$(APTLY_URL)" ] || [ -z "$(APTLY_PASS)" ]; then \
echo "Error: APTLY_URL and APTLY_PASS must be set"; \ echo "Error: APTLY_URL and APTLY_PASS must be set"; \
echo " Run: source .envrc"; \ echo " These must be set in your environment"; \
exit 1; \ exit 1; \
fi fi
@./scripts/deploy-apt-repository.sh @./scripts/deploy-apt-repository.sh
@@ -129,7 +129,7 @@ release: $(ARM64_DEB) $(AMD64_DEB)
@if [ -z "$(GITEA_TOKEN)" ]; then \ @if [ -z "$(GITEA_TOKEN)" ]; then \
echo "Error: GITEA_TOKEN environment variable not set"; \ echo "Error: GITEA_TOKEN environment variable not set"; \
echo " Get a token from $(GITEA_URL)/user/settings/applications"; \ echo " Get a token from $(GITEA_URL)/user/settings/applications"; \
echo " Or run: source .envrc"; \ echo " These must be set in your environment"; \
exit 1; \ exit 1; \
fi fi
@if git tag | grep -q "^$(RELEASE_TAG)$$"; then \ @if git tag | grep -q "^$(RELEASE_TAG)$$"; then \
@@ -150,7 +150,7 @@ release-edge: $(ARM64_DEB) $(AMD64_DEB)
@echo "Creating edge release ($(EDGE_VERSION))..." @echo "Creating edge release ($(EDGE_VERSION))..."
@if [ -z "$(GITEA_TOKEN)" ]; then \ @if [ -z "$(GITEA_TOKEN)" ]; then \
echo "Error: GITEA_TOKEN environment variable not set"; \ echo "Error: GITEA_TOKEN environment variable not set"; \
echo " Or run: source .envrc"; \ echo " These must be set in your environment"; \
exit 1; \ exit 1; \
fi fi
@echo "Force-pushing edge tag to HEAD..." @echo "Force-pushing edge tag to HEAD..."

2
dist/README.md vendored
View File

@@ -71,6 +71,6 @@ dist/bin/ Standalone binaries
dist/packages/ .deb packages dist/packages/ .deb packages
``` ```
Environment variables (see `.envrc`): Environment variables (must be set in your environment):
- `GITEA_TOKEN` — required for `make release` / `make release-edge` - `GITEA_TOKEN` — required for `make release` / `make release-edge`
- `APTLY_URL` / `APTLY_PASS` — required for APT deployment - `APTLY_URL` / `APTLY_PASS` — required for APT deployment

View File

@@ -28,16 +28,16 @@ case "$1" in
chgrp wildcloud /etc/dnsmasq.d chgrp wildcloud /etc/dnsmasq.d
chmod 775 /etc/dnsmasq.d chmod 775 /etc/dnsmasq.d
# Create or fix ownership of wild-cloud.conf # Create or fix ownership of wild-central.conf
if [ ! -f /etc/dnsmasq.d/wild-cloud.conf ]; then if [ ! -f /etc/dnsmasq.d/wild-central.conf ]; then
touch /etc/dnsmasq.d/wild-cloud.conf touch /etc/dnsmasq.d/wild-central.conf
echo "Created /etc/dnsmasq.d/wild-cloud.conf" echo "Created /etc/dnsmasq.d/wild-central.conf"
else else
echo "Found existing /etc/dnsmasq.d/wild-cloud.conf - updating ownership" echo "Found existing /etc/dnsmasq.d/wild-central.conf - updating ownership"
fi fi
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud.conf chown wildcloud:wildcloud /etc/dnsmasq.d/wild-central.conf
chmod 644 /etc/dnsmasq.d/wild-cloud.conf chmod 644 /etc/dnsmasq.d/wild-central.conf
# Set ownership and permissions for instance configs directory # Set ownership and permissions for instance configs directory
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud-instances chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud-instances
@@ -47,14 +47,14 @@ case "$1" in
# Set up systemd-resolved configuration directory and file # Set up systemd-resolved configuration directory and file
mkdir -p /etc/systemd/resolved.conf.d mkdir -p /etc/systemd/resolved.conf.d
if [ ! -f /etc/systemd/resolved.conf.d/wild-cloud.conf ]; then if [ ! -f /etc/systemd/resolved.conf.d/wild-central.conf ]; then
touch /etc/systemd/resolved.conf.d/wild-cloud.conf touch /etc/systemd/resolved.conf.d/wild-central.conf
echo "Created /etc/systemd/resolved.conf.d/wild-cloud.conf" echo "Created /etc/systemd/resolved.conf.d/wild-central.conf"
else else
echo "Found existing /etc/systemd/resolved.conf.d/wild-cloud.conf" echo "Found existing /etc/systemd/resolved.conf.d/wild-central.conf"
fi fi
chown wildcloud:wildcloud /etc/systemd/resolved.conf.d/wild-cloud.conf chown wildcloud:wildcloud /etc/systemd/resolved.conf.d/wild-central.conf
chmod 644 /etc/systemd/resolved.conf.d/wild-cloud.conf chmod 644 /etc/systemd/resolved.conf.d/wild-central.conf
# Ensure /etc/resolv.conf is a symlink to systemd-resolved stub # Ensure /etc/resolv.conf is a symlink to systemd-resolved stub
# Skip in Docker/container environments where resolv.conf might be bind-mounted # Skip in Docker/container environments where resolv.conf might be bind-mounted
@@ -101,16 +101,16 @@ case "$1" in
# Set up nftables configuration directory # Set up nftables configuration directory
mkdir -p /etc/nftables.d mkdir -p /etc/nftables.d
if [ ! -f /etc/nftables.d/wild-cloud.nft ]; then if [ ! -f /etc/nftables.d/wild-central.nft ]; then
touch /etc/nftables.d/wild-cloud.nft touch /etc/nftables.d/wild-central.nft
echo "Created /etc/nftables.d/wild-cloud.nft" echo "Created /etc/nftables.d/wild-central.nft"
fi fi
chown wildcloud:wildcloud /etc/nftables.d/wild-cloud.nft chown wildcloud:wildcloud /etc/nftables.d/wild-central.nft
chmod 644 /etc/nftables.d/wild-cloud.nft chmod 644 /etc/nftables.d/wild-central.nft
# Ensure nftables.conf includes wild-cloud rules # Ensure nftables.conf includes wild-central rules
if [ -f /etc/nftables.conf ] && ! grep -q 'wild-cloud.nft' /etc/nftables.conf; then if [ -f /etc/nftables.conf ] && ! grep -q 'wild-central.nft' /etc/nftables.conf; then
echo 'include "/etc/nftables.d/wild-cloud.nft"' >> /etc/nftables.conf echo 'include "/etc/nftables.d/wild-central.nft"' >> /etc/nftables.conf
echo "Added Wild Central nftables include to /etc/nftables.conf" echo "Added Wild Central nftables include to /etc/nftables.conf"
fi fi
echo "Configured nftables for Wild Central management" echo "Configured nftables for Wild Central management"
@@ -192,7 +192,7 @@ AUTHELIA_EOF
# Install sudoers rules for privileged operations # Install sudoers rules for privileged operations
cat > /etc/sudoers.d/wild-central << 'SUDOERS_EOF' cat > /etc/sudoers.d/wild-central << 'SUDOERS_EOF'
wildcloud ALL=(ALL) NOPASSWD: /usr/sbin/nft -c -f *, /usr/sbin/nft list table inet wild-cloud, /usr/bin/cscli bouncers *, /usr/bin/cscli machines *, /usr/bin/cscli decisions *, /usr/bin/cscli alerts *, /usr/bin/cscli version, /usr/bin/wg-quick up wg0, /usr/bin/wg-quick down wg0, /usr/bin/wg show wg0, /usr/bin/certbot *, /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx wildcloud ALL=(ALL) NOPASSWD: /usr/sbin/nft -c -f *, /usr/sbin/nft list table inet wild-central, /usr/bin/cscli bouncers *, /usr/bin/cscli machines *, /usr/bin/cscli decisions *, /usr/bin/cscli alerts *, /usr/bin/cscli version, /usr/bin/wg-quick up wg0, /usr/bin/wg-quick down wg0, /usr/bin/wg show wg0, /usr/bin/certbot *, /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx
SUDOERS_EOF SUDOERS_EOF
chmod 440 /etc/sudoers.d/wild-central chmod 440 /etc/sudoers.d/wild-central
echo "Installed sudoers rules for Wild Central" echo "Installed sudoers rules for Wild Central"

View File

@@ -18,14 +18,14 @@ case "$1" in
fi fi
# Remove dnsmasq configuration # Remove dnsmasq configuration
if [ -f /etc/dnsmasq.d/wild-cloud.conf ]; then if [ -f /etc/dnsmasq.d/wild-central.conf ]; then
rm -f /etc/dnsmasq.d/wild-cloud.conf rm -f /etc/dnsmasq.d/wild-central.conf
echo "Removed dnsmasq configuration" echo "Removed dnsmasq configuration"
fi fi
# Remove systemd-resolved configuration # Remove systemd-resolved configuration
if [ -f /etc/systemd/resolved.conf.d/wild-cloud.conf ]; then if [ -f /etc/systemd/resolved.conf.d/wild-central.conf ]; then
rm -f /etc/systemd/resolved.conf.d/wild-cloud.conf rm -f /etc/systemd/resolved.conf.d/wild-central.conf
echo "Removed systemd-resolved configuration" echo "Removed systemd-resolved configuration"
systemctl restart systemd-resolved 2>/dev/null || true systemctl restart systemd-resolved 2>/dev/null || true
fi fi

View File

@@ -5,5 +5,5 @@ After=network.target nftables.service
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/sbin/nft -f /etc/nftables.d/wild-cloud.nft ExecStart=/usr/sbin/nft -f /etc/nftables.d/wild-central.nft
RemainAfterExit=no RemainAfterExit=no

View File

@@ -60,9 +60,9 @@ type API struct {
// NewAPI creates a new Central API handler with all dependencies // NewAPI creates a new Central API handler with all dependencies
func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) { func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
// Determine config paths from env or defaults // Determine config paths from env or defaults
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-cloud.conf") dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-central.conf")
haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg") haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg")
nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-cloud.nft") nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-central.nft")
vpnConfigPath := envOrDefault("WILD_CENTRAL_VPN_CONFIG_PATH", "/etc/wireguard/wg0.conf") vpnConfigPath := envOrDefault("WILD_CENTRAL_VPN_CONFIG_PATH", "/etc/wireguard/wg0.conf")
sseManager := sse.NewManager() sseManager := sse.NewManager()
@@ -256,10 +256,16 @@ func (api *API) StartConvergenceLoop(ctx gocontext.Context, interval time.Durati
slog.Info("convergence loop started", "component", "reconcile", "interval", interval) slog.Info("convergence loop started", "component", "reconcile", "interval", interval)
} }
func (api *API) RegisterRoutes(r *mux.Router) { func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool) {
r.Use(RequestLoggingMiddleware) r.Use(RequestLoggingMiddleware)
// Health // Bearer token authentication — protects all /api/ routes.
// Dev mode skips auth for ease of development.
if !devMode && bearerToken != "" {
r.Use(BearerAuthMiddleware(bearerToken))
}
// Health (accessible even with auth — middleware exempts /health)
r.HandleFunc("/api/v1/health/reconcile", api.ReconcileHealth).Methods("GET") r.HandleFunc("/api/v1/health/reconcile", api.ReconcileHealth).Methods("GET")
// Resource-oriented settings (persisted in state.yaml) // Resource-oriented settings (persisted in state.yaml)
@@ -534,8 +540,8 @@ func getDaemonStatus() map[string]map[string]any {
result[name] = entry result[name] = entry
} }
// nftables has no persistent service — check if the wild-cloud table exists // nftables has no persistent service — check if the wild-central table exists
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run() nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-central").Run()
nftEntry := map[string]any{"active": nftErr == nil} nftEntry := map[string]any{"active": nftErr == nil}
if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil { if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil {
// "nftables v1.0.9 (Old Doc Yak #3)" // "nftables v1.0.9 (Old Doc Yak #3)"

View File

@@ -10,7 +10,7 @@ import (
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/haproxy"
) )
// NftablesStatus returns the current wild-cloud nftables table contents // NftablesStatus returns the current wild-central nftables table contents
func (api *API) NftablesStatus(w http.ResponseWriter, r *http.Request) { func (api *API) NftablesStatus(w http.ResponseWriter, r *http.Request) {
rules, err := api.nftables.GetStatus() rules, err := api.nftables.GetStatus()
if err != nil { if err != nil {
@@ -56,7 +56,7 @@ func (api *API) vpnAutoUDPPorts() []int {
func (api *API) syncNftablesOnly(globalCfg *config.State) { func (api *API) syncNftablesOnly(globalCfg *config.State) {
nftCfg := globalCfg.Cloud.Nftables nftCfg := globalCfg.Cloud.Nftables
// Explicitly disabled: flush the wild-cloud table // Explicitly disabled: flush the wild-central table
if nftCfg.Enabled != nil && !*nftCfg.Enabled { if nftCfg.Enabled != nil && !*nftCfg.Enabled {
if err := api.nftables.WriteDisabledRules(); err != nil { if err := api.nftables.WriteDisabledRules(); err != nil {
slog.Error("failed to write disabled nftables rules", "component", "nftables-sync", "error", err) slog.Error("failed to write disabled nftables rules", "component", "nftables-sync", "error", err)

View File

@@ -22,7 +22,7 @@ func setupTestNftables(t *testing.T) (*API, string) {
} }
// TestNftablesStatus_ReturnsOK verifies the status endpoint returns 200. // TestNftablesStatus_ReturnsOK verifies the status endpoint returns 200.
// GetStatus() runs `nft list table inet wild-cloud` and silently returns an // GetStatus() runs `nft list table inet wild-central` and silently returns an
// empty string if nft is not installed or the table doesn't exist, so this // empty string if nft is not installed or the table doesn't exist, so this
// endpoint always returns 200. // endpoint always returns 200.
func TestNftablesStatus_ReturnsOK(t *testing.T) { func TestNftablesStatus_ReturnsOK(t *testing.T) {
@@ -109,7 +109,7 @@ func TestNftablesApply_ServiceUnavailable(t *testing.T) {
api.NftablesApply(w, req) api.NftablesApply(w, req)
// ApplyRules calls `systemctl start wild-cloud-nftables-reload.service` // ApplyRules calls `systemctl start wild-central-nftables-reload.service`
// which requires polkit/root — always fails in test environments. // which requires polkit/root — always fails in test environments.
if w.Code != http.StatusInternalServerError { if w.Code != http.StatusInternalServerError {
// If it happened to succeed (running on the actual Wild Central device // If it happened to succeed (running on the actual Wild Central device

View File

@@ -37,6 +37,43 @@ func (w *statusResponseWriter) Flush() {
} }
} }
// BearerAuthMiddleware returns middleware that requires a valid Bearer token
// on all API endpoints except health checks and the SSE event stream.
func BearerAuthMiddleware(token string) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Public endpoints — no auth required
if path == "/api/v1/health" || path == "/api/v1/health/reconcile" ||
strings.HasSuffix(path, "/events") {
next.ServeHTTP(w, r)
return
}
// Non-API paths (frontend static files) — no auth required
if !strings.HasPrefix(path, "/api/") {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
if auth == "" {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) || auth[len(prefix):] != token {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequestLoggingMiddleware logs method, path, status, and duration for each request. // RequestLoggingMiddleware logs method, path, status, and duration for each request.
// Long-lived connections (SSE, WebSocket) are excluded. // Long-lived connections (SSE, WebSocket) are excluded.
func RequestLoggingMiddleware(next http.Handler) http.Handler { func RequestLoggingMiddleware(next http.Handler) http.Handler {

View File

@@ -34,7 +34,7 @@ type Manager struct {
// NewManager creates a new dnsmasq config generator // NewManager creates a new dnsmasq config generator
func NewManager(configPath string) *Manager { func NewManager(configPath string) *Manager {
if configPath == "" { if configPath == "" {
configPath = "/etc/dnsmasq.d/wild-cloud.conf" configPath = "/etc/dnsmasq.d/wild-central.conf"
} }
return &Manager{ return &Manager{
configPath: configPath, configPath: configPath,
@@ -388,7 +388,7 @@ func (g *Manager) ConfigureSystemDNS() error {
// Write systemd-resolved configuration to file owned by wildcloud user // Write systemd-resolved configuration to file owned by wildcloud user
// (created during package installation in postinst) // (created during package installation in postinst)
resolvedConfPath := "/etc/systemd/resolved.conf.d/wild-cloud.conf" resolvedConfPath := "/etc/systemd/resolved.conf.d/wild-central.conf"
resolvedConf := fmt.Sprintf("[Resolve]\nDNS=%s\nDomains=~.\n", dnsIP) resolvedConf := fmt.Sprintf("[Resolve]\nDNS=%s\nDomains=~.\n", dnsIP)
if err := os.WriteFile(resolvedConfPath, []byte(resolvedConf), 0644); err != nil { if err := os.WriteFile(resolvedConfPath, []byte(resolvedConf), 0644); err != nil {

View File

@@ -191,8 +191,8 @@ func TestNewManager_CustomPath(t *testing.T) {
// Test: NewManager uses default path when empty // Test: NewManager uses default path when empty
func TestNewManager_DefaultPath(t *testing.T) { func TestNewManager_DefaultPath(t *testing.T) {
g := NewManager("") g := NewManager("")
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-cloud.conf" { if g.GetConfigPath() != "/etc/dnsmasq.d/wild-central.conf" {
t.Errorf("got %q, want /etc/dnsmasq.d/wild-cloud.conf", g.GetConfigPath()) t.Errorf("got %q, want /etc/dnsmasq.d/wild-central.conf", g.GetConfigPath())
} }
} }

View File

@@ -11,7 +11,7 @@ import (
"github.com/wild-cloud/wild-central/internal/storage" "github.com/wild-cloud/wild-central/internal/storage"
) )
const defaultRulesPath = "/etc/nftables.d/wild-cloud.nft" const defaultRulesPath = "/etc/nftables.d/wild-central.nft"
// Manager handles nftables rule generation and application // Manager handles nftables rule generation and application
type Manager struct { type Manager struct {
@@ -68,15 +68,15 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
var sb strings.Builder var sb strings.Builder
sb.WriteString("# Wild Cloud nftables rules\n") sb.WriteString("# Wild Central nftables rules\n")
sb.WriteString("# Managed by Wild Cloud Central API — do not edit manually\n\n") sb.WriteString("# Managed by Wild Central — do not edit manually\n\n")
// Delete and recreate the table so re-applying never accumulates stale set elements. // Delete and recreate the table so re-applying never accumulates stale set elements.
// The first line ensures the table exists (so delete never errors on first run). // The first line ensures the table exists (so delete never errors on first run).
sb.WriteString("table inet wild-cloud {}\n") sb.WriteString("table inet wild-central {}\n")
sb.WriteString("delete table inet wild-cloud\n\n") sb.WriteString("delete table inet wild-central\n\n")
sb.WriteString("table inet wild-cloud {\n") sb.WriteString("table inet wild-central {\n")
sb.WriteString(" # TCP ports allowed through the firewall (HAProxy + extra TCP)\n") sb.WriteString(" # TCP ports allowed through the firewall (HAProxy + extra TCP)\n")
sb.WriteString(" set allowed_tcp_ports {\n") sb.WriteString(" set allowed_tcp_ports {\n")
sb.WriteString(" type inet_service\n") sb.WriteString(" type inet_service\n")
@@ -148,7 +148,7 @@ func ValidateWANInterface(name string) error {
// On apply failure, rolls back to the previous rules. // On apply failure, rolls back to the previous rules.
func (m *Manager) SafeApply(content string) error { func (m *Manager) SafeApply(content string) error {
// Validate syntax // Validate syntax
tmpFile := "/tmp/wild-cloud-nft-safeapply.tmp" tmpFile := "/tmp/wild-central-nft-safeapply.tmp"
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
return fmt.Errorf("writing validation file: %w", err) return fmt.Errorf("writing validation file: %w", err)
} }
@@ -204,7 +204,7 @@ func (m *Manager) verify() error {
// Validation uses a temp file in /tmp (world-writable) to avoid needing // Validation uses a temp file in /tmp (world-writable) to avoid needing
// write permission on the /etc/nftables.d/ directory itself. // write permission on the /etc/nftables.d/ directory itself.
func (m *Manager) WriteRules(content string) error { func (m *Manager) WriteRules(content string) error {
tempFile := "/tmp/wild-cloud-nft-validate.tmp" tempFile := "/tmp/wild-central-nft-validate.tmp"
if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil { if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil {
return fmt.Errorf("writing temp rules: %w", err) return fmt.Errorf("writing temp rules: %w", err)
@@ -224,9 +224,9 @@ func (m *Manager) WriteRules(content string) error {
} }
// ApplyRules loads the rules file into the kernel via a systemd oneshot service. // ApplyRules loads the rules file into the kernel via a systemd oneshot service.
// The wildcloud user has polkit permission to start wild-cloud-nftables-reload.service. // The wildcloud user has polkit permission to start wild-central-nftables-reload.service.
func (m *Manager) ApplyRules() error { func (m *Manager) ApplyRules() error {
cmd := exec.Command("systemctl", "start", "wild-cloud-nftables-reload.service") cmd := exec.Command("systemctl", "start", "wild-central-nftables-reload.service")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("applying nftables rules: %w (output: %s)", err, string(output)) return fmt.Errorf("applying nftables rules: %w (output: %s)", err, string(output))
@@ -235,13 +235,13 @@ func (m *Manager) ApplyRules() error {
return nil return nil
} }
// WriteDisabledRules writes a rules file that flushes the wild-cloud table, // WriteDisabledRules writes a rules file that flushes the wild-central table,
// removing all Wild Cloud firewall rules from the kernel when applied. // removing all Wild Central firewall rules from the kernel when applied.
func (m *Manager) WriteDisabledRules() error { func (m *Manager) WriteDisabledRules() error {
content := "# Wild Cloud nftables rules — firewall disabled\n" + content := "# Wild Central nftables rules — firewall disabled\n" +
"# Managed by Wild Cloud Central API — do not edit manually\n\n" + "# Managed by Wild Central — do not edit manually\n\n" +
"table inet wild-cloud {}\n" + "table inet wild-central {}\n" +
"delete table inet wild-cloud\n" "delete table inet wild-central\n"
if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil { if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil {
return fmt.Errorf("writing disabled rules file: %w", err) return fmt.Errorf("writing disabled rules file: %w", err)
} }
@@ -249,10 +249,10 @@ func (m *Manager) WriteDisabledRules() error {
return nil return nil
} }
// GetStatus returns the current wild-cloud nftables table as a string. // GetStatus returns the current wild-central nftables table as a string.
// Uses sudo to allow the wildcloud user to read kernel state. // Uses sudo to allow the wildcloud user to read kernel state.
func (m *Manager) GetStatus() (string, error) { func (m *Manager) GetStatus() (string, error) {
cmd := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud") cmd := exec.Command("sudo", "nft", "list", "table", "inet", "wild-central")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
// Table may not exist yet — not an error // Table may not exist yet — not an error

View File

@@ -13,9 +13,9 @@ func TestNewManager_DefaultPath(t *testing.T) {
} }
func TestNewManager_CustomPath(t *testing.T) { func TestNewManager_CustomPath(t *testing.T) {
m := NewManager("/tmp/wild-cloud.nft") m := NewManager("/tmp/wild-central.nft")
if m.GetRulesPath() != "/tmp/wild-cloud.nft" { if m.GetRulesPath() != "/tmp/wild-central.nft" {
t.Errorf("got %q, want /tmp/wild-cloud.nft", m.GetRulesPath()) t.Errorf("got %q, want /tmp/wild-central.nft", m.GetRulesPath())
} }
} }
@@ -40,7 +40,7 @@ func TestGenerate_AlwaysIncludesStructure(t *testing.T) {
out := m.Generate([]int{80, 443}, nil, nil, "") out := m.Generate([]int{80, 443}, nil, nil, "")
for _, want := range []string{ for _, want := range []string{
"table inet wild-cloud", "table inet wild-central",
"set allowed_tcp_ports", "set allowed_tcp_ports",
"chain input", "chain input",
"type filter hook input priority filter; policy accept;", "type filter hook input priority filter; policy accept;",

15
main.go
View File

@@ -119,6 +119,19 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Load or generate API bearer token
apiToken, _ := secretsMgr.GetSecret("api.bearerToken")
if apiToken == "" {
apiToken, _ = secrets.GenerateSecret(32)
_ = secretsMgr.SetSecret("api.bearerToken", apiToken)
slog.Info("generated API bearer token", "component", "startup")
}
isDev := os.Getenv("WILD_CENTRAL_ENV") == "development"
if isDev {
slog.Info("development mode: API authentication disabled", "component", "startup")
}
allowedOrigins := buildAllowedOrigins(dataDir) allowedOrigins := buildAllowedOrigins(dataDir)
api, err := v1.NewAPI(dataDir, Version, allowedOrigins) api, err := v1.NewAPI(dataDir, Version, allowedOrigins)
@@ -137,7 +150,7 @@ func main() {
api.StartDNSFilter(ctx) api.StartDNSFilter(ctx)
router := mux.NewRouter() router := mux.NewRouter()
api.RegisterRoutes(router) api.RegisterRoutes(router, apiToken, isDev)
router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { Card, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Alert, AlertDescription } from './ui/alert';
import { ShieldCheck, Loader2, AlertCircle, LogIn } from 'lucide-react';
import { apiClient } from '../services/api/client';
interface AuthGateProps {
children: React.ReactNode;
}
export function AuthGate({ children }: AuthGateProps) {
const [state, setState] = useState<'checking' | 'authenticated' | 'login'>('checking');
const [token, setToken] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
// On mount, check if saved token is still valid
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
// If no token saved, might be dev mode (no auth required)
try {
const resp = await fetch(`${apiClient.getBaseURL()}/api/v1/health`);
if (!resp.ok) {
setState('login');
return;
}
// Health is public, but try a protected endpoint to see if auth is needed
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (apiClient.hasToken()) {
headers['Authorization'] = `Bearer ${localStorage.getItem('wild-central:token')}`;
}
const statusResp = await fetch(`${apiClient.getBaseURL()}/api/v1/operator`, { headers });
if (statusResp.ok) {
setState('authenticated');
} else if (statusResp.status === 401) {
// Auth required but token is missing or invalid
if (apiClient.hasToken()) {
apiClient.clearToken();
}
setState('login');
} else {
// Other error — maybe server is starting up, treat as authenticated
setState('authenticated');
}
} catch {
// Can't reach server — show app anyway, it'll show connection errors
setState('authenticated');
}
}
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
// Test the token against a protected endpoint
const resp = await fetch(`${apiClient.getBaseURL()}/api/v1/operator`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (resp.ok) {
apiClient.setToken(token);
setState('authenticated');
} else if (resp.status === 401) {
setError('Invalid token');
} else {
setError(`Server error: ${resp.status}`);
}
} catch {
setError('Cannot reach Wild Central');
} finally {
setSubmitting(false);
}
}
if (state === 'checking') {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (state === 'authenticated') {
return <>{children}</>;
}
// Login screen
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Card className="w-full max-w-sm mx-4">
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldCheck className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Wild Central</h1>
<p className="text-sm text-muted-foreground">Enter your API token to continue</p>
</div>
</div>
{error && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleLogin} className="space-y-3">
<div>
<Label htmlFor="token">API Token</Label>
<Input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste your bearer token"
className="mt-1 font-mono"
autoFocus
/>
</div>
<Button type="submit" className="w-full gap-2" disabled={submitting || !token}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
Sign In
</Button>
</form>
<p className="text-xs text-muted-foreground text-center">
Find your token in <code className="bg-muted px-1 rounded">secrets.yaml</code> under <code className="bg-muted px-1 rounded">api.bearerToken</code>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Outlet } from 'react-router'; import { Outlet } from 'react-router';
import { AppSidebar } from '../components/AppSidebar'; import { AppSidebar } from '../components/AppSidebar';
import { AuthGate } from '../components/AuthGate';
import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar'; import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar';
import { HelpProvider, useHelp } from '../contexts/HelpContext'; import { HelpProvider, useHelp } from '../contexts/HelpContext';
import { HelpPanel } from '../components/HelpPanel'; import { HelpPanel } from '../components/HelpPanel';
@@ -46,6 +47,7 @@ export function CentralLayout() {
); );
return ( return (
<AuthGate>
<HelpProvider> <HelpProvider>
<SidebarProvider <SidebarProvider
open={sidebarOpen} open={sidebarOpen}
@@ -57,5 +59,6 @@ export function CentralLayout() {
<CentralLayoutContent /> <CentralLayoutContent />
</SidebarProvider> </SidebarProvider>
</HelpProvider> </HelpProvider>
</AuthGate>
); );
} }

View File

@@ -21,17 +21,43 @@ interface ErrorResponseBody {
import { getApiBaseUrl } from './config'; import { getApiBaseUrl } from './config';
const TOKEN_KEY = 'wild-central:token';
export class ApiClient { export class ApiClient {
private baseUrl: string; private baseUrl: string;
private token: string | null = null;
constructor(baseUrl: string = getApiBaseUrl()) { constructor(baseUrl: string = getApiBaseUrl()) {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
// Load token from localStorage on init
try { this.token = localStorage.getItem(TOKEN_KEY); } catch { /* noop */ }
} }
getBaseURL(): string { getBaseURL(): string {
return this.baseUrl; return this.baseUrl;
} }
setToken(token: string) {
this.token = token;
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* noop */ }
}
clearToken() {
this.token = null;
try { localStorage.removeItem(TOKEN_KEY); } catch { /* noop */ }
}
hasToken(): boolean {
return this.token !== null && this.token !== '';
}
private authHeaders(): Record<string, string> {
if (this.token) {
return { 'Authorization': `Bearer ${this.token}` };
}
return {};
}
private async request<T>( private async request<T>(
endpoint: string, endpoint: string,
options?: RequestInit options?: RequestInit
@@ -43,6 +69,7 @@ export class ApiClient {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...this.authHeaders(),
...options?.headers, ...options?.headers,
}, },
}); });
@@ -104,7 +131,7 @@ export class ApiClient {
async getText(endpoint: string): Promise<string> { async getText(endpoint: string): Promise<string> {
const url = `${this.baseUrl}${endpoint}`; const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url); const response = await fetch(url, { headers: this.authHeaders() });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
@@ -122,7 +149,7 @@ export class ApiClient {
const url = `${this.baseUrl}${endpoint}`; const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain', ...this.authHeaders() },
body: text, body: text,
}); });

View File

@@ -13,7 +13,7 @@ export default defineConfig({
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5174, port: 5173,
strictPort: true, strictPort: true,
allowedHosts: true, allowedHosts: true,
} }