+ Public status
+Platform status
+Live health for the public API, database connectivity, and gateway runtime telemetry.
+Components
+Service health
+Estate
+diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index e32fd3a..5d6a83a 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -17,6 +17,7 @@ import ( "nexavpn/backend/internal/ipam" "nexavpn/backend/internal/policy" "nexavpn/backend/internal/servicecatalog" + "nexavpn/backend/internal/statuspage" "nexavpn/backend/internal/user" ) @@ -46,6 +47,7 @@ func New(cfg config.Config) (*App, error) { gatewayService := gateway.NewService(gateway.NewPGRepository(pool)) deviceService := device.NewService(device.NewPGRepository(pool), policyService, gatewayService, ipam.NewService()) auditService := audit.NewService(audit.NewPGRepository(pool)) + statusService := statuspage.NewService(pool) router := httpserver.NewRouter(cfg.JWTSecret, httpserver.Handlers{ Auth: auth.NewHandler(authService, auditService), @@ -56,6 +58,7 @@ func New(cfg config.Config) (*App, error) { Policy: policy.NewHandler(policyService, auditService), Gateway: gateway.NewHandler(gatewayService, cfg.GatewayBootstrapToken), Audit: audit.NewHandler(auditService), + Status: statuspage.NewHandler(statusService), }) return &App{ diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index 1ea5444..17e590f 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -13,6 +13,7 @@ import ( "nexavpn/backend/internal/group" "nexavpn/backend/internal/policy" "nexavpn/backend/internal/servicecatalog" + "nexavpn/backend/internal/statuspage" "nexavpn/backend/internal/user" ) @@ -25,6 +26,7 @@ type Handlers struct { Gateway *gateway.Handler Group *group.Handler Audit *audit.Handler + Status *statuspage.Handler } func NewRouter(jwtSecret string, handlers Handlers) http.Handler { @@ -36,6 +38,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler { }) r.Route("/api/v1", func(r chi.Router) { + r.Get("/status", handlers.Status.PublicStatus) r.Get("/auth/bootstrap/status", handlers.Auth.BootstrapStatus) r.Post("/auth/bootstrap", handlers.Auth.Bootstrap) r.Post("/auth/login", handlers.Auth.Login) diff --git a/backend/internal/statuspage/handler.go b/backend/internal/statuspage/handler.go new file mode 100644 index 0000000..4d05b9d --- /dev/null +++ b/backend/internal/statuspage/handler.go @@ -0,0 +1,24 @@ +package statuspage + +import ( + "net/http" + + "nexavpn/backend/internal/apiutil" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) PublicStatus(w http.ResponseWriter, r *http.Request) { + snapshot := h.service.Snapshot(r.Context()) + statusCode := http.StatusOK + if snapshot.Status != "operational" { + statusCode = http.StatusServiceUnavailable + } + apiutil.JSON(w, statusCode, snapshot) +} diff --git a/backend/internal/statuspage/service.go b/backend/internal/statuspage/service.go new file mode 100644 index 0000000..d5127ec --- /dev/null +++ b/backend/internal/statuspage/service.go @@ -0,0 +1,155 @@ +package statuspage + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Service struct { + db *pgxpool.Pool +} + +func NewService(db *pgxpool.Pool) *Service { + return &Service{db: db} +} + +func (s *Service) Snapshot(ctx context.Context) PublicStatusResponse { + now := time.Now().UTC() + resp := PublicStatusResponse{ + Status: "operational", + GeneratedAt: now.Format(time.RFC3339), + Components: map[string]ComponentStatus{ + "api": { + Status: "operational", + Message: "Public API is responding.", + }, + }, + } + + if err := s.db.Ping(ctx); err != nil { + resp.Status = "degraded" + resp.Components["database"] = ComponentStatus{ + Status: "degraded", + Message: "Database ping failed.", + } + return resp + } + + resp.Components["database"] = ComponentStatus{ + Status: "operational", + Message: "Database connectivity is healthy.", + } + + if err := s.loadSummary(ctx, &resp.Summary); err != nil { + resp.Status = "degraded" + resp.Components["database"] = ComponentStatus{ + Status: "degraded", + Message: "Database query failed.", + } + return resp + } + + lastGatewayRuntime, err := s.latestGatewayRuntime(ctx) + if err != nil { + resp.Status = "degraded" + resp.Components["gateway"] = ComponentStatus{ + Status: "degraded", + Message: "Gateway runtime state could not be read.", + } + return resp + } + + resp.Components["gateway"] = componentFromGatewayRuntime(now, lastGatewayRuntime, resp.Summary.ActiveGateways) + if resp.Components["gateway"].Status != "operational" { + resp.Status = "degraded" + } + + return resp +} + +func (s *Service) loadSummary(ctx context.Context, summary *Summary) error { + return s.db.QueryRow(ctx, ` + select + (select count(*) from users where deleted_at is null), + (select count(*) from devices where deleted_at is null), + ( + select count(*) + from devices d + join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null + where d.deleted_at is null + and d.status = 'active' + and wp.latest_handshake_at is not null + and to_timestamp(wp.latest_handshake_at) >= now() - interval '3 minutes' + ), + (select count(*) from gateways where deleted_at is null), + (select count(*) from gateways where deleted_at is null and is_active = true), + (select count(*) from services where deleted_at is null and is_active = true), + (select count(*) from policies where deleted_at is null and is_active = true) + `).Scan( + &summary.Users, + &summary.Devices, + &summary.ConnectedDevices, + &summary.Gateways, + &summary.ActiveGateways, + &summary.Services, + &summary.Policies, + ) +} + +func (s *Service) latestGatewayRuntime(ctx context.Context) (*time.Time, error) { + var updatedAt *time.Time + err := s.db.QueryRow(ctx, ` + select max(updated_at) + from settings + where category = 'gateway_runtime' + `).Scan(&updatedAt) + if err != nil { + return nil, err + } + return updatedAt, nil +} + +func componentFromGatewayRuntime(now time.Time, lastRuntime *time.Time, activeGateways int) ComponentStatus { + if activeGateways == 0 { + return ComponentStatus{ + Status: "degraded", + Message: "No active gateway is configured.", + } + } + if lastRuntime == nil { + return ComponentStatus{ + Status: "degraded", + Message: "No gateway telemetry has been received yet.", + } + } + + age := now.Sub(lastRuntime.UTC()) + if age <= 90*time.Second { + return ComponentStatus{ + Status: "operational", + Message: fmt.Sprintf("Last gateway telemetry %s ago.", humanizeAge(age)), + } + } + + return ComponentStatus{ + Status: "degraded", + Message: fmt.Sprintf("Gateway telemetry is stale (%s ago).", humanizeAge(age)), + } +} + +func humanizeAge(age time.Duration) string { + if age < time.Minute { + seconds := int(age.Seconds()) + if seconds < 1 { + seconds = 1 + } + return fmt.Sprintf("%ds", seconds) + } + if age < time.Hour { + return fmt.Sprintf("%dm", int(age.Minutes())) + } + return fmt.Sprintf("%dh", int(age.Hours())) +} diff --git a/backend/internal/statuspage/types.go b/backend/internal/statuspage/types.go new file mode 100644 index 0000000..9bcd265 --- /dev/null +++ b/backend/internal/statuspage/types.go @@ -0,0 +1,23 @@ +package statuspage + +type ComponentStatus struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +type Summary struct { + Users int `json:"users"` + Devices int `json:"devices"` + ConnectedDevices int `json:"connected_devices"` + Gateways int `json:"gateways"` + ActiveGateways int `json:"active_gateways"` + Services int `json:"services"` + Policies int `json:"policies"` +} + +type PublicStatusResponse struct { + Status string `json:"status"` + GeneratedAt string `json:"generated_at"` + Components map[string]ComponentStatus `json:"components"` + Summary Summary `json:"summary"` +} diff --git a/public-web/index.html b/public-web/index.html index 9d67c2f..54a225b 100644 --- a/public-web/index.html +++ b/public-web/index.html @@ -17,7 +17,8 @@ Use the desktop client to sign in, provision this device, and connect to your private network.
diff --git a/public-web/nginx.conf b/public-web/nginx.conf index 31e6d87..260b7c2 100644 --- a/public-web/nginx.conf +++ b/public-web/nginx.conf @@ -12,6 +12,10 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location = /status { + try_files /status.html =404; + } + location / { try_files $uri /index.html; } diff --git a/public-web/status.html b/public-web/status.html new file mode 100644 index 0000000..86d958c --- /dev/null +++ b/public-web/status.html @@ -0,0 +1,171 @@ + + + + + +
+ Public status
+Live health for the public API, database connectivity, and gateway runtime telemetry.
+Components
+Estate
+