Compare commits
10 Commits
60f3ca4a3a
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c85d27a5d1 | ||
|
|
a98557cb55 | ||
|
|
0437494715 | ||
|
|
e8b6449df3 | ||
|
|
245d260a2b | ||
|
|
282d255c0a | ||
|
|
519df610e8 | ||
|
|
93700d7956 | ||
|
|
6f85d25362 | ||
|
|
f5a030fd44 |
7
.envrc
7
.envrc
@@ -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
8
.envrc.example
Normal 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
1
.gitignore
vendored
@@ -4,3 +4,4 @@ coverage.out
|
||||
coverage.html
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.envrc
|
||||
|
||||
8
dist/Makefile
vendored
8
dist/Makefile
vendored
@@ -65,7 +65,7 @@ help:
|
||||
@echo " GITEA_TOKEN - Gitea API token (required for release)"
|
||||
@echo " APTLY_URL - Aptly instance URL (required for APT deployment)"
|
||||
@echo " APTLY_PASS - Aptly API password"
|
||||
@echo " Run: source .envrc"
|
||||
@echo " These must be set in your environment"
|
||||
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
@@ -119,7 +119,7 @@ deploy-repo: $(ARM64_DEB) $(AMD64_DEB)
|
||||
@echo "Deploying APT repository..."
|
||||
@if [ -z "$(APTLY_URL)" ] || [ -z "$(APTLY_PASS)" ]; then \
|
||||
echo "Error: APTLY_URL and APTLY_PASS must be set"; \
|
||||
echo " Run: source .envrc"; \
|
||||
echo " These must be set in your environment"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@./scripts/deploy-apt-repository.sh
|
||||
@@ -129,7 +129,7 @@ release: $(ARM64_DEB) $(AMD64_DEB)
|
||||
@if [ -z "$(GITEA_TOKEN)" ]; then \
|
||||
echo "Error: GITEA_TOKEN environment variable not set"; \
|
||||
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; \
|
||||
fi
|
||||
@if git tag | grep -q "^$(RELEASE_TAG)$$"; then \
|
||||
@@ -150,7 +150,7 @@ release-edge: $(ARM64_DEB) $(AMD64_DEB)
|
||||
@echo "Creating edge release ($(EDGE_VERSION))..."
|
||||
@if [ -z "$(GITEA_TOKEN)" ]; then \
|
||||
echo "Error: GITEA_TOKEN environment variable not set"; \
|
||||
echo " Or run: source .envrc"; \
|
||||
echo " These must be set in your environment"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Force-pushing edge tag to HEAD..."
|
||||
|
||||
2
dist/README.md
vendored
2
dist/README.md
vendored
@@ -71,6 +71,6 @@ dist/bin/ Standalone binaries
|
||||
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`
|
||||
- `APTLY_URL` / `APTLY_PASS` — required for APT deployment
|
||||
|
||||
44
dist/debian/DEBIAN/postinst
vendored
44
dist/debian/DEBIAN/postinst
vendored
@@ -28,16 +28,16 @@ case "$1" in
|
||||
chgrp wildcloud /etc/dnsmasq.d
|
||||
chmod 775 /etc/dnsmasq.d
|
||||
|
||||
# Create or fix ownership of wild-cloud.conf
|
||||
if [ ! -f /etc/dnsmasq.d/wild-cloud.conf ]; then
|
||||
touch /etc/dnsmasq.d/wild-cloud.conf
|
||||
echo "Created /etc/dnsmasq.d/wild-cloud.conf"
|
||||
# Create or fix ownership of wild-central.conf
|
||||
if [ ! -f /etc/dnsmasq.d/wild-central.conf ]; then
|
||||
touch /etc/dnsmasq.d/wild-central.conf
|
||||
echo "Created /etc/dnsmasq.d/wild-central.conf"
|
||||
else
|
||||
echo "Found existing /etc/dnsmasq.d/wild-cloud.conf - updating ownership"
|
||||
echo "Found existing /etc/dnsmasq.d/wild-central.conf - updating ownership"
|
||||
fi
|
||||
|
||||
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud.conf
|
||||
chmod 644 /etc/dnsmasq.d/wild-cloud.conf
|
||||
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-central.conf
|
||||
chmod 644 /etc/dnsmasq.d/wild-central.conf
|
||||
|
||||
# Set ownership and permissions for instance configs directory
|
||||
chown wildcloud:wildcloud /etc/dnsmasq.d/wild-cloud-instances
|
||||
@@ -47,14 +47,14 @@ case "$1" in
|
||||
# Set up systemd-resolved configuration directory and file
|
||||
mkdir -p /etc/systemd/resolved.conf.d
|
||||
|
||||
if [ ! -f /etc/systemd/resolved.conf.d/wild-cloud.conf ]; then
|
||||
touch /etc/systemd/resolved.conf.d/wild-cloud.conf
|
||||
echo "Created /etc/systemd/resolved.conf.d/wild-cloud.conf"
|
||||
if [ ! -f /etc/systemd/resolved.conf.d/wild-central.conf ]; then
|
||||
touch /etc/systemd/resolved.conf.d/wild-central.conf
|
||||
echo "Created /etc/systemd/resolved.conf.d/wild-central.conf"
|
||||
else
|
||||
echo "Found existing /etc/systemd/resolved.conf.d/wild-cloud.conf"
|
||||
echo "Found existing /etc/systemd/resolved.conf.d/wild-central.conf"
|
||||
fi
|
||||
chown wildcloud:wildcloud /etc/systemd/resolved.conf.d/wild-cloud.conf
|
||||
chmod 644 /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-central.conf
|
||||
|
||||
# Ensure /etc/resolv.conf is a symlink to systemd-resolved stub
|
||||
# Skip in Docker/container environments where resolv.conf might be bind-mounted
|
||||
@@ -101,16 +101,16 @@ case "$1" in
|
||||
|
||||
# Set up nftables configuration directory
|
||||
mkdir -p /etc/nftables.d
|
||||
if [ ! -f /etc/nftables.d/wild-cloud.nft ]; then
|
||||
touch /etc/nftables.d/wild-cloud.nft
|
||||
echo "Created /etc/nftables.d/wild-cloud.nft"
|
||||
if [ ! -f /etc/nftables.d/wild-central.nft ]; then
|
||||
touch /etc/nftables.d/wild-central.nft
|
||||
echo "Created /etc/nftables.d/wild-central.nft"
|
||||
fi
|
||||
chown wildcloud:wildcloud /etc/nftables.d/wild-cloud.nft
|
||||
chmod 644 /etc/nftables.d/wild-cloud.nft
|
||||
chown wildcloud:wildcloud /etc/nftables.d/wild-central.nft
|
||||
chmod 644 /etc/nftables.d/wild-central.nft
|
||||
|
||||
# Ensure nftables.conf includes wild-cloud rules
|
||||
if [ -f /etc/nftables.conf ] && ! grep -q 'wild-cloud.nft' /etc/nftables.conf; then
|
||||
echo 'include "/etc/nftables.d/wild-cloud.nft"' >> /etc/nftables.conf
|
||||
# Ensure nftables.conf includes wild-central rules
|
||||
if [ -f /etc/nftables.conf ] && ! grep -q 'wild-central.nft' /etc/nftables.conf; then
|
||||
echo 'include "/etc/nftables.d/wild-central.nft"' >> /etc/nftables.conf
|
||||
echo "Added Wild Central nftables include to /etc/nftables.conf"
|
||||
fi
|
||||
echo "Configured nftables for Wild Central management"
|
||||
@@ -192,7 +192,7 @@ AUTHELIA_EOF
|
||||
|
||||
# Install sudoers rules for privileged operations
|
||||
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
|
||||
chmod 440 /etc/sudoers.d/wild-central
|
||||
echo "Installed sudoers rules for Wild Central"
|
||||
|
||||
8
dist/debian/DEBIAN/postrm
vendored
8
dist/debian/DEBIAN/postrm
vendored
@@ -18,14 +18,14 @@ case "$1" in
|
||||
fi
|
||||
|
||||
# Remove dnsmasq configuration
|
||||
if [ -f /etc/dnsmasq.d/wild-cloud.conf ]; then
|
||||
rm -f /etc/dnsmasq.d/wild-cloud.conf
|
||||
if [ -f /etc/dnsmasq.d/wild-central.conf ]; then
|
||||
rm -f /etc/dnsmasq.d/wild-central.conf
|
||||
echo "Removed dnsmasq configuration"
|
||||
fi
|
||||
|
||||
# Remove systemd-resolved configuration
|
||||
if [ -f /etc/systemd/resolved.conf.d/wild-cloud.conf ]; then
|
||||
rm -f /etc/systemd/resolved.conf.d/wild-cloud.conf
|
||||
if [ -f /etc/systemd/resolved.conf.d/wild-central.conf ]; then
|
||||
rm -f /etc/systemd/resolved.conf.d/wild-central.conf
|
||||
echo "Removed systemd-resolved configuration"
|
||||
systemctl restart systemd-resolved 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -5,5 +5,5 @@ After=network.target nftables.service
|
||||
|
||||
[Service]
|
||||
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
|
||||
|
||||
@@ -60,9 +60,9 @@ type API struct {
|
||||
// NewAPI creates a new Central API handler with all dependencies
|
||||
func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
||||
// 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")
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool) {
|
||||
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")
|
||||
|
||||
// Resource-oriented settings (persisted in state.yaml)
|
||||
@@ -534,8 +540,8 @@ func getDaemonStatus() map[string]map[string]any {
|
||||
result[name] = entry
|
||||
}
|
||||
|
||||
// nftables has no persistent service — check if the wild-cloud table exists
|
||||
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
|
||||
// nftables has no persistent service — check if the wild-central table exists
|
||||
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-central").Run()
|
||||
nftEntry := map[string]any{"active": nftErr == nil}
|
||||
if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil {
|
||||
// "nftables v1.0.9 (Old Doc Yak #3)"
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"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) {
|
||||
rules, err := api.nftables.GetStatus()
|
||||
if err != nil {
|
||||
@@ -56,7 +56,7 @@ func (api *API) vpnAutoUDPPorts() []int {
|
||||
func (api *API) syncNftablesOnly(globalCfg *config.State) {
|
||||
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 err := api.nftables.WriteDisabledRules(); err != nil {
|
||||
slog.Error("failed to write disabled nftables rules", "component", "nftables-sync", "error", err)
|
||||
|
||||
@@ -22,7 +22,7 @@ func setupTestNftables(t *testing.T) (*API, string) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// endpoint always returns 200.
|
||||
func TestNftablesStatus_ReturnsOK(t *testing.T) {
|
||||
@@ -109,7 +109,7 @@ func TestNftablesApply_ServiceUnavailable(t *testing.T) {
|
||||
|
||||
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.
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
// If it happened to succeed (running on the actual Wild Central device
|
||||
|
||||
@@ -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.
|
||||
// Long-lived connections (SSE, WebSocket) are excluded.
|
||||
func RequestLoggingMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
@@ -34,7 +34,7 @@ type Manager struct {
|
||||
// NewManager creates a new dnsmasq config generator
|
||||
func NewManager(configPath string) *Manager {
|
||||
if configPath == "" {
|
||||
configPath = "/etc/dnsmasq.d/wild-cloud.conf"
|
||||
configPath = "/etc/dnsmasq.d/wild-central.conf"
|
||||
}
|
||||
return &Manager{
|
||||
configPath: configPath,
|
||||
@@ -388,7 +388,7 @@ func (g *Manager) ConfigureSystemDNS() error {
|
||||
|
||||
// Write systemd-resolved configuration to file owned by wildcloud user
|
||||
// (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)
|
||||
|
||||
if err := os.WriteFile(resolvedConfPath, []byte(resolvedConf), 0644); err != nil {
|
||||
|
||||
@@ -191,8 +191,8 @@ func TestNewManager_CustomPath(t *testing.T) {
|
||||
// Test: NewManager uses default path when empty
|
||||
func TestNewManager_DefaultPath(t *testing.T) {
|
||||
g := NewManager("")
|
||||
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-cloud.conf" {
|
||||
t.Errorf("got %q, want /etc/dnsmasq.d/wild-cloud.conf", g.GetConfigPath())
|
||||
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-central.conf" {
|
||||
t.Errorf("got %q, want /etc/dnsmasq.d/wild-central.conf", g.GetConfigPath())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"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
|
||||
type Manager struct {
|
||||
@@ -68,15 +68,15 @@ func (m *Manager) Generate(haproxyPorts []int, extraTCPPorts []int, extraUDPPort
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("# Wild Cloud nftables rules\n")
|
||||
sb.WriteString("# Managed by Wild Cloud Central API — do not edit manually\n\n")
|
||||
sb.WriteString("# Wild Central nftables rules\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.
|
||||
// The first line ensures the table exists (so delete never errors on first run).
|
||||
sb.WriteString("table inet wild-cloud {}\n")
|
||||
sb.WriteString("delete table inet wild-cloud\n\n")
|
||||
sb.WriteString("table inet wild-central {}\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(" set allowed_tcp_ports {\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.
|
||||
func (m *Manager) SafeApply(content string) error {
|
||||
// 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 {
|
||||
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
|
||||
// write permission on the /etc/nftables.d/ directory itself.
|
||||
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 {
|
||||
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.
|
||||
// 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 {
|
||||
cmd := exec.Command("systemctl", "start", "wild-cloud-nftables-reload.service")
|
||||
cmd := exec.Command("systemctl", "start", "wild-central-nftables-reload.service")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying nftables rules: %w (output: %s)", err, string(output))
|
||||
@@ -235,13 +235,13 @@ func (m *Manager) ApplyRules() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteDisabledRules writes a rules file that flushes the wild-cloud table,
|
||||
// removing all Wild Cloud firewall rules from the kernel when applied.
|
||||
// WriteDisabledRules writes a rules file that flushes the wild-central table,
|
||||
// removing all Wild Central firewall rules from the kernel when applied.
|
||||
func (m *Manager) WriteDisabledRules() error {
|
||||
content := "# Wild Cloud nftables rules — firewall disabled\n" +
|
||||
"# Managed by Wild Cloud Central API — do not edit manually\n\n" +
|
||||
"table inet wild-cloud {}\n" +
|
||||
"delete table inet wild-cloud\n"
|
||||
content := "# Wild Central nftables rules — firewall disabled\n" +
|
||||
"# Managed by Wild Central — do not edit manually\n\n" +
|
||||
"table inet wild-central {}\n" +
|
||||
"delete table inet wild-central\n"
|
||||
if err := storage.WriteFileAtomic(m.rulesPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("writing disabled rules file: %w", err)
|
||||
}
|
||||
@@ -249,10 +249,10 @@ func (m *Manager) WriteDisabledRules() error {
|
||||
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.
|
||||
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()
|
||||
if err != nil {
|
||||
// Table may not exist yet — not an error
|
||||
|
||||
@@ -13,9 +13,9 @@ func TestNewManager_DefaultPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewManager_CustomPath(t *testing.T) {
|
||||
m := NewManager("/tmp/wild-cloud.nft")
|
||||
if m.GetRulesPath() != "/tmp/wild-cloud.nft" {
|
||||
t.Errorf("got %q, want /tmp/wild-cloud.nft", m.GetRulesPath())
|
||||
m := NewManager("/tmp/wild-central.nft")
|
||||
if m.GetRulesPath() != "/tmp/wild-central.nft" {
|
||||
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, "")
|
||||
|
||||
for _, want := range []string{
|
||||
"table inet wild-cloud",
|
||||
"table inet wild-central",
|
||||
"set allowed_tcp_ports",
|
||||
"chain input",
|
||||
"type filter hook input priority filter; policy accept;",
|
||||
|
||||
15
main.go
15
main.go
@@ -119,6 +119,19 @@ func main() {
|
||||
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)
|
||||
|
||||
api, err := v1.NewAPI(dataDir, Version, allowedOrigins)
|
||||
@@ -137,7 +150,7 @@ func main() {
|
||||
api.StartDNSFilter(ctx)
|
||||
|
||||
router := mux.NewRouter()
|
||||
api.RegisterRoutes(router)
|
||||
api.RegisterRoutes(router, apiToken, isDev)
|
||||
|
||||
router.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
148
web/src/components/AuthGate.tsx
Normal file
148
web/src/components/AuthGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { AppSidebar } from '../components/AppSidebar';
|
||||
import { AuthGate } from '../components/AuthGate';
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar';
|
||||
import { HelpProvider, useHelp } from '../contexts/HelpContext';
|
||||
import { HelpPanel } from '../components/HelpPanel';
|
||||
@@ -46,6 +47,7 @@ export function CentralLayout() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthGate>
|
||||
<HelpProvider>
|
||||
<SidebarProvider
|
||||
open={sidebarOpen}
|
||||
@@ -57,5 +59,6 @@ export function CentralLayout() {
|
||||
<CentralLayoutContent />
|
||||
</SidebarProvider>
|
||||
</HelpProvider>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,17 +21,43 @@ interface ErrorResponseBody {
|
||||
|
||||
import { getApiBaseUrl } from './config';
|
||||
|
||||
const TOKEN_KEY = 'wild-central:token';
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
|
||||
constructor(baseUrl: string = getApiBaseUrl()) {
|
||||
this.baseUrl = baseUrl;
|
||||
// Load token from localStorage on init
|
||||
try { this.token = localStorage.getItem(TOKEN_KEY); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
getBaseURL(): string {
|
||||
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>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
@@ -43,6 +69,7 @@ export class ApiClient {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.authHeaders(),
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
@@ -104,7 +131,7 @@ export class ApiClient {
|
||||
|
||||
async getText(endpoint: string): Promise<string> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { headers: this.authHeaders() });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -122,7 +149,7 @@ export class ApiClient {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
headers: { 'Content-Type': 'text/plain', ...this.authHeaders() },
|
||||
body: text,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user