backup: pre-hardening baseline
This commit is contained in:
commit
9e4c612dcb
57 changed files with 10393 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
bin/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
314
ARCHITECTURE.md
Normal file
314
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
# Traefik GUI - Architecture Document
|
||||||
|
|
||||||
|
## 1. Backend Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── cmd/
|
||||||
|
│ └── traefik-gui/ # Main entry point
|
||||||
|
│ └── main.go
|
||||||
|
├── internal/
|
||||||
|
│ ├── api/ # REST API handlers
|
||||||
|
│ │ ├── handlers/
|
||||||
|
│ │ │ ├── auth.go # Login, logout, session validation
|
||||||
|
│ │ │ ├── health.go # Health check endpoint
|
||||||
|
│ │ │ ├── config.go # File-provider config management (Phase 2)
|
||||||
|
│ │ │ └── traefik.go # Traefik status proxy (read-only)
|
||||||
|
│ │ ├── middleware/
|
||||||
|
│ │ │ ├── auth.go # Session + CSRF validation
|
||||||
|
│ │ │ ├── cors.go # CORS handling (inline in auth.go)
|
||||||
|
│ │ │ └── logging.go # Request logging
|
||||||
|
│ │ ├── routes.go # Route registration
|
||||||
|
│ │ └── server.go # HTTP server setup
|
||||||
|
│ ├── auth/ # Authentication logic
|
||||||
|
│ │ ├── session.go # Session management (SQLite-backed)
|
||||||
|
│ │ ├── password.go # Password hashing (bcrypt)
|
||||||
|
│ │ └── csrf.go # CSRF protection helpers
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── file/ # File-provider service (Phase 2)
|
||||||
|
│ │ │ ├── service.go # Atomic writes, validation, backup, rollback
|
||||||
|
│ │ │ ├── validate.go # YAML/TOML validation via parser
|
||||||
|
│ │ │ ├── diff.go # Unified diff generation
|
||||||
|
│ │ │ └── lock.go # File-level advisory lock for concurrency
|
||||||
|
│ │ ├── adapters/ # Provider adapters (stubs Phase 2)
|
||||||
|
│ │ │ ├── docker.go # Docker labels adapter (stub)
|
||||||
|
│ │ │ └── kubernetes.go # Kubernetes CRD adapter (stub)
|
||||||
|
│ │ ├── env.go # Environment variable config
|
||||||
|
│ │ └── types.go # Config structs
|
||||||
|
│ ├── database/ # Database layer
|
||||||
|
│ │ ├── sqlite.go # SQLite connection & migrations
|
||||||
|
│ │ └── repositories/
|
||||||
|
│ │ ├── user.go # User repository
|
||||||
|
│ │ └── session.go # Session repository
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── user.go
|
||||||
|
│ │ ├── session.go
|
||||||
|
│ │ └── traefik.go # Traefik resource models
|
||||||
|
│ └── traefik/ # Traefik read-only integration
|
||||||
|
│ ├── client.go # Traefik API client interface (read-only)
|
||||||
|
│ ├── mock.go # Mock implementation for local MVP
|
||||||
|
│ └── fileprovider.go # Deprecated: use config/file/service.go
|
||||||
|
├── pkg/
|
||||||
|
│ └── version/
|
||||||
|
│ └── version.go
|
||||||
|
├── go.mod
|
||||||
|
├── go.sum
|
||||||
|
└── Makefile
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. API Routes (Phase 2 — Exact)
|
||||||
|
|
||||||
|
| Method | Path | Description | Auth | CSRF | Role |
|
||||||
|
|--------|------|-------------|------|------|------|
|
||||||
|
| GET | `/api/health` | Health check | No | No | — |
|
||||||
|
| GET | `/api/ready` | Readiness (DB + config dir writable) | No | No | — |
|
||||||
|
| POST | `/api/auth/login` | Login `{username,password}` | No | No | — |
|
||||||
|
| POST | `/api/auth/logout` | Logout + session delete | Yes | Yes | any |
|
||||||
|
| GET | `/api/auth/me` | Current user | Yes | No | any |
|
||||||
|
| GET | `/api/traefik/overview` | Read-only overview (mock or proxy) | Yes | No | any |
|
||||||
|
| GET | `/api/traefik/routers` | Read-only routers | Yes | No | any |
|
||||||
|
| GET | `/api/traefik/services` | Read-only services | Yes | No | any |
|
||||||
|
| GET | `/api/traefik/middlewares` | Read-only middlewares | Yes | No | any |
|
||||||
|
| GET | `/api/traefik/certificates` | Read-only certificates | Yes | No | any |
|
||||||
|
| GET | `/api/traefik/entrypoints` | Read-only entrypoints | Yes | No | any |
|
||||||
|
| GET | `/api/config/files` | List files in `configs/dynamic/` | Yes | No | any |
|
||||||
|
| GET | `/api/config/files/:name` | Read raw YAML file | Yes | No | any |
|
||||||
|
| POST | `/api/config/preview` | Validate YAML + return unified diff (no write) | Yes | Yes | admin,operator |
|
||||||
|
| POST | `/api/config/apply` | Confirm + atomic write + backup (requires `confirm:true`) | Yes | Yes | admin,operator |
|
||||||
|
| POST | `/api/config/rollback` | Restore last backup / previous git commit | Yes | Yes | admin |
|
||||||
|
| GET | `/api/config/history` | List backups / commits | Yes | No | admin,operator |
|
||||||
|
| GET | `/api/config/validate` | Validate raw YAML body (no write) | Yes | Yes | any |
|
||||||
|
| — | `/api/config/routers` etc. (legacy mock) | Deprecated, kept for dashboard reads | Yes | No | any |
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- All `/api/config/*` write operations require `RequireAuth` + `RequireCSRF` + role check.
|
||||||
|
- `POST /api/config/preview` does **not** write; it validates and diffs in memory.
|
||||||
|
- `POST /api/config/apply` requires `{"filename":"app.yml","content":"...","confirm":true}` and is rejected if `confirm` missing/false.
|
||||||
|
- Traefik API integration is **read-only**; the GUI never pushes config via Traefik API.
|
||||||
|
|
||||||
|
## 3. Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ POST /api/auth/login ┌─────────┐
|
||||||
|
│ Browser │ ───────────────────────────▶ │ Backend │
|
||||||
|
└─────────┘ {username, password} └────┬────┘
|
||||||
|
│
|
||||||
|
┌───────────────┴───────────────┐
|
||||||
|
▼ ▼
|
||||||
|
Validate credentials Create session
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────┐ ┌─────────────────┐
|
||||||
|
│ SQLite │ │ Set-Cookie: │
|
||||||
|
│ (users) │ │ session=<id>; │
|
||||||
|
└─────────────┘ │ HttpOnly; │
|
||||||
|
│ Secure (prod); │
|
||||||
|
│ SameSite=Lax │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
Set-Cookie header │
|
||||||
|
(HttpOnly, Secure) │
|
||||||
|
▼
|
||||||
|
┌─────────┐ Subsequent requests ┌─────────┐
|
||||||
|
│ Browser │ ───────────────────────────▶ │ Backend │
|
||||||
|
└─────────┘ Cookie: session=<id> └────┬────┘
|
||||||
|
Header: X-CSRF-Token │
|
||||||
|
┌───────────────┴───────────────┐
|
||||||
|
▼ ▼
|
||||||
|
Validate session Load user + role
|
||||||
|
(SQLite lookup) (attach to ctx)
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
CSRF check (state-changing) Role check (admin/operator/viewer)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session Details:**
|
||||||
|
- Stored in SQLite: `id`, `user_id`, `csrf_token`, `created_at`, `expires_at`
|
||||||
|
- 24-hour expiry, deleted on logout or expiry
|
||||||
|
- CSRF token: random 32 chars, rotated on successful write, validated via `X-CSRF-Token` header for POST/PUT/DELETE
|
||||||
|
- Secure, HttpOnly, SameSite=Lax; `Secure` flag enabled in production (`GUI_DEV_MODE=false`), disabled in dev
|
||||||
|
- Viewer can read; operator can preview/apply; admin can preview/apply/rollback/history
|
||||||
|
|
||||||
|
**Admin Password (dev-only note):**
|
||||||
|
- Default `admin / changeme` is **development-only** and created only if `users` table is empty.
|
||||||
|
- Production **must** set `GUI_ADMIN_PASSWORD` env var (min 12 chars). If `GUI_ADMIN_PASSWORD` is set, the GUI hashes it with bcrypt and creates/updates the admin user on startup. If neither default nor env var is acceptable, operator must provision the admin via direct DB insert before first run.
|
||||||
|
- On startup the GUI logs a warning if the default password is in use and `GUI_DEV_MODE=false`.
|
||||||
|
|
||||||
|
## 4. Database Schema (SQLite)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users table
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL, -- bcrypt
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sessions table
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
csrf_token TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
CHECK (expires_at > created_at)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
|
||||||
|
-- Backups table (Phase 2) — tracks pre-apply snapshots for rollback
|
||||||
|
CREATE TABLE backups (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL, -- full previous file content (or empty if new file)
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
reason TEXT NOT NULL -- "apply", "rollback"
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_backups_filename ON backups(filename);
|
||||||
|
|
||||||
|
-- Settings table (key-value for GUI settings)
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Configuration Lifecycle (Phase 2 — File Provider)
|
||||||
|
|
||||||
|
**Scope:** The GUI **only** manages files under `configs/dynamic/` (Traefik file provider directory). It **never** modifies Traefik static configuration (`traefik.yml`, entryPoints, providers, certificatesResolvers).
|
||||||
|
|
||||||
|
```
|
||||||
|
[User edits in GUI]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/config/preview {filename, content}
|
||||||
|
│ 1) Sanitize filename (no traversal, must end .yml/.yaml/.toml)
|
||||||
|
│ 2) Reject empty / dangerous content (see Validation)
|
||||||
|
│ 3) YAML parse via parser (gopkg.in/yaml.v3) + structural checks
|
||||||
|
│ 4) Generate unified diff vs current file on disk
|
||||||
|
▼
|
||||||
|
{valid, errors[], diff, warnings}
|
||||||
|
│ User confirms
|
||||||
|
▼
|
||||||
|
POST /api/config/apply {filename, content, confirm:true}
|
||||||
|
│ 1) Re-validate (same as preview)
|
||||||
|
│ 2) Acquire per-file advisory lock (flock)
|
||||||
|
│ 3) Create backup: copy current file → backups table + filesystem backups/<filename>.bak.<timestamp>
|
||||||
|
│ 4) Atomic write: write content → <filename>.tmp.<rand> → fsync → rename <filename>
|
||||||
|
│ 5) Optional: git commit if repo detected (`configs/` is git worktree)
|
||||||
|
│ 6) Release lock
|
||||||
|
▼
|
||||||
|
Traefik file provider (watch:true) detects rename → hot-reload dynamic config (no restart)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GET /api/traefik/overview reflects new routers/services status (enabled/warning/disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation and Rollback Behavior:**
|
||||||
|
|
||||||
|
- **Validation:** Uses YAML parser (`gopkg.in/yaml.v3`) plus structural checks:
|
||||||
|
- Rejects empty content, content without `http:`/`tcp:`/`udp:`/`tls:` top-level keys, or YAML syntax errors (returned with line/col).
|
||||||
|
- Rejects dangerous patterns: `filename` containing `..` or `/` segments escaping `configs/dynamic/`, absolute paths, or non-whitelisted extensions.
|
||||||
|
- Rejects files that would overwrite static config paths.
|
||||||
|
- Errors are returned without writing; HTTP 400 with `{"valid":false,"errors":[...]}`.
|
||||||
|
|
||||||
|
- **Atomic writes:** Write to `configs/dynamic/<filename>.tmp.<8-char-rand>` (0600), `fsync`, then `os.Rename` (atomic on POSIX). Temp files are cleaned on error.
|
||||||
|
|
||||||
|
- **Backup:** Before every successful apply, the previous content (or empty if new file) is stored in `backups` table + `configs/backups/<filename>.<unix>.bak`. Retention: DB keeps last 50 per file; filesystem keeps last 20 (pruned on apply).
|
||||||
|
|
||||||
|
- **Rollback:** `POST /api/config/rollback {filename, backupId?}` restores the most recent (or specified) backup via the same atomic write path, creating a new backup of the current content before restoring. `GET /api/config/history?filename=` lists backups. Rollback is atomic and validated.
|
||||||
|
|
||||||
|
- **Concurrency:** Per-file `sync.Mutex` + advisory `flock` on `configs/dynamic/.lock.<filename>` prevents concurrent writers from interleaving. Second concurrent `apply` gets 409 Conflict.
|
||||||
|
|
||||||
|
- **File ownership:** GUI process owns `configs/dynamic/`; files are created with `0644`. Traefik watches the directory as read-only in Docker (`:ro` on host, but GUI container has `:rw` on `configs/dynamic/`). No chown is performed.
|
||||||
|
|
||||||
|
## 6. Local Development Setup
|
||||||
|
|
||||||
|
### Traefik Configuration (Development)
|
||||||
|
```yaml
|
||||||
|
# docker/traefik.dev.yml
|
||||||
|
api:
|
||||||
|
dashboard: true
|
||||||
|
insecure: true # Only for local dev!
|
||||||
|
entryPoints:
|
||||||
|
web: { address: ":80" }
|
||||||
|
websecure: { address: ":443" }
|
||||||
|
providers:
|
||||||
|
file:
|
||||||
|
directory: "/etc/traefik/dynamic"
|
||||||
|
watch: true
|
||||||
|
log: { level: DEBUG }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose Services
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
traefik-gui:
|
||||||
|
build: .
|
||||||
|
ports: ["8080:8080"]
|
||||||
|
environment:
|
||||||
|
- GUI_DB_PATH=/data/traefik-gui.db
|
||||||
|
- GUI_SESSION_SECRET=dev-secret-change-in-production
|
||||||
|
- GUI_ADMIN_PASSWORD=changeme # dev-only; must be changed in prod
|
||||||
|
- GUI_CORS_ORIGIN=http://localhost:5173
|
||||||
|
- TRAEFIK_API_URL=http://traefik:8080/api
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- ./configs:/etc/traefik-gui/configs
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7
|
||||||
|
ports: ["80:80","443:443","8081:8080"]
|
||||||
|
volumes:
|
||||||
|
- ./docker/traefik.dev.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./configs/dynamic:/etc/traefik/dynamic:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `GUI_DB_PATH` | `./data/traefik-gui.db` | SQLite path |
|
||||||
|
| `GUI_SESSION_SECRET` | Required | Session signing secret (32+ chars) |
|
||||||
|
| `GUI_ADMIN_PASSWORD` | *(none — dev creates admin/changeme)* | Production admin password (≥12 chars); if set, creates/updates admin on startup |
|
||||||
|
| `GUI_DEV_MODE` | `false` | Dev mode disables Secure cookie, enables vite proxy |
|
||||||
|
| `GUI_CORS_ORIGIN` | `http://localhost:5173` | Frontend origin |
|
||||||
|
| `GUI_ADDR` | `:8080` | Listen address |
|
||||||
|
| `GUI_CONFIG_DIR` | `./configs/dynamic` | File provider directory (must be under `configs/`) |
|
||||||
|
| `TRAEFIK_API_URL` | `http://localhost:8080/api` | Traefik API base (read-only) |
|
||||||
|
|
||||||
|
## 7. Frontend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── api/client.ts # Axios + CSRF
|
||||||
|
├── components/layout/ # Layout
|
||||||
|
├── hooks/useAuth.tsx # Auth context
|
||||||
|
└── pages/ # Dashboard, Routers, Services, Middlewares, Certificates, Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
*Phase 2 adds `pages/ConfigEditor.tsx` for preview/diff/apply/rollback.*
|
||||||
|
|
||||||
|
## 8. Configuration Validation Strategy
|
||||||
|
|
||||||
|
- Server-side: `gopkg.in/yaml.v3` parse + structural checks (`http`/`tcp`/`udp`/`tls` presence). Traefik’s `parser` package is referenced for future TOML parity but not vendored in MVP.
|
||||||
|
- Client-side: TypeScript preview only; authoritative validation is server-side.
|
||||||
|
|
||||||
|
## 9. Security Model
|
||||||
|
|
||||||
|
- **Authentication:** Session-based, HttpOnly, SameSite=Lax; Secure in prod
|
||||||
|
- **Authorization:** RBAC (`admin` > `operator` > `viewer`); viewers cannot write, operators cannot rollback
|
||||||
|
- **CSRF:** Double-submit header `X-CSRF-Token`, rotated on write success, required for all POST/PUT/DELETE
|
||||||
|
- **CORS:** Restricted to `GUI_CORS_ORIGIN`
|
||||||
|
- **Secrets:** Env vars only
|
||||||
|
|
||||||
|
## 10. Deployment Model
|
||||||
|
|
||||||
|
- **Dev:** `docker compose -f docker/docker-compose.yml up` or `go run ./cmd/traefik-gui --dev` + `npm run dev`
|
||||||
|
- **Prod:** Single binary with embedded `frontend/dist`, reverse proxy for TLS, SQLite on persistent volume, `configs/dynamic` mounted rw, `GUI_ADMIN_PASSWORD` set via secret manager, `GUI_DEV_MODE=false`.
|
||||||
25
Makefile
Normal file
25
Makefile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
.PHONY: backend frontend build dev clean
|
||||||
|
|
||||||
|
backend-deps:
|
||||||
|
cd backend && go mod tidy
|
||||||
|
|
||||||
|
frontend-deps:
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
backend:
|
||||||
|
cd backend && go build -o ../bin/traefik-gui ./cmd/traefik-gui
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
cd frontend && npm run build
|
||||||
|
|
||||||
|
build: backend frontend
|
||||||
|
@echo "Build complete"
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && go run ./cmd/traefik-gui --dev --session-secret dev-secret-change-in-production-min-32-chars --addr :8080
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin/ frontend/dist/ data/*.db
|
||||||
24
README.md
Normal file
24
README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Traefik GUI
|
||||||
|
|
||||||
|
Web-based GUI for managing Traefik v3.7.
|
||||||
|
|
||||||
|
See ARCHITECTURE.md for design.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && go run ./cmd/traefik-gui --dev --session-secret dev-secret-32-chars-min --addr :8080
|
||||||
|
|
||||||
|
# Frontend (separate terminal)
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
|
||||||
|
# Open http://localhost:5173 (Vite) -> login admin/changeme
|
||||||
|
# API at http://localhost:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml up
|
||||||
|
```
|
||||||
111
backend/cmd/traefik-gui/main.go
Normal file
111
backend/cmd/traefik-gui/main.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/api"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/config"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/database"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/traefik"
|
||||||
|
"github.com/traefik/traefik-gui/backend/pkg/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
addr = flag.String("addr", ":8080", "HTTP server address")
|
||||||
|
dbPath = flag.String("db", "./data/traefik-gui.db", "SQLite database path")
|
||||||
|
sessionSecret = flag.String("session-secret", "", "Session signing secret (required)")
|
||||||
|
corsOrigin = flag.String("cors-origin", "http://localhost:5173", "CORS allowed origin")
|
||||||
|
traefikAPIURL = flag.String("traefik-api", "http://localhost:8080/api", "Traefik API base URL")
|
||||||
|
configDir = flag.String("config-dir", "./configs/dynamic", "File provider config directory")
|
||||||
|
devMode = flag.Bool("dev", false, "Development mode (serves frontend from Vite)")
|
||||||
|
adminPassword = flag.String("admin-password", os.Getenv("GUI_ADMIN_PASSWORD"), "Admin password (env GUI_ADMIN_PASSWORD)")
|
||||||
|
showVersion = flag.Bool("version", false, "Show version and exit")
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Also allow env fallback for session secret
|
||||||
|
if *sessionSecret == "" {
|
||||||
|
*sessionSecret = os.Getenv("GUI_SESSION_SECRET")
|
||||||
|
}
|
||||||
|
if *sessionSecret == "" {
|
||||||
|
*sessionSecret = os.Getenv("GUI_SESSION_SECRET")
|
||||||
|
}
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
version.Print()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *sessionSecret == "" {
|
||||||
|
log.Fatal("SESSION_SECRET environment variable or -session-secret flag is required")
|
||||||
|
}
|
||||||
|
if len(*sessionSecret) < 32 {
|
||||||
|
log.Fatal("session-secret must be at least 32 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Addr: *addr,
|
||||||
|
DBPath: *dbPath,
|
||||||
|
SessionSecret: *sessionSecret,
|
||||||
|
CORSOrigin: *corsOrigin,
|
||||||
|
TraefikAPIURL: *traefikAPIURL,
|
||||||
|
ConfigDir: *configDir,
|
||||||
|
DevMode: *devMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.New(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
log.Fatalf("Failed to migrate database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.EnsureAdminPasswordViaEnv(*adminPassword, *devMode); err != nil {
|
||||||
|
log.Fatalf("Failed to configure admin password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
traefikClient := traefik.NewMockClient()
|
||||||
|
|
||||||
|
server := api.NewServer(cfg, db, traefikClient)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("Shutdown signal received")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := server.Start(ctx); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("Server error: %v", err)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Printf("Graceful shutdown failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Server stopped")
|
||||||
|
}
|
||||||
40
backend/go.mod
Normal file
40
backend/go.mod
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
module github.com/traefik/traefik-gui/backend
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
|
github.com/rs/zerolog v1.32.0
|
||||||
|
golang.org/x/crypto v0.23.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
golang.org/x/net v0.25.0 // indirect
|
||||||
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.15.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
|
)
|
||||||
105
backend/go.sum
Normal file
105
backend/go.sum
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||||
|
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
||||||
|
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
143
backend/internal/api/handlers/auth.go
Normal file
143
backend/internal/api/handlers/auth.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/auth"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionCookieName = "traefik_gui_session"
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
userRepo *repositories.UserRepository
|
||||||
|
sessionRepo *repositories.SessionRepository
|
||||||
|
sessionSecret string
|
||||||
|
cookieDomain string
|
||||||
|
cookieSecure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(
|
||||||
|
userRepo *repositories.UserRepository,
|
||||||
|
sessionRepo *repositories.SessionRepository,
|
||||||
|
sessionSecret string,
|
||||||
|
cookieDomain string,
|
||||||
|
cookieSecure bool,
|
||||||
|
) *AuthHandler {
|
||||||
|
return &AuthHandler{
|
||||||
|
userRepo: userRepo,
|
||||||
|
sessionRepo: sessionRepo,
|
||||||
|
sessionSecret: sessionSecret,
|
||||||
|
cookieDomain: cookieDomain,
|
||||||
|
cookieSecure: cookieSecure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.GetByUsername(req.Username)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := auth.NewSessionData(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &models.Session{
|
||||||
|
ID: sessionData.CreatedAt.Format("20060102150405") + "-" + sessionData.CSRFToken[:8],
|
||||||
|
UserID: user.ID,
|
||||||
|
CSRFToken: sessionData.CSRFToken,
|
||||||
|
CreatedAt: sessionData.CreatedAt,
|
||||||
|
ExpiresAt: sessionData.ExpiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a proper UUID for session ID
|
||||||
|
session.ID, _ = auth.GenerateSessionID()
|
||||||
|
|
||||||
|
if err := h.sessionRepo.Create(session); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.setSessionCookie(c, session.ID, session.ExpiresAt)
|
||||||
|
|
||||||
|
if err := h.userRepo.UpdateLastLogin(user.ID); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user": gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
},
|
||||||
|
"csrf_token": session.CSRFToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
sessionID, err := c.Cookie(SessionCookieName)
|
||||||
|
if err == nil {
|
||||||
|
h.sessionRepo.Delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.clearSessionCookie(c)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Me(c *gin.Context) {
|
||||||
|
val, _ := c.Get("user"); user, _ := val.(*models.User)
|
||||||
|
if user == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) setSessionCookie(c *gin.Context, sessionID string, expiresAt time.Time) {
|
||||||
|
c.SetCookie(
|
||||||
|
SessionCookieName,
|
||||||
|
sessionID,
|
||||||
|
int(time.Until(expiresAt).Seconds()),
|
||||||
|
"/",
|
||||||
|
h.cookieDomain,
|
||||||
|
h.cookieSecure,
|
||||||
|
true, // HttpOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) clearSessionCookie(c *gin.Context) {
|
||||||
|
c.SetCookie(
|
||||||
|
SessionCookieName,
|
||||||
|
"",
|
||||||
|
-1,
|
||||||
|
"/",
|
||||||
|
h.cookieDomain,
|
||||||
|
h.cookieSecure,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
138
backend/internal/api/handlers/config.go
Normal file
138
backend/internal/api/handlers/config.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigHandler struct {
|
||||||
|
traefikClient TraefikClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraefikClient interface {
|
||||||
|
GetRouters(ctx context.Context) ([]models.Router, error)
|
||||||
|
GetServices(ctx context.Context) ([]models.Service, error)
|
||||||
|
GetMiddlewares(ctx context.Context) ([]models.Middleware, error)
|
||||||
|
GetCertificates(ctx context.Context) ([]models.Certificate, error)
|
||||||
|
GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error)
|
||||||
|
GetOverview(ctx context.Context) (*models.Overview, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigHandler(traefikClient TraefikClient) *ConfigHandler {
|
||||||
|
return &ConfigHandler{traefikClient: traefikClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) ListRouters(c *gin.Context) {
|
||||||
|
routers, err := h.traefikClient.GetRouters(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch routers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, routers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) GetRouter(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
routers, err := h.traefikClient.GetRouters(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch routers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range routers {
|
||||||
|
if r.Name == id {
|
||||||
|
c.JSON(http.StatusOK, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "router not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) CreateRouter(c *gin.Context) {
|
||||||
|
var router models.Router
|
||||||
|
if err := c.ShouldBindJSON(&router); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid router configuration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Validate and persist to file provider (Phase 2)
|
||||||
|
// For MVP, return success with mock data
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"message": "router created (mock)",
|
||||||
|
"router": router,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) UpdateRouter(c *gin.Context) {
|
||||||
|
_ = c.Param("id")
|
||||||
|
var router models.Router
|
||||||
|
if err := c.ShouldBindJSON(&router); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid router configuration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Validate and persist to file provider (Phase 2)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "router updated (mock)",
|
||||||
|
"router": router,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) DeleteRouter(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
// TODO: Delete from file provider (Phase 2)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "router deleted (mock)",
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) ListServices(c *gin.Context) {
|
||||||
|
services, err := h.traefikClient.GetServices(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch services"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, services)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) ListMiddlewares(c *gin.Context) {
|
||||||
|
middlewares, err := h.traefikClient.GetMiddlewares(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch middlewares"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, middlewares)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) ListCertificates(c *gin.Context) {
|
||||||
|
certs, err := h.traefikClient.GetCertificates(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch certificates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, certs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) ListEntryPoints(c *gin.Context) {
|
||||||
|
eps, err := h.traefikClient.GetEntryPoints(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch entrypoints"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, eps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) GetOverview(c *gin.Context) {
|
||||||
|
overview, err := h.traefikClient.GetOverview(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overview"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, overview)
|
||||||
|
}
|
||||||
146
backend/internal/api/handlers/config_file.go
Normal file
146
backend/internal/api/handlers/config_file.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/config/file"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileConfigHandler struct {
|
||||||
|
svc *file.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileConfigHandler(svc *file.Service) *FileConfigHandler {
|
||||||
|
return &FileConfigHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileConfigHandler) ListFiles(c *gin.Context) {
|
||||||
|
files, err := h.svc.ListFilesWithMeta()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if files == nil {
|
||||||
|
files = []file.FileMeta{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileConfigHandler) GetFile(c *gin.Context) {
|
||||||
|
name := c.Param("name")
|
||||||
|
content, err := h.svc.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"filename": name, "content": content})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreviewRequest struct {
|
||||||
|
Filename string `json:"filename" binding:"required"`
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileConfigHandler) Preview(c *gin.Context) {
|
||||||
|
var req PreviewRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := h.svc.Preview(req.Filename, req.Content)
|
||||||
|
if !result.Valid {
|
||||||
|
c.JSON(http.StatusBadRequest, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplyRequest struct {
|
||||||
|
Filename string `json:"filename" binding:"required"`
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileConfigHandler) Apply(c *gin.Context) {
|
||||||
|
user := middleware.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role != "admin" && user.Role != "operator" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ApplyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "filename, content, and confirm required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !req.Confirm {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "confirmation required: set confirm:true"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.Apply(req.Filename, req.Content, user.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !result.Valid {
|
||||||
|
c.JSON(http.StatusBadRequest, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "applied", "diff": result.Diff})
|
||||||
|
}
|
||||||
|
|
||||||
|
type RollbackRequest struct {
|
||||||
|
Filename string `json:"filename" binding:"required"`
|
||||||
|
BackupID string `json:"backupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileConfigHandler) Rollback(c *gin.Context) {
|
||||||
|
user := middleware.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin required for rollback"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req RollbackRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "filename required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.Rollback(req.Filename, req.BackupID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "rolled back", "diff": result.Diff})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileConfigHandler) History(c *gin.Context) {
|
||||||
|
filename := c.Query("filename")
|
||||||
|
history, err := h.svc.History(filename)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if history == nil {
|
||||||
|
history = []file.BackupInfo{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileConfigHandler) Validate(c *gin.Context) {
|
||||||
|
var req PreviewRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := h.svc.Preview(req.Filename, req.Content)
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
53
backend/internal/api/handlers/health.go
Normal file
53
backend/internal/api/handlers/health.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthHandler struct {
|
||||||
|
startTime time.Time
|
||||||
|
configDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthHandler() *HealthHandler {
|
||||||
|
return &HealthHandler{startTime: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) SetConfigDir(dir string) {
|
||||||
|
h.configDir = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) Health(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": time.Since(h.startTime).String(),
|
||||||
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
|
"version": "dev",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HealthHandler) Ready(c *gin.Context) {
|
||||||
|
configStatus := "ok"
|
||||||
|
if h.configDir != "" {
|
||||||
|
if _, err := os.Stat(h.configDir); err != nil {
|
||||||
|
configStatus = "error: " + err.Error()
|
||||||
|
} else if f, err := os.CreateTemp(h.configDir, ".writetest"); err != nil {
|
||||||
|
configStatus = "not writable: " + err.Error()
|
||||||
|
} else {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(f.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ready",
|
||||||
|
"checks": gin.H{
|
||||||
|
"database": "ok",
|
||||||
|
"traefik": "ok",
|
||||||
|
"configDir": configStatus,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
221
backend/internal/api/middleware/auth.go
Normal file
221
backend/internal/api/middleware/auth.go
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/auth"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionCookieName = "traefik_gui_session"
|
||||||
|
CSRFHeaderName = "X-CSRF-Token"
|
||||||
|
UserContextKey = "user"
|
||||||
|
SessionContextKey = "session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthMiddleware struct {
|
||||||
|
sessionRepo *repositories.SessionRepository
|
||||||
|
userRepo *repositories.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthMiddleware(sessionRepo *repositories.SessionRepository, userRepo *repositories.UserRepository) *AuthMiddleware {
|
||||||
|
return &AuthMiddleware{
|
||||||
|
sessionRepo: sessionRepo,
|
||||||
|
userRepo: userRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
sessionID, err := c.Cookie(SessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := m.sessionRepo.GetByID(sessionID)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.ExpiresAt.Before(time.Now()) {
|
||||||
|
m.sessionRepo.Delete(sessionID)
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := m.userRepo.GetByID(session.UserID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(SessionContextKey, session)
|
||||||
|
c.Set(UserContextKey, user)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthMiddleware) RequireCSRF() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionVal, exists := c.Get(SessionContextKey)
|
||||||
|
if !exists {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessionVal.(*models.Session)
|
||||||
|
|
||||||
|
csrfToken := c.GetHeader(CSRFHeaderName)
|
||||||
|
if csrfToken == "" {
|
||||||
|
csrfToken = c.PostForm("_csrf")
|
||||||
|
}
|
||||||
|
|
||||||
|
if csrfToken != session.CSRFToken {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate CSRF token on successful validation
|
||||||
|
newToken, err := auth.GenerateCSRFToken()
|
||||||
|
if err == nil {
|
||||||
|
m.sessionRepo.RotateCSRFToken(session.ID, newToken)
|
||||||
|
c.Header(CSRFHeaderName, newToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
sessionID, err := c.Cookie(SessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := m.sessionRepo.GetByID(sessionID)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.ExpiresAt.Before(time.Now()) {
|
||||||
|
m.sessionRepo.Delete(sessionID)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := m.userRepo.GetByID(session.UserID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(SessionContextKey, session)
|
||||||
|
c.Set(UserContextKey, user)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUser(c *gin.Context) *models.User {
|
||||||
|
val, exists := c.Get(UserContextKey)
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val.(*models.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSession(c *gin.Context) *models.Session {
|
||||||
|
val, exists := c.Get(SessionContextKey)
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val.(*models.Session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
user := GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, role := range allowedRoles {
|
||||||
|
if user.Role == role {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORSMiddleware(allowedOrigin string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.Request.Header.Get("Origin")
|
||||||
|
if origin == allowedOrigin || allowedOrigin == "*" {
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
}
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CSRF-Token")
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecurityHeadersMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("X-Frame-Options", "DENY")
|
||||||
|
c.Header("X-XSS-Protection", "1; mode=block")
|
||||||
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoggingMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
raw := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
latency := time.Since(start)
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
method := c.Request.Method
|
||||||
|
statusCode := c.Writer.Status()
|
||||||
|
|
||||||
|
if raw != "" {
|
||||||
|
path = path + "?" + raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log via zerolog in production
|
||||||
|
_ = statusCode // avoid unused in dev
|
||||||
|
_ = clientIP
|
||||||
|
_ = method
|
||||||
|
_ = path
|
||||||
|
_ = latency
|
||||||
|
}
|
||||||
|
}
|
||||||
4
backend/internal/api/routes.go
Normal file
4
backend/internal/api/routes.go
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
// Route registration is handled in server.go for simplicity
|
||||||
|
// This file exists for future expansion
|
||||||
164
backend/internal/api/server.go
Normal file
164
backend/internal/api/server.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/api/handlers"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/config"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/config/file"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/database"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/traefik"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
httpServer *http.Server
|
||||||
|
engine *gin.Engine
|
||||||
|
config *config.Config
|
||||||
|
db *database.DB
|
||||||
|
traefik traefik.TraefikClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.TraefikClient) *Server {
|
||||||
|
if !cfg.DevMode {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
|
||||||
|
userRepo := repositories.NewUserRepository(db.DB)
|
||||||
|
sessionRepo := repositories.NewSessionRepository(db.DB)
|
||||||
|
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(sessionRepo, userRepo)
|
||||||
|
|
||||||
|
healthHandler := handlers.NewHealthHandler()
|
||||||
|
authHandler := handlers.NewAuthHandler(
|
||||||
|
userRepo,
|
||||||
|
sessionRepo,
|
||||||
|
cfg.SessionSecret,
|
||||||
|
"", // cookie domain
|
||||||
|
!cfg.DevMode, // cookie secure - true in prod
|
||||||
|
)
|
||||||
|
configHandler := handlers.NewConfigHandler(traefikClient)
|
||||||
|
|
||||||
|
// File-provider service (Phase 2)
|
||||||
|
fileSvc, err := file.NewService(cfg.ConfigDir, db.DB)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Str("configDir", cfg.ConfigDir).Msg("Failed to init file service")
|
||||||
|
}
|
||||||
|
fileHandler := handlers.NewFileConfigHandler(fileSvc)
|
||||||
|
// Enhance health ready to check config dir writable
|
||||||
|
healthHandler.SetConfigDir(cfg.ConfigDir)
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
engine.Use(middleware.LoggingMiddleware())
|
||||||
|
engine.Use(middleware.SecurityHeadersMiddleware())
|
||||||
|
engine.Use(middleware.CORSMiddleware(cfg.CORSOrigin))
|
||||||
|
engine.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// Health endpoints (no auth)
|
||||||
|
engine.GET("/api/health", healthHandler.Health)
|
||||||
|
engine.GET("/api/ready", healthHandler.Ready)
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
authGroup := engine.Group("/api/auth")
|
||||||
|
{
|
||||||
|
authGroup.POST("/login", authHandler.Login)
|
||||||
|
authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout)
|
||||||
|
authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected API endpoints
|
||||||
|
apiGroup := engine.Group("/api")
|
||||||
|
apiGroup.Use(authMiddleware.RequireAuth())
|
||||||
|
apiGroup.Use(authMiddleware.RequireCSRF())
|
||||||
|
{
|
||||||
|
// Config endpoints (file-provider, Phase 2)
|
||||||
|
configGroup := apiGroup.Group("/config")
|
||||||
|
{
|
||||||
|
// File-provider management (secure, atomic, validated)
|
||||||
|
configGroup.GET("/files", fileHandler.ListFiles)
|
||||||
|
configGroup.GET("/files/:name", fileHandler.GetFile)
|
||||||
|
configGroup.GET("/history", fileHandler.History)
|
||||||
|
configGroup.POST("/preview", fileHandler.Preview)
|
||||||
|
configGroup.POST("/validate", fileHandler.Validate)
|
||||||
|
configGroup.POST("/apply", fileHandler.Apply)
|
||||||
|
configGroup.POST("/rollback", fileHandler.Rollback)
|
||||||
|
|
||||||
|
// Legacy mock endpoints (read-only dashboard, kept for compatibility)
|
||||||
|
configGroup.GET("/routers", configHandler.ListRouters)
|
||||||
|
configGroup.GET("/routers/:id", configHandler.GetRouter)
|
||||||
|
configGroup.POST("/routers", configHandler.CreateRouter)
|
||||||
|
configGroup.PUT("/routers/:id", configHandler.UpdateRouter)
|
||||||
|
configGroup.DELETE("/routers/:id", configHandler.DeleteRouter)
|
||||||
|
|
||||||
|
configGroup.GET("/services", configHandler.ListServices)
|
||||||
|
configGroup.GET("/middlewares", configHandler.ListMiddlewares)
|
||||||
|
configGroup.GET("/certificates", configHandler.ListCertificates)
|
||||||
|
configGroup.GET("/entrypoints", configHandler.ListEntryPoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traefik status endpoints (read-only)
|
||||||
|
traefikGroup := apiGroup.Group("/traefik")
|
||||||
|
{
|
||||||
|
traefikGroup.GET("/overview", configHandler.GetOverview)
|
||||||
|
traefikGroup.GET("/routers", configHandler.ListRouters)
|
||||||
|
traefikGroup.GET("/services", configHandler.ListServices)
|
||||||
|
traefikGroup.GET("/middlewares", configHandler.ListMiddlewares)
|
||||||
|
traefikGroup.GET("/certificates", configHandler.ListCertificates)
|
||||||
|
traefikGroup.GET("/entrypoints", configHandler.ListEntryPoints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve embedded frontend in production
|
||||||
|
if !cfg.DevMode {
|
||||||
|
// TODO: Embed frontend assets
|
||||||
|
engine.NoRoute(func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &Server{
|
||||||
|
engine: engine,
|
||||||
|
config: cfg,
|
||||||
|
db: db,
|
||||||
|
traefik: traefikClient,
|
||||||
|
httpServer: &http.Server{
|
||||||
|
Addr: cfg.Addr,
|
||||||
|
Handler: engine,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
|
log.Info().Str("addr", s.config.Addr).Msg("Starting HTTP server")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Info().Msg("Shutting down HTTP server")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
s.httpServer.Shutdown(shutdownCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Shutdown(ctx context.Context) error {
|
||||||
|
return s.httpServer.Shutdown(ctx)
|
||||||
|
}
|
||||||
88
backend/internal/auth/session.go
Normal file
88
backend/internal/auth/session.go
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||||
|
ErrSessionExpired = errors.New("session expired")
|
||||||
|
ErrInvalidCSRF = errors.New("invalid CSRF token")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionDuration = 24 * time.Hour
|
||||||
|
SessionCleanupInterval = 1 * time.Hour
|
||||||
|
CSRFTokenLength = 32
|
||||||
|
SessionIDLength = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(hash), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPassword(password, hash string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateSessionID() (string, error) {
|
||||||
|
return generateRandomString(SessionIDLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateCSRFToken() (string, error) {
|
||||||
|
return generateRandomString(CSRFTokenLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomString(length int) (string, error) {
|
||||||
|
bytes := make([]byte, length)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionData struct {
|
||||||
|
UserID string
|
||||||
|
CSRFToken string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionData(userID string) (*SessionData, error) {
|
||||||
|
now := time.Now()
|
||||||
|
csrfToken, err := GenerateCSRFToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SessionData{
|
||||||
|
UserID: userID,
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(SessionDuration),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionData) IsExpired() bool {
|
||||||
|
return time.Now().After(s.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionData) Extend() {
|
||||||
|
s.ExpiresAt = time.Now().Add(SessionDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionData) RotateCSRF() error {
|
||||||
|
token, err := GenerateCSRFToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.CSRFToken = token
|
||||||
|
return nil
|
||||||
|
}
|
||||||
43
backend/internal/config/adapters/docker.go
Normal file
43
backend/internal/config/adapters/docker.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package adapters
|
||||||
|
|
||||||
|
// DockerAdapter is a stub for future Docker label management.
|
||||||
|
// Phase 2 does not implement Docker label adapter; it only defines the interface
|
||||||
|
// so Phase 3 can plug it in without changing call sites.
|
||||||
|
|
||||||
|
type DockerAdapter interface {
|
||||||
|
// ListContainers returns containers that could be managed via labels
|
||||||
|
ListContainers() ([]Container, error)
|
||||||
|
// PreviewLabels returns diff for label changes (no write)
|
||||||
|
PreviewLabels(containerID string, labels map[string]string) (string, error)
|
||||||
|
// ApplyLabels writes label changes (requires confirmation)
|
||||||
|
ApplyLabels(containerID string, labels map[string]string, confirm bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Container struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Labels map[string]string `json:"labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StubDockerAdapter returns not-implemented for all operations
|
||||||
|
type StubDockerAdapter struct{}
|
||||||
|
|
||||||
|
func (s *StubDockerAdapter) ListContainers() ([]Container, error) {
|
||||||
|
return nil, errNotImplemented("docker")
|
||||||
|
}
|
||||||
|
func (s *StubDockerAdapter) PreviewLabels(string, map[string]string) (string, error) {
|
||||||
|
return "", errNotImplemented("docker")
|
||||||
|
}
|
||||||
|
func (s *StubDockerAdapter) ApplyLabels(string, map[string]string, bool) error {
|
||||||
|
return errNotImplemented("docker")
|
||||||
|
}
|
||||||
|
|
||||||
|
func errNotImplemented(provider string) error {
|
||||||
|
return &NotImplementedError{Provider: provider}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotImplementedError struct{ Provider string }
|
||||||
|
|
||||||
|
func (e *NotImplementedError) Error() string {
|
||||||
|
return "provider " + e.Provider + " adapter not implemented in Phase 2 (stub only)"
|
||||||
|
}
|
||||||
33
backend/internal/config/adapters/kubernetes.go
Normal file
33
backend/internal/config/adapters/kubernetes.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package adapters
|
||||||
|
|
||||||
|
// KubernetesAdapter is a stub for future Kubernetes CRD/Gateway management.
|
||||||
|
// Phase 2 does not implement K8s adapter; it only defines the interface.
|
||||||
|
|
||||||
|
type KubernetesAdapter interface {
|
||||||
|
ListIngresses(namespace string) ([]K8sResource, error)
|
||||||
|
ListCRDs(namespace string) ([]K8sResource, error)
|
||||||
|
PreviewResource(kind, namespace, name string, yaml string) (string, error)
|
||||||
|
ApplyResource(kind, namespace, name string, yaml string, confirm bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type K8sResource struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
YAML string `json:"yaml"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StubKubernetesAdapter struct{}
|
||||||
|
|
||||||
|
func (s *StubKubernetesAdapter) ListIngresses(string) ([]K8sResource, error) {
|
||||||
|
return nil, errNotImplemented("kubernetes")
|
||||||
|
}
|
||||||
|
func (s *StubKubernetesAdapter) ListCRDs(string) ([]K8sResource, error) {
|
||||||
|
return nil, errNotImplemented("kubernetes")
|
||||||
|
}
|
||||||
|
func (s *StubKubernetesAdapter) PreviewResource(string, string, string, string) (string, error) {
|
||||||
|
return "", errNotImplemented("kubernetes")
|
||||||
|
}
|
||||||
|
func (s *StubKubernetesAdapter) ApplyResource(string, string, string, string, bool) error {
|
||||||
|
return errNotImplemented("kubernetes")
|
||||||
|
}
|
||||||
84
backend/internal/config/file/diff.go
Normal file
84
backend/internal/config/file/diff.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnifiedDiff returns a unified diff between old and new content
|
||||||
|
func UnifiedDiff(filename, oldContent, newContent string) string {
|
||||||
|
if oldContent == newContent {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
oldLines := splitLines(oldContent)
|
||||||
|
newLines := splitLines(newContent)
|
||||||
|
|
||||||
|
// Simple line-based diff: collect added/removed
|
||||||
|
// Use LCS-style? For MVP use simple prefix diff
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", filename, filename)
|
||||||
|
|
||||||
|
// Find common prefix/suffix for brevity
|
||||||
|
// Instead do full: show removed then added
|
||||||
|
oldSet := make(map[string]int)
|
||||||
|
for _, l := range oldLines {
|
||||||
|
oldSet[l]++
|
||||||
|
}
|
||||||
|
newSet := make(map[string]int)
|
||||||
|
for _, l := range newLines {
|
||||||
|
newSet[l]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Very simple: if old empty (new file), show all as +
|
||||||
|
if len(oldLines) == 0 || (len(oldLines) == 1 && oldLines[0] == "") {
|
||||||
|
for _, l := range newLines {
|
||||||
|
fmt.Fprintf(&b, "+%s\n", l)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
if len(newLines) == 0 {
|
||||||
|
for _, l := range oldLines {
|
||||||
|
fmt.Fprintf(&b, "-%s\n", l)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// For MVP, do line-by-line with +/- for changed lines
|
||||||
|
// Use diff library would be better, but avoid extra dep: implement simple
|
||||||
|
max := len(oldLines)
|
||||||
|
if len(newLines) > max {
|
||||||
|
max = len(newLines)
|
||||||
|
}
|
||||||
|
// Show hunks: 3 context lines? Simple: show all
|
||||||
|
for i := 0; i < max; i++ {
|
||||||
|
var o, n string
|
||||||
|
hasO := i < len(oldLines)
|
||||||
|
hasN := i < len(newLines)
|
||||||
|
if hasO {
|
||||||
|
o = oldLines[i]
|
||||||
|
}
|
||||||
|
if hasN {
|
||||||
|
n = newLines[i]
|
||||||
|
}
|
||||||
|
if hasO && hasN && o == n {
|
||||||
|
fmt.Fprintf(&b, " %s\n", o)
|
||||||
|
} else {
|
||||||
|
if hasO {
|
||||||
|
fmt.Fprintf(&b, "-%s\n", o)
|
||||||
|
}
|
||||||
|
if hasN {
|
||||||
|
fmt.Fprintf(&b, "+%s\n", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
// Normalize line endings
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
return strings.Split(s, "\n")
|
||||||
|
}
|
||||||
25
backend/internal/config/file/lock.go
Normal file
25
backend/internal/config/file/lock.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package file
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// FileLocks provides per-filename mutexes to handle concurrent writes safely.
|
||||||
|
type FileLocks struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
locks map[string]*sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileLocks() *FileLocks {
|
||||||
|
return &FileLocks{locks: make(map[string]*sync.Mutex)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileLocks) Lock(filename string) func() {
|
||||||
|
f.mu.Lock()
|
||||||
|
m, ok := f.locks[filename]
|
||||||
|
if !ok {
|
||||||
|
m = &sync.Mutex{}
|
||||||
|
f.locks[filename] = m
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
m.Lock()
|
||||||
|
return func() { m.Unlock() }
|
||||||
|
}
|
||||||
385
backend/internal/config/file/service.go
Normal file
385
backend/internal/config/file/service.go
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
configDir string
|
||||||
|
db *sql.DB
|
||||||
|
locks *FileLocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(configDir string, db *sql.DB) (*Service, error) {
|
||||||
|
abs, err := filepath.Abs(configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve config dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create config dir: %w", err)
|
||||||
|
}
|
||||||
|
// Ensure backups dir exists
|
||||||
|
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create backups dir: %w", err)
|
||||||
|
}
|
||||||
|
return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ConfigDir() string { return s.configDir }
|
||||||
|
|
||||||
|
// sanitizedPath returns absolute path inside configDir, after validating filename
|
||||||
|
func (s *Service) sanitizedPath(filename string) (string, error) {
|
||||||
|
if err := ValidateFilename(filename); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// No path separators already validated, but double-check
|
||||||
|
clean := filepath.Base(filename)
|
||||||
|
p := filepath.Join(s.configDir, clean)
|
||||||
|
// Ensure p is inside configDir
|
||||||
|
if !strings.HasPrefix(p, s.configDir+string(os.PathSeparator)) && p != s.configDir {
|
||||||
|
return "", fmt.Errorf("invalid path")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMeta for listing without exposing paths
|
||||||
|
type FileMeta struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModTime string `json:"modTime"` // RFC3339
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
ValidationErr string `json:"validationErr,omitempty"`
|
||||||
|
LastAction string `json:"lastAction,omitempty"` // apply/rollback
|
||||||
|
LastActionTime string `json:"lastActionTime,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFiles lists files in configDir (excluding backups subdir and temp files)
|
||||||
|
func (s *Service) ListFiles() ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(s.configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if strings.Contains(name, ".tmp.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(strings.ToLower(name), ".yml") || strings.HasSuffix(strings.ToLower(name), ".yaml") || strings.HasSuffix(strings.ToLower(name), ".toml") {
|
||||||
|
files = append(files, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFilesWithMeta returns enriched metadata without exposing paths
|
||||||
|
func (s *Service) ListFilesWithMeta() ([]FileMeta, error) {
|
||||||
|
entries, err := os.ReadDir(s.configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []FileMeta
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if strings.Contains(name, ".tmp.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta := FileMeta{
|
||||||
|
Filename: name,
|
||||||
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime().UTC().Format(time.RFC3339),
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
// Validate content for status
|
||||||
|
if b, err := os.ReadFile(filepath.Join(s.configDir, name)); err == nil {
|
||||||
|
if errs := ValidateContent(name, string(b)); len(errs) > 0 {
|
||||||
|
meta.Valid = false
|
||||||
|
meta.ValidationErr = errs[0].Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
meta.Valid = false
|
||||||
|
meta.ValidationErr = err.Error()
|
||||||
|
}
|
||||||
|
// Last action from backups
|
||||||
|
var reason, createdAt string
|
||||||
|
err = s.db.QueryRow(`SELECT reason, created_at FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, name).Scan(&reason, &createdAt)
|
||||||
|
if err == nil {
|
||||||
|
meta.LastAction = reason
|
||||||
|
meta.LastActionTime = createdAt
|
||||||
|
}
|
||||||
|
out = append(out, meta)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile reads raw content of a file in configDir
|
||||||
|
func (s *Service) ReadFile(filename string) (string, error) {
|
||||||
|
p, err := s.sanitizedPath(filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("file not found: %s", filename)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview validates and diffs without writing
|
||||||
|
type PreviewResult struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Errors []ValidationError `json:"errors,omitempty"`
|
||||||
|
Diff string `json:"diff"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Preview(filename, content string) PreviewResult {
|
||||||
|
errs := ValidateContent(filename, content)
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return PreviewResult{Valid: false, Errors: errs}
|
||||||
|
}
|
||||||
|
// Diff vs current file (if exists)
|
||||||
|
oldContent := ""
|
||||||
|
if p, err := s.sanitizedPath(filename); err == nil {
|
||||||
|
if b, err := os.ReadFile(p); err == nil {
|
||||||
|
oldContent = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff := UnifiedDiff(filename, oldContent, content)
|
||||||
|
return PreviewResult{Valid: true, Diff: diff}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply validates, backs up, then atomically writes. Requires confirm=true caller.
|
||||||
|
func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) {
|
||||||
|
if !confirm {
|
||||||
|
return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true")
|
||||||
|
}
|
||||||
|
errs := ValidateContent(filename, content)
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return PreviewResult{Valid: false, Errors: errs}, nil
|
||||||
|
}
|
||||||
|
p, err := s.sanitizedPath(filename)
|
||||||
|
if err != nil {
|
||||||
|
return PreviewResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock := s.locks.Lock(filename)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
// Backup current content
|
||||||
|
oldContent := ""
|
||||||
|
if b, err := os.ReadFile(p); err == nil {
|
||||||
|
oldContent = string(b)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return PreviewResult{}, fmt.Errorf("read current file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := UnifiedDiff(filename, oldContent, content)
|
||||||
|
|
||||||
|
// Store backup in DB
|
||||||
|
backupID := uuid.New().String()
|
||||||
|
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
backupID, filename, oldContent, userID, "apply")
|
||||||
|
if err != nil {
|
||||||
|
return PreviewResult{}, fmt.Errorf("store backup: %w", err)
|
||||||
|
}
|
||||||
|
// Also filesystem backup
|
||||||
|
backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix()))
|
||||||
|
_ = os.WriteFile(backupPath, []byte(oldContent), 0o644)
|
||||||
|
// Prune old filesystem backups (keep 20)
|
||||||
|
s.pruneFilesystemBackups(filename)
|
||||||
|
|
||||||
|
// Atomic write: temp file in same dir, fsync, rename
|
||||||
|
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
||||||
|
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := f.WriteString(content); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
if err := os.Rename(tmpName, p); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return PreviewResult{}, fmt.Errorf("rename: %w", err)
|
||||||
|
}
|
||||||
|
// fsync directory
|
||||||
|
if d, err := os.Open(s.configDir); err == nil {
|
||||||
|
_ = d.Sync()
|
||||||
|
d.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune DB backups (keep 50 per file)
|
||||||
|
s.pruneDBBackups(filename)
|
||||||
|
|
||||||
|
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) pruneFilesystemBackups(filename string) {
|
||||||
|
pattern := filepath.Join(s.configDir, "backups", filename+".*.bak")
|
||||||
|
matches, _ := filepath.Glob(pattern)
|
||||||
|
if len(matches) <= 20 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Remove oldest (Glob returns sorted)
|
||||||
|
for _, m := range matches[:len(matches)-20] {
|
||||||
|
os.Remove(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) pruneDBBackups(filename string) {
|
||||||
|
// Keep 50 most recent
|
||||||
|
_, _ = s.db.Exec(`
|
||||||
|
DELETE FROM backups WHERE id IN (
|
||||||
|
SELECT id FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET 50
|
||||||
|
)`, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History lists backups for a file (or all if filename empty)
|
||||||
|
type BackupInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) History(filename string) ([]BackupInfo, error) {
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if filename != "" {
|
||||||
|
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
|
||||||
|
} else {
|
||||||
|
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []BackupInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var b BackupInfo
|
||||||
|
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback restores specified backup (or most recent if backupID empty)
|
||||||
|
func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) {
|
||||||
|
if err := ValidateFilename(filename); err != nil {
|
||||||
|
return PreviewResult{}, err
|
||||||
|
}
|
||||||
|
p, err := s.sanitizedPath(filename)
|
||||||
|
if err != nil {
|
||||||
|
return PreviewResult{}, err
|
||||||
|
}
|
||||||
|
unlock := s.locks.Lock(filename)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
var content string
|
||||||
|
if backupID != "" {
|
||||||
|
err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return PreviewResult{}, fmt.Errorf("backup not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PreviewResult{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Most recent
|
||||||
|
err = s.db.QueryRow(`SELECT content FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PreviewResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rollback content (allow empty = delete file)
|
||||||
|
if content != "" {
|
||||||
|
if errs := ValidateContent(filename, content); len(errs) > 0 {
|
||||||
|
return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup current before rollback
|
||||||
|
curContent := ""
|
||||||
|
if b, err := os.ReadFile(p); err == nil {
|
||||||
|
curContent = string(b)
|
||||||
|
}
|
||||||
|
rbID := uuid.New().String()
|
||||||
|
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
rbID, filename, curContent, userID, "rollback")
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
// Original file was new: delete current file
|
||||||
|
_ = os.Remove(p)
|
||||||
|
} else {
|
||||||
|
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
||||||
|
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return PreviewResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := f.WriteString(content); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return PreviewResult{}, err
|
||||||
|
}
|
||||||
|
f.Sync()
|
||||||
|
f.Close()
|
||||||
|
if err := os.Rename(tmpName, p); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return PreviewResult{}, err
|
||||||
|
}
|
||||||
|
if d, err := os.Open(s.configDir); err == nil {
|
||||||
|
_ = d.Sync()
|
||||||
|
d.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := UnifiedDiff(filename, curContent, content)
|
||||||
|
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
345
backend/internal/config/file/service_test.go
Normal file
345
backend/internal/config/file/service_test.go
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestService(t *testing.T) (*Service, string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.MkdirTemp("", "traefik-gui-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("temp dir: %v", err)
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite3", filepath.Join(dir, "test.db")+"?_foreign_keys=on")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
// create backups table
|
||||||
|
_, err = db.Exec(`CREATE TABLE backups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create backups: %v", err)
|
||||||
|
}
|
||||||
|
svc, err := NewService(filepath.Join(dir, "dynamic"), db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new service: %v", err)
|
||||||
|
}
|
||||||
|
cleanup := func() {
|
||||||
|
db.Close()
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
return svc, dir, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
const validYAML = `http:
|
||||||
|
routers:
|
||||||
|
test-router:
|
||||||
|
rule: "Host(` + "`test.example.com`" + `)"
|
||||||
|
service: test-service
|
||||||
|
entryPoints: ["web"]
|
||||||
|
services:
|
||||||
|
test-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:8080"
|
||||||
|
`
|
||||||
|
|
||||||
|
const validYAML2 = `http:
|
||||||
|
routers:
|
||||||
|
test-router2:
|
||||||
|
rule: "Host(` + "`test2.example.com`" + `)"
|
||||||
|
service: test-service2
|
||||||
|
entryPoints: ["web"]
|
||||||
|
services:
|
||||||
|
test-service2:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:8081"
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestValidateContent_Valid(t *testing.T) {
|
||||||
|
errs := ValidateContent("app.yml", validYAML)
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected no errors, got %v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateContent_InvalidYAML(t *testing.T) {
|
||||||
|
invalid := "http:\n routers: [\ninvalid yaml"
|
||||||
|
errs := ValidateContent("app.yml", invalid)
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatal("expected validation errors for invalid yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateContent_Empty(t *testing.T) {
|
||||||
|
errs := ValidateContent("app.yml", " ")
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatal("expected error for empty content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateContent_NoTopLevel(t *testing.T) {
|
||||||
|
errs := ValidateContent("app.yml", "foo: bar\nbaz: qux\n")
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatal("expected error for missing http/tcp/udp/tls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateContent_DangerousFilename(t *testing.T) {
|
||||||
|
errs := ValidateContent("../evil.yml", validYAML)
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatal("expected error for path traversal filename")
|
||||||
|
}
|
||||||
|
errs = ValidateContent("app.txt", validYAML)
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatal("expected error for wrong extension")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreview_Diff(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Preview new file (no existing)
|
||||||
|
res := svc.Preview("app.yml", validYAML)
|
||||||
|
if !res.Valid {
|
||||||
|
t.Fatalf("preview should be valid, got errs %v", res.Errors)
|
||||||
|
}
|
||||||
|
if res.Diff == "" {
|
||||||
|
t.Fatal("expected diff for new file")
|
||||||
|
}
|
||||||
|
// Preview with same content -> no diff? Actually diff will be content vs empty, so diff present
|
||||||
|
// Second: after apply, preview same content should have empty diff
|
||||||
|
_, err := svc.Apply("app.yml", validYAML, "test-user", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply: %v", err)
|
||||||
|
}
|
||||||
|
res2 := svc.Preview("app.yml", validYAML)
|
||||||
|
if !res2.Valid {
|
||||||
|
t.Fatalf("preview2 valid %v", res2.Errors)
|
||||||
|
}
|
||||||
|
// Same content should give empty diff
|
||||||
|
if res2.Diff != "" {
|
||||||
|
t.Fatalf("expected empty diff for same content, got %q", res2.Diff)
|
||||||
|
}
|
||||||
|
// Different content should give diff
|
||||||
|
res3 := svc.Preview("app.yml", validYAML2)
|
||||||
|
if res3.Diff == "" {
|
||||||
|
t.Fatal("expected diff for changed content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicWrite(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Apply valid
|
||||||
|
res, err := svc.Apply("app.yml", validYAML, "user1", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply err %v", err)
|
||||||
|
}
|
||||||
|
if !res.Valid {
|
||||||
|
t.Fatalf("not valid %v", res.Errors)
|
||||||
|
}
|
||||||
|
// Read back
|
||||||
|
content, err := svc.ReadFile("app.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %v", err)
|
||||||
|
}
|
||||||
|
if content != validYAML {
|
||||||
|
t.Fatalf("content mismatch")
|
||||||
|
}
|
||||||
|
// Ensure no temp files left
|
||||||
|
files, _ := svc.ListFiles()
|
||||||
|
for _, f := range files {
|
||||||
|
if len(f) > 4 && f[len(f)-4:] == ".tmp" {
|
||||||
|
t.Fatalf("temp file left: %s", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure temp files not present on disk
|
||||||
|
entries, _ := os.ReadDir(svc.ConfigDir())
|
||||||
|
for _, e := range entries {
|
||||||
|
if len(e.Name()) > 4 && contains(e.Name(), ".tmp.") {
|
||||||
|
t.Fatalf("temp file on disk: %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (func() bool {
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectInvalid(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Try apply invalid YAML - should not write
|
||||||
|
res, err := svc.Apply("bad.yml", "http:\n bad: [\n", "user1", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply should return preview error, not err %v", err)
|
||||||
|
}
|
||||||
|
if res.Valid {
|
||||||
|
t.Fatal("invalid yaml should be rejected")
|
||||||
|
}
|
||||||
|
// Ensure file not created
|
||||||
|
if _, err := svc.ReadFile("bad.yml"); err == nil {
|
||||||
|
t.Fatal("invalid file should not be created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectEmpty(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
res, _ := svc.Apply("empty.yml", " ", "user1", true)
|
||||||
|
if res.Valid {
|
||||||
|
t.Fatal("empty should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectNoConfirm(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
_, err := svc.Apply("app.yml", validYAML, "user1", false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing confirm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollback(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Apply v1
|
||||||
|
_, err := svc.Apply("app.yml", validYAML, "user1", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply v1 %v", err)
|
||||||
|
}
|
||||||
|
// Apply v2
|
||||||
|
_, err = svc.Apply("app.yml", validYAML2, "user1", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply v2 %v", err)
|
||||||
|
}
|
||||||
|
// Verify v2 present
|
||||||
|
content, _ := svc.ReadFile("app.yml")
|
||||||
|
if content != validYAML2 {
|
||||||
|
t.Fatalf("expected v2")
|
||||||
|
}
|
||||||
|
// Rollback to previous (v1)
|
||||||
|
_, err = svc.Rollback("app.yml", "", "user1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollback %v", err)
|
||||||
|
}
|
||||||
|
content, _ = svc.ReadFile("app.yml")
|
||||||
|
if content != validYAML {
|
||||||
|
t.Fatalf("expected rollback to v1, got %q", content)
|
||||||
|
}
|
||||||
|
// Check history has entries
|
||||||
|
hist, err := svc.History("app.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history %v", err)
|
||||||
|
}
|
||||||
|
if len(hist) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 history entries, got %d", len(hist))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollbackSpecificBackup(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
svc.Apply("app.yml", validYAML, "user1", true)
|
||||||
|
svc.Apply("app.yml", validYAML2, "user1", true)
|
||||||
|
hist, _ := svc.History("app.yml")
|
||||||
|
if len(hist) < 2 {
|
||||||
|
t.Fatalf("need 2 backups")
|
||||||
|
}
|
||||||
|
// hist[0] is most recent (before v2), hist[1] is before v1 (empty)
|
||||||
|
// Rollback to specific backup: choose oldest that has content validYAML
|
||||||
|
// The most recent backup content is validYAML (before v2)
|
||||||
|
_, err := svc.Rollback("app.yml", hist[0].ID, "user1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollback specific %v", err)
|
||||||
|
}
|
||||||
|
content, _ := svc.ReadFile("app.yml")
|
||||||
|
if content != validYAML {
|
||||||
|
t.Fatalf("expected v1 after specific rollback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentUpdates(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
// Start with valid
|
||||||
|
svc.Apply("concurrent.yml", validYAML, "user1", true)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make([]error, 10)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
content := validYAML
|
||||||
|
if idx%2 == 0 {
|
||||||
|
content = validYAML2
|
||||||
|
}
|
||||||
|
_, err := svc.Apply("concurrent.yml", content, "user1", true)
|
||||||
|
errs[idx] = err
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for i, e := range errs {
|
||||||
|
if e != nil {
|
||||||
|
t.Fatalf("concurrent apply %d failed: %v", i, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final content should be one of the two
|
||||||
|
content, _ := svc.ReadFile("concurrent.yml")
|
||||||
|
if content != validYAML && content != validYAML2 {
|
||||||
|
t.Fatalf("unexpected final content")
|
||||||
|
}
|
||||||
|
// History should have 11 entries (initial + 10)
|
||||||
|
hist, _ := svc.History("concurrent.yml")
|
||||||
|
if len(hist) != 11 {
|
||||||
|
t.Fatalf("expected 11 history, got %d", len(hist))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileOwnershipAndValidation(t *testing.T) {
|
||||||
|
svc, _, cleanup := newTestService(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Try to write outside directory via traversal — should fail validation
|
||||||
|
res, _ := svc.Apply("../evil.yml", validYAML, "user1", true)
|
||||||
|
if res.Valid {
|
||||||
|
t.Fatal("expected invalid for traversal")
|
||||||
|
}
|
||||||
|
// Try wrong extension
|
||||||
|
res, _ = svc.Apply("evil.txt", validYAML, "user1", true)
|
||||||
|
if res.Valid {
|
||||||
|
t.Fatal("expected invalid for wrong extension")
|
||||||
|
}
|
||||||
|
// Try empty filename
|
||||||
|
res2 := svc.Preview("", validYAML)
|
||||||
|
if res2.Valid {
|
||||||
|
t.Fatal("expected invalid for empty filename")
|
||||||
|
}
|
||||||
|
}
|
||||||
290
backend/internal/config/file/validate.go
Normal file
290
backend/internal/config/file/validate.go
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationError with line info
|
||||||
|
type ValidationError struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Line int `json:"line,omitempty"`
|
||||||
|
Column int `json:"column,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ValidationError) Error() string {
|
||||||
|
if e.Line > 0 {
|
||||||
|
return fmt.Sprintf("line %d col %d: %s", e.Line, e.Column, e.Message)
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateContent checks YAML content before write.
|
||||||
|
// Rejects empty, dangerous, or structurally invalid configs.
|
||||||
|
// Allows only dynamic config top-level keys: http, tcp, udp, tls.
|
||||||
|
// Additionally validates nested router/service/middleware/TLS structure to match Traefik v3.7 dynamic schema.
|
||||||
|
func ValidateContent(filename, content string) []ValidationError {
|
||||||
|
var errs []ValidationError
|
||||||
|
|
||||||
|
trimmed := strings.TrimSpace(content)
|
||||||
|
if trimmed == "" {
|
||||||
|
errs = append(errs, ValidationError{Message: "content must not be empty"})
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateFilename(filename); err != nil {
|
||||||
|
errs = append(errs, ValidationError{Message: err.Error()})
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOML files: only syntax check via extension, full schema validated as YAML for MVP.
|
||||||
|
// If filename is .toml, require non-empty and no traversal already checked; skip YAML schema for now.
|
||||||
|
isTOML := strings.HasSuffix(strings.ToLower(filename), ".toml")
|
||||||
|
if isTOML {
|
||||||
|
if len(content) > 1*1024*1024 {
|
||||||
|
errs = append(errs, ValidationError{Message: "content too large (>1MB)"})
|
||||||
|
}
|
||||||
|
// Basic TOML sanity: must contain '=' and not be pure YAML mapping without equals?
|
||||||
|
// Accept any non-empty TOML for MVP, but reject obvious YAML-only constructs without '='
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAML syntax check with line extraction
|
||||||
|
var raw map[string]interface{}
|
||||||
|
var node yaml.Node
|
||||||
|
if err := yaml.Unmarshal([]byte(content), &raw); err != nil {
|
||||||
|
// Try to extract line/col via yaml.Node
|
||||||
|
if err2 := yaml.Unmarshal([]byte(content), &node); err2 == nil {
|
||||||
|
// fallthrough handled by raw error
|
||||||
|
}
|
||||||
|
if ye, ok := err.(*yaml.TypeError); ok {
|
||||||
|
for _, msg := range ye.Errors {
|
||||||
|
errs = append(errs, ValidationError{Message: msg})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Parse line from error string like "yaml: line 3: ..."
|
||||||
|
msg := err.Error()
|
||||||
|
line, col := parseYAMLLineCol(msg)
|
||||||
|
errs = append(errs, ValidationError{Message: msg, Line: line, Column: col})
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
errs = append(errs, ValidationError{Message: "YAML must be a mapping"})
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedTop := map[string]bool{"http": true, "tcp": true, "udp": true, "tls": true}
|
||||||
|
hasAllowed := false
|
||||||
|
for k := range raw {
|
||||||
|
if allowedTop[k] {
|
||||||
|
hasAllowed = true
|
||||||
|
} else {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("unknown top-level key %q: allowed keys are http, tcp, udp, tls", k)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasAllowed {
|
||||||
|
errs = append(errs, ValidationError{Message: "config must contain at least one of: http, tcp, udp, tls"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(content) > 1*1024*1024 {
|
||||||
|
errs = append(errs, ValidationError{Message: "content too large (>1MB)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep schema validation matching Traefik v3.7 dynamic config
|
||||||
|
errs = append(errs, validateHTTPBlock(raw["http"])...)
|
||||||
|
errs = append(errs, validateTCPBlock(raw["tcp"])...)
|
||||||
|
errs = append(errs, validateUDPBlock(raw["udp"])...)
|
||||||
|
errs = append(errs, validateTLSBlock(raw["tls"])...)
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseYAMLLineCol(msg string) (int, int) {
|
||||||
|
// Example: "yaml: line 3: did not find expected ','"
|
||||||
|
var line, col int
|
||||||
|
_, _ = fmt.Sscanf(msg, "yaml: line %d: ", &line)
|
||||||
|
// Column rarely present in gopkg.in/yaml.v3 errors; leave 0
|
||||||
|
return line, col
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateHTTPBlock(raw interface{}) []ValidationError {
|
||||||
|
if raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return []ValidationError{{Message: "http must be a mapping"}}
|
||||||
|
}
|
||||||
|
var errs []ValidationError
|
||||||
|
allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true, "models": true}
|
||||||
|
for k := range m {
|
||||||
|
if !allowed[k] {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http: unknown key %q (allowed: routers, services, middlewares, serversTransports, models)", k)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if routers, ok := m["routers"]; ok {
|
||||||
|
if rm, ok := routers.(map[string]interface{}); ok {
|
||||||
|
for name, rv := range rm {
|
||||||
|
if r, ok := rv.(map[string]interface{}); ok {
|
||||||
|
if _, hasRule := r["rule"]; !hasRule {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'rule'", name)})
|
||||||
|
}
|
||||||
|
if _, hasService := r["service"]; !hasService {
|
||||||
|
// service is required unless it's a middleware chain? For MVP require service
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'service'", name)})
|
||||||
|
}
|
||||||
|
if rule, ok := r["rule"].(string); ok && strings.TrimSpace(rule) == "" {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: rule must not be empty", name)})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: must be a mapping", name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errs = append(errs, ValidationError{Message: "http.routers must be a mapping"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if services, ok := m["services"]; ok {
|
||||||
|
if sm, ok := services.(map[string]interface{}); ok {
|
||||||
|
for name, sv := range sm {
|
||||||
|
if s, ok := sv.(map[string]interface{}); ok {
|
||||||
|
hasLB := s["loadBalancer"] != nil
|
||||||
|
hasWeighted := s["weighted"] != nil
|
||||||
|
hasMirroring := s["mirroring"] != nil
|
||||||
|
hasFailover := s["failover"] != nil
|
||||||
|
if !hasLB && !hasWeighted && !hasMirroring && !hasFailover {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must define one of loadBalancer, weighted, mirroring, failover", name)})
|
||||||
|
}
|
||||||
|
if lb, ok := s["loadBalancer"]; ok && lb != nil {
|
||||||
|
if lbm, ok := lb.(map[string]interface{}); ok {
|
||||||
|
if servers, ok := lbm["servers"]; ok {
|
||||||
|
if arr, ok := servers.([]interface{}); ok {
|
||||||
|
if len(arr) == 0 {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers: must not be empty", name)})
|
||||||
|
}
|
||||||
|
for i, srv := range arr {
|
||||||
|
if sm, ok := srv.(map[string]interface{}); ok {
|
||||||
|
if _, hasURL := sm["url"]; !hasURL {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers[%d]: missing 'url'", name, i)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must be a mapping", name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errs = append(errs, ValidationError{Message: "http.services must be a mapping"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if middlewares, ok := m["middlewares"]; ok {
|
||||||
|
if mm, ok := middlewares.(map[string]interface{}); ok {
|
||||||
|
for name, mv := range mm {
|
||||||
|
if _, ok := mv.(map[string]interface{}); !ok {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.middlewares.%q: must be a mapping", name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errs = append(errs, ValidationError{Message: "http.middlewares must be a mapping"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTCPBlock(raw interface{}) []ValidationError {
|
||||||
|
if raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return []ValidationError{{Message: "tcp must be a mapping"}}
|
||||||
|
}
|
||||||
|
var errs []ValidationError
|
||||||
|
allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true}
|
||||||
|
for k := range m {
|
||||||
|
if !allowed[k] {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp: unknown key %q", k)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if routers, ok := m["routers"]; ok {
|
||||||
|
if rm, ok := routers.(map[string]interface{}); ok {
|
||||||
|
for name, rv := range rm {
|
||||||
|
if r, ok := rv.(map[string]interface{}); ok {
|
||||||
|
if _, hasRule := r["rule"]; !hasRule {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'rule'", name)})
|
||||||
|
}
|
||||||
|
if _, hasService := r["service"]; !hasService {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'service'", name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUDPBlock(raw interface{}) []ValidationError {
|
||||||
|
if raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return []ValidationError{{Message: "udp must be a mapping"}}
|
||||||
|
}
|
||||||
|
var errs []ValidationError
|
||||||
|
allowed := map[string]bool{"routers": true, "services": true}
|
||||||
|
for k := range m {
|
||||||
|
if !allowed[k] {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("udp: unknown key %q", k)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTLSBlock(raw interface{}) []ValidationError {
|
||||||
|
if raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return []ValidationError{{Message: "tls must be a mapping"}}
|
||||||
|
}
|
||||||
|
var errs []ValidationError
|
||||||
|
allowed := map[string]bool{"certificates": true, "options": true, "stores": true}
|
||||||
|
for k := range m {
|
||||||
|
if !allowed[k] {
|
||||||
|
errs = append(errs, ValidationError{Message: fmt.Sprintf("tls: unknown key %q", k)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFilename ensures filename is safe and within dynamic dir
|
||||||
|
func ValidateFilename(filename string) error {
|
||||||
|
if filename == "" {
|
||||||
|
return fmt.Errorf("filename must not be empty")
|
||||||
|
}
|
||||||
|
if strings.Contains(filename, "..") {
|
||||||
|
return fmt.Errorf("filename must not contain '..'")
|
||||||
|
}
|
||||||
|
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||||
|
return fmt.Errorf("filename must not contain path separators — use a single file name")
|
||||||
|
}
|
||||||
|
// Must end with allowed extension
|
||||||
|
lower := strings.ToLower(filename)
|
||||||
|
if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) {
|
||||||
|
return fmt.Errorf("filename must end with .yml, .yaml, or .toml")
|
||||||
|
}
|
||||||
|
if len(filename) > 255 {
|
||||||
|
return fmt.Errorf("filename too long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
11
backend/internal/config/types.go
Normal file
11
backend/internal/config/types.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DBPath string
|
||||||
|
SessionSecret string
|
||||||
|
CORSOrigin string
|
||||||
|
TraefikAPIURL string
|
||||||
|
ConfigDir string
|
||||||
|
DevMode bool
|
||||||
|
}
|
||||||
55
backend/internal/database/repositories/session.go
Normal file
55
backend/internal/database/repositories/session.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionRepository(db *sql.DB) *SessionRepository {
|
||||||
|
return &SessionRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) Create(session *models.Session) error {
|
||||||
|
_, err := r.db.Exec(
|
||||||
|
`INSERT INTO sessions (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
session.ID, session.UserID, session.CSRFToken, session.CreatedAt, session.ExpiresAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) GetByID(id string) (*models.Session, error) {
|
||||||
|
session := &models.Session{}
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
`SELECT id, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id = ?`, id,
|
||||||
|
).Scan(&session.ID, &session.UserID, &session.CSRFToken, &session.CreatedAt, &session.ExpiresAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return session, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) Delete(id string) error {
|
||||||
|
_, err := r.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) DeleteExpired() error {
|
||||||
|
_, err := r.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) RotateCSRFToken(id, newToken string) error {
|
||||||
|
_, err := r.db.Exec(`UPDATE sessions SET csrf_token = ? WHERE id = ?`, newToken, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) ExtendExpiry(id string, newExpiry time.Time) error {
|
||||||
|
_, err := r.db.Exec(`UPDATE sessions SET expires_at = ? WHERE id = ?`, newExpiry, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
56
backend/internal/database/repositories/user.go
Normal file
56
backend/internal/database/repositories/user.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(db *sql.DB) *UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByID(id string) (*models.User, error) {
|
||||||
|
user := &models.User{}
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
`SELECT id, username, email, password_hash, role, created_at, updated_at, last_login
|
||||||
|
FROM users WHERE id = ?`, id,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.LastLogin)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
|
||||||
|
user := &models.User{}
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
`SELECT id, username, email, password_hash, role, created_at, updated_at, last_login
|
||||||
|
FROM users WHERE username = ?`, username,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.LastLogin)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UpdateLastLogin(id string) error {
|
||||||
|
_, err := r.db.Exec(
|
||||||
|
`UPDATE users SET last_login = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
time.Now(), time.Now(), id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(user *models.User) error {
|
||||||
|
_, err := r.db.Exec(
|
||||||
|
`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
user.ID, user.Username, user.Email, user.PasswordHash, user.Role,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
144
backend/internal/database/sqlite.go
Normal file
144
backend/internal/database/sqlite.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) (*DB, error) {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create db directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
return &DB{db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) Migrate() error {
|
||||||
|
queries := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login DATETIME
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
csrf_token TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
CHECK (expires_at > created_at)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS backups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_backups_filename ON backups(filename)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range queries {
|
||||||
|
if _, err := d.Exec(q); err != nil {
|
||||||
|
return fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.ensureAdminUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ensureAdminUser() error {
|
||||||
|
var count int
|
||||||
|
err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
// Default admin: admin / changeme (bcrypt hash) — development-only
|
||||||
|
hash := "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju"
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
"admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", hash, "admin",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create admin user: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAdminPasswordViaEnv enforces production password policy.
|
||||||
|
// If envPassword is set, it must be >=12 chars; it will create or update the admin user.
|
||||||
|
// If devMode is false and the default admin/changeme is still in use, it logs a warning.
|
||||||
|
func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error {
|
||||||
|
if envPassword != "" {
|
||||||
|
if len(envPassword) < 12 {
|
||||||
|
return fmt.Errorf("GUI_ADMIN_PASSWORD must be at least 12 characters")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(envPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hash admin password: %w", err)
|
||||||
|
}
|
||||||
|
// Upsert admin user
|
||||||
|
_, err = d.Exec(`
|
||||||
|
INSERT INTO users (id, username, email, password_hash, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(username) DO UPDATE SET password_hash=excluded.password_hash, updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, "admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", string(hash), "admin")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert admin via env: %w", err)
|
||||||
|
}
|
||||||
|
log.Println("Admin password set via GUI_ADMIN_PASSWORD")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !devMode {
|
||||||
|
// Check if default password still in use
|
||||||
|
var hash string
|
||||||
|
err := d.QueryRow(`SELECT password_hash FROM users WHERE username='admin'`).Scan(&hash)
|
||||||
|
if err == nil {
|
||||||
|
// Compare against known dev hash
|
||||||
|
if hash == "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju" {
|
||||||
|
log.Println("WARNING: default admin password 'changeme' is in use — set GUI_ADMIN_PASSWORD (min 12 chars) for production")
|
||||||
|
}
|
||||||
|
// Also try bcrypt check in case hash was regenerated for same password
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("changeme")) == nil {
|
||||||
|
log.Println("WARNING: admin password is still 'changeme' — change it or set GUI_ADMIN_PASSWORD for production")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
131
backend/internal/models/traefik.go
Normal file
131
backend/internal/models/traefik.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Router struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
EntryPoints []string `json:"entry_points"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Middlewares []string `json:"middlewares"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
TLS *RouterTLSConfig `json:"tls,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Using []string `json:"using,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouterTLSConfig struct {
|
||||||
|
Options string `json:"options,omitempty"`
|
||||||
|
CertResolver string `json:"cert_resolver,omitempty"`
|
||||||
|
Domains []Domain `json:"domains,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Domain struct {
|
||||||
|
Main string `json:"main"`
|
||||||
|
SANs []string `json:"sans,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
LoadBalancer *LoadBalancer `json:"load_balancer,omitempty"`
|
||||||
|
ServerStatus map[string]string `json:"server_status,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadBalancer struct {
|
||||||
|
Servers []Server `json:"servers,omitempty"`
|
||||||
|
Strategy string `json:"strategy,omitempty"`
|
||||||
|
PassHostHeader *bool `json:"pass_host_header,omitempty"`
|
||||||
|
HealthCheck *HealthCheck `json:"health_check,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Weight *int `json:"weight,omitempty"`
|
||||||
|
PreservePath bool `json:"preserve_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthCheck struct {
|
||||||
|
Scheme string `json:"scheme,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Status int `json:"status,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
Interval string `json:"interval,omitempty"`
|
||||||
|
Timeout string `json:"timeout,omitempty"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
FollowRedirects *bool `json:"follow_redirects,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Middleware struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Spec map[string]interface{} `json:"spec,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Certificate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
CommonName string `json:"common_name"`
|
||||||
|
SANs []string `json:"sans"`
|
||||||
|
IssuerOrg string `json:"issuer_org"`
|
||||||
|
IssuerCN string `json:"issuer_cn"`
|
||||||
|
NotBefore time.Time `json:"not_before"`
|
||||||
|
NotAfter time.Time `json:"not_after"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryPoint struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
HTTP *EntryPointHTTP `json:"http,omitempty"`
|
||||||
|
ForwardedHeaders *ForwardedHeaders `json:"forwarded_headers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryPointHTTP struct {
|
||||||
|
TLS *EntryPointTLS `json:"tls,omitempty"`
|
||||||
|
Middlewares []string `json:"middlewares,omitempty"`
|
||||||
|
RedirectToHTTPS bool `json:"redirect_to_https,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryPointTLS struct {
|
||||||
|
CertResolver string `json:"cert_resolver,omitempty"`
|
||||||
|
Domains []Domain `json:"domains,omitempty"`
|
||||||
|
Options string `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForwardedHeaders struct {
|
||||||
|
InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
|
||||||
|
TrustedIPs []string `json:"trusted_ips,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Overview struct {
|
||||||
|
HTTP struct {
|
||||||
|
Routers Section `json:"routers"`
|
||||||
|
Services Section `json:"services"`
|
||||||
|
Middlewares Section `json:"middlewares"`
|
||||||
|
} `json:"http"`
|
||||||
|
TCP struct {
|
||||||
|
Routers Section `json:"routers"`
|
||||||
|
Services Section `json:"services"`
|
||||||
|
Middlewares Section `json:"middlewares"`
|
||||||
|
} `json:"tcp"`
|
||||||
|
UDP struct {
|
||||||
|
Routers Section `json:"routers"`
|
||||||
|
Services Section `json:"services"`
|
||||||
|
} `json:"udp"`
|
||||||
|
Certificates *Section `json:"certificates,omitempty"`
|
||||||
|
Providers []string `json:"providers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Warnings int `json:"warnings"`
|
||||||
|
Errors int `json:"errors"`
|
||||||
|
}
|
||||||
56
backend/internal/models/user.go
Normal file
56
backend/internal/models/user.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
LastLogin sql.NullTime `json:"last_login,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
CSRFToken string `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthResponse struct {
|
||||||
|
User *User `json:"user"`
|
||||||
|
Token string `json:"-"` // Not sent in JSON, only in cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleAdmin UserRole = "admin"
|
||||||
|
RoleOperator UserRole = "operator"
|
||||||
|
RoleViewer UserRole = "viewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r UserRole) Can(permission string) bool {
|
||||||
|
switch r {
|
||||||
|
case RoleAdmin:
|
||||||
|
return true
|
||||||
|
case RoleOperator:
|
||||||
|
return permission != "users:write" && permission != "settings:write"
|
||||||
|
case RoleViewer:
|
||||||
|
return permission == "config:read" || permission == "traefik:read"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/internal/traefik/client.go
Normal file
16
backend/internal/traefik/client.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package traefik
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TraefikClient interface {
|
||||||
|
GetRouters(ctx context.Context) ([]models.Router, error)
|
||||||
|
GetServices(ctx context.Context) ([]models.Service, error)
|
||||||
|
GetMiddlewares(ctx context.Context) ([]models.Middleware, error)
|
||||||
|
GetCertificates(ctx context.Context) ([]models.Certificate, error)
|
||||||
|
GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error)
|
||||||
|
GetOverview(ctx context.Context) (*models.Overview, error)
|
||||||
|
}
|
||||||
358
backend/internal/traefik/mock.go
Normal file
358
backend/internal/traefik/mock.go
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
package traefik
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockClient struct{}
|
||||||
|
|
||||||
|
func NewMockClient() *MockClient {
|
||||||
|
return &MockClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) GetRouters(ctx context.Context) ([]models.Router, error) {
|
||||||
|
return []models.Router{
|
||||||
|
{
|
||||||
|
Name: "api@internal",
|
||||||
|
Provider: "internal",
|
||||||
|
Rule: "PathPrefix(`/api`) || PathPrefix(`/dashboard`)",
|
||||||
|
EntryPoints: []string{"traefik"},
|
||||||
|
Service: "api@internal",
|
||||||
|
Middlewares: []string{},
|
||||||
|
Priority: 0,
|
||||||
|
TLS: nil,
|
||||||
|
Status: "enabled",
|
||||||
|
Using: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dashboard@internal",
|
||||||
|
Provider: "internal",
|
||||||
|
Rule: "PathPrefix(`/dashboard`)",
|
||||||
|
EntryPoints: []string{"traefik"},
|
||||||
|
Service: "dashboard@internal",
|
||||||
|
Middlewares: []string{},
|
||||||
|
Priority: 0,
|
||||||
|
TLS: nil,
|
||||||
|
Status: "enabled",
|
||||||
|
Using: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "web-router@docker",
|
||||||
|
Provider: "docker",
|
||||||
|
Rule: "Host(`web.example.com`)",
|
||||||
|
EntryPoints: []string{"web", "websecure"},
|
||||||
|
Service: "web-service@docker",
|
||||||
|
Middlewares: []string{"secure-headers@docker", "redirect-https@docker"},
|
||||||
|
Priority: 10,
|
||||||
|
TLS: &models.RouterTLSConfig{
|
||||||
|
Options: "default@default",
|
||||||
|
CertResolver: "letsencrypt",
|
||||||
|
Domains: []models.Domain{
|
||||||
|
{Main: "web.example.com", SANs: []string{"www.web.example.com"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
Using: []string{"secure-headers@docker", "redirect-https@docker"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "api-router@file",
|
||||||
|
Provider: "file",
|
||||||
|
Rule: "Host(`api.example.com`) && PathPrefix(`/v1`)",
|
||||||
|
EntryPoints: []string{"websecure"},
|
||||||
|
Service: "api-service@file",
|
||||||
|
Middlewares: []string{"rate-limit@file", "cors@file"},
|
||||||
|
Priority: 20,
|
||||||
|
TLS: &models.RouterTLSConfig{
|
||||||
|
Options: "modern@default",
|
||||||
|
CertResolver: "letsencrypt",
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
Using: []string{"rate-limit@file", "cors@file"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "disabled-router@file",
|
||||||
|
Provider: "file",
|
||||||
|
Rule: "Host(`old.example.com`)",
|
||||||
|
EntryPoints: []string{"web"},
|
||||||
|
Service: "old-service@file",
|
||||||
|
Middlewares: []string{},
|
||||||
|
Priority: 5,
|
||||||
|
TLS: nil,
|
||||||
|
Status: "disabled",
|
||||||
|
Using: []string{},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) GetServices(ctx context.Context) ([]models.Service, error) {
|
||||||
|
return []models.Service{
|
||||||
|
{
|
||||||
|
Name: "api@internal",
|
||||||
|
Provider: "internal",
|
||||||
|
Type: "loadbalancer",
|
||||||
|
LoadBalancer: &models.LoadBalancer{
|
||||||
|
Servers: []models.Server{
|
||||||
|
{URL: "http://127.0.0.1:8080", Weight: ptr(1)},
|
||||||
|
},
|
||||||
|
Strategy: "wrr",
|
||||||
|
},
|
||||||
|
ServerStatus: map[string]string{"http://127.0.0.1:8080": "UP"},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dashboard@internal",
|
||||||
|
Provider: "internal",
|
||||||
|
Type: "loadbalancer",
|
||||||
|
LoadBalancer: &models.LoadBalancer{
|
||||||
|
Servers: []models.Server{
|
||||||
|
{URL: "http://127.0.0.1:8080", Weight: ptr(1)},
|
||||||
|
},
|
||||||
|
Strategy: "wrr",
|
||||||
|
},
|
||||||
|
ServerStatus: map[string]string{"http://127.0.0.1:8080": "UP"},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "web-service@docker",
|
||||||
|
Provider: "docker",
|
||||||
|
Type: "loadbalancer",
|
||||||
|
LoadBalancer: &models.LoadBalancer{
|
||||||
|
Servers: []models.Server{
|
||||||
|
{URL: "http://172.17.0.2:80", Weight: ptr(1)},
|
||||||
|
{URL: "http://172.17.0.3:80", Weight: ptr(1)},
|
||||||
|
},
|
||||||
|
Strategy: "wrr",
|
||||||
|
PassHostHeader: ptr(true),
|
||||||
|
HealthCheck: &models.HealthCheck{
|
||||||
|
Scheme: "http",
|
||||||
|
Path: "/health",
|
||||||
|
Method: "GET",
|
||||||
|
Status: 200,
|
||||||
|
Interval: "30s",
|
||||||
|
Timeout: "5s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ServerStatus: map[string]string{
|
||||||
|
"http://172.17.0.2:80": "UP",
|
||||||
|
"http://172.17.0.3:80": "UP",
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "api-service@file",
|
||||||
|
Provider: "file",
|
||||||
|
Type: "loadbalancer",
|
||||||
|
LoadBalancer: &models.LoadBalancer{
|
||||||
|
Servers: []models.Server{
|
||||||
|
{URL: "http://10.0.0.10:8080", Weight: ptr(3)},
|
||||||
|
{URL: "http://10.0.0.11:8080", Weight: ptr(1)},
|
||||||
|
},
|
||||||
|
Strategy: "wrr",
|
||||||
|
PassHostHeader: ptr(true),
|
||||||
|
HealthCheck: &models.HealthCheck{
|
||||||
|
Scheme: "http",
|
||||||
|
Path: "/health",
|
||||||
|
Method: "GET",
|
||||||
|
Status: 200,
|
||||||
|
Interval: "30s",
|
||||||
|
Timeout: "5s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ServerStatus: map[string]string{
|
||||||
|
"http://10.0.0.10:8080": "UP",
|
||||||
|
"http://10.0.0.11:8080": "UP",
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "old-service@file",
|
||||||
|
Provider: "file",
|
||||||
|
Type: "loadbalancer",
|
||||||
|
LoadBalancer: &models.LoadBalancer{
|
||||||
|
Servers: []models.Server{
|
||||||
|
{URL: "http://192.168.1.100:80", Weight: ptr(1)},
|
||||||
|
},
|
||||||
|
Strategy: "wrr",
|
||||||
|
},
|
||||||
|
ServerStatus: map[string]string{"http://192.168.1.100:80": "DOWN"},
|
||||||
|
Status: "warning",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) GetMiddlewares(ctx context.Context) ([]models.Middleware, error) {
|
||||||
|
return []models.Middleware{
|
||||||
|
{
|
||||||
|
Name: "secure-headers@docker",
|
||||||
|
Provider: "docker",
|
||||||
|
Type: "headers",
|
||||||
|
Spec: map[string]interface{}{
|
||||||
|
"customResponseHeaders": map[string]string{
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "redirect-https@docker",
|
||||||
|
Provider: "docker",
|
||||||
|
Type: "redirectscheme",
|
||||||
|
Spec: map[string]interface{}{
|
||||||
|
"scheme": "https",
|
||||||
|
"permanent": true,
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "rate-limit@file",
|
||||||
|
Provider: "file",
|
||||||
|
Type: "ratelimit",
|
||||||
|
Spec: map[string]interface{}{
|
||||||
|
"average": 100,
|
||||||
|
"burst": 50,
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cors@file",
|
||||||
|
Provider: "file",
|
||||||
|
Type: "headers",
|
||||||
|
Spec: map[string]interface{}{
|
||||||
|
"accessControlAllowMethods": []string{"GET", "POST", "PUT", "DELETE"},
|
||||||
|
"accessControlAllowOriginList": []string{"https://app.example.com"},
|
||||||
|
"accessControlAllowHeaders": []string{"Authorization", "Content-Type"},
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "strip-prefix@file",
|
||||||
|
Provider: "file",
|
||||||
|
Type: "stripprefix",
|
||||||
|
Spec: map[string]interface{}{
|
||||||
|
"prefixes": []string{"/v1"},
|
||||||
|
},
|
||||||
|
Status: "enabled",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) GetCertificates(ctx context.Context) ([]models.Certificate, error) {
|
||||||
|
now := time.Now()
|
||||||
|
return []models.Certificate{
|
||||||
|
{
|
||||||
|
Name: "letsencrypt-web-example-com",
|
||||||
|
CommonName: "web.example.com",
|
||||||
|
SANs: []string{"web.example.com", "www.web.example.com"},
|
||||||
|
IssuerOrg: "Let's Encrypt",
|
||||||
|
IssuerCN: "R3",
|
||||||
|
NotBefore: now.AddDate(0, -2, 0),
|
||||||
|
NotAfter: now.AddDate(0, 1, 0),
|
||||||
|
Status: "valid",
|
||||||
|
Provider: "letsencrypt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "letsencrypt-api-example-com",
|
||||||
|
CommonName: "api.example.com",
|
||||||
|
SANs: []string{"api.example.com"},
|
||||||
|
IssuerOrg: "Let's Encrypt",
|
||||||
|
IssuerCN: "R3",
|
||||||
|
NotBefore: now.AddDate(0, -1, -15),
|
||||||
|
NotAfter: now.AddDate(0, 2, -15),
|
||||||
|
Status: "valid",
|
||||||
|
Provider: "letsencrypt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "self-signed-local",
|
||||||
|
CommonName: "local.example.com",
|
||||||
|
SANs: []string{"local.example.com", "*.local.example.com"},
|
||||||
|
IssuerOrg: "Local CA",
|
||||||
|
IssuerCN: "Local Root CA",
|
||||||
|
NotBefore: now.AddDate(-1, 0, 0),
|
||||||
|
NotAfter: now.AddDate(1, 0, 0),
|
||||||
|
Status: "valid",
|
||||||
|
Provider: "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "expired-cert",
|
||||||
|
CommonName: "old.example.com",
|
||||||
|
SANs: []string{"old.example.com"},
|
||||||
|
IssuerOrg: "Let's Encrypt",
|
||||||
|
IssuerCN: "R3",
|
||||||
|
NotBefore: now.AddDate(0, -4, 0),
|
||||||
|
NotAfter: now.AddDate(0, -1, 0),
|
||||||
|
Status: "expired",
|
||||||
|
Provider: "letsencrypt",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error) {
|
||||||
|
return []models.EntryPoint{
|
||||||
|
{
|
||||||
|
Name: "web",
|
||||||
|
Address: ":80",
|
||||||
|
HTTP: &models.EntryPointHTTP{
|
||||||
|
Middlewares: []string{"redirect-https@docker"},
|
||||||
|
RedirectToHTTPS: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "websecure",
|
||||||
|
Address: ":443",
|
||||||
|
HTTP: &models.EntryPointHTTP{
|
||||||
|
TLS: &models.EntryPointTLS{
|
||||||
|
CertResolver: "letsencrypt",
|
||||||
|
Options: "default@default",
|
||||||
|
},
|
||||||
|
Middlewares: []string{"secure-headers@docker"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "traefik",
|
||||||
|
Address: ":8080",
|
||||||
|
HTTP: &models.EntryPointHTTP{
|
||||||
|
Middlewares: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) GetOverview(ctx context.Context) (*models.Overview, error) {
|
||||||
|
return &models.Overview{
|
||||||
|
HTTP: struct {
|
||||||
|
Routers models.Section `json:"routers"`
|
||||||
|
Services models.Section `json:"services"`
|
||||||
|
Middlewares models.Section `json:"middlewares"`
|
||||||
|
}{
|
||||||
|
Routers: models.Section{Total: 4, Warnings: 0, Errors: 1},
|
||||||
|
Services: models.Section{Total: 5, Warnings: 1, Errors: 0},
|
||||||
|
Middlewares: models.Section{Total: 5, Warnings: 0, Errors: 0},
|
||||||
|
},
|
||||||
|
TCP: struct {
|
||||||
|
Routers models.Section `json:"routers"`
|
||||||
|
Services models.Section `json:"services"`
|
||||||
|
Middlewares models.Section `json:"middlewares"`
|
||||||
|
}{
|
||||||
|
Routers: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||||
|
Services: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||||
|
Middlewares: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||||
|
},
|
||||||
|
UDP: struct {
|
||||||
|
Routers models.Section `json:"routers"`
|
||||||
|
Services models.Section `json:"services"`
|
||||||
|
}{
|
||||||
|
Routers: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||||
|
Services: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||||
|
},
|
||||||
|
Certificates: &models.Section{Total: 4, Warnings: 1, Errors: 1},
|
||||||
|
Providers: []string{"docker", "file", "internal"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
13
backend/pkg/version/version.go
Normal file
13
backend/pkg/version/version.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package version
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
Commit = "unknown"
|
||||||
|
Date = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Print() {
|
||||||
|
fmt.Printf("traefik-gui %s (%s) built at %s\n", Version, Commit, Date)
|
||||||
|
}
|
||||||
20
docker/Dockerfile
Normal file
20
docker/Dockerfile
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
FROM golang:1.23-alpine AS backend
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY backend/ ./
|
||||||
|
RUN go build -o /traefik-gui ./cmd/traefik-gui
|
||||||
|
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm ci || npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM alpine:3.19
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY --from=backend /traefik-gui /usr/local/bin/traefik-gui
|
||||||
|
COPY --from=frontend /app/frontend/dist /app/frontend/dist
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["traefik-gui"]
|
||||||
47
docker/docker-compose.yml
Normal file
47
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik-gui:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- GUI_DB_PATH=/data/traefik-gui.db
|
||||||
|
- GUI_SESSION_SECRET=dev-secret-change-in-production-min-32-chars
|
||||||
|
- GUI_CORS_ORIGIN=http://localhost:5173
|
||||||
|
- GUI_ADDR=:8080
|
||||||
|
- GUI_DEV_MODE=true
|
||||||
|
- TRAEFIK_API_URL=http://traefik:8080/api
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- ./configs:/etc/traefik-gui/configs
|
||||||
|
depends_on:
|
||||||
|
- traefik
|
||||||
|
networks:
|
||||||
|
- traefik-gui-net
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.7
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "8081:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./docker/traefik.dev.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./configs/dynamic:/etc/traefik/dynamic:ro
|
||||||
|
command:
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--api.dashboard=true"
|
||||||
|
- "--log.level=DEBUG"
|
||||||
|
networks:
|
||||||
|
- traefik-gui-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik-gui-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
traefik-gui-data:
|
||||||
16
docker/traefik.dev.yml
Normal file
16
docker/traefik.dev.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
api:
|
||||||
|
dashboard: true
|
||||||
|
insecure: true
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
traefik:
|
||||||
|
address: ":8080"
|
||||||
|
providers:
|
||||||
|
file:
|
||||||
|
directory: "/etc/traefik/dynamic"
|
||||||
|
watch: true
|
||||||
|
log:
|
||||||
|
level: DEBUG
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Traefik GUI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3951
frontend/package-lock.json
generated
Normal file
3951
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "traefik-gui-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"build:check": "tsc --noEmit && vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"zustand": "^4.5.0",
|
||||||
|
"date-fns": "^3.2.0",
|
||||||
|
"lucide-react": "^0.309.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/App.tsx
Normal file
30
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider, useAuth } from './hooks/useAuth'
|
||||||
|
import Layout from './components/layout/Layout'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Routers from './pages/Routers'
|
||||||
|
import Services from './pages/Services'
|
||||||
|
import Middlewares from './pages/Middlewares'
|
||||||
|
import Certificates from './pages/Certificates'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
|
||||||
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
|
if (isLoading) {
|
||||||
|
return <div style={{display:'flex',alignItems:'center',justifyContent:'center',minHeight:'100vh'}}>Loading...</div>
|
||||||
|
}
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/*" element={<PrivateRoute><Layout /></PrivateRoute>} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
276
frontend/src/api/client.ts
Normal file
276
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let csrfToken: string | null = null
|
||||||
|
|
||||||
|
export const setCSRFToken = (token: string) => {
|
||||||
|
csrfToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCSRFToken = () => csrfToken
|
||||||
|
|
||||||
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
if (csrfToken && ['post', 'put', 'patch', 'delete'].includes(config.method?.toLowerCase() || '')) {
|
||||||
|
config.headers['X-CSRF-Token'] = csrfToken
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
// Capture rotated CSRF token from response header if present
|
||||||
|
const newToken = (response.headers as Record<string,string>)['x-csrf-token'] || (response.headers as Record<string,string>)['X-CSRF-Token']
|
||||||
|
if (newToken) csrfToken = newToken
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authApi = {
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
api.post<{ user: User; csrf_token: string }>('/auth/login', { username, password }),
|
||||||
|
|
||||||
|
logout: () =>
|
||||||
|
api.post('/auth/logout'),
|
||||||
|
|
||||||
|
me: () =>
|
||||||
|
api.get<User>('/auth/me'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config API
|
||||||
|
export const configApi = {
|
||||||
|
listRouters: () =>
|
||||||
|
api.get<Router[]>('/config/routers'),
|
||||||
|
|
||||||
|
getRouter: (id: string) =>
|
||||||
|
api.get<Router>(`/config/routers/${id}`),
|
||||||
|
|
||||||
|
createRouter: (router: Partial<Router>) =>
|
||||||
|
api.post<Router>('/config/routers', router),
|
||||||
|
|
||||||
|
updateRouter: (id: string, router: Partial<Router>) =>
|
||||||
|
api.put<Router>(`/config/routers/${id}`, router),
|
||||||
|
|
||||||
|
deleteRouter: (id: string) =>
|
||||||
|
api.delete(`/config/routers/${id}`),
|
||||||
|
|
||||||
|
listServices: () =>
|
||||||
|
api.get<Service[]>('/config/services'),
|
||||||
|
|
||||||
|
listMiddlewares: () =>
|
||||||
|
api.get<Middleware[]>('/config/middlewares'),
|
||||||
|
|
||||||
|
listCertificates: () =>
|
||||||
|
api.get<Certificate[]>('/config/certificates'),
|
||||||
|
|
||||||
|
listEntryPoints: () =>
|
||||||
|
api.get<EntryPoint[]>('/config/entrypoints'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// File-provider API (Phase 2/3)
|
||||||
|
export const fileApi = {
|
||||||
|
listFiles: () => api.get<FileMeta[]>('/config/files'),
|
||||||
|
getFile: (name: string) => api.get<{ filename: string; content: string }>(`/config/files/${encodeURIComponent(name)}`),
|
||||||
|
preview: (filename: string, content: string) => api.post<PreviewResult>('/config/preview', { filename, content }),
|
||||||
|
validate: (filename: string, content: string) => api.post<PreviewResult>('/config/validate', { filename, content }),
|
||||||
|
apply: (filename: string, content: string, confirm: boolean) => api.post<{ message: string; diff: string }>('/config/apply', { filename, content, confirm }),
|
||||||
|
rollback: (filename: string, backupId?: string) => api.post<{ message: string; diff: string }>('/config/rollback', { filename, backupId }),
|
||||||
|
history: (filename?: string) => api.get<BackupInfo[]>(`/config/history${filename ? `?filename=${encodeURIComponent(filename)}` : ''}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traefik API (read-only)
|
||||||
|
export const traefikApi = {
|
||||||
|
getOverview: () =>
|
||||||
|
api.get<Overview>('/traefik/overview'),
|
||||||
|
|
||||||
|
listRouters: () =>
|
||||||
|
api.get<Router[]>('/traefik/routers'),
|
||||||
|
|
||||||
|
listServices: () =>
|
||||||
|
api.get<Service[]>('/traefik/services'),
|
||||||
|
|
||||||
|
listMiddlewares: () =>
|
||||||
|
api.get<Middleware[]>('/traefik/middlewares'),
|
||||||
|
|
||||||
|
listCertificates: () =>
|
||||||
|
api.get<Certificate[]>('/traefik/certificates'),
|
||||||
|
|
||||||
|
listEntryPoints: () =>
|
||||||
|
api.get<EntryPoint[]>('/traefik/entrypoints'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
role: 'admin' | 'operator' | 'viewer'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Router {
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
rule: string
|
||||||
|
entry_points: string[]
|
||||||
|
service: string
|
||||||
|
middlewares: string[]
|
||||||
|
priority: number
|
||||||
|
tls?: RouterTLSConfig
|
||||||
|
status: string
|
||||||
|
using?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouterTLSConfig {
|
||||||
|
options?: string
|
||||||
|
cert_resolver?: string
|
||||||
|
domains?: Domain[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Domain {
|
||||||
|
main: string
|
||||||
|
sans?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
type: string
|
||||||
|
load_balancer?: LoadBalancer
|
||||||
|
server_status?: Record<string, string>
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadBalancer {
|
||||||
|
servers?: Server[]
|
||||||
|
strategy?: string
|
||||||
|
pass_host_header?: boolean
|
||||||
|
health_check?: HealthCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
url: string
|
||||||
|
weight?: number
|
||||||
|
preserve_path?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheck {
|
||||||
|
scheme?: string
|
||||||
|
path?: string
|
||||||
|
method?: string
|
||||||
|
status?: number
|
||||||
|
port?: number
|
||||||
|
interval?: string
|
||||||
|
timeout?: string
|
||||||
|
hostname?: string
|
||||||
|
follow_redirects?: boolean
|
||||||
|
headers?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Middleware {
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
type: string
|
||||||
|
spec?: Record<string, unknown>
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
name: string
|
||||||
|
common_name: string
|
||||||
|
sans: string[]
|
||||||
|
issuer_org: string
|
||||||
|
issuer_cn: string
|
||||||
|
not_before: string
|
||||||
|
not_after: string
|
||||||
|
status: string
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryPoint {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
http?: EntryPointHTTP
|
||||||
|
forwarded_headers?: ForwardedHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryPointHTTP {
|
||||||
|
tls?: EntryPointTLS
|
||||||
|
middlewares?: string[]
|
||||||
|
redirect_to_https?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryPointTLS {
|
||||||
|
cert_resolver?: string
|
||||||
|
domains?: Domain[]
|
||||||
|
options?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardedHeaders {
|
||||||
|
insecure_skip_verify?: boolean
|
||||||
|
trusted_ips?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Overview {
|
||||||
|
http: {
|
||||||
|
routers: Section
|
||||||
|
services: Section
|
||||||
|
middlewares: Section
|
||||||
|
}
|
||||||
|
tcp: {
|
||||||
|
routers: Section
|
||||||
|
services: Section
|
||||||
|
middlewares: Section
|
||||||
|
}
|
||||||
|
udp: {
|
||||||
|
routers: Section
|
||||||
|
services: Section
|
||||||
|
}
|
||||||
|
certificates?: Section
|
||||||
|
providers?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
total: number
|
||||||
|
warnings: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileMeta {
|
||||||
|
filename: string
|
||||||
|
size: number
|
||||||
|
modTime: string
|
||||||
|
valid: boolean
|
||||||
|
validationErr?: string
|
||||||
|
lastAction?: string
|
||||||
|
lastActionTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewResult {
|
||||||
|
valid: boolean
|
||||||
|
errors?: Array<{ message: string; line?: number; column?: number }>
|
||||||
|
diff: string
|
||||||
|
warnings?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupInfo {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
created_at: string
|
||||||
|
created_by: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
11
frontend/src/api/queryClient.ts
Normal file
11
frontend/src/api/queryClient.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
74
frontend/src/components/layout/Layout.tsx
Normal file
74
frontend/src/components/layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { Home, Network, Server, Shield, Settings, LogOut, Menu, ChevronDown, FileText } from 'lucide-react'
|
||||||
|
import Dashboard from '../../pages/Dashboard'
|
||||||
|
import Routers from '../../pages/Routers'
|
||||||
|
import Services from '../../pages/Services'
|
||||||
|
import Middlewares from '../../pages/Middlewares'
|
||||||
|
import Certificates from '../../pages/Certificates'
|
||||||
|
import SettingsPage from '../../pages/Settings'
|
||||||
|
import ConfigEditor from '../../pages/ConfigEditor'
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||||
|
{ name: 'Config Editor', href: '/config', icon: FileText },
|
||||||
|
{ name: 'Routers', href: '/routers', icon: Network },
|
||||||
|
{ name: 'Services', href: '/services', icon: Server },
|
||||||
|
{ name: 'Middlewares', href: '/middlewares', icon: Shield },
|
||||||
|
{ name: 'Certificates', href: '/certificates', icon: Settings },
|
||||||
|
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<aside style={{ width: 'var(--sidebar-width)', background: 'var(--color-surface)', borderRight: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', position: 'fixed', top:0, left:0, height:'100vh', zIndex:100 }}>
|
||||||
|
<div style={{ padding: '20px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ width: 36, height: 36, background: 'var(--color-primary)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 700 }}>TG</div>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 18 }}>Traefik GUI</span>
|
||||||
|
</div>
|
||||||
|
<nav style={{ flex: 1, padding: '16px 12px', overflowY: 'auto' }}>
|
||||||
|
<ul style={{ listStyle: 'none' }}>
|
||||||
|
{navigation.map(item => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<li key={item.name}>
|
||||||
|
<NavLink to={item.href} style={({ isActive }) => ({ display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 12px', borderRadius: '8px', color: isActive ? 'var(--color-primary)' : 'var(--color-text)', background: isActive ? 'var(--color-primary-light)' : 'transparent', textDecoration: 'none' })}>
|
||||||
|
<Icon size={20} />{item.name}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div style={{ padding: '16px', borderTop: '1px solid var(--color-border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ width: 36, height: 36, background: 'var(--color-primary)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white' }}>{user?.username?.charAt(0).toUpperCase()}</div>
|
||||||
|
<div style={{ flex: 1 }}><p style={{ fontWeight: 500, fontSize: 14 }}>{user?.username}</p><p style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{user?.role}</p></div>
|
||||||
|
<button onClick={() => setUserMenuOpen(!userMenuOpen)}><ChevronDown size={16} /></button>
|
||||||
|
</div>
|
||||||
|
{userMenuOpen && <div style={{ marginTop: 8 }}><button onClick={() => logout()} style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--color-danger)' }}><LogOut size={16} /> Sign out</button></div>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div style={{ flex: 1, marginLeft: 'var(--sidebar-width)', minWidth: 0 }}>
|
||||||
|
<main style={{ padding: '24px' }}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/config" element={<ConfigEditor />} />
|
||||||
|
<Route path="/routers" element={<Routers />} />
|
||||||
|
<Route path="/services" element={<Services />} />
|
||||||
|
<Route path="/middlewares" element={<Middlewares />} />
|
||||||
|
<Route path="/certificates" element={<Certificates />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
frontend/src/components/ui/Toast.tsx
Normal file
2
frontend/src/components/ui/Toast.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export function Toaster(){ return null }
|
||||||
|
export function useToast(){ return { showToast: (_t:string,_m:string)=>{} } }
|
||||||
14
frontend/src/hooks/useAuth.tsx
Normal file
14
frontend/src/hooks/useAuth.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
|
import { authApi, setCSRFToken } from '../api/client'
|
||||||
|
import type { User } from '../api/client'
|
||||||
|
interface AuthContextType { user: User | null; isAuthenticated: boolean; isLoading: boolean; login: (u:string,p:string)=>Promise<void>; logout: ()=>Promise<void> }
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null)
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
useEffect(()=>{ authApi.me().then(r=>setUser(r.data)).catch(()=>setUser(null)).finally(()=>setIsLoading(false)) },[])
|
||||||
|
const login = async (username:string,password:string)=>{ const {data}=await authApi.login(username,password); if (data.csrf_token) setCSRFToken(data.csrf_token); setUser(data.user) }
|
||||||
|
const logout = async ()=>{ await authApi.logout(); setCSRFToken(''); setUser(null) }
|
||||||
|
return <AuthContext.Provider value={{user,isAuthenticated:!!user,isLoading,login,logout}}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
export function useAuth(){ const c=useContext(AuthContext); if(!c) throw new Error('useAuth must be used within AuthProvider'); return c }
|
||||||
463
frontend/src/index.css
Normal file
463
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #2496d7;
|
||||||
|
--color-primary-hover: #1a7bc4;
|
||||||
|
--color-primary-light: #e8f4fd;
|
||||||
|
--color-success: #28a745;
|
||||||
|
--color-warning: #ffc107;
|
||||||
|
--color-danger: #dc3545;
|
||||||
|
--color-info: #17a2b8;
|
||||||
|
--color-bg: #f8f9fa;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-border: #dee2e6;
|
||||||
|
--color-text: #212529;
|
||||||
|
--color-text-muted: #6c757d;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--sidebar-width: 260px;
|
||||||
|
--header-height: 64px;
|
||||||
|
--transition: 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-primary: #4da8e0;
|
||||||
|
--color-primary-hover: #6db8e8;
|
||||||
|
--color-primary-light: #1a3a5c;
|
||||||
|
--color-bg: #1a1d21;
|
||||||
|
--color-surface: #22272e;
|
||||||
|
--color-border: #30363d;
|
||||||
|
--color-text: #e6edf3;
|
||||||
|
--color-text-muted: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: all var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-bg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
td:first-child, th:first-child {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:last-child, th:last-child {
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-enabled { background: #dcfce7; color: #166534; }
|
||||||
|
.status-enabled::before { background: #22c55e; }
|
||||||
|
|
||||||
|
.status-disabled { background: #fef2f2; color: #991b1b; }
|
||||||
|
.status-disabled::before { background: #ef4444; }
|
||||||
|
|
||||||
|
.status-warning { background: #fef9c3; color: #854d0e; }
|
||||||
|
.status-warning::before { background: #eab308; }
|
||||||
|
|
||||||
|
.status-expired { background: #fef2f2; color: #991b1b; }
|
||||||
|
.status-expired::before { background: #ef4444; }
|
||||||
|
|
||||||
|
.status-valid { background: #dcfce7; color: #166534; }
|
||||||
|
.status-valid::before { background: #22c55e; }
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .form-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-provider {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 450px;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success { border-left: 4px solid var(--color-success); }
|
||||||
|
.toast-error { border-left: 4px solid var(--color-danger); }
|
||||||
|
.toast-warning { border-left: 4px solid var(--color-warning); }
|
||||||
|
.toast-info { border-left: 4px solid var(--color-info); }
|
||||||
|
|
||||||
|
.toast-message { flex: 1; font-size: 14px; }
|
||||||
|
.toast-close { color: var(--color-text-muted); padding: 4px; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateX(100%); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-text {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30000, retry: 1, refetchOnWindowFocus: false } } })
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter><App /></BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
186
frontend/src/pages/Certificates.tsx
Normal file
186
frontend/src/pages/Certificates.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { traefikApi, type Certificate } from '../api/client'
|
||||||
|
import { Search, Plus, Filter, ChevronDown, Shield, AlertTriangle, XCircle, CheckCircle, AlertCircle, Calendar, Lock } from 'lucide-react'
|
||||||
|
import { format, differenceInDays } from 'date-fns'
|
||||||
|
|
||||||
|
const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
||||||
|
valid: { bg: '#dcfce7', color: '#166534', icon: CheckCircle },
|
||||||
|
expired: { bg: '#fef2f2', color: '#991b1b', icon: XCircle },
|
||||||
|
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Certificates() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider' | 'not_after'>('not_after')
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const { data: certificates, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['certificates'],
|
||||||
|
queryFn: traefikApi.listCertificates,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredCertificates = certificates?.filter(c => {
|
||||||
|
if (search && !c.name.toLowerCase().includes(search.toLowerCase()) && !c.common_name.toLowerCase().includes(search.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all' && c.status !== statusFilter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1
|
||||||
|
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||||
|
if (sortBy === 'status') return a.status.localeCompare(b.status) * dir
|
||||||
|
if (sortBy === 'provider') return a.provider.localeCompare(b.provider) * dir
|
||||||
|
return new Date(a.not_after).getTime() - new Date(b.not_after).getTime() * dir
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
const handleSort = (field: 'name' | 'status' | 'provider' | 'not_after') => {
|
||||||
|
if (sortBy === field) {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortBy(field)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' | 'not_after' }) => (
|
||||||
|
<th style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => handleSort(field)}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
{children}
|
||||||
|
{sortBy === field && (sortDir === 'asc' ? <ChevronDown size={12} /> : <ChevronDown size={12} style={{ transform: 'rotate(-180deg)' }} />)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
|
||||||
|
const getDaysUntilExpiry = (notAfter: string) => {
|
||||||
|
return differenceInDays(new Date(notAfter), new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpiryStatus = (days: number) => {
|
||||||
|
if (days < 0) return { label: 'Expired', color: 'var(--color-danger)', bg: '#fef2f2', icon: XCircle }
|
||||||
|
if (days <= 30) return { label: `${days} days`, color: 'var(--color-warning)', bg: '#fef9c3', icon: AlertTriangle }
|
||||||
|
return { label: `${days} days`, color: 'var(--color-success)', bg: '#dcfce7', icon: CheckCircle }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||||
|
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Certificates</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)' }}>Monitor TLS certificates and expiration dates</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" disabled>
|
||||||
|
<Plus size={18} /> Add Certificate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: '16px' }}>
|
||||||
|
<div className="card-body" style={{ padding: '16px 20px' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative', flex: 1, minWidth: 250 }}>
|
||||||
|
<Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search certificates..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ paddingLeft: '40px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 'auto', minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="valid">Valid</option>
|
||||||
|
<option value="warning">Expiring Soon</option>
|
||||||
|
<option value="expired">Expired</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader field="status"><span style={{ width: 36 }}>Status</span></SortableHeader>
|
||||||
|
<SortableHeader field="name">Common Name</SortableHeader>
|
||||||
|
<th>SANs</th>
|
||||||
|
<th>Issuer</th>
|
||||||
|
<SortableHeader field="not_after">Valid Until</SortableHeader>
|
||||||
|
<SortableHeader field="not_after">Expires In</SortableHeader>
|
||||||
|
<SortableHeader field="provider">Provider</SortableHeader>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredCertificates.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||||
|
No certificates found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredCertificates.map(cert => {
|
||||||
|
const statusConfig = statusColors[cert.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertCircle }
|
||||||
|
const StatusIcon = statusConfig.icon
|
||||||
|
const daysLeft = getDaysUntilExpiry(cert.not_after)
|
||||||
|
const expiryInfo = getExpiryStatus(daysLeft)
|
||||||
|
const ExpiryIcon = expiryInfo.icon
|
||||||
|
return (
|
||||||
|
<tr key={cert.name}>
|
||||||
|
<td>
|
||||||
|
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||||
|
<StatusIcon size={10} /> {cert.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: 13, fontWeight: 500 }}>{cert.common_name}</td>
|
||||||
|
<td style={{ maxWidth: 250 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{cert.sans.length > 0 ? cert.sans.join(', ') : '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 13 }}>
|
||||||
|
{cert.issuer_org || cert.issuer_cn}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: 13, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<Calendar size={14} style={{ color: 'var(--color-text-muted)' }} />
|
||||||
|
{format(new Date(cert.not_after), 'MMM d, yyyy')}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '2px 8px', borderRadius: 'var(--radius-sm)', fontSize: 12, fontWeight: 500, background: expiryInfo.bg, color: expiryInfo.color }}>
|
||||||
|
<ExpiryIcon size={10} /> {expiryInfo.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><span className="badge badge-provider">{cert.provider}</span></td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{filteredCertificates.length > 0 && (
|
||||||
|
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||||
|
<span>Showing {filteredCertificates.length} of {certificates?.length} certificates</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
522
frontend/src/pages/ConfigEditor.tsx
Normal file
522
frontend/src/pages/ConfigEditor.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { fileApi } from '../api/client'
|
||||||
|
import type { FileMeta, PreviewResult, BackupInfo } from '../api/client'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { Save, Eye, RotateCcw, FilePlus, AlertTriangle, CheckCircle, XCircle, Clock, FileText, Shield } from 'lucide-react'
|
||||||
|
|
||||||
|
function formatBytes(n: number) {
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`
|
||||||
|
return `${(n/1024/1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(s: string) {
|
||||||
|
try { return new Date(s).toLocaleString() } catch { return s }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigEditor() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isAdmin = user?.role === 'admin'
|
||||||
|
const canApply = user?.role === 'admin' || user?.role === 'operator'
|
||||||
|
const canRollback = isAdmin
|
||||||
|
|
||||||
|
// File list state
|
||||||
|
const [files, setFiles] = useState<FileMeta[]>([])
|
||||||
|
const [filesLoading, setFilesLoading] = useState(true)
|
||||||
|
const [filesError, setFilesError] = useState<string|null>(null)
|
||||||
|
|
||||||
|
// Editor state
|
||||||
|
const [selected, setSelected] = useState<string>("")
|
||||||
|
const [content, setContent] = useState("")
|
||||||
|
const [originalContent, setOriginalContent] = useState("")
|
||||||
|
const [newFilename, setNewFilename] = useState("")
|
||||||
|
const [creatingNew, setCreatingNew] = useState(false)
|
||||||
|
const editorRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
// Preview/apply state
|
||||||
|
const [preview, setPreview] = useState<PreviewResult|null>(null)
|
||||||
|
const [previewError, setPreviewError] = useState<string|null>(null)
|
||||||
|
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||||
|
const [isApplying, setIsApplying] = useState(false)
|
||||||
|
const [applyConfirmOpen, setApplyConfirmOpen] = useState(false)
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string|null>(null)
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string|null>(null)
|
||||||
|
|
||||||
|
// History
|
||||||
|
const [history, setHistory] = useState<BackupInfo[]>([])
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [rollbackConfirm, setRollbackConfirm] = useState<{id:string, filename:string}|null>(null)
|
||||||
|
|
||||||
|
const isDirty = content !== originalContent
|
||||||
|
const filename = creatingNew ? newFilename.trim() : selected
|
||||||
|
|
||||||
|
// Load file list
|
||||||
|
const loadFiles = async () => {
|
||||||
|
setFilesLoading(true)
|
||||||
|
setFilesError(null)
|
||||||
|
try {
|
||||||
|
const res = await fileApi.listFiles()
|
||||||
|
setFiles(res.data)
|
||||||
|
} catch (e:any) {
|
||||||
|
setFilesError(e.response?.data?.error || e.message || "Failed to load files")
|
||||||
|
} finally {
|
||||||
|
setFilesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadHistory = async (fname?: string) => {
|
||||||
|
setHistoryLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fileApi.history(fname)
|
||||||
|
setHistory(res.data)
|
||||||
|
} catch (e:any) {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles()
|
||||||
|
loadHistory()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("beforeunload", handler)
|
||||||
|
return () => window.removeEventListener("beforeunload", handler)
|
||||||
|
}, [isDirty])
|
||||||
|
|
||||||
|
const handleSelectFile = async (fname: string) => {
|
||||||
|
if (isDirty && !confirm("You have unsaved changes. Discard them?")) return
|
||||||
|
setSelected(fname)
|
||||||
|
setCreatingNew(false)
|
||||||
|
setNewFilename("")
|
||||||
|
setPreview(null)
|
||||||
|
setPreviewError(null)
|
||||||
|
setErrorMsg(null)
|
||||||
|
setSuccessMsg(null)
|
||||||
|
try {
|
||||||
|
const res = await fileApi.getFile(fname)
|
||||||
|
setContent(res.data.content)
|
||||||
|
setOriginalContent(res.data.content)
|
||||||
|
loadHistory(fname)
|
||||||
|
setTimeout(() => editorRef.current?.focus(), 100)
|
||||||
|
} catch (e:any) {
|
||||||
|
setErrorMsg(e.response?.data?.error || "Failed to load file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
if (isDirty && !confirm("You have unsaved changes. Discard them?")) return
|
||||||
|
setCreatingNew(true)
|
||||||
|
setSelected("")
|
||||||
|
setNewFilename("")
|
||||||
|
setContent(sampleYAML)
|
||||||
|
setOriginalContent("")
|
||||||
|
setPreview(null)
|
||||||
|
setPreviewError(null)
|
||||||
|
setHistory([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFilenameClient = (name: string): string|null => {
|
||||||
|
if (!name) return "Filename is required"
|
||||||
|
if (name.includes("..")) return "Filename must not contain '..'"
|
||||||
|
if (name.includes("/") || name.includes("\\")) return "Use a single file name without path separators"
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
if (!(lower.endsWith(".yml") || lower.endsWith(".yaml") || lower.endsWith(".toml"))) return "Filename must end with .yml, .yaml, or .toml"
|
||||||
|
if (name.length > 255) return "Filename too long"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
setPreviewError(null)
|
||||||
|
setPreview(null)
|
||||||
|
setErrorMsg(null)
|
||||||
|
const fnameErr = validateFilenameClient(filename)
|
||||||
|
if (fnameErr) {
|
||||||
|
setPreviewError(fnameErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!content.trim()) {
|
||||||
|
setPreviewError("Content must not be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsPreviewing(true)
|
||||||
|
try {
|
||||||
|
const res = await fileApi.preview(filename, content)
|
||||||
|
setPreview(res.data)
|
||||||
|
} catch (e:any) {
|
||||||
|
const data = e.response?.data
|
||||||
|
if (data?.errors) {
|
||||||
|
setPreview({ valid: false, errors: data.errors, diff: data.diff || "" })
|
||||||
|
} else if (e.response?.status === 400) {
|
||||||
|
setPreview({ valid: false, errors: [{message: data?.error || "Validation failed"}], diff: "" })
|
||||||
|
} else if (e.response?.status === 401) {
|
||||||
|
setErrorMsg("Session expired. Please log in again.")
|
||||||
|
} else if (e.response?.status === 403) {
|
||||||
|
setErrorMsg("You don't have permission to preview. Requires operator or admin.")
|
||||||
|
} else {
|
||||||
|
setErrorMsg(data?.error || e.message || "Preview failed")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsPreviewing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!preview || !preview.valid) return
|
||||||
|
setIsApplying(true)
|
||||||
|
setErrorMsg(null)
|
||||||
|
try {
|
||||||
|
const res = await fileApi.apply(filename, content, true)
|
||||||
|
setSuccessMsg(`Applied ${filename} successfully`)
|
||||||
|
setOriginalContent(content)
|
||||||
|
setPreview(null)
|
||||||
|
setApplyConfirmOpen(false)
|
||||||
|
// Refresh
|
||||||
|
await loadFiles()
|
||||||
|
await loadHistory(filename)
|
||||||
|
// If was new file, select it
|
||||||
|
if (creatingNew) {
|
||||||
|
setCreatingNew(false)
|
||||||
|
setSelected(filename)
|
||||||
|
}
|
||||||
|
} catch (e:any) {
|
||||||
|
const status = e.response?.status
|
||||||
|
const data = e.response?.data
|
||||||
|
if (status === 400) {
|
||||||
|
if (data?.errors) setPreview({ valid: false, errors: data.errors, diff: data.diff || "" })
|
||||||
|
else setErrorMsg(data?.error || "Validation failed")
|
||||||
|
} else if (status === 403) setErrorMsg("Forbidden: operator or admin required")
|
||||||
|
else if (status === 409) setErrorMsg("Conflict: file is being modified concurrently, try again")
|
||||||
|
else if (status === 401) setErrorMsg("Session expired. Please log in again.")
|
||||||
|
else setErrorMsg(data?.error || e.message || "Apply failed")
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRollback = async (backupId?: string) => {
|
||||||
|
const targetFilename = selected || filename
|
||||||
|
if (!targetFilename) return
|
||||||
|
setErrorMsg(null)
|
||||||
|
try {
|
||||||
|
const res = await fileApi.rollback(targetFilename, backupId)
|
||||||
|
setSuccessMsg(`Rolled back ${targetFilename}`)
|
||||||
|
setRollbackConfirm(null)
|
||||||
|
// Reload file content
|
||||||
|
try {
|
||||||
|
const fileRes = await fileApi.getFile(targetFilename)
|
||||||
|
setContent(fileRes.data.content)
|
||||||
|
setOriginalContent(fileRes.data.content)
|
||||||
|
} catch {
|
||||||
|
// File was deleted (rollback to empty)
|
||||||
|
setContent("")
|
||||||
|
setOriginalContent("")
|
||||||
|
}
|
||||||
|
await loadFiles()
|
||||||
|
await loadHistory(targetFilename)
|
||||||
|
setPreview(null)
|
||||||
|
} catch (e:any) {
|
||||||
|
const status = e.response?.status
|
||||||
|
if (status === 403) setErrorMsg("Rollback requires admin role")
|
||||||
|
else setErrorMsg(e.response?.data?.error || e.message || "Rollback failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived stats for empty state
|
||||||
|
const hasFiles = files.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Configuration Editor</h1>
|
||||||
|
<p style={{color:'var(--color-text-muted)'}}>Manage dynamic file-provider YAML — validated, atomic, with backup & rollback</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreateNew}>
|
||||||
|
<FilePlus size={18}/> New file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMsg && (
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:8,padding:'12px',background:'#dcfce7',border:'1px solid #bbf7d0',borderRadius:8,color:'#166534',marginBottom:16}}>
|
||||||
|
<CheckCircle size={18}/>{successMsg}
|
||||||
|
<button onClick={()=>setSuccessMsg(null)} style={{marginLeft:'auto',color:'#166534'}}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorMsg && (
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:8,padding:'12px',background:'#fef2f2',border:'1px solid #fecaca',borderRadius:8,color:'#991b1b',marginBottom:16}}>
|
||||||
|
<XCircle size={18}/>{errorMsg}
|
||||||
|
<button onClick={()=>setErrorMsg(null)} style={{marginLeft:'auto',color:'#991b1b'}}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{display:'grid',gridTemplateColumns:'320px 1fr',gap:16, alignItems:'start'}}>
|
||||||
|
{/* File list */}
|
||||||
|
<div className="card" style={{position:'sticky',top:16}}>
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 style={{fontSize:14,fontWeight:600,display:'flex',alignItems:'center',gap:8}}><FileText size={16}/> Files in configs/dynamic</h3>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={loadFiles}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{padding:0}}>
|
||||||
|
{filesLoading ? (
|
||||||
|
<div style={{padding:20,textAlign:'center',color:'var(--color-text-muted)'}}>Loading…</div>
|
||||||
|
) : filesError ? (
|
||||||
|
<div style={{padding:16,color:'var(--color-danger)',fontSize:14}}>{filesError}</div>
|
||||||
|
) : !hasFiles ? (
|
||||||
|
<div className="empty-state" style={{padding:24}}>
|
||||||
|
<div className="empty-state-title">No files yet</div>
|
||||||
|
<div className="empty-state-text">Create a new .yml file to start. Files are never exposed with full paths.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{maxHeight:'60vh',overflowY:'auto'}}>
|
||||||
|
{files.map(f => (
|
||||||
|
<button
|
||||||
|
key={f.filename}
|
||||||
|
onClick={()=>handleSelectFile(f.filename)}
|
||||||
|
style={{
|
||||||
|
display:'flex',flexDirection:'column',alignItems:'flex-start',width:'100%',padding:'12px 16px',textAlign:'left',
|
||||||
|
background: selected===f.filename ? 'var(--color-primary-light)' : 'transparent',
|
||||||
|
borderBottom:'1px solid var(--color-border)',
|
||||||
|
borderLeft: selected===f.filename ? '3px solid var(--color-primary)' : '3px solid transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{fontFamily:'monospace',fontSize:13,fontWeight: selected===f.filename?600:500,display:'flex',alignItems:'center',gap:6}}>
|
||||||
|
{f.valid ? <CheckCircle size={12} style={{color:'var(--color-success)'}}/> : <XCircle size={12} style={{color:'var(--color-danger)'}}/>}
|
||||||
|
{f.filename}
|
||||||
|
</span>
|
||||||
|
<span style={{fontSize:12,color:'var(--color-text-muted)',display:'flex',gap:8,marginTop:4,flexWrap:'wrap'}}>
|
||||||
|
<span>{formatBytes(f.size)}</span>
|
||||||
|
<span>· {formatDate(f.modTime)}</span>
|
||||||
|
{!f.valid && <span style={{color:'var(--color-danger)'}}>· invalid</span>}
|
||||||
|
</span>
|
||||||
|
{f.lastAction && <span style={{fontSize:11,color:'var(--color-text-muted)',marginTop:2}}>last: {f.lastAction} {f.lastActionTime ? formatDate(f.lastActionTime) : ''}</span>}
|
||||||
|
{f.validationErr && <span style={{fontSize:11,color:'var(--color-danger)',marginTop:2,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis',maxWidth:'100%'}}>{f.validationErr}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{padding:'12px 16px',borderTop:'1px solid var(--color-border)',fontSize:12,color:'var(--color-text-muted)'}}>
|
||||||
|
<Shield size={12} style={{display:'inline',marginRight:4}}/> Only filenames shown — no paths exposed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor + preview */}
|
||||||
|
<div style={{display:'flex',flexDirection:'column',gap:16, minWidth:0}}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 style={{fontSize:14,fontWeight:600}}>
|
||||||
|
{creatingNew ? "New file" : selected ? `Editing: ${selected}` : "Select a file or create new"}
|
||||||
|
{isDirty && <span style={{marginLeft:8,color:'var(--color-warning)',fontSize:12}}>• unsaved changes</span>}
|
||||||
|
</h3>
|
||||||
|
<div style={{display:'flex',gap:8}}>
|
||||||
|
<span style={{fontSize:12,color:'var(--color-text-muted)',display:'flex',alignItems:'center',gap:4}}>
|
||||||
|
{canApply ? <Shield size={12}/> : <AlertTriangle size={12}/>} {user?.role} {canApply ? "can apply" : "read-only"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{display:'flex',flexDirection:'column',gap:12}}>
|
||||||
|
{creatingNew && (
|
||||||
|
<div className="form-group" style={{marginBottom:0}}>
|
||||||
|
<label className="form-label">Filename (must end .yml/.yaml, no path)</label>
|
||||||
|
<input className="form-input" placeholder="my-service.yml" value={newFilename} onChange={e=>setNewFilename(e.target.value)} style={{fontFamily:'monospace'}} />
|
||||||
|
<div className="form-help">Example: api.yml — traversal and unsupported extensions are rejected client and server side</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{position:'relative'}}>
|
||||||
|
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}>
|
||||||
|
<label className="form-label" style={{marginBottom:0}}>YAML Content</label>
|
||||||
|
<span style={{fontSize:12,color:'var(--color-text-muted)'}}>{content.length} chars</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={editorRef}
|
||||||
|
value={content}
|
||||||
|
onChange={e=>setContent(e.target.value)}
|
||||||
|
placeholder="http:\n routers:\n my-router:\n rule: \"Host(`example.com`)\"\n service: my-service\n..."
|
||||||
|
style={{
|
||||||
|
width:'100%',minHeight:340,padding:'12px',fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace',fontSize:13,
|
||||||
|
lineHeight:1.5,border:'1px solid var(--color-border)',borderRadius:8,background:'var(--color-bg)',
|
||||||
|
tabSize:2,whiteSpace:'pre',overflowX:'auto'
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div style={{fontSize:11,color:'var(--color-text-muted)',marginTop:4}}>Syntax highlighting via monospace + server-side parser validation. Secrets are never logged or stored in localStorage.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
|
||||||
|
<button className="btn btn-secondary" onClick={handlePreview} disabled={isPreviewing || isApplying}>
|
||||||
|
<Eye size={16}/>{isPreviewing ? "Validating..." : "Validate & Preview"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={()=>setApplyConfirmOpen(true)}
|
||||||
|
disabled={!preview || !preview.valid || isApplying || isPreviewing || !canApply}
|
||||||
|
title={!canApply ? "Requires operator or admin" : preview && !preview.valid ? "Fix validation errors first" : ""}
|
||||||
|
>
|
||||||
|
<Save size={16}/>{isApplying ? "Applying..." : "Apply (confirm)"}
|
||||||
|
</button>
|
||||||
|
{isDirty && <span style={{fontSize:12,color:'var(--color-warning)',display:'flex',alignItems:'center',gap:4}}><AlertTriangle size={12}/> Unsaved changes</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewError && (
|
||||||
|
<div style={{padding:12,background:'#fef2f2',border:'1px solid #fecaca',borderRadius:8,color:'#991b1b',fontSize:13}}>
|
||||||
|
<strong>Validation error:</strong> {previewError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && !preview.valid && preview.errors && (
|
||||||
|
<div style={{padding:12,background:'#fef2f2',border:'1px solid #fecaca',borderRadius:8}}>
|
||||||
|
<div style={{fontWeight:600,color:'#991b1b',display:'flex',alignItems:'center',gap:6}}><XCircle size={16}/> Validation failed</div>
|
||||||
|
<ul style={{marginTop:8,paddingLeft:16}}>
|
||||||
|
{preview.errors.map((e, i) => (
|
||||||
|
<li key={i} style={{fontSize:13,fontFamily:'monospace',color:'#991b1b'}}>
|
||||||
|
{e.line ? `line ${e.line}${e.column ? ` col ${e.column}` : ''}: ` : ''}{e.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && preview.valid && (
|
||||||
|
<div style={{border:'1px solid var(--color-border)',borderRadius:8,overflow:'hidden'}}>
|
||||||
|
<div style={{padding:'8px 12px',background:'var(--color-bg)',borderBottom:'1px solid var(--color-border)',display:'flex',alignItems:'center',gap:8,fontSize:13,fontWeight:600}}>
|
||||||
|
<CheckCircle size={14} style={{color:'var(--color-success)'}}/> Preview diff — no file modified yet
|
||||||
|
</div>
|
||||||
|
<pre style={{
|
||||||
|
margin:0,padding:12,background:'#fafafa',overflowX:'auto',fontSize:12,lineHeight:1.5,
|
||||||
|
fontFamily:'ui-monospace, monospace',whiteSpace:'pre-wrap',wordBreak:'break-word', maxHeight:260, overflowY:'auto'
|
||||||
|
}}>
|
||||||
|
{preview.diff ? preview.diff.split('\n').map((line, idx) => {
|
||||||
|
let style: any = {}
|
||||||
|
if (line.startsWith('+') && !line.startsWith('+++')) style = {background:'#dcfce7',color:'#166534'}
|
||||||
|
else if (line.startsWith('-') && !line.startsWith('---')) style = {background:'#fef2f2',color:'#991b1b'}
|
||||||
|
else if (line.startsWith('---') || line.startsWith('+++')) style = {color:'var(--color-text-muted)',fontWeight:600}
|
||||||
|
return <div key={idx} style={style}>{line || ' '}</div>
|
||||||
|
}) : <span style={{color:'var(--color-text-muted)'}}>No changes (identical to current file, or new file with same content)</span>}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History / rollback */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 style={{fontSize:14,fontWeight:600,display:'flex',alignItems:'center',gap:8}}><Clock size={16}/> History & Rollback {selected ? `— ${selected}` : ""}</h3>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={()=>loadHistory(selected || undefined)} disabled={historyLoading}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{padding:0}}>
|
||||||
|
{historyLoading ? (
|
||||||
|
<div style={{padding:16,textAlign:'center',color:'var(--color-text-muted)'}}>Loading history…</div>
|
||||||
|
) : history.length===0 ? (
|
||||||
|
<div style={{padding:16,textAlign:'center',color:'var(--color-text-muted)',fontSize:13}}>No backups yet. Apply a change to create history.</div>
|
||||||
|
) : (
|
||||||
|
<div style={{overflowX:'auto'}}>
|
||||||
|
<table style={{width:'100%',fontSize:13}}>
|
||||||
|
<thead><tr><th>Time</th><th>Reason</th><th>User</th><th>Action</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{history.map(h => (
|
||||||
|
<tr key={h.id}>
|
||||||
|
<td style={{fontFamily:'monospace',fontSize:12}}>{formatDate(h.created_at)}</td>
|
||||||
|
<td><span className="badge" style={{background: h.reason==='rollback'?'#fef9c3':'#e0f7fa',color: h.reason==='rollback'?'#854d0e':'#006064',textTransform:'capitalize'}}>{h.reason}</span></td>
|
||||||
|
<td style={{fontFamily:'monospace',fontSize:11}}>{h.created_by.slice(0,8)}…</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={!canRollback}
|
||||||
|
title={!canRollback ? "Admin only" : ""}
|
||||||
|
onClick={()=>setRollbackConfirm({id:h.id, filename:h.filename})}
|
||||||
|
style={{color: canRollback ? 'var(--color-danger)' : 'var(--color-text-muted)'}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={12}/> Rollback
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!canRollback && <div style={{padding:'8px 16px',fontSize:12,color:'var(--color-text-muted)',borderTop:'1px solid var(--color-border)'}}>Rollback requires admin role (backend enforced, not just UI hidden)</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apply confirmation modal */}
|
||||||
|
{applyConfirmOpen && preview && (
|
||||||
|
<div className="modal-overlay" onClick={()=>setApplyConfirmOpen(false)}>
|
||||||
|
<div className="modal" onClick={e=>e.stopPropagation()} style={{maxWidth:720}}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 className="modal-title">Confirm apply</h3>
|
||||||
|
<button className="modal-close" onClick={()=>setApplyConfirmOpen(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p style={{fontSize:14}}>You are about to overwrite <code style={{background:'var(--color-bg)',padding:'2px 6px',borderRadius:4}}>{filename}</code> with the following diff:</p>
|
||||||
|
<pre style={{marginTop:12,padding:12,background:'#fafafa',border:'1px solid var(--color-border)',borderRadius:8,fontSize:12,overflowX:'auto',maxHeight:300,whiteSpace:'pre-wrap'}}>
|
||||||
|
{preview.diff || "(no diff — file will be overwritten with identical content)"}
|
||||||
|
</pre>
|
||||||
|
<div style={{marginTop:12,padding:10,background:'#fef9c3',border:'1px solid #fde68a',borderRadius:8,fontSize:12,color:'#854d0e'}}>
|
||||||
|
This will create a backup, then atomically replace the file. Traefik will hot-reload. This action is audited.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-ghost" onClick={()=>setApplyConfirmOpen(false)} disabled={isApplying}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleApply} disabled={isApplying} style={{background:'var(--color-danger)',borderColor:'var(--color-danger)'}}>
|
||||||
|
{isApplying ? "Applying..." : "Confirm & Apply"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rollback confirmation */}
|
||||||
|
{rollbackConfirm && (
|
||||||
|
<div className="modal-overlay" onClick={()=>setRollbackConfirm(null)}>
|
||||||
|
<div className="modal" onClick={e=>e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 className="modal-title" style={{color:'var(--color-danger)'}}>Confirm rollback</h3>
|
||||||
|
<button className="modal-close" onClick={()=>setRollbackConfirm(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p style={{fontSize:14}}>Restore <code>{rollbackConfirm.filename}</code> to backup <code>{rollbackConfirm.id.slice(0,8)}…</code>?</p>
|
||||||
|
<p style={{fontSize:12,color:'var(--color-text-muted)',marginTop:8}}>A backup of the current version will be created before rollback. This is a destructive action.</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-ghost" onClick={()=>setRollbackConfirm(null)}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={()=>handleRollback(rollbackConfirm.id)} style={{background:'var(--color-danger)'}}>Rollback</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`@media (max-width: 900px) { div[style*="grid-template-columns:320px"] { grid-template-columns: 1fr !important; } }`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleYAML = `http:
|
||||||
|
routers:
|
||||||
|
my-router:
|
||||||
|
rule: "Host(\`example.com\`)"
|
||||||
|
service: my-service
|
||||||
|
entryPoints: ["web"]
|
||||||
|
services:
|
||||||
|
my-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:8080"
|
||||||
|
middlewares:
|
||||||
|
my-strip:
|
||||||
|
stripPrefix:
|
||||||
|
prefixes: ["/api"]
|
||||||
|
`
|
||||||
156
frontend/src/pages/Dashboard.tsx
Normal file
156
frontend/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { traefikApi } from '../api/client'
|
||||||
|
import { Server, Network, Shield, AlertTriangle, CheckCircle, XCircle, AlertCircle, FileText, Database, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ title: 'HTTP Routers', key: 'http.routers.total', icon: Network, color: 'var(--color-primary)', bg: 'var(--color-primary-light)' },
|
||||||
|
{ title: 'HTTP Services', key: 'http.services.total', icon: Server, color: 'var(--color-info)', bg: '#e0f7fa' },
|
||||||
|
{ title: 'Middlewares', key: 'http.middlewares.total', icon: Shield, color: 'var(--color-success)', bg: '#e8f5e9' },
|
||||||
|
{ title: 'Certificates', key: 'certificates.total', icon: FileText, color: 'var(--color-warning)', bg: '#fff8e1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data: overview, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['overview'],
|
||||||
|
queryFn: traefikApi.getOverview,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||||
|
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: '40px', textAlign: 'center' }}>
|
||||||
|
<AlertCircle size={48} style={{ color: 'var(--color-danger)', marginBottom: '16px' }} />
|
||||||
|
<h2>Failed to load dashboard</h2>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)', marginTop: '8px' }}>{(error as Error).message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getValue = (obj: any, path: string) => {
|
||||||
|
return path.split('.').reduce((o, k) => o?.[k], obj) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||||
|
{statCards.map(card => {
|
||||||
|
const Icon = card.icon
|
||||||
|
const value = getValue(overview, card.key)
|
||||||
|
const warnings = getValue(overview, card.key.replace('.total', '.warnings'))
|
||||||
|
const errors = getValue(overview, card.key.replace('.total', '.errors'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={card.title} className="card" style={{ padding: '20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: '4px' }}>{card.title}</p>
|
||||||
|
<p style={{ fontSize: 32, fontWeight: 700, color: 'var(--color-text)' }}>{value}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', fontSize: 12 }}>
|
||||||
|
{warnings > 0 && (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--color-warning)' }}>
|
||||||
|
<AlertTriangle size={12} /> {warnings} warnings
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{errors > 0 && (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--color-danger)' }}>
|
||||||
|
<XCircle size={12} /> {errors} errors
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48,
|
||||||
|
background: card.bg,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: card.color,
|
||||||
|
}}>
|
||||||
|
<Icon size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '16px' }}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Providers</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{overview?.providers?.length ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{overview.providers.map((provider: string) => (
|
||||||
|
<span key={provider} className="badge badge-provider">{provider}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: 'var(--color-text-muted)' }}>No providers configured</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Certificate Status</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{overview?.certificates && (
|
||||||
|
<div style={{ display: 'flex', gap: '24px' }}>
|
||||||
|
<div style={{ textAlign: 'center', flex: 1 }}>
|
||||||
|
<p style={{ fontSize: 36, fontWeight: 700, color: 'var(--color-success)' }}>{overview.certificates.total - overview.certificates.warnings - overview.certificates.errors}</p>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--color-text-muted)' }}>Valid</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', flex: 1 }}>
|
||||||
|
<p style={{ fontSize: 36, fontWeight: 700, color: 'var(--color-warning)' }}>{overview.certificates.warnings}</p>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--color-text-muted)' }}>Expiring Soon</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', flex: 1 }}>
|
||||||
|
<p style={{ fontSize: 36, fontWeight: 700, color: 'var(--color-danger)' }}>{overview.certificates.errors}</p>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--color-text-muted)' }}>Expired</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: '16px' }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Quick Actions</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
|
<a href="/routers" className="btn btn-primary">
|
||||||
|
<Network size={18} /> Manage Routers
|
||||||
|
</a>
|
||||||
|
<a href="/services" className="btn btn-secondary">
|
||||||
|
<Server size={18} /> Manage Services
|
||||||
|
</a>
|
||||||
|
<a href="/middlewares" className="btn btn-secondary">
|
||||||
|
<Shield size={18} /> Manage Middlewares
|
||||||
|
</a>
|
||||||
|
<a href="/certificates" className="btn btn-secondary">
|
||||||
|
<FileText size={18} /> View Certificates
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
frontend/src/pages/Login.tsx
Normal file
129
frontend/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { useToast } from '../components/ui/Toast' // keep
|
||||||
|
import { Lock, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(username, password)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Login failed')
|
||||||
|
// showToast error suppressed
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '24px',
|
||||||
|
}}>
|
||||||
|
<div className="card" style={{ width: '100%', maxWidth: 400, padding: '32px' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64,
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 24,
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
}}>TG</div>
|
||||||
|
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Traefik GUI</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)', marginTop: '8px' }}>Sign in to manage your Traefik configuration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
background: '#fef2f2',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: '#991b1b',
|
||||||
|
marginBottom: '20px',
|
||||||
|
fontSize: 14,
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username" className="form-label">Username</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Lock size={18} style={{ color: 'var(--color-text-muted)', marginTop: '10px', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
className="form-input"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password" className="form-label">Password</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Lock size={18} style={{ color: 'var(--color-text-muted)', marginTop: '10px', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
className="form-input"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: '100%', padding: '12px' }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px', paddingTop: '24px', borderTop: '1px solid var(--color-border)', textAlign: 'center', fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
|
<p>Default credentials: <code>admin</code> / <code>changeme</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
frontend/src/pages/Middlewares.tsx
Normal file
179
frontend/src/pages/Middlewares.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { traefikApi, type Middleware } from '../api/client'
|
||||||
|
import { Search, Plus, Filter, ChevronDown, Shield, AlertTriangle, XCircle, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
||||||
|
enabled: { bg: '#dcfce7', color: '#166534', icon: CheckCircle },
|
||||||
|
disabled: { bg: '#fef2f2', color: '#991b1b', icon: XCircle },
|
||||||
|
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||||
|
}
|
||||||
|
|
||||||
|
const middlewareTypeIcons: Record<string, any> = {
|
||||||
|
headers: Shield,
|
||||||
|
redirectscheme: Shield,
|
||||||
|
ratelimit: Shield,
|
||||||
|
stripprefix: Shield,
|
||||||
|
addprefix: Shield,
|
||||||
|
replacepath: Shield,
|
||||||
|
replacepathregex: Shield,
|
||||||
|
chain: Shield,
|
||||||
|
circuitbreaker: Shield,
|
||||||
|
compress: Shield,
|
||||||
|
retry: Shield,
|
||||||
|
timeout: Shield,
|
||||||
|
buffering: Shield,
|
||||||
|
passTLSClientCert: Shield,
|
||||||
|
forwardauth: Shield,
|
||||||
|
ipwhitelist: Shield,
|
||||||
|
inFlightReq: Shield,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Middlewares() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider' | 'type'>('name')
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const { data: middlewares, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['middlewares'],
|
||||||
|
queryFn: traefikApi.listMiddlewares,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredMiddlewares = middlewares?.filter(m => {
|
||||||
|
if (search && !m.name.toLowerCase().includes(search.toLowerCase()) && !m.type.toLowerCase().includes(search.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all' && m.status !== statusFilter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1
|
||||||
|
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||||
|
if (sortBy === 'status') return a.status.localeCompare(b.status) * dir
|
||||||
|
if (sortBy === 'provider') return a.provider.localeCompare(b.provider) * dir
|
||||||
|
return a.type.localeCompare(b.type) * dir
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
const handleSort = (field: 'name' | 'status' | 'provider' | 'type') => {
|
||||||
|
if (sortBy === field) {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortBy(field)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' | 'type' }) => (
|
||||||
|
<th style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => handleSort(field)}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
{children}
|
||||||
|
{sortBy === field && (sortDir === 'asc' ? <ChevronDown size={12} /> : <ChevronDown size={12} style={{ transform: 'rotate(-180deg)' }} />)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||||
|
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">HTTP Middlewares</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)' }}>Manage middleware for request/response processing</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" disabled>
|
||||||
|
<Plus size={18} /> Add Middleware
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: '16px' }}>
|
||||||
|
<div className="card-body" style={{ padding: '16px 20px' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative', flex: 1, minWidth: 250 }}>
|
||||||
|
<Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search middlewares..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ paddingLeft: '40px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 'auto', minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="enabled">Enabled</option>
|
||||||
|
<option value="disabled">Disabled</option>
|
||||||
|
<option value="warning">Warning</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader field="status"><span style={{ width: 36 }}>Status</span></SortableHeader>
|
||||||
|
<SortableHeader field="name">Name</SortableHeader>
|
||||||
|
<SortableHeader field="type">Type</SortableHeader>
|
||||||
|
<SortableHeader field="provider">Provider</SortableHeader>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredMiddlewares.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||||
|
No middlewares found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredMiddlewares.map(middleware => {
|
||||||
|
const statusConfig = statusColors[middleware.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertTriangle }
|
||||||
|
const StatusIcon = statusConfig.icon
|
||||||
|
const TypeIcon = middlewareTypeIcons[middleware.type] || Shield
|
||||||
|
return (
|
||||||
|
<tr key={middleware.name}>
|
||||||
|
<td>
|
||||||
|
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||||
|
<StatusIcon size={10} /> {middleware.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{middleware.name}</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<TypeIcon size={14} style={{ color: 'var(--color-text-muted)' }} />
|
||||||
|
<span className="badge badge-provider">{middleware.type}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><span className="badge badge-provider">{middleware.provider}</span></td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{filteredMiddlewares.length > 0 && (
|
||||||
|
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||||
|
<span>Showing {filteredMiddlewares.length} of {middlewares?.length} middlewares</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
frontend/src/pages/Routers.tsx
Normal file
172
frontend/src/pages/Routers.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { traefikApi, configApi, type Router } from '../api/client'
|
||||||
|
import { Search, Plus, Filter, ChevronDown, ExternalLink, Shield, AlertTriangle, XCircle, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
||||||
|
enabled: { bg: '#dcfce7', color: '#166534', icon: CheckCircle },
|
||||||
|
disabled: { bg: '#fef2f2', color: '#991b1b', icon: XCircle },
|
||||||
|
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Routers() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider'>('name')
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const { data: routers, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['routers'],
|
||||||
|
queryFn: traefikApi.listRouters,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredRouters = routers?.filter(r => {
|
||||||
|
if (search && !r.name.toLowerCase().includes(search.toLowerCase()) && !r.rule.toLowerCase().includes(search.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all' && r.status !== statusFilter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1
|
||||||
|
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||||
|
if (sortBy === 'status') return a.status.localeCompare(b.status) * dir
|
||||||
|
return a.provider.localeCompare(b.provider) * dir
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
const handleSort = (field: 'name' | 'status' | 'provider') => {
|
||||||
|
if (sortBy === field) {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortBy(field)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' }) => (
|
||||||
|
<th style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => handleSort(field)}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
{children}
|
||||||
|
{sortBy === field && (sortDir === 'asc' ? <ChevronDown size={12} /> : <ChevronDown size={12} style={{ transform: 'rotate(-180deg)' }} />)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||||
|
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">HTTP Routers</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)' }}>Manage routing rules for your HTTP services</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" disabled>
|
||||||
|
<Plus size={18} /> Add Router
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: '16px' }}>
|
||||||
|
<div className="card-body" style={{ padding: '16px 20px' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative', flex: 1, minWidth: 250 }}>
|
||||||
|
<Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search routers..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ paddingLeft: '40px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 'auto', minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="enabled">Enabled</option>
|
||||||
|
<option value="disabled">Disabled</option>
|
||||||
|
<option value="warning">Warning</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader field="status"><span style={{ width: 36 }}>Status</span></SortableHeader>
|
||||||
|
<SortableHeader field="name">TLS</SortableHeader>
|
||||||
|
<SortableHeader field="name">Rule</SortableHeader>
|
||||||
|
<th>Entry Points</th>
|
||||||
|
<SortableHeader field="name">Name</SortableHeader>
|
||||||
|
<th>Service</th>
|
||||||
|
<SortableHeader field="provider">Provider</SortableHeader>
|
||||||
|
<th>Priority</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredRouters.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||||
|
No routers found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredRouters.map(router => {
|
||||||
|
const statusConfig = statusColors[router.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertTriangle }
|
||||||
|
const StatusIcon = statusConfig.icon
|
||||||
|
return (
|
||||||
|
<tr key={router.name}>
|
||||||
|
<td>
|
||||||
|
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||||
|
<StatusIcon size={10} /> {router.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ width: 40, textAlign: 'center' }}>
|
||||||
|
{router.tls && (
|
||||||
|
<Shield size={16} style={{ color: 'var(--color-primary)' }} title="TLS enabled" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ maxWidth: 300 }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{router.rule}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{router.entry_points.map(ep => (
|
||||||
|
<span key={ep} className="badge badge-provider" style={{ marginRight: 4 }}>{ep}</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{router.name}</td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--color-text-muted)' }}>{router.service}</td>
|
||||||
|
<td><span className="badge badge-provider">{router.provider}</span></td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{router.priority}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{filteredRouters.length > 0 && (
|
||||||
|
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||||
|
<span>Showing {filteredRouters.length} of {routers?.length} routers</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
175
frontend/src/pages/Services.tsx
Normal file
175
frontend/src/pages/Services.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { traefikApi, type Service } from '../api/client'
|
||||||
|
import { Search, Plus, Filter, ChevronDown, Server, Database, AlertTriangle, XCircle, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const statusColors: Record<string, { bg: string; color: string; icon: any }> = {
|
||||||
|
enabled: { bg: '#dcfce7', color: '#166534', icon: CheckCircle },
|
||||||
|
disabled: { bg: '#fef2f2', color: '#991b1b', icon: XCircle },
|
||||||
|
warning: { bg: '#fef9c3', color: '#854d0e', icon: AlertTriangle },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Services() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [sortBy, setSortBy] = useState<'name' | 'status' | 'provider' | 'type'>('name')
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const { data: services, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['services'],
|
||||||
|
queryFn: traefikApi.listServices,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredServices = services?.filter(s => {
|
||||||
|
if (search && !s.name.toLowerCase().includes(search.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all' && s.status !== statusFilter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1
|
||||||
|
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||||
|
if (sortBy === 'status') return a.status.localeCompare(b.status) * dir
|
||||||
|
if (sortBy === 'provider') return a.provider.localeCompare(b.provider) * dir
|
||||||
|
return a.type.localeCompare(b.type) * dir
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
const handleSort = (field: 'name' | 'status' | 'provider' | 'type') => {
|
||||||
|
if (sortBy === field) {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortBy(field)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableHeader = ({ children, field }: { children: React.ReactNode; field: 'name' | 'status' | 'provider' | 'type' }) => (
|
||||||
|
<th style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => handleSort(field)}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
{children}
|
||||||
|
{sortBy === field && (sortDir === 'asc' ? <ChevronDown size={12} /> : <ChevronDown size={12} style={{ transform: 'rotate(-180deg)' }} />)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
|
||||||
|
const getServerCount = (service: Service) => {
|
||||||
|
return service.load_balancer?.servers?.length || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUpServers = (service: Service) => {
|
||||||
|
const statuses = service.server_status || {}
|
||||||
|
return Object.values(statuses).filter(s => s === 'UP').length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px' }}>
|
||||||
|
<div className="spinner" style={{ width: 40, height: 40, border: '3px solid var(--color-border)', borderTopColor: 'var(--color-primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">HTTP Services</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)' }}>Manage load balancers and backend services</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" disabled>
|
||||||
|
<Plus size={18} /> Add Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: '16px' }}>
|
||||||
|
<div className="card-body" style={{ padding: '16px 20px' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative', flex: 1, minWidth: 250 }}>
|
||||||
|
<Search size={18} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search services..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ paddingLeft: '40px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 'auto', minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="enabled">Enabled</option>
|
||||||
|
<option value="disabled">Disabled</option>
|
||||||
|
<option value="warning">Warning</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader field="status"><span style={{ width: 36 }}>Status</span></SortableHeader>
|
||||||
|
<SortableHeader field="name">Name</SortableHeader>
|
||||||
|
<SortableHeader field="type">Type</SortableHeader>
|
||||||
|
<th>Servers</th>
|
||||||
|
<SortableHeader field="provider">Provider</SortableHeader>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredServices.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} style={{ textAlign: 'center', padding: '48px', color: 'var(--color-text-muted)' }}>
|
||||||
|
No services found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredServices.map(service => {
|
||||||
|
const statusConfig = statusColors[service.status] || { bg: '#f3f4f6', color: '#374151', icon: AlertTriangle }
|
||||||
|
const StatusIcon = statusConfig.icon
|
||||||
|
const serverCount = getServerCount(service)
|
||||||
|
const upServers = getUpServers(service)
|
||||||
|
return (
|
||||||
|
<tr key={service.name}>
|
||||||
|
<td>
|
||||||
|
<span className="status-badge" style={{ background: statusConfig.bg, color: statusConfig.color }}>
|
||||||
|
<StatusIcon size={10} /> {service.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{service.name}</td>
|
||||||
|
<td><span className="badge badge-provider">{service.type}</span></td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||||
|
{upServers}/{serverCount}
|
||||||
|
{serverCount > 0 && (
|
||||||
|
<span style={{ marginLeft: 8, fontSize: 11, color: upServers === serverCount ? 'var(--color-success)' : upServers > 0 ? 'var(--color-warning)' : 'var(--color-danger)' }}>
|
||||||
|
{upServers === serverCount ? 'All UP' : upServers > 0 ? 'Partial' : 'All DOWN'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><span className="badge badge-provider">{service.provider}</span></td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{filteredServices.length > 0 && (
|
||||||
|
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||||
|
<span>Showing {filteredServices.length} of {services?.length} services</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
213
frontend/src/pages/Settings.tsx
Normal file
213
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Shield, Database, Server, Bell, User, Key, Save, Loader2 } from 'lucide-react'
|
||||||
|
import { useToast } from '../components/ui/Toast'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'general', label: 'General', icon: Server },
|
||||||
|
{ id: 'auth', label: 'Authentication', icon: Shield },
|
||||||
|
{ id: 'traefik', label: 'Traefik', icon: Database },
|
||||||
|
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const [activeTab, setActiveTab] = useState('general')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
setSaving(false)
|
||||||
|
showToast('success', 'Settings saved successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ maxWidth: 800 }}>
|
||||||
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', overflowX: 'auto' }}>
|
||||||
|
{tabs.map(tab => {
|
||||||
|
const Icon = tab.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '14px 20px',
|
||||||
|
background: activeTab === tab.id ? 'var(--color-primary-light)' : 'transparent',
|
||||||
|
color: activeTab === tab.id ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||||
|
borderBottom: activeTab === tab.id ? '2px solid var(--color-primary)' : '2px solid transparent',
|
||||||
|
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||||
|
fontSize: 14,
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: 'all var(--transition)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={18} /> {tab.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body" style={{ padding: '24px' }}>
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: '24px' }}>General Settings</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Application Name</label>
|
||||||
|
<input type="text" className="form-input" defaultValue="Traefik GUI" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Theme</label>
|
||||||
|
<select className="form-input" style={{ width: 'auto', maxWidth: 300 }}>
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Default Page Size</label>
|
||||||
|
<input type="number" className="form-input" defaultValue="25" style={{ width: 'auto', maxWidth: 100 }} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" defaultChecked />
|
||||||
|
<span>Enable auto-refresh on dashboard</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" />
|
||||||
|
<span>Show advanced options by default</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'auth' && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: '24px' }}>Authentication</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Session Timeout</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<input type="number" className="form-input" defaultValue="24" style={{ width: 'auto', maxWidth: 100 }} />
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>hours</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" defaultChecked />
|
||||||
|
<span>Require CSRF protection</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" defaultChecked />
|
||||||
|
<span>Secure cookies (HTTPS only)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" />
|
||||||
|
<span>Enable OIDC/OAuth2 integration</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">OIDC Issuer URL</label>
|
||||||
|
<input type="url" className="form-input" placeholder="https://accounts.example.com" />
|
||||||
|
<p className="form-help">Leave empty to disable OIDC</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">OIDC Client ID</label>
|
||||||
|
<input type="text" className="form-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'traefik' && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: '24px' }}>Traefik Connection</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Traefik API URL</label>
|
||||||
|
<input type="url" className="form-input" defaultValue="http://localhost:8080/api" />
|
||||||
|
<p className="form-help">Base URL for Traefik's REST API</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">File Provider Config Directory</label>
|
||||||
|
<input type="text" className="form-input" defaultValue="/etc/traefik/dynamic" />
|
||||||
|
<p className="form-help">Directory watched by Traefik's file provider for dynamic configuration</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" defaultChecked />
|
||||||
|
<span>Auto-reload on config changes</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" />
|
||||||
|
<span>Validate config before applying</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Config Backup Retention</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<input type="number" className="form-input" defaultValue="30" style={{ width: 'auto', maxWidth: 100 }} />
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'notifications' && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: '24px' }}>Notifications</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" defaultChecked />
|
||||||
|
<span>Enable email notifications</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">SMTP Host</label>
|
||||||
|
<input type="text" className="form-input" placeholder="smtp.example.com" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">SMTP Port</label>
|
||||||
|
<input type="number" className="form-input" defaultValue="587" style={{ width: 'auto', maxWidth: 100 }} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">From Address</label>
|
||||||
|
<input type="email" className="form-input" placeholder="traefik@example.com" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Notification Events</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||||
|
{['Certificate Expiring', 'Certificate Expired', 'Config Applied', 'Config Validation Failed', 'Service Down'].map(event => (
|
||||||
|
<label key={event} style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" defaultChecked />
|
||||||
|
<span>{event}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px', paddingTop: '24px', borderTop: '1px solid var(--color-border)' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <> <Save size={18} /> Save Changes</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
30
frontend/vite.config.ts
Normal file
30
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue