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
|
coverage.html
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
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 " 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
2
dist/README.md
vendored
@@ -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
|
||||||
|
|||||||
44
dist/debian/DEBIAN/postinst
vendored
44
dist/debian/DEBIAN/postinst
vendored
@@ -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"
|
||||||
|
|||||||
8
dist/debian/DEBIAN/postrm
vendored
8
dist/debian/DEBIAN/postrm
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
15
main.go
@@ -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")
|
||||||
|
|||||||
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 { 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,16 +47,18 @@ export function CentralLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HelpProvider>
|
<AuthGate>
|
||||||
<SidebarProvider
|
<HelpProvider>
|
||||||
open={sidebarOpen}
|
<SidebarProvider
|
||||||
onOpenChange={(open) => {
|
open={sidebarOpen}
|
||||||
setSidebarOpen(open);
|
onOpenChange={(open) => {
|
||||||
localStorage.setItem('sidebar_state', String(open));
|
setSidebarOpen(open);
|
||||||
}}
|
localStorage.setItem('sidebar_state', String(open));
|
||||||
>
|
}}
|
||||||
<CentralLayoutContent />
|
>
|
||||||
</SidebarProvider>
|
<CentralLayoutContent />
|
||||||
</HelpProvider>
|
</SidebarProvider>
|
||||||
|
</HelpProvider>
|
||||||
|
</AuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user