diff --git a/.env.example b/.env.example index 49d36b3..b339e15 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ -INFRAPULSE_ENV=development -INFRAPULSE_SECRET_KEY=change-me -DATABASE_URL=postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse +ORBITALWARD_ENV=development +ORBITALWARD_SECRET_KEY=change-me +DATABASE_URL=postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward REDIS_URL=redis://redis:6379/0 FRONTEND_URL=http://localhost:5173 BACKEND_URL=http://localhost:8000 diff --git a/AGENTS.md b/AGENTS.md index faf5183..0cb87be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# InfraPulse Agent Notes +# OrbitalWard Agent Notes -InfraPulse should be built incrementally. Keep the first release focused on a secure monitoring appliance with guided setup, website monitoring, alerts, and notifications. +OrbitalWard should be built incrementally. Keep the first release focused on a secure monitoring appliance with guided setup, website monitoring, alerts, and notifications. ## Product Guardrails diff --git a/LICENSE b/LICENSE index 835e547..668210d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 InfraPulse contributors +Copyright (c) 2026 OrbitalWard contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ece76a2..03909b1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# InfraPulse +# OrbitalWard Beautiful, self-hosted infrastructure monitoring without the enterprise-tool headache. -InfraPulse is an open-source monitoring appliance for homelabs, small businesses, and internal IT teams. The first target is a secure and attractive monitoring foundation with guided setup, website checks, alerts, notifications, and clean dashboards. It is not trying to be a full Zabbix or LibreNMS replacement in the first release. +OrbitalWard is an open-source monitoring appliance for homelabs, small businesses, and internal IT teams. The first target is a secure and attractive monitoring foundation with guided setup, website checks, alerts, notifications, and clean dashboards. It is not trying to be a full Zabbix or LibreNMS replacement in the first release. ## Current Status @@ -57,7 +57,7 @@ Default local admin credentials come from `.env`: - `INITIAL_ADMIN_EMAIL=admin@example.com` - `INITIAL_ADMIN_PASSWORD=change-me` -Change these values before using InfraPulse anywhere beyond local development. +Change these values before using OrbitalWard anywhere beyond local development. ## Project Structure diff --git a/backend/alembic.ini b/backend/alembic.ini index f47786b..6d59d28 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -2,7 +2,7 @@ script_location = alembic prepend_sys_path = . -sqlalchemy.url = postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse +sqlalchemy.url = postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward [loggers] keys = root,sqlalchemy,alembic diff --git a/backend/alembic/versions/20260522_0001_initial_schema.py b/backend/alembic/versions/20260522_0001_initial_schema.py index 1428bda..64897f6 100644 --- a/backend/alembic/versions/20260522_0001_initial_schema.py +++ b/backend/alembic/versions/20260522_0001_initial_schema.py @@ -1,4 +1,4 @@ -"""Initial InfraPulse schema. +"""Initial OrbitalWard schema. Revision ID: 20260522_0001 Revises: diff --git a/backend/app/__init__.py b/backend/app/__init__.py index aa4c655..f399ca0 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1 +1 @@ -"""InfraPulse backend package.""" +"""OrbitalWard backend package.""" diff --git a/backend/app/api/health.py b/backend/app/api/health.py index c634f59..5aaafd9 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -5,4 +5,4 @@ router = APIRouter(tags=["health"]) @router.get("/health") def health() -> dict[str, str]: - return {"status": "ok", "service": "infrapulse-backend"} + return {"status": "ok", "service": "orbitalward-backend"} diff --git a/backend/app/api/monitors.py b/backend/app/api/monitors.py index 6aaacd4..d55e3df 100644 --- a/backend/app/api/monitors.py +++ b/backend/app/api/monitors.py @@ -53,6 +53,8 @@ def create_website_monitor( "expected_text": payload.expected_text, "unexpected_text": payload.unexpected_text, "timeout_seconds": payload.timeout_seconds, + "check_tls_expiry": payload.check_tls_expiry, + "tls_warning_days": payload.tls_warning_days, }, interval_seconds=payload.interval_seconds, status="unknown", diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 15bc306..4866741 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -14,7 +14,7 @@ router = APIRouter(prefix="/notifications/channels", tags=["notifications"]) def _channel_to_read(channel: NotificationChannel) -> NotificationChannelRead: settings = dict(channel.settings or {}) - settings.setdefault("username", "InfraPulse") + settings.setdefault("username", "OrbitalWard") return NotificationChannelRead( id=channel.id, name=channel.name, @@ -40,7 +40,7 @@ def create_channel( db: Session = Depends(get_db), ) -> NotificationChannelRead: channel_settings = dict(payload.settings or {}) - channel_settings.setdefault("username", "InfraPulse") + channel_settings.setdefault("username", "OrbitalWard") channel = NotificationChannel( name=payload.name, channel_type=payload.channel_type, @@ -70,8 +70,8 @@ def test_channel( response = httpx.post( url, json={ - "username": (channel.settings or {}).get("username") or "InfraPulse", - "text": f"InfraPulse test notification for {channel.name}", + "username": (channel.settings or {}).get("username") or "OrbitalWard", + "text": f"OrbitalWard test notification for {channel.name}", }, timeout=10, ) diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index d6204e1..e3d714b 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -20,12 +20,12 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def create_access_token(subject: str) -> str: expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes) payload = {"sub": subject, "exp": expires_at} - return jwt.encode(payload, settings.infrapulse_secret_key, algorithm=ALGORITHM) + return jwt.encode(payload, settings.orbitalward_secret_key, algorithm=ALGORITHM) def decode_access_token(token: str) -> str | None: try: - payload = jwt.decode(token, settings.infrapulse_secret_key, algorithms=[ALGORITHM]) + payload = jwt.decode(token, settings.orbitalward_secret_key, algorithms=[ALGORITHM]) return payload.get("sub") except JWTError: return None diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e46e82c..70b125d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,9 +7,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") - infrapulse_env: str = "development" - infrapulse_secret_key: str = Field(default="change-me", min_length=8) - database_url: str = "postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse" + orbitalward_env: str = "development" + orbitalward_secret_key: str = Field(default="change-me", min_length=8) + database_url: str = "postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward" redis_url: str = "redis://redis:6379/0" frontend_url: AnyHttpUrl | str = "http://localhost:5173" backend_url: AnyHttpUrl | str = "http://localhost:8000" diff --git a/backend/app/core/secrets.py b/backend/app/core/secrets.py index 4570ba6..faf668d 100644 --- a/backend/app/core/secrets.py +++ b/backend/app/core/secrets.py @@ -7,7 +7,7 @@ from app.core.config import settings def _fernet() -> Fernet: - digest = hashlib.sha256(settings.infrapulse_secret_key.encode("utf-8")).digest() + digest = hashlib.sha256(settings.orbitalward_secret_key.encode("utf-8")).digest() return Fernet(base64.urlsafe_b64encode(digest)) diff --git a/backend/app/main.py b/backend/app/main.py index e316c06..7aee9fa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,7 +25,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: app = FastAPI( - title="InfraPulse API", + title="OrbitalWard API", version="0.1.0", description="Self-hosted infrastructure monitoring API", lifespan=lifespan, diff --git a/backend/app/schemas/core.py b/backend/app/schemas/core.py index 5fc8d34..a71fbaa 100644 --- a/backend/app/schemas/core.py +++ b/backend/app/schemas/core.py @@ -64,6 +64,8 @@ class WebsiteMonitorCreate(BaseModel): expected_text: str | None = None unexpected_text: str | None = None timeout_seconds: int = Field(default=10, ge=1, le=120) + check_tls_expiry: bool = False + tls_warning_days: int = Field(default=30, ge=1, le=365) interval_seconds: int = Field(default=60, ge=10) create_asset: bool = True alert_enabled: bool = True diff --git a/backend/pyproject.toml b/backend/pyproject.toml index cd9df5f..a1ec77d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "infrapulse-backend" +name = "orbitalward-backend" version = "0.1.0" -description = "InfraPulse FastAPI backend" +description = "OrbitalWard FastAPI backend" requires-python = ">=3.12" dependencies = [ "alembic>=1.13.3", @@ -27,6 +27,10 @@ test = [ [tool.pytest.ini_options] testpaths = ["tests"] +[tool.setuptools.packages.find] +include = ["app*"] +exclude = ["alembic*"] + [build-system] requires = ["setuptools>=75.0"] build-backend = "setuptools.build_meta" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b8bb651..2a6350d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,13 +2,13 @@ services: postgres: image: postgres:16-alpine environment: - POSTGRES_USER: infrapulse - POSTGRES_PASSWORD: infrapulse - POSTGRES_DB: infrapulse + POSTGRES_USER: orbitalward + POSTGRES_PASSWORD: orbitalward + POSTGRES_DB: orbitalward volumes: - postgres-dev-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U infrapulse -d infrapulse"] + test: ["CMD-SHELL", "pg_isready -U orbitalward -d orbitalward"] interval: 10s timeout: 5s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index 66cc988..067d26a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,13 @@ services: postgres: image: postgres:16-alpine environment: - POSTGRES_USER: infrapulse - POSTGRES_PASSWORD: infrapulse - POSTGRES_DB: infrapulse + POSTGRES_USER: orbitalward + POSTGRES_PASSWORD: orbitalward + POSTGRES_DB: orbitalward volumes: - postgres-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U infrapulse -d infrapulse"] + test: ["CMD-SHELL", "pg_isready -U orbitalward -d orbitalward"] interval: 10s timeout: 5s retries: 5 diff --git a/docs/alerting-design.md b/docs/alerting-design.md index efaa496..95ae0c0 100644 --- a/docs/alerting-design.md +++ b/docs/alerting-design.md @@ -26,4 +26,4 @@ Initial channels: - Zoom Team Chat incoming webhook - Generic webhook -Alert messages should be human-readable and include asset, check, status, duration, timestamps, and a link back to InfraPulse. +Alert messages should be human-readable and include asset, check, status, duration, timestamps, and a link back to OrbitalWard. diff --git a/docs/architecture.md b/docs/architecture.md index 6c33210..0ab5688 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -InfraPulse is a monorepo with four main areas: +OrbitalWard is a monorepo with four main areas: - `backend`: FastAPI service exposing REST endpoints and owning database access. - `worker`: Background scheduler and collectors for checks and alert evaluation. diff --git a/docs/discovery-design.md b/docs/discovery-design.md index 06427b1..f11ca75 100644 --- a/docs/discovery-design.md +++ b/docs/discovery-design.md @@ -1,6 +1,6 @@ # Discovery Design -Guided discovery is a core InfraPulse workflow. +Guided discovery is a core OrbitalWard workflow. ```text Add target @@ -16,7 +16,7 @@ Create monitors and optional alert rules ## Monitor vs Alert Separation -InfraPulse must allow monitoring without alerting. Every discovered item should eventually support separate choices: +OrbitalWard must allow monitoring without alerting. Every discovered item should eventually support separate choices: - Collect metric - Graph metric diff --git a/docs/plugin-design.md b/docs/plugin-design.md index 73cf8e8..5b92e76 100644 --- a/docs/plugin-design.md +++ b/docs/plugin-design.md @@ -1,11 +1,11 @@ # Plugin Design -Plugins will let InfraPulse add collectors and discovery logic without hard-coding every integration into the core API. +Plugins will let OrbitalWard add collectors and discovery logic without hard-coding every integration into the core API. Target shape: ```python -class InfraPulsePlugin: +class OrbitalWardPlugin: name: str display_name: str diff --git a/docs/progress.md b/docs/progress.md index cd7ccd3..f99ccd6 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,10 +1,10 @@ -# InfraPulse Progress +# OrbitalWard Progress -Last updated: 2026-05-22 +Last updated: 2026-05-23 ## Current State -InfraPulse has a working Docker Compose development stack with PostgreSQL, Redis, FastAPI backend, Python worker, and React/Vite frontend. +OrbitalWard has a working Docker Compose development stack with PostgreSQL, Redis, FastAPI backend, Python worker, and React/Vite frontend. Implemented foundation: @@ -18,6 +18,7 @@ Implemented website-monitor slice: - Create, edit, delete website monitors from the UI. - HTTP status and expected-text checks. +- Optional TLS certificate expiry checks for HTTPS monitors. - Monitor status and recent incident visibility on dashboard. - Basic alert rules created with website monitors. - Incidents can be acknowledged and silenced from the UI. @@ -27,7 +28,7 @@ Implemented notification slice: - Create, edit, test, and delete notification channels from the UI. - Generic webhook, Mattermost, and Zoom Team Chat channel types. -- Webhook URLs encrypted at rest using `INFRAPULSE_SECRET_KEY`. +- Webhook URLs encrypted at rest using `ORBITALWARD_SECRET_KEY`. - Saved webhook URLs are not returned to the UI. - Configurable post username per notification channel. - Worker sends incident open and recovery notifications. @@ -42,7 +43,6 @@ Implemented notification slice: - Alert rule editing UI is not implemented. - Notification routing/policies are not implemented; all enabled webhook channels receive incident notifications. - Email/SMTP notifications are not implemented yet. -- TLS certificate expiry checks are not implemented yet. - Ping and TCP checks are not implemented yet. - Graphing exists only as placeholders; metric visualization is not implemented. - Worker scheduling is simple polling, not a Redis queue yet. @@ -51,16 +51,15 @@ Implemented notification slice: ## Recommended Next Work -1. Add TLS certificate expiry monitor support. -2. Add ping and TCP port monitors. -3. Add alert rule editing UI and richer alert conditions. -4. Add notification policy/routing controls. -5. Add email/SMTP notification channel. -6. Add audit event writes for auth, monitor, credential, notification, and incident actions. -7. Build credential vault UI with masked secret handling. -8. Add user administration UI. -9. Add graphs for website response time and monitor status history. -10. Add backend and worker tests for the website-monitor and notification flows. +1. Add ping and TCP port monitors. +2. Add alert rule editing UI and richer alert conditions. +3. Add notification policy/routing controls. +4. Add email/SMTP notification channel. +5. Add audit event writes for auth, monitor, credential, notification, and incident actions. +6. Build credential vault UI with masked secret handling. +7. Add user administration UI. +8. Add graphs for website response time and monitor status history. +9. Add backend and worker tests for the website-monitor and notification flows. ## Operational Notes @@ -75,4 +74,4 @@ Default local login comes from `.env`: - `INITIAL_ADMIN_EMAIL=admin@example.com` - `INITIAL_ADMIN_PASSWORD=change-me` -Change these values before using InfraPulse outside local development. +Change these values before using OrbitalWard outside local development. diff --git a/docs/security.md b/docs/security.md index d5dd416..0ecb9df 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,6 +1,6 @@ # Security -InfraPulse must be secure from the beginning because it will store infrastructure credentials. +OrbitalWard must be secure from the beginning because it will store infrastructure credentials. ## Authentication @@ -19,7 +19,7 @@ Credential records are modeled separately from monitors and assets. Secret field Rules: -- Use `INFRAPULSE_SECRET_KEY` from the environment. +- Use `ORBITALWARD_SECRET_KEY` from the environment. - Never log secrets. - Mask saved secrets in the UI. - Audit credential create, update, and delete events. diff --git a/docs/vision.md b/docs/vision.md index 9dcac86..d78e4ea 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -1,12 +1,12 @@ -# InfraPulse Vision +# OrbitalWard Vision -InfraPulse is a secure, self-hosted monitoring platform for homelabs, small businesses, and internal IT teams. +OrbitalWard is a secure, self-hosted monitoring platform for homelabs, small businesses, and internal IT teams. The v0.1 product should feel like a polished appliance, not a pile of raw monitoring configuration. Users should be guided through adding targets, testing connections, discovering useful items, choosing what to monitor, and separately choosing what should alert. ## Design Philosophy -InfraPulse exposes intent, not implementation details. +OrbitalWard exposes intent, not implementation details. Raw SNMP OIDs, probe internals, and collector details belong behind friendly profiles and advanced tools. The normal UI should say things like "Port 5 outbound traffic", "Graph this port", and "Alert if port goes down". diff --git a/frontend/index.html b/frontend/index.html index 43d0656..de63a13 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - InfraPulse + OrbitalWard
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 164ffc3..6a980b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "infrapulse-frontend", + "name": "orbitalward-frontend", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "infrapulse-frontend", + "name": "orbitalward-frontend", "version": "0.1.0", "dependencies": { "@vitejs/plugin-react": "^5.0.0", diff --git a/frontend/package.json b/frontend/package.json index 75d31dc..e275fd7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "infrapulse-frontend", + "name": "orbitalward-frontend", "version": "0.1.0", "private": true, "type": "module", diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index c664ac5..e88dce2 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -61,7 +61,7 @@ export function App() { } if (auth.loading) { - return
Loading InfraPulse...
; + return
Loading OrbitalWard...
; } if (!auth.user || !auth.token) { diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index 842f8f8..f098fa3 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -47,7 +47,7 @@ export function Shell({ children, currentPage, onPageChange, onSignOut, user }:
-
InfraPulse
+
OrbitalWard
Monitoring appliance
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index fbc6017..e2bec77 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { api, login } from "../api/client"; import type { User } from "../types/api"; -const TOKEN_KEY = "infrapulse_token"; +const TOKEN_KEY = "orbitalward_token"; export function useAuth() { const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 014256f..7cc6ea0 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -9,7 +9,7 @@ interface DashboardPageProps { } export function DashboardPage({ assets, monitors, incidents }: DashboardPageProps) { - const downMonitors = monitors.filter((monitor) => monitor.status === "down").length; + const attentionMonitors = monitors.filter((monitor) => monitor.status !== "up" && monitor.status !== "unknown").length; const activeIncidents = incidents.filter((incident) => incident.status === "open").length; const websites = monitors.filter((monitor) => monitor.monitor_type === "http"); @@ -33,7 +33,7 @@ export function DashboardPage({ assets, monitors, incidents }: DashboardPageProp

Website Monitors

- {downMonitors} down + {attentionMonitors} need attention
{websites.length ? ( @@ -110,6 +110,8 @@ function StatusBadge({ status }: { status: string }) { ? "border-teal-500/40 bg-teal-950/40 text-teal-200" : status === "down" ? "border-red-500/40 bg-red-950/40 text-red-200" - : "border-slate-600 bg-slate-900 text-slate-300"; + : status === "warning" + ? "border-amber-500/40 bg-amber-950/40 text-amber-200" + : "border-slate-600 bg-slate-900 text-slate-300"; return {status}; } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 1720534..d727771 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -33,7 +33,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
-
InfraPulse
+
OrbitalWard

diff --git a/frontend/src/pages/NotificationsPage.tsx b/frontend/src/pages/NotificationsPage.tsx index a2fb6c6..8175490 100644 --- a/frontend/src/pages/NotificationsPage.tsx +++ b/frontend/src/pages/NotificationsPage.tsx @@ -14,7 +14,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) { const [name, setName] = useState(""); const [channelType, setChannelType] = useState("generic_webhook"); const [url, setUrl] = useState(""); - const [username, setUsername] = useState("InfraPulse"); + const [username, setUsername] = useState("OrbitalWard"); const [enabled, setEnabled] = useState(true); const [editingChannelId, setEditingChannelId] = useState(null); const [busyId, setBusyId] = useState(null); @@ -38,7 +38,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) { await api.updateNotificationChannel(token, editingChannelId, { name, channel_type: channelType, - settings: { username: username.trim() || "InfraPulse" }, + settings: { username: username.trim() || "OrbitalWard" }, secret: url.trim() ? url.trim() : undefined, is_enabled: enabled, }); @@ -46,7 +46,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) { await api.createNotificationChannel(token, { name, channel_type: channelType, - settings: { username: username.trim() || "InfraPulse" }, + settings: { username: username.trim() || "OrbitalWard" }, secret: url, is_enabled: enabled, }); @@ -65,7 +65,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) { setName(channel.name); setChannelType(channel.channel_type); setUrl(""); - setUsername(String(channel.settings.username || "InfraPulse")); + setUsername(String(channel.settings.username || "OrbitalWard")); setEnabled(channel.is_enabled); setMessage(null); } @@ -75,7 +75,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) { setName(""); setChannelType("generic_webhook"); setUrl(""); - setUsername("InfraPulse"); + setUsername("OrbitalWard"); setEnabled(true); } @@ -180,7 +180,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
{channel.name}
-
{String(channel.settings.username || "InfraPulse")}
+
{String(channel.settings.username || "OrbitalWard")}
{channel.has_secret ? "Secret stored" : "No secret"}
{channel.channel_type}
diff --git a/frontend/src/pages/WebsitesPage.tsx b/frontend/src/pages/WebsitesPage.tsx index 4046a13..ce8ee8d 100644 --- a/frontend/src/pages/WebsitesPage.tsx +++ b/frontend/src/pages/WebsitesPage.tsx @@ -15,8 +15,11 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps) const websites = monitors.filter((monitor) => monitor.monitor_type === "http"); const [name, setName] = useState(""); const [url, setUrl] = useState("https://"); + const supportsTlsExpiry = url.trim().toLowerCase().startsWith("https://"); const [expectedStatus, setExpectedStatus] = useState(200); const [expectedText, setExpectedText] = useState(""); + const [checkTlsExpiry, setCheckTlsExpiry] = useState(true); + const [tlsWarningDays, setTlsWarningDays] = useState(30); const [intervalSeconds, setIntervalSeconds] = useState(60); const [failureThreshold, setFailureThreshold] = useState(3); const [alertEnabled, setAlertEnabled] = useState(true); @@ -40,6 +43,8 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps) expected_text: expectedText.trim() ? expectedText.trim() : null, unexpected_text: null, timeout_seconds: 10, + check_tls_expiry: supportsTlsExpiry && checkTlsExpiry, + tls_warning_days: tlsWarningDays, }, }); } else { @@ -50,6 +55,8 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps) expected_text: expectedText.trim() ? expectedText.trim() : null, unexpected_text: null, timeout_seconds: 10, + check_tls_expiry: supportsTlsExpiry && checkTlsExpiry, + tls_warning_days: tlsWarningDays, interval_seconds: intervalSeconds, create_asset: true, alert_enabled: alertEnabled, @@ -72,6 +79,8 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps) setUrl(monitor.target); setExpectedStatus(Number(monitor.config?.expected_status ?? 200)); setExpectedText(typeof monitor.config?.expected_text === "string" ? monitor.config.expected_text : ""); + setCheckTlsExpiry(Boolean(monitor.config?.check_tls_expiry ?? false)); + setTlsWarningDays(Number(monitor.config?.tls_warning_days ?? 30)); setIntervalSeconds(monitor.interval_seconds); setAlertEnabled(true); setFailureThreshold(3); @@ -84,6 +93,8 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps) setUrl("https://"); setExpectedStatus(200); setExpectedText(""); + setCheckTlsExpiry(true); + setTlsWarningDays(30); setIntervalSeconds(60); setFailureThreshold(3); setAlertEnabled(true); @@ -148,6 +159,17 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps) setExpectedText(event.target.value)} /> +
+
+ TLS expiry check + setCheckTlsExpiry(event.target.checked)} type="checkbox" /> +
+ +
+ {!editingMonitorId ? (
Alert on repeated failures @@ -189,6 +211,7 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
{monitor.name}
{monitor.target}
+ {monitor.config?.check_tls_expiry ?
TLS warning at {String(monitor.config.tls_warning_days ?? 30)} days
: null}
@@ -218,6 +241,8 @@ function Status({ status }: { status: string }) { ? "border-teal-500/40 bg-teal-950/40 text-teal-200" : status === "down" ? "border-red-500/40 bg-red-950/40 text-red-200" - : "border-slate-600 bg-slate-900 text-slate-300"; + : status === "warning" + ? "border-amber-500/40 bg-amber-950/40 text-amber-200" + : "border-slate-600 bg-slate-900 text-slate-300"; return {status}; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 8603db9..bec6ab3 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -86,6 +86,8 @@ export interface WebsiteMonitorCreate { expected_text?: string | null; unexpected_text?: string | null; timeout_seconds: number; + check_tls_expiry: boolean; + tls_warning_days: number; interval_seconds: number; create_asset: boolean; alert_enabled: boolean; diff --git a/worker/app/__init__.py b/worker/app/__init__.py index f7a0a6f..5f55d65 100644 --- a/worker/app/__init__.py +++ b/worker/app/__init__.py @@ -1 +1 @@ -"""InfraPulse worker package.""" +"""OrbitalWard worker package.""" diff --git a/worker/app/collectors/website.py b/worker/app/collectors/website.py index 5f8cf39..bfbab32 100644 --- a/worker/app/collectors/website.py +++ b/worker/app/collectors/website.py @@ -1,7 +1,13 @@ from dataclasses import dataclass +from datetime import UTC, datetime +import asyncio +import socket +import ssl from time import perf_counter +from urllib.parse import urlparse import httpx +from cryptography import x509 @dataclass(frozen=True) @@ -11,6 +17,8 @@ class WebsiteCheckConfig: expected_text: str | None = None unexpected_text: str | None = None timeout_seconds: float = 10.0 + check_tls_expiry: bool = False + tls_warning_days: int = 30 @dataclass(frozen=True) @@ -22,6 +30,12 @@ class WebsiteCheckResult: async def run_website_check(config: WebsiteCheckConfig) -> WebsiteCheckResult: started = perf_counter() + + if config.check_tls_expiry: + tls_result = await check_tls_expiry(config.url, config.tls_warning_days, config.timeout_seconds) + if tls_result is not None: + return tls_result + try: async with httpx.AsyncClient(follow_redirects=True, timeout=config.timeout_seconds) as client: response = await client.get(config.url) @@ -48,3 +62,52 @@ async def run_website_check(config: WebsiteCheckConfig) -> WebsiteCheckResult: message="Unexpected text was present", ) return WebsiteCheckResult(status="up", response_time_ms=response_time_ms, message="Website check passed") + + +async def check_tls_expiry(url: str, warning_days: int, timeout_seconds: float) -> WebsiteCheckResult | None: + parsed_url = urlparse(url) + if parsed_url.scheme != "https": + return None + if not parsed_url.hostname: + return WebsiteCheckResult(status="down", response_time_ms=None, message="TLS check could not determine the hostname") + + started = perf_counter() + try: + expires_at = await _get_certificate_expiry(parsed_url.hostname, parsed_url.port or 443, timeout_seconds) + except (OSError, ssl.SSLError, ValueError) as exc: + return WebsiteCheckResult(status="down", response_time_ms=None, message=f"TLS certificate check failed: {exc}") + + response_time_ms = int((perf_counter() - started) * 1000) + now = datetime.now(UTC) + days_remaining = (expires_at - now).total_seconds() / 86400 + if days_remaining < 0: + return WebsiteCheckResult( + status="down", + response_time_ms=response_time_ms, + message=f"TLS certificate expired on {expires_at.date().isoformat()}", + ) + if days_remaining <= warning_days: + return WebsiteCheckResult( + status="warning", + response_time_ms=response_time_ms, + message=f"TLS certificate expires in {int(days_remaining)} days on {expires_at.date().isoformat()}", + ) + return None + + +async def _get_certificate_expiry(hostname: str, port: int, timeout_seconds: float) -> datetime: + return await asyncio.to_thread(_get_certificate_expiry_sync, hostname, port, timeout_seconds) + + +def _get_certificate_expiry_sync(hostname: str, port: int, timeout_seconds: float) -> datetime: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + with socket.create_connection((hostname, port), timeout=timeout_seconds) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as tls_sock: + certificate_bytes = tls_sock.getpeercert(binary_form=True) + + if not certificate_bytes: + raise ValueError("certificate did not include an expiry date") + certificate = x509.load_der_x509_certificate(certificate_bytes) + return certificate.not_valid_after_utc diff --git a/worker/app/config.py b/worker/app/config.py index 222815c..0fc8222 100644 --- a/worker/app/config.py +++ b/worker/app/config.py @@ -6,9 +6,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") - infrapulse_env: str = "development" - infrapulse_secret_key: str = "change-me" - database_url: str = "postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse" + orbitalward_env: str = "development" + orbitalward_secret_key: str = "change-me" + database_url: str = "postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward" redis_url: str = "redis://redis:6379/0" frontend_url: str = "http://localhost:5173" backend_url: str = "http://localhost:8000" diff --git a/worker/app/scheduler.py b/worker/app/scheduler.py index 22ee1ff..47d2305 100644 --- a/worker/app/scheduler.py +++ b/worker/app/scheduler.py @@ -22,7 +22,7 @@ class Scheduler: self._stopped = asyncio.Event() async def run(self) -> None: - logger.info("InfraPulse worker started for %s", settings.infrapulse_env) + logger.info("OrbitalWard worker started for %s", settings.orbitalward_env) while not self._stopped.is_set(): await self.tick() try: @@ -63,6 +63,8 @@ class Scheduler: expected_text=monitor.config.get("expected_text") or None, unexpected_text=monitor.config.get("unexpected_text") or None, timeout_seconds=float(monitor.config.get("timeout_seconds", 10)), + check_tls_expiry=bool(monitor.config.get("check_tls_expiry", False)), + tls_warning_days=int(monitor.config.get("tls_warning_days", 30)), ) result = await run_website_check(config) now = datetime.now(UTC) @@ -164,7 +166,7 @@ class Scheduler: await self._post_webhook( url, self._format_incident_message(incident, monitor, event_type), - str((channel.settings or {}).get("username") or "InfraPulse"), + str((channel.settings or {}).get("username") or "OrbitalWard"), ) except httpx.HTTPError: logger.exception("Notification delivery failed for channel %s", channel.id) @@ -205,5 +207,5 @@ class Scheduler: last_message = (incident.details or {}).get("last_message") if last_message: body.append(f"Last response: {last_message}") - body.extend(["", f"View in InfraPulse: {settings.frontend_url}/incidents/{incident.id}"]) + body.extend(["", f"View in OrbitalWard: {settings.frontend_url}/incidents/{incident.id}"]) return "\n".join(str(line) for line in body) diff --git a/worker/app/secrets.py b/worker/app/secrets.py index b1ccb50..4586f94 100644 --- a/worker/app/secrets.py +++ b/worker/app/secrets.py @@ -7,7 +7,7 @@ from app.config import settings def _fernet() -> Fernet: - digest = hashlib.sha256(settings.infrapulse_secret_key.encode("utf-8")).digest() + digest = hashlib.sha256(settings.orbitalward_secret_key.encode("utf-8")).digest() return Fernet(base64.urlsafe_b64encode(digest)) diff --git a/worker/pyproject.toml b/worker/pyproject.toml index 752bf11..4e09d4f 100644 --- a/worker/pyproject.toml +++ b/worker/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "infrapulse-worker" +name = "orbitalward-worker" version = "0.1.0" -description = "InfraPulse background worker" +description = "OrbitalWard background worker" requires-python = ">=3.12" dependencies = [ "cryptography>=48.0.0",