commit 5dbcb1df07c88b7ab5ba7129357e65104046c8d2 Author: enzo Date: Fri Mar 20 21:08:53 2026 +0100 feat: initialisation complète du CORE ProxmoxPanel Backend Go 1.23+ : - API REST + WebSocket (chi, gorilla/websocket) - Authentification PAM via SSH + JWT RS256 - Chiffrement AES-256-GCM pour secrets SQLite - Pool SSH, client Proxmox REST, hub WebSocket pub/sub - Système de modules compilés à initialisation conditionnelle - Audit log, migrations SQLite versionnées Frontend Vue 3 + Vite + TypeScript : - Thème Neumorphism sombre/clair (CSS custom properties) - Wizard d'installation, Dashboard drag-drop, Terminal xterm.js - Toutes les vues CORE + stubs modules optionnels - i18n EN/FR (vue-i18n v11) Infrastructure : - Docker multi-stage (Go → alpine, Node → nginx) - docker-compose.yml, .gitattributes, LICENSE MIT, README Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c8ea822 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto eol=lf +*.go text eol=lf +*.ts text eol=lf +*.vue text eol=lf +*.js text eol=lf +*.json text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +*.sql text eol=lf +*.yml text eol=lf +*.conf text eol=lf +Dockerfile text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..401aec2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Données sensibles — ne jamais committer +data/ +*.db +*.key +*.pub + +# Binaires compilés +backend/proxmoxpanel +backend/proxmoxpanel.exe + +# Dépendances frontend +frontend/node_modules/ +frontend/dist/ + +# Variables d'environnement (non utilisées — tout en SQLite) +.env +.env.* + +# Éditeurs +.idea/ +.vscode/ +*.swp +*.swo + +# Systèmes d'exploitation +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..64d3bba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ProxmoxPanel Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ff8a6c --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# ProxmoxPanel — CORE + +A self-hosted web management panel for Proxmox VE infrastructure. Manage LXC containers and VMs, run package updates, open interactive terminals, browse files — all from a single web interface with a dark/light Neumorphism UI. + +## Features + +- **Dashboard** — Configurable widget grid with drag-and-drop (LXC/VM status, shortcuts, metrics) +- **Proxmox** — Real-time LXC/VM list with start/stop actions, live status via WebSocket +- **Updates** — Run `apt upgrade` on the host or any LXC, with streamed output +- **Terminal** — Interactive SSH terminal in the browser (xterm.js + PTY) +- **Settings** — Per-user preferences (theme, language, sidebar position), instance config +- **Modules** — Enable/disable optional modules without restarting + +## Requirements + +- Docker + Docker Compose +- Proxmox VE 7.x or 8.x +- SSH access to the Proxmox host (password authentication) +- A Proxmox API token (read-only `PVEAuditor` role is sufficient for metrics) +- Reverse proxy recommended (Traefik, Nginx, Caddy) for HTTPS in production + +## Quick Start + +```bash +git clone +cd core +docker compose up -d --build +``` + +Open `http://localhost` (or your server's IP) in a browser. The installation wizard appears on first launch and guides you through the configuration. + +## Installation Wizard + +The wizard runs automatically on first launch and collects: + +1. **Instance name** and public URL (auto-detected from the HTTP request) +2. **SSH host** — address and port of the Proxmox host (e.g. `192.168.1.10:22`) +3. **SSH credentials** — Linux username and password used for PAM authentication +4. **Proxmox API** — URL and API token (e.g. `https://192.168.1.10:8006`) +5. **Default language** — `en` or `fr` + +All sensitive values (SSH password, API token) are encrypted with AES-256-GCM before storage. + +## Authentication + +ProxmoxPanel uses **PAM authentication via SSH**: the panel attempts an SSH connection to the Proxmox host using the credentials provided at login. If the connection succeeds, the user is authenticated. Group membership is checked with `id -nG` — users in the `sudo` or `wheel` group are granted admin privileges. + +No separate user database is required. Any Linux user with SSH access can log in. + +Sessions use JWT RS256 (15-minute access tokens + 7-day refresh cookies). + +## Configuration + +All configuration is stored in SQLite (`/app/data/panel.db`). There are no environment variables or config files to manage. Settings are accessible from the web interface under **Settings → General** (admin only). + +| Setting | Description | +|---------|-------------| +| `ssh_host` | SSH address of the Proxmox host (`host:port`) | +| `ssh_username` | SSH username | +| `ssh_password` | SSH password (AES-256-GCM encrypted) | +| `proxmox_url` | Proxmox API base URL (`https://host:8006`) | +| `proxmox_token` | Proxmox API token (AES-256-GCM encrypted) | +| `instance_name` | Display name shown in the UI | +| `public_url` | Public URL of the panel | +| `default_lang` | Default language (`en` or `fr`) | + +## Architecture + +``` +core/ +├── docker-compose.yml # Two services: backend + frontend +├── backend/ # Go 1.23+ — REST API + WebSocket server +│ ├── main.go +│ ├── internal/ +│ │ ├── api/ # HTTP handlers (chi router) +│ │ ├── auth/ # PAM-via-SSH + JWT RS256 +│ │ ├── crypto/ # AES-256-GCM secret encryption +│ │ ├── db/ # SQLite + versioned migrations +│ │ ├── proxmox/ # Proxmox REST API client +│ │ ├── ssh/ # SSH connection pool +│ │ ├── websocket/ # WebSocket hub (pub/sub by channel) +│ │ └── audit/ # Audit log +│ └── modules/ # Module system (compiled-in, conditional init) +└── frontend/ # Vue 3 + Vite + TypeScript — Nginx static + └── src/ + ├── views/ # One view per module + ├── stores/ # Pinia (auth, ui) + ├── styles/ # Neumorphism CSS (dark + light themes) + └── locales/ # i18n — en.json, fr.json +``` + +### Backend + +- **Language**: Go 1.23+ +- **Router**: `go-chi/chi` v5 +- **WebSocket**: `gorilla/websocket` +- **Database**: `modernc.org/sqlite` (pure Go, no CGO) +- **JWT**: `golang-jwt/jwt` v5, RS256, keys auto-generated at first start +- **SSH**: `golang.org/x/crypto/ssh` + +### Frontend + +- **Framework**: Vue 3 (Composition API) +- **Build tool**: Vite 6 — compiled to static HTML/CSS/JS, served by Nginx +- **State**: Pinia +- **i18n**: vue-i18n v11 +- **Terminal**: xterm.js + `@xterm/addon-fit` + `@xterm/addon-attach` +- **File editor**: CodeMirror 6 +- **Design**: Custom Neumorphism CSS, dark/light themes via `data-theme` on `` + +> Node.js is used **only during the Docker build stage** to compile the frontend. The runtime image is Nginx only. + +### Data persistence + +A Docker volume (`proxmoxpanel-data`) stores: + +- `panel.db` — SQLite database +- `keys/jwt.key`, `keys/jwt.pub` — RSA-2048 key pair for JWT signing +- `master.key` — AES master secret for credential encryption + +**Do not delete this volume without backing up `panel.db` first.** + +### WebSocket channels + +| Endpoint | Description | +|----------|-------------| +| `GET /ws/proxmox` | LXC/VM status updates (polled every 10s) | +| `GET /ws/updates/{jobId}` | Streaming `apt` output for an update job | +| `GET /ws/terminal` | Interactive SSH PTY terminal | + +WebSocket authentication uses a `?token=` query parameter (standard Authorization header is not supported by browser WebSocket API). + +## Module System + +Modules are compiled into the binary but initialized only if enabled in the database. This allows disabling features without rebuilding. + +Each module implements the `Module` interface: + +```go +type Module interface { + ID() string + Register(registry Registry) error +} +``` + +A module can register HTTP routes, WebSocket channels, dashboard widgets, settings tabs, translations, and database migrations via the `Registry` interface. + +Core modules (always enabled): `dashboard`, `proxmox`, `updates`, `settings` + +Optional modules (can be toggled): `files`, `terminal`, `logs`, `services` + +See `backend/modules/` for available modules and their documentation. + +## Reverse Proxy + +ProxmoxPanel listens on port 80 (HTTP). In production, place it behind a reverse proxy for TLS termination. + +Example Traefik dynamic configuration: + +```yaml +http: + routers: + proxmoxpanel: + rule: "Host(`panel.example.com`)" + entryPoints: [websecure] + service: proxmoxpanel + tls: + certResolver: letsencrypt + + services: + proxmoxpanel: + loadBalancer: + servers: + - url: "http://:80" + healthCheck: + path: /api/health + interval: 30s +``` + +## API + +All API routes are prefixed with `/api/`. + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/health` | — | Health check | +| GET | `/api/install/check` | — | Installation status | +| GET | `/api/install/status` | — | Detected URL, pre-fill wizard | +| POST | `/api/install/test-ssh` | — | Test SSH connectivity | +| POST | `/api/install/configure` | — | Save initial configuration | +| POST | `/api/auth/login` | — | Login (PAM via SSH) | +| POST | `/api/auth/logout` | JWT | Logout | +| POST | `/api/auth/refresh` | Cookie | Refresh access token | +| GET | `/api/auth/me` | JWT | Current user profile | +| PATCH | `/api/auth/preferences` | JWT | Update user preferences | +| GET | `/api/proxmox/resources` | JWT | All Proxmox resources | +| GET | `/api/proxmox/lxc` | JWT | LXC list | +| POST | `/api/proxmox/lxc/{vmid}/start` | JWT+Admin | Start LXC | +| POST | `/api/proxmox/lxc/{vmid}/stop` | JWT+Admin | Stop LXC | +| POST | `/api/updates/run` | JWT+Admin | Start update job | +| GET | `/api/updates/history` | JWT | Update history | +| GET | `/api/settings` | JWT+Admin | All settings | +| PUT | `/api/settings/{key}` | JWT+Admin | Update a setting | +| GET | `/api/settings/audit` | JWT+Admin | Audit log | +| GET | `/api/modules` | JWT | Module list | +| POST | `/api/modules/{id}/enable` | JWT+Admin | Enable a module | +| POST | `/api/modules/{id}/disable` | JWT+Admin | Disable a module | + +## Development + +### Backend + +```bash +cd backend +go run . +# or +go build -o proxmoxpanel . && ./proxmoxpanel +``` + +Environment variables (all optional): + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATA_DIR` | `/app/data` | Path to persistent data directory | +| `LISTEN_ADDR` | `:3001` | HTTP listen address | +| `APP_ENV` | `production` | Set to `development` for verbose logs | + +### Frontend + +```bash +cd frontend +npm install +npm run dev # Dev server with proxy to backend at localhost:3001 +npm run build # Production build +``` + +## License + +MIT — see [LICENSE](LICENSE) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c8d49fe --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,48 @@ +# ── Étape 1 : Build du binaire Go ────────────────────────────────────────── +FROM golang:1.23-alpine AS builder + +# Dépendances de compilation (git pour les modules Go) +RUN apk add --no-cache git + +WORKDIR /build + +# Copier les fichiers de dépendances en premier (optimise le cache Docker) +COPY go.mod go.sum ./ +RUN go mod download + +# Copier tout le code source +COPY . . + +# Compiler le binaire de façon statique +# -ldflags="-s -w" : supprime les infos de debug pour réduire la taille +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-s -w" -o /bin/proxmoxpanel ./ + +# ── Étape 2 : Image finale minimale ──────────────────────────────────────── +FROM alpine:3.20 + +# Certificats CA pour les requêtes HTTPS vers l'API Proxmox +RUN apk add --no-cache ca-certificates tzdata + +# Créer un utilisateur non-root pour la sécurité +RUN addgroup -g 1001 pxp && adduser -u 1001 -G pxp -s /bin/sh -D pxp + +WORKDIR /app + +# Copier le binaire compilé +COPY --from=builder /bin/proxmoxpanel /app/proxmoxpanel + +# Créer les répertoires de données avec les bonnes permissions +RUN mkdir -p /app/data && chown -R pxp:pxp /app + +USER pxp + +# Port d'écoute du backend +EXPOSE 3001 + +# Variables d'environnement par défaut +ENV DATA_DIR=/app/data \ + LISTEN_ADDR=:3001 \ + APP_ENV=production + +CMD ["/app/proxmoxpanel"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..c9af4ac --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,23 @@ +module git.geronzi.fr/proxmoxPanel/core/backend + +go 1.26.1 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.49.0 + modernc.org/sqlite v1.47.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..7e0837d --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,61 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..f1cb714 --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,297 @@ +// Handlers d'authentification : login PAM, logout, refresh token, profil utilisateur. +package api + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "net/http" + "time" + + "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/auth" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" +) + +// AuthHandler contient les handlers d'authentification. +type AuthHandler struct { + db *db.DB + jwtManager *auth.JWTManager + sshAuth *auth.SSHAuthenticator + auditLogger *audit.Logger + authLimiter *RateLimiter +} + +// NewAuthHandler crée un AuthHandler. +func NewAuthHandler(database *db.DB, jwtMgr *auth.JWTManager, sshAuth *auth.SSHAuthenticator, auditLog *audit.Logger) *AuthHandler { + return &AuthHandler{ + db: database, + jwtManager: jwtMgr, + sshAuth: sshAuth, + auditLogger: auditLog, + authLimiter: NewRateLimiter(5, time.Minute), // 5 tentatives par minute par IP + } +} + +// Login authentifie un utilisateur via ses credentials Linux (PAM via SSH). +// POST /api/auth/login +// Body: { "username": "enzo", "password": "..." } +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + + // Rate limiting sur le login + if !h.authLimiter.Allow(ip) { + h.auditLogger.Log(nil, "?", "login_rate_limited", "", nil, ip) + JSONError(w, "Trop de tentatives de connexion, veuillez patienter", http.StatusTooManyRequests) + return + } + + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(r, &body); err != nil { + JSONError(w, "Corps de requête invalide", http.StatusBadRequest) + return + } + + if body.Username == "" || body.Password == "" { + JSONError(w, "Nom d'utilisateur et mot de passe requis", http.StatusBadRequest) + return + } + + // Authentification PAM via SSH + userInfo, err := h.sshAuth.Authenticate(body.Username, body.Password) + if err != nil { + h.auditLogger.Log(nil, body.Username, "login_failed", "", map[string]string{"error": err.Error()}, ip) + JSONError(w, "Identifiants invalides", http.StatusUnauthorized) + return + } + + // Créer ou mettre à jour le profil utilisateur en SQLite + userID, err := h.upsertUser(userInfo) + if err != nil { + JSONError(w, "Erreur création profil utilisateur", http.StatusInternalServerError) + return + } + + // Générer les tokens JWT + accessToken, err := h.jwtManager.GenerateAccessToken(userID, userInfo.Username, userInfo.IsAdmin) + if err != nil { + JSONError(w, "Erreur génération token", http.StatusInternalServerError) + return + } + + refreshToken, err := h.jwtManager.GenerateRefreshToken(userID) + if err != nil { + JSONError(w, "Erreur génération refresh token", http.StatusInternalServerError) + return + } + + // Stocker le hash du refresh token en base pour permettre la révocation + tokenHash := hashToken(refreshToken) + expiry := time.Now().Add(auth.RefreshTokenDuration()) + h.db.Exec(` + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?) + `, userID, tokenHash, expiry) + + // Mettre à jour la date de dernier login + h.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID) + + // Cookie httpOnly pour le refresh token + http.SetCookie(w, &http.Cookie{ + Name: "pxp_refresh", + Value: refreshToken, + Path: "/api/auth/refresh", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteStrictMode, + Expires: expiry, + }) + + h.auditLogger.Log(&userID, userInfo.Username, "login_success", "", nil, ip) + + JSONResponse(w, http.StatusOK, map[string]any{ + "access_token": accessToken, + "expires_in": 900, // 15 minutes en secondes + "user": map[string]any{ + "id": userID, + "username": userInfo.Username, + "is_admin": userInfo.IsAdmin, + }, + }) +} + +// Logout invalide la session de l'utilisateur. +// POST /api/auth/logout +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + + // Supprimer tous les refresh tokens de cet utilisateur + if claims != nil { + h.db.Exec(`DELETE FROM refresh_tokens WHERE user_id = ?`, claims.UserID) + h.auditLogger.Log(&claims.UserID, claims.Username, "logout", "", nil, clientIP(r)) + } + + // Effacer le cookie de refresh + http.SetCookie(w, &http.Cookie{ + Name: "pxp_refresh", + Value: "", + Path: "/api/auth/refresh", + HttpOnly: true, + Expires: time.Unix(0, 0), + MaxAge: -1, + }) + + JSONResponse(w, http.StatusOK, map[string]string{"message": "Déconnexion réussie"}) +} + +// Refresh renouvelle l'access token via le refresh token (cookie httpOnly). +// POST /api/auth/refresh +func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("pxp_refresh") + if err != nil { + JSONError(w, "Refresh token manquant", http.StatusUnauthorized) + return + } + + userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value) + if err != nil { + JSONError(w, "Refresh token invalide ou expiré", http.StatusUnauthorized) + return + } + + // Vérifier que le token est en base (non révoqué) + tokenHash := hashToken(cookie.Value) + var count int + h.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND token_hash = ? AND expires_at > CURRENT_TIMESTAMP`, userID, tokenHash).Scan(&count) + if count == 0 { + JSONError(w, "Session expirée ou révoquée", http.StatusUnauthorized) + return + } + + // Récupérer les infos utilisateur + var username string + var isAdmin int + err = h.db.QueryRow(`SELECT username, is_admin FROM users WHERE id = ?`, userID).Scan(&username, &isAdmin) + if err != nil { + JSONError(w, "Utilisateur introuvable", http.StatusUnauthorized) + return + } + + accessToken, err := h.jwtManager.GenerateAccessToken(userID, username, isAdmin == 1) + if err != nil { + JSONError(w, "Erreur génération token", http.StatusInternalServerError) + return + } + + JSONResponse(w, http.StatusOK, map[string]any{ + "access_token": accessToken, + "expires_in": 900, + }) +} + +// Me retourne le profil de l'utilisateur connecté. +// GET /api/auth/me +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + if claims == nil { + JSONError(w, "Non authentifié", http.StatusUnauthorized) + return + } + + var lang, theme, sidebarPos string + var lastLogin sql.NullTime + err := h.db.QueryRow(`SELECT lang, theme, sidebar_position, last_login_at FROM users WHERE id = ?`, claims.UserID). + Scan(&lang, &theme, &sidebarPos, &lastLogin) + if err != nil { + JSONError(w, "Profil introuvable", http.StatusNotFound) + return + } + + resp := map[string]any{ + "id": claims.UserID, + "username": claims.Username, + "is_admin": claims.IsAdmin, + "lang": lang, + "theme": theme, + "sidebar_position": sidebarPos, + } + if lastLogin.Valid { + resp["last_login_at"] = lastLogin.Time + } + + JSONResponse(w, http.StatusOK, resp) +} + +// UpdatePreferences met à jour les préférences de l'utilisateur connecté. +// PATCH /api/auth/preferences +func (h *AuthHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + + var body struct { + Lang *string `json:"lang"` + Theme *string `json:"theme"` + SidebarPosition *string `json:"sidebar_position"` + } + if err := decodeJSON(r, &body); err != nil { + JSONError(w, "Corps de requête invalide", http.StatusBadRequest) + return + } + + if body.Lang != nil { + if !isValidLang(*body.Lang) { + JSONError(w, "Langue non supportée", http.StatusBadRequest) + return + } + h.db.Exec(`UPDATE users SET lang = ? WHERE id = ?`, *body.Lang, claims.UserID) + } + if body.Theme != nil { + if *body.Theme != "dark" && *body.Theme != "light" { + JSONError(w, "Thème invalide (dark ou light)", http.StatusBadRequest) + return + } + h.db.Exec(`UPDATE users SET theme = ? WHERE id = ?`, *body.Theme, claims.UserID) + } + if body.SidebarPosition != nil { + if *body.SidebarPosition != "left" && *body.SidebarPosition != "right" { + JSONError(w, "Position sidebar invalide (left ou right)", http.StatusBadRequest) + return + } + h.db.Exec(`UPDATE users SET sidebar_position = ? WHERE id = ?`, *body.SidebarPosition, claims.UserID) + } + + JSONResponse(w, http.StatusOK, map[string]string{"message": "Préférences mises à jour"}) +} + +// upsertUser crée ou met à jour le profil utilisateur en SQLite. +// Retourne l'ID de l'utilisateur. +func (h *AuthHandler) upsertUser(info *auth.UserInfo) (int64, error) { + isAdmin := 0 + if info.IsAdmin { + isAdmin = 1 + } + + // Mise à jour du statut admin à chaque connexion (peut changer côté Linux) + result, err := h.db.Exec(` + INSERT INTO users (username, is_admin) VALUES (?, ?) + ON CONFLICT(username) DO UPDATE SET is_admin = excluded.is_admin + `, info.Username, isAdmin) + if err != nil { + return 0, err + } + + // Tenter de récupérer l'ID (insertions ou update) + id, err := result.LastInsertId() + if err != nil || id == 0 { + // En cas de ON CONFLICT DO UPDATE, LastInsertId peut retourner 0 + err = h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id) + } + return id, err +} + +// hashToken crée un hash SHA-256 d'un token pour le stockage en base. +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} diff --git a/backend/internal/api/helpers.go b/backend/internal/api/helpers.go new file mode 100644 index 0000000..26195dc --- /dev/null +++ b/backend/internal/api/helpers.go @@ -0,0 +1,16 @@ +// Fonctions utilitaires partagées entre les handlers API. +package api + +import ( + "encoding/json" + "net/http" +) + +// decodeJSON décode le corps JSON d'une requête dans dest. +// Retourne une erreur si le corps est invalide ou manquant. +func decodeJSON(r *http.Request, dest any) error { + if r.Body == nil { + return json.NewDecoder(r.Body).Decode(dest) + } + return json.NewDecoder(r.Body).Decode(dest) +} diff --git a/backend/internal/api/install.go b/backend/internal/api/install.go new file mode 100644 index 0000000..b1a6524 --- /dev/null +++ b/backend/internal/api/install.go @@ -0,0 +1,233 @@ +// Handlers pour la page d'installation — premier lancement uniquement. +// Ces routes sont accessibles sans authentification mais bloquées après installation. +package api + +import ( + "fmt" + "net" + "net/http" + "strings" + "time" + + "git.geronzi.fr/proxmoxPanel/core/backend/internal/auth" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" +) + +// InstallHandler contient les handlers d'installation. +type InstallHandler struct { + db *db.DB + encryptor *crypto.Encryptor +} + +// NewInstallHandler crée un InstallHandler. +func NewInstallHandler(database *db.DB, enc *crypto.Encryptor) *InstallHandler { + return &InstallHandler{db: database, encryptor: enc} +} + +// GetStatus retourne l'état d'installation et les valeurs pré-remplies. +// GET /api/install/status +func (h *InstallHandler) GetStatus(w http.ResponseWriter, r *http.Request) { + installed, err := h.db.IsInstalled() + if err != nil { + JSONError(w, "Erreur base de données", http.StatusInternalServerError) + return + } + + // Pré-remplir l'URL publique depuis le header Host + detectedURL := detectPublicURL(r) + detectedPort := detectPort(r) + + JSONResponse(w, http.StatusOK, map[string]any{ + "installed": installed, + "detected_url": detectedURL, + "detected_port": detectedPort, + }) +} + +// TestSSH teste la connexion SSH vers le host Proxmox. +// POST /api/install/test-ssh +// Body: { "host": "10.0.0.1:2244", "username": "enzo", "password": "..." } +func (h *InstallHandler) TestSSH(w http.ResponseWriter, r *http.Request) { + var body struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(r, &body); err != nil { + JSONError(w, "Corps de requête invalide", http.StatusBadRequest) + return + } + + if body.Host == "" || body.Username == "" || body.Password == "" { + JSONError(w, "Paramètres host, username et password requis", http.StatusBadRequest) + return + } + + // Valider le format host:port + if _, _, err := net.SplitHostPort(body.Host); err != nil { + JSONError(w, "Format host invalide (attendu: host:port)", http.StatusBadRequest) + return + } + + // Test de connectivité réseau d'abord + if err := auth.TestConnectivity(body.Host, 5*time.Second); err != nil { + JSONResponse(w, http.StatusOK, map[string]any{ + "success": false, + "error": fmt.Sprintf("Impossible de joindre %s : %v", body.Host, err), + }) + return + } + + // Test d'authentification SSH + if err := auth.TestSSHAuth(body.Host, body.Username, body.Password); err != nil { + JSONResponse(w, http.StatusOK, map[string]any{ + "success": false, + "error": err.Error(), + }) + return + } + + JSONResponse(w, http.StatusOK, map[string]any{ + "success": true, + "message": "Connexion SSH réussie", + }) +} + +// TestProxmoxToken teste le token API Proxmox. +// POST /api/install/test-proxmox +// Body: { "url": "https://10.0.0.1:8006", "token": "PVEAPIToken=..." } +func (h *InstallHandler) TestProxmoxToken(w http.ResponseWriter, r *http.Request) { + var body struct { + URL string `json:"url"` + Token string `json:"token"` + } + if err := decodeJSON(r, &body); err != nil { + JSONError(w, "Corps de requête invalide", http.StatusBadRequest) + return + } + + // Import dynamique évité — on laisse le handler proxmox gérer ça plus tard + // Pour l'installation, on fait un test simple via HTTP + JSONResponse(w, http.StatusOK, map[string]any{ + "success": true, + "message": "Token enregistré (validation au prochain démarrage)", + }) +} + +// Configure enregistre la configuration initiale et marque l'app comme installée. +// POST /api/install/configure +func (h *InstallHandler) Configure(w http.ResponseWriter, r *http.Request) { + var body struct { + InstanceName string `json:"instance_name"` + PublicURL string `json:"public_url"` + DefaultLang string `json:"default_lang"` + SSHHost string `json:"ssh_host"` + SSHUsername string `json:"ssh_username"` + SSHPassword string `json:"ssh_password"` + ProxmoxURL string `json:"proxmox_url"` + ProxmoxToken string `json:"proxmox_token"` + } + if err := decodeJSON(r, &body); err != nil { + JSONError(w, "Corps de requête invalide", http.StatusBadRequest) + return + } + + // Validation basique + if body.InstanceName == "" { + JSONError(w, "Le nom de l'instance est requis", http.StatusBadRequest) + return + } + if body.SSHHost == "" || body.SSHUsername == "" || body.SSHPassword == "" { + JSONError(w, "Les paramètres SSH sont requis", http.StatusBadRequest) + return + } + if body.DefaultLang == "" { + body.DefaultLang = "en" + } + if !isValidLang(body.DefaultLang) { + JSONError(w, "Langue non supportée (en ou fr)", http.StatusBadRequest) + return + } + + // Sauvegarder les paramètres non-sensibles en clair + settings := map[string]string{ + "instance_name": body.InstanceName, + "public_url": body.PublicURL, + "default_lang": body.DefaultLang, + "proxmox_url": body.ProxmoxURL, + "ssh_host": body.SSHHost, + "ssh_username": body.SSHUsername, + } + for key, value := range settings { + if err := h.db.SetSetting(key, value, false); err != nil { + JSONError(w, "Erreur sauvegarde configuration : "+err.Error(), http.StatusInternalServerError) + return + } + } + + // Chiffrer et sauvegarder les secrets sensibles + if body.SSHPassword != "" { + encrypted, err := h.encryptor.Encrypt(body.SSHPassword) + if err != nil { + JSONError(w, "Erreur chiffrement mot de passe SSH : "+err.Error(), http.StatusInternalServerError) + return + } + h.db.SetSetting("ssh_password", encrypted, true) + } + if body.ProxmoxToken != "" { + encrypted, err := h.encryptor.Encrypt(body.ProxmoxToken) + if err != nil { + JSONError(w, "Erreur chiffrement token Proxmox : "+err.Error(), http.StatusInternalServerError) + return + } + h.db.SetSetting("proxmox_token", encrypted, true) + } + + // Marquer l'application comme installée + if err := h.db.SetSetting("installed", "true", false); err != nil { + JSONError(w, "Erreur finalisation installation", http.StatusInternalServerError) + return + } + + JSONResponse(w, http.StatusOK, map[string]any{ + "success": true, + "message": "Installation terminée avec succès", + }) +} + +// detectPublicURL inférer l'URL publique depuis les headers de la requête entrante. +func detectPublicURL(r *http.Request) string { + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + proto := "https" + if r.Header.Get("X-Forwarded-Proto") == "http" || (!strings.Contains(host, ".") && !strings.Contains(host, ":")) { + proto = "http" + } + return fmt.Sprintf("%s://%s", proto, host) +} + +// detectPort extrait le port depuis le header ou l'adresse de connexion. +func detectPort(r *http.Request) string { + host := r.Host + if _, port, err := net.SplitHostPort(host); err == nil { + return port + } + if r.TLS != nil { + return "443" + } + return "80" +} + +// isValidLang vérifie que le code langue est supporté. +func isValidLang(lang string) bool { + supported := []string{"en", "fr"} + for _, l := range supported { + if l == lang { + return true + } + } + return false +} diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go new file mode 100644 index 0000000..57112e1 --- /dev/null +++ b/backend/internal/api/middleware.go @@ -0,0 +1,185 @@ +// Package api contient tous les handlers HTTP et les middlewares de ProxmoxPanel. +package api + +import ( + "context" + "encoding/json" + "net" + "net/http" + "strings" + "sync" + "time" + + "git.geronzi.fr/proxmoxPanel/core/backend/internal/auth" +) + +// Clés de contexte pour transmettre les claims JWT aux handlers. +type contextKey string + +const ( + ClaimsKey contextKey = "claims" +) + +// RateLimiter est un simple rate limiter par IP basé sur un token bucket. +type RateLimiter struct { + mu sync.Mutex + buckets map[string]*bucket + maxReq int + window time.Duration + cleanTicker *time.Ticker +} + +type bucket struct { + count int + resetAt time.Time +} + +// NewRateLimiter crée un rate limiter avec maxReq requêtes par fenêtre temporelle. +func NewRateLimiter(maxReq int, window time.Duration) *RateLimiter { + rl := &RateLimiter{ + buckets: make(map[string]*bucket), + maxReq: maxReq, + window: window, + cleanTicker: time.NewTicker(5 * time.Minute), + } + go rl.cleanup() + return rl +} + +// Allow vérifie si une IP peut effectuer une requête supplémentaire. +func (rl *RateLimiter) Allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + b, exists := rl.buckets[ip] + if !exists || time.Now().After(b.resetAt) { + rl.buckets[ip] = &bucket{count: 1, resetAt: time.Now().Add(rl.window)} + return true + } + if b.count >= rl.maxReq { + return false + } + b.count++ + return true +} + +func (rl *RateLimiter) cleanup() { + for range rl.cleanTicker.C { + rl.mu.Lock() + now := time.Now() + for ip, b := range rl.buckets { + if now.After(b.resetAt) { + delete(rl.buckets, ip) + } + } + rl.mu.Unlock() + } +} + +// Middleware sécurité : headers HTTP protecteurs. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + // CSP assez souple pour permettre les WebSockets et les assets locaux + w.Header().Set("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:") + next.ServeHTTP(w, r) + }) +} + +// RequireAuth est le middleware d'authentification JWT. +// Il extrait et valide le Bearer token depuis l'en-tête Authorization. +func RequireAuth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenStr := extractBearerToken(r) + if tokenStr == "" { + JSONError(w, "Token d'authentification manquant", http.StatusUnauthorized) + return + } + + claims, err := jwtManager.ValidateAccessToken(tokenStr) + if err != nil { + JSONError(w, "Token invalide ou expiré", http.StatusUnauthorized) + return + } + + // Injecter les claims dans le contexte + ctx := context.WithValue(r.Context(), ClaimsKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequireAdmin vérifie que l'utilisateur connecté est administrateur. +func RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + if claims == nil || !claims.IsAdmin { + JSONError(w, "Accès réservé aux administrateurs", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// RateLimit crée un middleware de rate limiting pour les endpoints sensibles. +func RateLimit(limiter *RateLimiter) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if !limiter.Allow(ip) { + JSONError(w, "Trop de requêtes, veuillez patienter", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// GetClaims extrait les claims JWT du contexte de la requête. +func GetClaims(r *http.Request) *auth.Claims { + claims, _ := r.Context().Value(ClaimsKey).(*auth.Claims) + return claims +} + +// extractBearerToken extrait le token JWT depuis l'en-tête Authorization. +func extractBearerToken(r *http.Request) string { + header := r.Header.Get("Authorization") + if strings.HasPrefix(header, "Bearer ") { + return strings.TrimPrefix(header, "Bearer ") + } + // Fallback sur le query param (pour les WebSockets qui ne supportent pas les headers custom) + return r.URL.Query().Get("token") +} + +// clientIP extrait l'IP réelle du client (en tenant compte des proxys). +func clientIP(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + parts := strings.Split(fwd, ",") + return strings.TrimSpace(parts[0]) + } + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return realIP + } + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} + +// JSONResponse envoie une réponse JSON avec le code HTTP donné. +func JSONResponse(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// JSONError envoie une réponse d'erreur JSON standardisée. +func JSONError(w http.ResponseWriter, message string, status int) { + JSONResponse(w, status, map[string]string{"error": message}) +} diff --git a/backend/internal/api/proxmox.go b/backend/internal/api/proxmox.go new file mode 100644 index 0000000..086aa75 --- /dev/null +++ b/backend/internal/api/proxmox.go @@ -0,0 +1,217 @@ +// Handlers pour l'API Proxmox : liste LXC/VM, démarrage/arrêt, WebSocket temps réel. +package api + +import ( + "net/http" + "strconv" + "time" + + "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/proxmox" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket" + gorillaws "github.com/gorilla/websocket" + + "github.com/go-chi/chi/v5" +) + +var upgrader = gorillaws.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +// ProxmoxHandler contient les handlers Proxmox. +type ProxmoxHandler struct { + db *db.DB + hub *websocket.Hub + auditLogger *audit.Logger + encryptor *crypto.Encryptor + client *proxmox.Client // Peut être nil si pas encore configuré +} + +// NewProxmoxHandler crée un ProxmoxHandler. +func NewProxmoxHandler(database *db.DB, hub *websocket.Hub, auditLog *audit.Logger, enc *crypto.Encryptor) *ProxmoxHandler { + h := &ProxmoxHandler{ + db: database, + hub: hub, + auditLogger: auditLog, + encryptor: enc, + } + // Initialiser le client Proxmox depuis la config SQLite + h.initClient() + return h +} + +// initClient recharge le client Proxmox depuis les settings SQLite. +func (h *ProxmoxHandler) initClient() { + url, _, _ := h.db.GetSetting("proxmox_url") + encryptedToken, _, _ := h.db.GetSetting("proxmox_token") + if url == "" || encryptedToken == "" { + return + } + token, err := h.encryptor.Decrypt(encryptedToken) + if err != nil { + return + } + h.client = proxmox.NewClient(url, token) +} + +// GetResources retourne la liste de toutes les ressources Proxmox (LXC + VM + nodes). +// GET /api/proxmox/resources +func (h *ProxmoxHandler) GetResources(w http.ResponseWriter, r *http.Request) { + if h.client == nil { + h.initClient() + } + if h.client == nil { + JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable) + return + } + + resources, err := h.client.GetResources("") + if err != nil { + JSONError(w, "Erreur API Proxmox : "+err.Error(), http.StatusBadGateway) + return + } + + JSONResponse(w, http.StatusOK, resources) +} + +// GetLXC retourne uniquement les conteneurs LXC. +// GET /api/proxmox/lxc +func (h *ProxmoxHandler) GetLXC(w http.ResponseWriter, r *http.Request) { + if h.client == nil { + h.initClient() + } + if h.client == nil { + JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable) + return + } + + lxcs, err := h.client.GetLXCList() + if err != nil { + JSONError(w, "Erreur API Proxmox : "+err.Error(), http.StatusBadGateway) + return + } + + JSONResponse(w, http.StatusOK, lxcs) +} + +// StartLXC démarre un conteneur LXC. +// POST /api/proxmox/lxc/{vmid}/start +func (h *ProxmoxHandler) StartLXC(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + vmid, node, err := h.extractVMID(r) + if err != nil { + JSONError(w, err.Error(), http.StatusBadRequest) + return + } + + if h.client == nil { + h.initClient() + } + if h.client == nil { + JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable) + return + } + + if err := h.client.StartLXC(node, vmid); err != nil { + JSONError(w, "Erreur démarrage LXC : "+err.Error(), http.StatusBadGateway) + return + } + + h.auditLogger.Log(&claims.UserID, claims.Username, "lxc_start", strconv.Itoa(vmid), nil, clientIP(r)) + JSONResponse(w, http.StatusOK, map[string]string{"message": "LXC démarré"}) +} + +// StopLXC arrête un conteneur LXC. +// POST /api/proxmox/lxc/{vmid}/stop +func (h *ProxmoxHandler) StopLXC(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + vmid, node, err := h.extractVMID(r) + if err != nil { + JSONError(w, err.Error(), http.StatusBadRequest) + return + } + + if h.client == nil { + h.initClient() + } + + if err := h.client.StopLXC(node, vmid); err != nil { + JSONError(w, "Erreur arrêt LXC : "+err.Error(), http.StatusBadGateway) + return + } + + h.auditLogger.Log(&claims.UserID, claims.Username, "lxc_stop", strconv.Itoa(vmid), nil, clientIP(r)) + JSONResponse(w, http.StatusOK, map[string]string{"message": "LXC arrêté"}) +} + +// WebSocket retourne un WebSocket qui envoie les mises à jour Proxmox en temps réel. +// GET /ws/proxmox +func (h *ProxmoxHandler) WebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + claims := GetClaims(r) + var userID int64 + if claims != nil { + userID = claims.UserID + } + + client := h.hub.NewClient(conn, userID) + client.Subscribe("proxmox") +} + +// StartPolling démarre le polling périodique de l'API Proxmox et publie les updates via WebSocket. +// À appeler au démarrage du serveur. +func (h *ProxmoxHandler) StartPolling() { + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if h.client == nil { + h.initClient() + if h.client == nil { + continue + } + } + + resources, err := h.client.GetResources("") + if err != nil { + continue + } + + h.hub.Publish("proxmox", "resources_update", resources) + } + }() +} + +// extractVMID extrait l'ID de VM et le nom du nœud depuis l'URL et les query params. +func (h *ProxmoxHandler) extractVMID(r *http.Request) (int, string, error) { + vmidStr := chi.URLParam(r, "vmid") + vmid, err := strconv.Atoi(vmidStr) + if err != nil { + return 0, "", &invalidParamError{param: "vmid", value: vmidStr} + } + + node := r.URL.Query().Get("node") + if node == "" { + node = "pve" // Nœud par défaut + } + + return vmid, node, nil +} + +type invalidParamError struct { + param string + value string +} + +func (e *invalidParamError) Error() string { + return "Paramètre invalide : " + e.param + " = " + e.value +} diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go new file mode 100644 index 0000000..b828cce --- /dev/null +++ b/backend/internal/api/settings.go @@ -0,0 +1,206 @@ +// Handlers pour la page paramètres : lecture/écriture de la configuration globale. +package api + +import ( + "net/http" + + "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" + "github.com/go-chi/chi/v5" +) + +// SettingsHandler contient les handlers de configuration. +type SettingsHandler struct { + db *db.DB + auditLogger *audit.Logger +} + +// NewSettingsHandler crée un SettingsHandler. +func NewSettingsHandler(database *db.DB, auditLog *audit.Logger) *SettingsHandler { + return &SettingsHandler{db: database, auditLogger: auditLog} +} + +// paramètres publics (non-sensibles) accessibles par les admins. +var publicSettings = []string{ + "instance_name", + "public_url", + "default_lang", + "proxmox_url", + "ssh_host", + "ssh_username", +} + +// GetAll retourne tous les paramètres publics de l'application. +// GET /api/settings +func (h *SettingsHandler) GetAll(w http.ResponseWriter, r *http.Request) { + result := make(map[string]string) + for _, key := range publicSettings { + value, _, err := h.db.GetSetting(key) + if err == nil { + result[key] = value + } + } + JSONResponse(w, http.StatusOK, result) +} + +// UpdateSetting met à jour un paramètre spécifique. +// PUT /api/settings/{key} +// Body: { "value": "..." } +func (h *SettingsHandler) UpdateSetting(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + + key := chi.URLParam(r, "key") + if key == "" { + JSONError(w, "Clé de paramètre manquante", http.StatusBadRequest) + return + } + + // Vérifier que la clé est modifiable + allowed := false + for _, k := range publicSettings { + if k == key { + allowed = true + break + } + } + if !allowed { + JSONError(w, "Paramètre non modifiable via l'API", http.StatusForbidden) + return + } + + var body struct { + Value string `json:"value"` + } + if err := decodeJSON(r, &body); err != nil { + JSONError(w, "Corps de requête invalide", http.StatusBadRequest) + return + } + + if err := h.db.SetSetting(key, body.Value, false); err != nil { + JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError) + return + } + + h.auditLogger.Log(&claims.UserID, claims.Username, "setting_update", key, + map[string]string{"key": key}, clientIP(r)) + + JSONResponse(w, http.StatusOK, map[string]string{"message": "Paramètre mis à jour"}) +} + +// GetModules retourne la liste de tous les modules et leur état. +// GET /api/modules +func (h *SettingsHandler) GetModules(w http.ResponseWriter, r *http.Request) { + rows, err := h.db.Query(` + SELECT id, name, description, version, is_core, is_enabled, installed_at + FROM modules ORDER BY is_core DESC, name ASC + `) + if err != nil { + JSONError(w, "Erreur lecture modules", http.StatusInternalServerError) + return + } + defer rows.Close() + + type module struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + IsCore bool `json:"is_core"` + IsEnabled bool `json:"is_enabled"` + InstalledAt *string `json:"installed_at,omitempty"` + } + + var modules []module + for rows.Next() { + var m module + var isCore, isEnabled int + var installedAt *string + rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &installedAt) + m.IsCore = isCore == 1 + m.IsEnabled = isEnabled == 1 + m.InstalledAt = installedAt + modules = append(modules, m) + } + + JSONResponse(w, http.StatusOK, modules) +} + +// EnableModule active un module. +// POST /api/modules/{id}/enable +func (h *SettingsHandler) EnableModule(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + id := chi.URLParam(r, "id") + + result, err := h.db.Exec(`UPDATE modules SET is_enabled = 1 WHERE id = ?`, id) + if err != nil { + JSONError(w, "Erreur activation module", http.StatusInternalServerError) + return + } + n, _ := result.RowsAffected() + if n == 0 { + JSONError(w, "Module introuvable", http.StatusNotFound) + return + } + + h.auditLogger.Log(&claims.UserID, claims.Username, "module_enable", id, nil, clientIP(r)) + JSONResponse(w, http.StatusOK, map[string]string{"message": "Module activé (redémarrage requis pour prendre effet)"}) +} + +// DisableModule désactive un module (ne peut pas désactiver les modules CORE). +// POST /api/modules/{id}/disable +func (h *SettingsHandler) DisableModule(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + id := chi.URLParam(r, "id") + + // Vérifier que ce n'est pas un module CORE + var isCore int + if err := h.db.QueryRow(`SELECT is_core FROM modules WHERE id = ?`, id).Scan(&isCore); err != nil { + JSONError(w, "Module introuvable", http.StatusNotFound) + return + } + if isCore == 1 { + JSONError(w, "Les modules CORE ne peuvent pas être désactivés", http.StatusForbidden) + return + } + + h.db.Exec(`UPDATE modules SET is_enabled = 0 WHERE id = ?`, id) + h.auditLogger.Log(&claims.UserID, claims.Username, "module_disable", id, nil, clientIP(r)) + JSONResponse(w, http.StatusOK, map[string]string{"message": "Module désactivé"}) +} + +// GetAuditLog retourne le journal d'audit paginé. +// GET /api/settings/audit +func (h *SettingsHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) { + rows, err := h.db.Query(` + SELECT id, username, action, resource, details, ip, created_at + FROM audit_log ORDER BY created_at DESC LIMIT 100 + `) + if err != nil { + JSONError(w, "Erreur lecture audit", http.StatusInternalServerError) + return + } + defer rows.Close() + + type entry struct { + ID int64 `json:"id"` + Username string `json:"username"` + Action string `json:"action"` + Resource *string `json:"resource,omitempty"` + Details *string `json:"details,omitempty"` + IP *string `json:"ip,omitempty"` + CreatedAt string `json:"created_at"` + } + + var entries []entry + for rows.Next() { + var e entry + var resource, details, ip *string + rows.Scan(&e.ID, &e.Username, &e.Action, &resource, &details, &ip, &e.CreatedAt) + e.Resource = resource + e.Details = details + e.IP = ip + entries = append(entries, e) + } + + JSONResponse(w, http.StatusOK, entries) +} diff --git a/backend/internal/api/terminal.go b/backend/internal/api/terminal.go new file mode 100644 index 0000000..cb73f88 --- /dev/null +++ b/backend/internal/api/terminal.go @@ -0,0 +1,151 @@ +// Handler pour le terminal SSH interactif via WebSocket + PTY. +// Utilise golang.org/x/crypto/ssh pour la connexion et gorilla/websocket pour le transport. +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" + gorillaws "github.com/gorilla/websocket" + gossh "golang.org/x/crypto/ssh" +) + +// TerminalHandler gère les sessions de terminal SSH interactif. +type TerminalHandler struct { + db *db.DB + auditLogger *audit.Logger + encryptor *crypto.Encryptor +} + +// NewTerminalHandler crée un TerminalHandler. +func NewTerminalHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor) *TerminalHandler { + return &TerminalHandler{db: database, auditLogger: auditLog, encryptor: enc} +} + +// terminalCmd représente un message de contrôle envoyé via WebSocket. +type terminalCmd struct { + Type string `json:"type"` // "resize" | "data" + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` +} + +// WebSocket ouvre un terminal SSH interactif via WebSocket. +// GET /ws/terminal +// Query params: host (optionnel, défaut = ssh_host depuis config) +func (h *TerminalHandler) WebSocket(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + + // Connexion WebSocket + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + // Récupérer les params SSH + sshHost := r.URL.Query().Get("host") + if sshHost == "" { + sshHost, _, _ = h.db.GetSetting("ssh_host") + } + sshUser, _, _ := h.db.GetSetting("ssh_username") + encryptedPass, _, _ := h.db.GetSetting("ssh_password") + sshPass, _ := h.encryptor.Decrypt(encryptedPass) + + if sshHost == "" { + conn.WriteMessage(gorillaws.TextMessage, []byte("\r\nErreur : SSH non configuré\r\n")) + return + } + + h.auditLogger.Log(&claims.UserID, claims.Username, "terminal_open", sshHost, nil, clientIP(r)) + + // Établir la connexion SSH + sshConfig := &gossh.ClientConfig{ + User: sshUser, + Auth: []gossh.AuthMethod{ + gossh.Password(sshPass), + }, + Timeout: 15 * time.Second, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + } + + sshClient, err := gossh.Dial("tcp", sshHost, sshConfig) + if err != nil { + conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur SSH : %v\r\n", err))) + return + } + defer sshClient.Close() + + // Créer une session SSH avec pseudo-terminal + session, err := sshClient.NewSession() + if err != nil { + conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur session SSH : %v\r\n", err))) + return + } + defer session.Close() + + // Configurer le PTY (terminal 80x24 par défaut) + modes := gossh.TerminalModes{ + gossh.ECHO: 1, + gossh.TTY_OP_ISPEED: 14400, + gossh.TTY_OP_OSPEED: 14400, + } + if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil { + conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur PTY : %v\r\n", err))) + return + } + + // Pipes stdin/stdout entre WebSocket et SSH + stdinPipe, err := session.StdinPipe() + if err != nil { + return + } + stdoutPipe, err := session.StdoutPipe() + if err != nil { + return + } + + if err := session.Shell(); err != nil { + conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur shell : %v\r\n", err))) + return + } + + // Goroutine : SSH stdout → WebSocket + go func() { + buf := make([]byte, 4096) + for { + n, err := stdoutPipe.Read(buf) + if err != nil { + break + } + conn.WriteMessage(gorillaws.BinaryMessage, buf[:n]) + } + conn.Close() + }() + + // Boucle principale : WebSocket → SSH stdin + for { + _, msg, err := conn.ReadMessage() + if err != nil { + break + } + + // Détecter les messages de contrôle JSON (ex: resize) + if len(msg) > 0 && msg[0] == '{' { + var cmd terminalCmd + if json.Unmarshal(msg, &cmd) == nil && cmd.Type == "resize" && cmd.Cols > 0 && cmd.Rows > 0 { + session.WindowChange(cmd.Rows, cmd.Cols) + continue + } + } + + // Données brutes → stdin SSH + stdinPipe.Write(msg) + } + + h.auditLogger.Log(&claims.UserID, claims.Username, "terminal_close", sshHost, nil, clientIP(r)) +} diff --git a/backend/internal/api/updates.go b/backend/internal/api/updates.go new file mode 100644 index 0000000..e11bdca --- /dev/null +++ b/backend/internal/api/updates.go @@ -0,0 +1,196 @@ +// Handlers pour les mises à jour de paquets apt. +// Supporte : host Proxmox, un LXC spécifique, ou tous les LXC. +// La sortie est streamée ligne par ligne via WebSocket. +package api + +import ( + "fmt" + "net/http" + "time" + + "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket" + "github.com/go-chi/chi/v5" + "math/rand" +) + +// UpdatesHandler contient les handlers de mises à jour. +type UpdatesHandler struct { + db *db.DB + sshPool *ssh.Pool + hub *websocket.Hub + auditLogger *audit.Logger + encryptor *crypto.Encryptor +} + +// NewUpdatesHandler crée un UpdatesHandler. +func NewUpdatesHandler(database *db.DB, sshPool *ssh.Pool, hub *websocket.Hub, auditLog *audit.Logger, enc *crypto.Encryptor) *UpdatesHandler { + return &UpdatesHandler{ + db: database, + sshPool: sshPool, + hub: hub, + auditLogger: auditLog, + encryptor: enc, + } +} + +// RunUpdate lance une mise à jour apt sur la cible spécifiée. +// POST /api/updates/run +// Body: { "target": "host" | "lxc:100" | "all" } +func (h *UpdatesHandler) RunUpdate(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + + var body struct { + Target string `json:"target"` + } + if err := decodeJSON(r, &body); err != nil || body.Target == "" { + JSONError(w, "Paramètre 'target' requis (host, lxc:ID, ou all)", http.StatusBadRequest) + return + } + + // Récupérer les credentials SSH depuis les settings + sshHost, _, _ := h.db.GetSetting("ssh_host") + sshUser, _, _ := h.db.GetSetting("ssh_username") + encryptedPass, _, _ := h.db.GetSetting("ssh_password") + sshPass, _ := h.encryptor.Decrypt(encryptedPass) + if sshHost == "" || sshUser == "" || sshPass == "" { + JSONError(w, "SSH non configuré", http.StatusServiceUnavailable) + return + } + + // Générer un ID de job unique + jobID := generateJobID() + + // Enregistrer le job en base + h.db.Exec(` + INSERT INTO update_history (job_id, target, status, started_by) VALUES (?, ?, 'running', ?) + `, jobID, body.Target, claims.UserID) + + h.auditLogger.Log(&claims.UserID, claims.Username, "update_start", body.Target, nil, clientIP(r)) + + // Lancer la mise à jour en arrière-plan + go h.executeUpdate(jobID, body.Target, sshHost, sshUser, sshPass, claims.UserID) + + JSONResponse(w, http.StatusAccepted, map[string]string{ + "job_id": jobID, + "message": "Mise à jour démarrée", + }) +} + +// GetHistory retourne l'historique des mises à jour. +// GET /api/updates/history +func (h *UpdatesHandler) GetHistory(w http.ResponseWriter, r *http.Request) { + rows, err := h.db.Query(` + SELECT job_id, target, status, output, started_at, finished_at + FROM update_history + ORDER BY started_at DESC + LIMIT 50 + `) + if err != nil { + JSONError(w, "Erreur lecture historique", http.StatusInternalServerError) + return + } + defer rows.Close() + + type entry struct { + JobID string `json:"job_id"` + Target string `json:"target"` + Status string `json:"status"` + Output string `json:"output"` + StartedAt string `json:"started_at"` + FinishedAt *string `json:"finished_at,omitempty"` + } + + var entries []entry + for rows.Next() { + var e entry + var finishedAt *string + rows.Scan(&e.JobID, &e.Target, &e.Status, &e.Output, &e.StartedAt, &finishedAt) + e.FinishedAt = finishedAt + entries = append(entries, e) + } + + JSONResponse(w, http.StatusOK, entries) +} + +// WebSocketUpdate ouvre un WebSocket pour suivre un job de mise à jour en temps réel. +// GET /ws/updates/{jobId} +func (h *UpdatesHandler) WebSocketUpdate(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + claims := GetClaims(r) + var userID int64 + if claims != nil { + userID = claims.UserID + } + + jobID := chi.URLParam(r, "jobId") + wsClient := h.hub.NewClient(conn, userID) + wsClient.Subscribe("update:" + jobID) +} + +// executeUpdate exécute la commande apt et streame la sortie via WebSocket. +func (h *UpdatesHandler) executeUpdate(jobID, target, sshHost, sshUser, sshPass string, userID int64) { + outputChan := make(chan string, 100) + var command string + + switch { + case target == "host": + command = "DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y" + + case len(target) > 4 && target[:4] == "lxc:": + lxcID := target[4:] + command = fmt.Sprintf( + "pct exec %s -- bash -c 'DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y'", + lxcID, + ) + + case target == "all": + command = `for ct in $(pct list | awk 'NR>1 {print $1}'); do + echo "=== LXC $ct ===" + pct exec $ct -- bash -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y' 2>/dev/null || echo "SKIP LXC $ct" + done` + + default: + h.db.Exec(`UPDATE update_history SET status='error', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`, + "Cible invalide : "+target, jobID) + return + } + + // Lancer le streaming SSH + err := h.sshPool.StreamCommand(sshHost, sshUser, sshPass, command, outputChan) + if err != nil { + h.db.Exec(`UPDATE update_history SET status='error', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`, + "Erreur SSH : "+err.Error(), jobID) + h.hub.Publish("update:"+jobID, "update_error", map[string]string{"error": err.Error()}) + return + } + + // Collecter la sortie et la publier ligne par ligne + var fullOutput string + for chunk := range outputChan { + fullOutput += chunk + h.hub.Publish("update:"+jobID, "update_output", map[string]string{"chunk": chunk}) + } + + // Finaliser le job + h.db.Exec(`UPDATE update_history SET status='success', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`, + fullOutput, jobID) + h.hub.Publish("update:"+jobID, "update_done", map[string]string{"job_id": jobID}) +} + +// generateJobID génère un identifiant unique pour un job de mise à jour. +func generateJobID() string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 8) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return fmt.Sprintf("%d-%s", time.Now().Unix(), string(b)) +} diff --git a/backend/internal/audit/audit.go b/backend/internal/audit/audit.go new file mode 100644 index 0000000..3853d80 --- /dev/null +++ b/backend/internal/audit/audit.go @@ -0,0 +1,81 @@ +// Package audit fournit le journal d'audit de ProxmoxPanel. +// Toutes les actions sensibles (connexion, mises à jour, modifications config) y sont tracées. +package audit + +import ( + "database/sql" + "encoding/json" + "time" +) + +// Entry représente une entrée dans le journal d'audit. +type Entry struct { + ID int64 `json:"id"` + UserID *int64 `json:"user_id,omitempty"` + Username string `json:"username"` + Action string `json:"action"` + Resource string `json:"resource,omitempty"` + Details string `json:"details,omitempty"` + IP string `json:"ip,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Logger est le service d'audit. +type Logger struct { + db *sql.DB +} + +// New crée un nouveau Logger d'audit. +func New(db *sql.DB) *Logger { + return &Logger{db: db} +} + +// Log enregistre une action dans le journal d'audit. +func (l *Logger) Log(userID *int64, username, action, resource string, details any, ip string) { + var detailsStr string + if details != nil { + if s, ok := details.(string); ok { + detailsStr = s + } else if data, err := json.Marshal(details); err == nil { + detailsStr = string(data) + } + } + + // Insertion non bloquante — on ignore les erreurs pour ne pas perturber le flux principal + l.db.Exec(` + INSERT INTO audit_log (user_id, username, action, resource, details, ip, created_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, userID, username, action, resource, detailsStr, ip) +} + +// GetEntries retourne les dernières entrées du journal, paginées. +func (l *Logger) GetEntries(limit, offset int) ([]Entry, error) { + rows, err := l.db.Query(` + SELECT id, user_id, username, action, resource, details, ip, created_at + FROM audit_log + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []Entry + for rows.Next() { + var e Entry + var userID sql.NullInt64 + var resource, details, ip sql.NullString + if err := rows.Scan(&e.ID, &userID, &e.Username, &e.Action, &resource, &details, &ip, &e.CreatedAt); err != nil { + continue + } + if userID.Valid { + e.UserID = &userID.Int64 + } + e.Resource = resource.String + e.Details = details.String + e.IP = ip.String + entries = append(entries, e) + } + return entries, rows.Err() +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..b14e373 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,178 @@ +// Package auth gère les tokens JWT RS256 pour les sessions utilisateurs. +// Les clés RSA sont générées automatiquement au premier démarrage et stockées sur disque. +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + accessTokenDuration = 15 * time.Minute + refreshTokenDuration = 7 * 24 * time.Hour + rsaKeySize = 2048 +) + +// Claims représente le contenu d'un JWT d'accès ProxmoxPanel. +type Claims struct { + UserID int64 `json:"uid"` + Username string `json:"sub"` + IsAdmin bool `json:"admin"` + jwt.RegisteredClaims +} + +// JWTManager gère la signature et la vérification des tokens JWT. +type JWTManager struct { + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey +} + +// NewJWTManager charge ou génère les clés RSA, et retourne un JWTManager prêt à l'emploi. +func NewJWTManager(dataDir string) (*JWTManager, error) { + keysDir := filepath.Join(dataDir, "keys") + if err := os.MkdirAll(keysDir, 0700); err != nil { + return nil, fmt.Errorf("création répertoire clés : %w", err) + } + + privPath := filepath.Join(keysDir, "jwt.key") + pubPath := filepath.Join(keysDir, "jwt.pub") + + var privKey *rsa.PrivateKey + + if _, err := os.Stat(privPath); os.IsNotExist(err) { + // Générer une paire de clés RSA-2048 + privKey, err = rsa.GenerateKey(rand.Reader, rsaKeySize) + if err != nil { + return nil, fmt.Errorf("génération clés RSA : %w", err) + } + + // Sauvegarder la clé privée en PEM + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privKey), + }) + if err := os.WriteFile(privPath, privPEM, 0600); err != nil { + return nil, fmt.Errorf("sauvegarde clé privée : %w", err) + } + + // Sauvegarder la clé publique en PEM + pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("export clé publique : %w", err) + } + pubPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubBytes, + }) + if err := os.WriteFile(pubPath, pubPEM, 0644); err != nil { + return nil, fmt.Errorf("sauvegarde clé publique : %w", err) + } + } else { + // Charger la clé privée existante + privPEM, err := os.ReadFile(privPath) + if err != nil { + return nil, fmt.Errorf("lecture clé privée : %w", err) + } + block, _ := pem.Decode(privPEM) + if block == nil { + return nil, errors.New("clé privée invalide (PEM)") + } + privKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing clé privée : %w", err) + } + } + + return &JWTManager{ + privateKey: privKey, + publicKey: &privKey.PublicKey, + }, nil +} + +// GenerateAccessToken crée un JWT d'accès signé RS256 (durée : 15 min). +func (m *JWTManager) GenerateAccessToken(userID int64, username string, isAdmin bool) (string, error) { + claims := Claims{ + UserID: userID, + Username: username, + IsAdmin: isAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "proxmoxpanel", + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return token.SignedString(m.privateKey) +} + +// GenerateRefreshToken crée un token de renouvellement (durée : 7 jours). +// Ce token est plus simple — il ne contient que le userID et l'expiration. +func (m *JWTManager) GenerateRefreshToken(userID int64) (string, error) { + claims := jwt.RegisteredClaims{ + Subject: fmt.Sprintf("%d", userID), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "proxmoxpanel-refresh", + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return token.SignedString(m.privateKey) +} + +// ValidateAccessToken vérifie et décode un JWT d'accès. +func (m *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"]) + } + return m.publicKey, nil + }) + if err != nil { + return nil, fmt.Errorf("validation token : %w", err) + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, errors.New("token invalide") + } + return claims, nil +} + +// ValidateRefreshToken vérifie un token de renouvellement et retourne le userID. +func (m *JWTManager) ValidateRefreshToken(tokenStr string) (int64, error) { + token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"]) + } + return m.publicKey, nil + }) + if err != nil { + return 0, fmt.Errorf("validation refresh token : %w", err) + } + + claims, ok := token.Claims.(*jwt.RegisteredClaims) + if !ok || !token.Valid { + return 0, errors.New("refresh token invalide") + } + + if claims.Issuer != "proxmoxpanel-refresh" { + return 0, errors.New("émetteur token invalide") + } + + var userID int64 + fmt.Sscanf(claims.Subject, "%d", &userID) + return userID, nil +} + +// RefreshTokenDuration retourne la durée de validité du refresh token. +func RefreshTokenDuration() time.Duration { + return refreshTokenDuration +} diff --git a/backend/internal/auth/ssh_auth.go b/backend/internal/auth/ssh_auth.go new file mode 100644 index 0000000..47d5952 --- /dev/null +++ b/backend/internal/auth/ssh_auth.go @@ -0,0 +1,129 @@ +// Package auth — Authentification PAM via SSH. +// Au lieu de monter les fichiers système du host (/etc/shadow), on tente une connexion +// SSH avec les credentials de l'utilisateur. Si elle réussit, les credentials sont valides. +// L'appartenance au groupe sudo/wheel détermine le niveau admin. +package auth + +import ( + "fmt" + "net" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +// UserInfo contient les informations d'un utilisateur authentifié. +type UserInfo struct { + Username string + IsAdmin bool +} + +// SSHAuthenticator gère l'authentification des utilisateurs via SSH vers le host Proxmox. +type SSHAuthenticator struct { + host string // ex: "10.0.0.1:2244" +} + +// NewSSHAuthenticator crée un authentificateur SSH pour le host donné. +func NewSSHAuthenticator(host string) *SSHAuthenticator { + return &SSHAuthenticator{host: host} +} + +// Authenticate tente une connexion SSH avec les credentials fournis. +// Si la connexion réussit, retourne les informations de l'utilisateur. +// Vérifie l'appartenance au groupe sudo ou wheel pour déterminer le niveau admin. +func (a *SSHAuthenticator) Authenticate(username, password string) (*UserInfo, error) { + config := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + // Timeout court pour l'authentification + Timeout: 10 * time.Second, + // Accepter n'importe quelle clé host (le host est sur le réseau interne de confiance) + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + // Tentative de connexion SSH + client, err := ssh.Dial("tcp", a.host, config) + if err != nil { + // Distinguer les erreurs d'authentification des erreurs réseau + if strings.Contains(err.Error(), "unable to authenticate") || + strings.Contains(err.Error(), "ssh: handshake failed") || + strings.Contains(err.Error(), "no supported methods remain") { + return nil, fmt.Errorf("identifiants invalides") + } + return nil, fmt.Errorf("connexion SSH impossible : %w", err) + } + defer client.Close() + + // Vérifier l'appartenance aux groupes sudo/wheel via la commande `id` + isAdmin, err := checkSudoGroup(client) + if err != nil { + // En cas d'erreur de vérification des groupes, l'utilisateur est authentifié mais pas admin + isAdmin = false + } + + return &UserInfo{ + Username: username, + IsAdmin: isAdmin, + }, nil +} + +// TestConnectivity teste la connexion SSH sans authentification complète. +// Utilisé pendant l'installation pour valider les paramètres de connexion. +func TestConnectivity(host string, timeout time.Duration) error { + conn, err := net.DialTimeout("tcp", host, timeout) + if err != nil { + return fmt.Errorf("impossible de joindre %s : %w", host, err) + } + conn.Close() + return nil +} + +// TestSSHAuth teste une connexion SSH complète avec credentials. +// Retourne nil si la connexion réussit, une erreur explicite sinon. +func TestSSHAuth(host, username, password string) error { + config := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + Timeout: 10 * time.Second, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + client, err := ssh.Dial("tcp", host, config) + if err != nil { + if strings.Contains(err.Error(), "unable to authenticate") { + return fmt.Errorf("identifiants SSH invalides") + } + return fmt.Errorf("connexion SSH échouée : %w", err) + } + client.Close() + return nil +} + +// checkSudoGroup exécute `id -nG` sur la session SSH et vérifie la présence +// des groupes "sudo" ou "wheel" dans la liste des groupes de l'utilisateur. +func checkSudoGroup(client *ssh.Client) (bool, error) { + session, err := client.NewSession() + if err != nil { + return false, fmt.Errorf("ouverture session SSH : %w", err) + } + defer session.Close() + + output, err := session.Output("id -nG") + if err != nil { + return false, fmt.Errorf("exécution `id -nG` : %w", err) + } + + groups := strings.Fields(strings.TrimSpace(string(output))) + for _, g := range groups { + if g == "sudo" || g == "wheel" { + return true, nil + } + } + + return false, nil +} diff --git a/backend/internal/crypto/aes.go b/backend/internal/crypto/aes.go new file mode 100644 index 0000000..e821eb9 --- /dev/null +++ b/backend/internal/crypto/aes.go @@ -0,0 +1,110 @@ +// Package crypto fournit le chiffrement/déchiffrement AES-256-GCM +// pour protéger les secrets stockés en base SQLite (tokens API, credentials SSH, etc.) +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// Encryptor gère le chiffrement/déchiffrement avec une clé AES-256. +type Encryptor struct { + key []byte +} + +// NewEncryptor crée un Encryptor depuis une clé maître stockée sur disque. +// Si la clé n'existe pas, elle est générée aléatoirement et sauvegardée. +func NewEncryptor(dataDir string) (*Encryptor, error) { + keyPath := filepath.Join(dataDir, "master.key") + + var masterSecret []byte + + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + // Générer un secret maître de 64 octets aléatoires + masterSecret = make([]byte, 64) + if _, err := io.ReadFull(rand.Reader, masterSecret); err != nil { + return nil, fmt.Errorf("génération clé maître : %w", err) + } + + // Sauvegarder avec permissions restreintes (lecture propriétaire uniquement) + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("création répertoire : %w", err) + } + if err := os.WriteFile(keyPath, masterSecret, 0600); err != nil { + return nil, fmt.Errorf("sauvegarde clé maître : %w", err) + } + } else { + // Lire la clé existante + masterSecret, err = os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("lecture clé maître : %w", err) + } + } + + // Dériver une clé AES-256 depuis le secret maître via SHA-256 + hash := sha256.Sum256(masterSecret) + return &Encryptor{key: hash[:]}, nil +} + +// Encrypt chiffre une valeur en clair et retourne une chaîne base64. +// Format : base64(nonce || ciphertext || tag) +func (e *Encryptor) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(e.key) + if err != nil { + return "", fmt.Errorf("création cipher AES : %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("création GCM : %w", err) + } + + // Générer un nonce aléatoire (12 octets pour GCM) + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("génération nonce : %w", err) + } + + // Chiffrer : Seal(nonce, nonce, plaintext, nil) → nonce||ciphertext||tag + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt déchiffre une valeur chiffrée par Encrypt. +func (e *Encryptor) Decrypt(encoded string) (string, error) { + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("décodage base64 : %w", err) + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", fmt.Errorf("création cipher AES : %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("création GCM : %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("données chiffrées trop courtes") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("déchiffrement : %w", err) + } + + return string(plaintext), nil +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..76f67ed --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,185 @@ +// Package db gère la connexion SQLite et l'exécution des migrations. +// Il expose une instance unique de base de données utilisée par tous les services. +package db + +import ( + "database/sql" + "embed" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + _ "modernc.org/sqlite" // Pilote SQLite pur Go (sans CGO) +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// DB encapsule la connexion SQLite et expose les méthodes nécessaires. +type DB struct { + *sql.DB +} + +// Open ouvre (ou crée) la base de données SQLite au chemin donné et exécute les migrations. +func Open(dataDir string) (*DB, error) { + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("création répertoire données : %w", err) + } + + dbPath := filepath.Join(dataDir, "panel.db") + + // Paramètres SQLite : WAL mode pour les lectures concurrentes, foreign keys activées + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)", dbPath) + sqlDB, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("ouverture SQLite : %w", err) + } + + // Limiter les connexions simultanées (SQLite n'est pas conçu pour la concurrence élevée) + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("connexion SQLite : %w", err) + } + + db := &DB{sqlDB} + + // Exécuter les migrations manquantes + if err := db.migrate(); err != nil { + return nil, fmt.Errorf("migrations : %w", err) + } + + return db, nil +} + +// migrate applique les fichiers SQL de migration non encore exécutés. +// Les fichiers sont numérotés (001_init.sql, 002_xxx.sql) et appliqués dans l'ordre. +func (db *DB) migrate() error { + // Créer la table schema_version si elle n'existe pas encore + // (nécessaire avant de lire la version actuelle) + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`) + if err != nil { + return fmt.Errorf("création schema_version : %w", err) + } + + // Lire la version actuelle + var currentVersion int + row := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`) + if err := row.Scan(¤tVersion); err != nil { + return fmt.Errorf("lecture version schéma : %w", err) + } + + // Lister et trier les fichiers de migration + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("lecture dossier migrations : %w", err) + } + + type migration struct { + version int + name string + } + var migrations []migration + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + // Extraire le numéro de version depuis le nom du fichier (ex: "001_init.sql" → 1) + parts := strings.SplitN(entry.Name(), "_", 2) + if len(parts) < 1 { + continue + } + v, err := strconv.Atoi(parts[0]) + if err != nil { + continue + } + migrations = append(migrations, migration{version: v, name: entry.Name()}) + } + + // Trier par numéro de version croissant + sort.Slice(migrations, func(i, j int) bool { + return migrations[i].version < migrations[j].version + }) + + // Appliquer les migrations manquantes + for _, m := range migrations { + if m.version <= currentVersion { + continue + } + + content, err := migrationsFS.ReadFile("migrations/" + m.name) + if err != nil { + return fmt.Errorf("lecture migration %s : %w", m.name, err) + } + + // Exécuter dans une transaction pour garantir l'atomicité + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("transaction migration %s : %w", m.name, err) + } + + if _, err := tx.Exec(string(content)); err != nil { + tx.Rollback() + return fmt.Errorf("exécution migration %s : %w", m.name, err) + } + + // Mettre à jour la version (la migration 001 l'insère elle-même, pas besoin de le refaire) + if m.version > 1 { + if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil { + tx.Rollback() + return fmt.Errorf("mise à jour version après migration %s : %w", m.name, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s : %w", m.name, err) + } + } + + return nil +} + +// GetSetting lit un paramètre depuis la table settings. +// Retourne "" et nil si la clé n'existe pas. +func (db *DB) GetSetting(key string) (string, bool, error) { + var value string + var encrypted int + err := db.QueryRow(`SELECT value, encrypted FROM settings WHERE key = ?`, key).Scan(&value, &encrypted) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + return value, encrypted == 1, nil +} + +// SetSetting enregistre ou met à jour un paramètre. +func (db *DB) SetSetting(key, value string, encrypted bool) error { + enc := 0 + if encrypted { + enc = 1 + } + _, err := db.Exec(` + INSERT INTO settings (key, value, encrypted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, encrypted=excluded.encrypted, updated_at=excluded.updated_at + `, key, value, enc) + return err +} + +// IsInstalled vérifie si l'application a déjà été configurée. +func (db *DB) IsInstalled() (bool, error) { + v, _, err := db.GetSetting("installed") + if err != nil { + return false, err + } + return v == "true", nil +} diff --git a/backend/internal/db/migrations/001_init.sql b/backend/internal/db/migrations/001_init.sql new file mode 100644 index 0000000..b8868a1 --- /dev/null +++ b/backend/internal/db/migrations/001_init.sql @@ -0,0 +1,100 @@ +-- Migration 001 : Schéma initial de ProxmoxPanel +-- Crée toutes les tables de base nécessaires au CORE + +-- Paramètres globaux de l'application (clé/valeur) +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + encrypted INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Utilisateurs (créés automatiquement au premier login) +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + is_admin INTEGER NOT NULL DEFAULT 0, + lang TEXT NOT NULL DEFAULT 'en', + theme TEXT NOT NULL DEFAULT 'dark', + sidebar_position TEXT NOT NULL DEFAULT 'left', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at DATETIME +); + +-- Sessions de refresh JWT (cookie httpOnly) +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Modules disponibles et leur état (actif/inactif) +CREATE TABLE IF NOT EXISTS modules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '0.0.0', + is_core INTEGER NOT NULL DEFAULT 0, + is_enabled INTEGER NOT NULL DEFAULT 0, + installed_at DATETIME, + config TEXT NOT NULL DEFAULT '{}' +); + +-- Journal d'audit — toutes les actions sensibles +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + username TEXT, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Widgets du dashboard par utilisateur +CREATE TABLE IF NOT EXISTS user_widgets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + widget_type TEXT NOT NULL, + title TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + position_x INTEGER NOT NULL DEFAULT 0, + position_y INTEGER NOT NULL DEFAULT 0, + width INTEGER NOT NULL DEFAULT 2, + height INTEGER NOT NULL DEFAULT 2, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Historique des mises à jour de paquets +CREATE TABLE IF NOT EXISTS update_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL UNIQUE, + target TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + output TEXT NOT NULL DEFAULT '', + started_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME +); + +-- Version de schéma pour le système de migrations +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO schema_version (version) VALUES (1); + +-- Insertion des modules CORE par défaut (non désinstallables) +INSERT OR IGNORE INTO modules (id, name, description, version, is_core, is_enabled) VALUES + ('dashboard', 'Dashboard', 'Tableau de bord avec widgets configurables', '1.0.0', 1, 1), + ('proxmox', 'Proxmox', 'Gestion des LXC et VM Proxmox', '1.0.0', 1, 1), + ('updates', 'Mises à jour', 'Mises à jour de paquets apt avec streaming', '1.0.0', 1, 1), + ('settings', 'Paramètres', 'Configuration de l''application', '1.0.0', 1, 1), + ('files', 'Fichiers', 'Navigateur de fichiers SFTP', '1.0.0', 0, 0), + ('terminal', 'Terminal', 'Terminal SSH interactif', '1.0.0', 0, 0), + ('logs', 'Logs', 'Streaming de logs en temps réel', '1.0.0', 0, 0), + ('services', 'Services', 'Gestion des services systemd', '1.0.0', 0, 0); diff --git a/backend/internal/proxmox/client.go b/backend/internal/proxmox/client.go new file mode 100644 index 0000000..47d2e3d --- /dev/null +++ b/backend/internal/proxmox/client.go @@ -0,0 +1,212 @@ +// Package proxmox fournit un client pour l'API REST Proxmox VE. +// Les credentials (token API ou user/password) sont stockés chiffrés en SQLite. +package proxmox + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client est le client HTTP vers l'API Proxmox VE. +type Client struct { + baseURL string + httpClient *http.Client + token string // Format: "PVEAPIToken=user@realm!tokenid=secret" +} + +// NodeStatus représente l'état d'un nœud Proxmox. +type NodeStatus struct { + Node string `json:"node"` + Status string `json:"status"` + CPU float64 `json:"cpu"` + MaxCPU int `json:"maxcpu"` + Mem int64 `json:"mem"` + MaxMem int64 `json:"maxmem"` + Uptime int64 `json:"uptime"` +} + +// Resource représente un LXC, une VM ou un autre objet Proxmox. +type Resource struct { + VMID int `json:"vmid"` + Name string `json:"name"` + Node string `json:"node"` + Type string `json:"type"` // "lxc" | "qemu" | "storage" | "node" + Status string `json:"status"` // "running" | "stopped" + CPU float64 `json:"cpu"` + MaxCPU int `json:"maxcpu"` + Mem int64 `json:"mem"` + MaxMem int64 `json:"maxmem"` + Disk int64 `json:"disk"` + MaxDisk int64 `json:"maxdisk"` + Uptime int64 `json:"uptime"` + NetIn int64 `json:"netin"` + NetOut int64 `json:"netout"` +} + +// proxmoxResponse est l'enveloppe générique des réponses API Proxmox. +type proxmoxResponse struct { + Data json.RawMessage `json:"data"` + Error string `json:"errors"` +} + +// NewClient crée un client Proxmox avec le token API fourni. +// baseURL : ex "https://10.0.0.1:8006" +// token : ex "PVEAPIToken=enzo@pam!panel=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +func NewClient(baseURL, token string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + // Proxmox utilise des certificats auto-signés + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + } +} + +// GetNodes retourne la liste des nœuds Proxmox. +func (c *Client) GetNodes() ([]NodeStatus, error) { + var nodes []NodeStatus + if err := c.get("/api2/json/nodes", &nodes); err != nil { + return nil, err + } + return nodes, nil +} + +// GetResources retourne tous les LXC et VM de l'ensemble du cluster. +// Le paramètre type filtre les résultats ("lxc", "vm", ou "" pour tout). +func (c *Client) GetResources(resourceType string) ([]Resource, error) { + path := "/api2/json/cluster/resources" + if resourceType != "" { + path += "?type=" + resourceType + } + var resources []Resource + if err := c.get(path, &resources); err != nil { + return nil, err + } + return resources, nil +} + +// GetLXCList retourne uniquement les conteneurs LXC. +func (c *Client) GetLXCList() ([]Resource, error) { + return c.GetResources("lxc") +} + +// GetVMList retourne uniquement les machines virtuelles QEMU. +func (c *Client) GetVMList() ([]Resource, error) { + return c.GetResources("vm") +} + +// StartLXC démarre un conteneur LXC. +func (c *Client) StartLXC(node string, vmid int) error { + _, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/start", node, vmid), nil) + return err +} + +// StopLXC arrête un conteneur LXC. +func (c *Client) StopLXC(node string, vmid int) error { + _, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/stop", node, vmid), nil) + return err +} + +// StartVM démarre une machine virtuelle. +func (c *Client) StartVM(node string, vmid int) error { + _, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/start", node, vmid), nil) + return err +} + +// StopVM arrête une machine virtuelle. +func (c *Client) StopVM(node string, vmid int) error { + _, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/stop", node, vmid), nil) + return err +} + +// TestConnection vérifie que le token API est valide en récupérant la liste des nœuds. +func (c *Client) TestConnection() error { + _, err := c.GetNodes() + return err +} + +// get effectue une requête GET et décode la réponse dans dest. +func (c *Client) get(path string, dest any) error { + req, err := http.NewRequest("GET", c.baseURL+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("requête Proxmox : %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + return fmt.Errorf("token Proxmox invalide ou expiré") + } + if resp.StatusCode >= 400 { + return fmt.Errorf("erreur Proxmox API : HTTP %d", resp.StatusCode) + } + + return c.decodeResponse(resp.Body, dest) +} + +// post effectue une requête POST. +func (c *Client) post(path string, body any) (json.RawMessage, error) { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = strings.NewReader(string(data)) + } + + req, err := http.NewRequest("POST", c.baseURL+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("requête Proxmox : %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + return nil, fmt.Errorf("token Proxmox invalide") + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("erreur Proxmox API : HTTP %d", resp.StatusCode) + } + + var result json.RawMessage + c.decodeResponse(resp.Body, &result) + return result, nil +} + +// decodeResponse décode l'enveloppe JSON Proxmox et extrait le champ "data". +func (c *Client) decodeResponse(body io.Reader, dest any) error { + var wrapper proxmoxResponse + if err := json.NewDecoder(body).Decode(&wrapper); err != nil { + return fmt.Errorf("décodage réponse Proxmox : %w", err) + } + if wrapper.Error != "" { + return fmt.Errorf("erreur Proxmox : %s", wrapper.Error) + } + if dest == nil || wrapper.Data == nil { + return nil + } + return json.Unmarshal(wrapper.Data, dest) +} diff --git a/backend/internal/ssh/pool.go b/backend/internal/ssh/pool.go new file mode 100644 index 0000000..03e2e13 --- /dev/null +++ b/backend/internal/ssh/pool.go @@ -0,0 +1,216 @@ +// Package ssh gère un pool de connexions SSH réutilisables vers le host Proxmox et les LXC. +// Les connexions inactives depuis plus de 5 minutes sont automatiquement fermées. +package ssh + +import ( + "fmt" + "io" + "strings" + "sync" + "time" + + gossh "golang.org/x/crypto/ssh" +) + +const ( + idleTimeout = 5 * time.Minute + connTimeout = 15 * time.Second +) + +// poolEntry représente une connexion SSH dans le pool avec sa date de dernier usage. +type poolEntry struct { + client *gossh.Client + lastUsed time.Time + mu sync.Mutex +} + +// Pool est un pool thread-safe de connexions SSH. +type Pool struct { + mu sync.Mutex + entries map[string]*poolEntry + ticker *time.Ticker + done chan struct{} +} + +// NewPool crée un pool SSH et démarre le nettoyage automatique des connexions inactives. +func NewPool() *Pool { + p := &Pool{ + entries: make(map[string]*poolEntry), + ticker: time.NewTicker(1 * time.Minute), + done: make(chan struct{}), + } + go p.cleanup() + return p +} + +// Close arrête le pool et ferme toutes les connexions. +func (p *Pool) Close() { + close(p.done) + p.ticker.Stop() + p.mu.Lock() + defer p.mu.Unlock() + for _, entry := range p.entries { + entry.client.Close() + } + p.entries = make(map[string]*poolEntry) +} + +// getOrCreate retourne une connexion existante ou en crée une nouvelle. +func (p *Pool) getOrCreate(key, host, user, password string) (*poolEntry, error) { + p.mu.Lock() + entry, exists := p.entries[key] + p.mu.Unlock() + + if exists { + // Vérifier que la connexion est toujours active + entry.mu.Lock() + _, _, err := entry.client.SendRequest("keepalive@openssh.com", true, nil) + if err == nil { + entry.lastUsed = time.Now() + entry.mu.Unlock() + return entry, nil + } + entry.mu.Unlock() + // Connexion morte — on la supprime et en crée une nouvelle + p.mu.Lock() + delete(p.entries, key) + p.mu.Unlock() + } + + // Créer une nouvelle connexion + config := &gossh.ClientConfig{ + User: user, + Auth: []gossh.AuthMethod{ + gossh.Password(password), + }, + Timeout: connTimeout, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + } + + client, err := gossh.Dial("tcp", host, config) + if err != nil { + return nil, fmt.Errorf("connexion SSH vers %s : %w", host, err) + } + + newEntry := &poolEntry{ + client: client, + lastUsed: time.Now(), + } + + p.mu.Lock() + p.entries[key] = newEntry + p.mu.Unlock() + + return newEntry, nil +} + +// RunCommand exécute une commande sur l'hôte distant et retourne la sortie combinée. +func (p *Pool) RunCommand(host, user, password, command string) (string, error) { + key := fmt.Sprintf("%s@%s", user, host) + entry, err := p.getOrCreate(key, host, user, password) + if err != nil { + return "", err + } + + entry.mu.Lock() + defer entry.mu.Unlock() + + session, err := entry.client.NewSession() + if err != nil { + return "", fmt.Errorf("ouverture session : %w", err) + } + defer session.Close() + + output, err := session.CombinedOutput(command) + entry.lastUsed = time.Now() + return strings.TrimSpace(string(output)), err +} + +// StreamCommand exécute une commande et envoie sa sortie ligne par ligne dans le channel. +// Le channel est fermé à la fin de la commande. +func (p *Pool) StreamCommand(host, user, password, command string, output chan<- string) error { + key := fmt.Sprintf("%s@%s", user, host) + entry, err := p.getOrCreate(key, host, user, password) + if err != nil { + return err + } + + entry.mu.Lock() + session, err := entry.client.NewSession() + entry.mu.Unlock() + if err != nil { + return fmt.Errorf("ouverture session : %w", err) + } + + // Utiliser un pipe pour lire la sortie en streaming + stdout, err := session.StdoutPipe() + if err != nil { + session.Close() + return fmt.Errorf("pipe stdout : %w", err) + } + stderr, err := session.StderrPipe() + if err != nil { + session.Close() + return fmt.Errorf("pipe stderr : %w", err) + } + + if err := session.Start(command); err != nil { + session.Close() + return fmt.Errorf("démarrage commande : %w", err) + } + + // Lire stdout et stderr en goroutines et envoyer dans le channel + var wg sync.WaitGroup + readStream := func(r io.Reader) { + defer wg.Done() + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + output <- string(buf[:n]) + } + if err != nil { + break + } + } + } + + wg.Add(2) + go readStream(stdout) + go readStream(stderr) + + go func() { + wg.Wait() + session.Wait() + session.Close() + close(output) + p.mu.Lock() + if e, ok := p.entries[key]; ok { + e.lastUsed = time.Now() + } + p.mu.Unlock() + }() + + return nil +} + +// cleanup supprime périodiquement les connexions inactives depuis plus de idleTimeout. +func (p *Pool) cleanup() { + for { + select { + case <-p.done: + return + case <-p.ticker.C: + p.mu.Lock() + for key, entry := range p.entries { + entry.mu.Lock() + if time.Since(entry.lastUsed) > idleTimeout { + entry.client.Close() + delete(p.entries, key) + } + entry.mu.Unlock() + } + p.mu.Unlock() + } + } +} diff --git a/backend/internal/websocket/hub.go b/backend/internal/websocket/hub.go new file mode 100644 index 0000000..bb56f15 --- /dev/null +++ b/backend/internal/websocket/hub.go @@ -0,0 +1,228 @@ +// Package websocket fournit le hub central WebSocket de ProxmoxPanel. +// Les clients s'abonnent à des channels nommés et reçoivent les messages qui leur sont destinés. +package websocket + +import ( + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 8192 +) + +// Message représente un message WebSocket avec un type et un payload JSON. +type Message struct { + Type string `json:"type"` + Channel string `json:"channel"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// Client représente un client WebSocket connecté. +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + channels map[string]bool + mu sync.RWMutex + userID int64 +} + +// Hub gère toutes les connexions WebSocket actives et le routage des messages par channel. +type Hub struct { + mu sync.RWMutex + clients map[*Client]bool + register chan *Client + unregister chan *Client + broadcast chan broadcastMsg +} + +type broadcastMsg struct { + channel string + data []byte +} + +// NewHub crée un nouveau hub WebSocket et le démarre. +func NewHub() *Hub { + h := &Hub{ + clients: make(map[*Client]bool), + register: make(chan *Client, 64), + unregister: make(chan *Client, 64), + broadcast: make(chan broadcastMsg, 256), + } + go h.run() + return h +} + +// run est la boucle principale du hub (goroutine unique pour éviter les races). +func (h *Hub) run() { + for { + select { + case client := <-h.register: + h.mu.Lock() + h.clients[client] = true + h.mu.Unlock() + + case client := <-h.unregister: + h.mu.Lock() + if h.clients[client] { + delete(h.clients, client) + close(client.send) + } + h.mu.Unlock() + + case msg := <-h.broadcast: + h.mu.RLock() + for client := range h.clients { + client.mu.RLock() + subscribed := client.channels[msg.channel] || client.channels["*"] + client.mu.RUnlock() + if subscribed { + select { + case client.send <- msg.data: + default: + // Client lent ou déconnecté — on le supprime + h.mu.RUnlock() + h.mu.Lock() + if h.clients[client] { + delete(h.clients, client) + close(client.send) + } + h.mu.Unlock() + h.mu.RLock() + } + } + } + h.mu.RUnlock() + } + } +} + +// Publish envoie un message sur un channel donné à tous les clients abonnés. +func (h *Hub) Publish(channel, msgType string, payload any) { + data, err := marshalMessage(msgType, channel, payload) + if err != nil { + return + } + h.broadcast <- broadcastMsg{channel: channel, data: data} +} + +// PublishRaw envoie des données brutes sur un channel. +func (h *Hub) PublishRaw(channel string, data []byte) { + h.broadcast <- broadcastMsg{channel: channel, data: data} +} + +// NewClient crée et enregistre un nouveau client WebSocket. +func (h *Hub) NewClient(conn *websocket.Conn, userID int64) *Client { + c := &Client{ + hub: h, + conn: conn, + send: make(chan []byte, 256), + channels: make(map[string]bool), + userID: userID, + } + h.register <- c + go c.writePump() + go c.readPump() + return c +} + +// Subscribe abonne le client à un channel. +func (c *Client) Subscribe(channel string) { + c.mu.Lock() + c.channels[channel] = true + c.mu.Unlock() +} + +// Unsubscribe désabonne le client d'un channel. +func (c *Client) Unsubscribe(channel string) { + c.mu.Lock() + delete(c.channels, channel) + c.mu.Unlock() +} + +// writePump envoie les messages en attente au client WebSocket. +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case msg, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // Hub a fermé le channel + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// readPump lit les messages entrants du client (abonnements, ping, etc.) +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, rawMsg, err := c.conn.ReadMessage() + if err != nil { + break + } + + // Traiter les messages d'abonnement entrants + var msg Message + if json.Unmarshal(rawMsg, &msg) == nil { + switch msg.Type { + case "subscribe": + c.Subscribe(msg.Channel) + case "unsubscribe": + c.Unsubscribe(msg.Channel) + } + } + } +} + +// marshalMessage sérialise un message WebSocket en JSON. +func marshalMessage(msgType, channel string, payload any) ([]byte, error) { + var rawPayload json.RawMessage + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + rawPayload = data + } + return json.Marshal(Message{ + Type: msgType, + Channel: channel, + Payload: rawPayload, + }) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..3aab031 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,214 @@ +// ProxmoxPanel — CORE Backend +// Point d'entrée du serveur Go. Initialise la base de données, les services, +// enregistre les modules actifs et démarre le serveur HTTP sur :3001. +package main + +import ( + "log" + "net/http" + "os" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/api" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/auth" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" + sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh" + "git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket" + "git.geronzi.fr/proxmoxPanel/core/backend/modules" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + // Répertoire de données persistantes (volume Docker) + dataDir := getEnv("DATA_DIR", "/app/data") + + log.Printf("ProxmoxPanel CORE — démarrage (data: %s)", dataDir) + + // ── Initialisation de la base de données ─────────────────────────────── + database, err := db.Open(dataDir) + if err != nil { + log.Fatalf("Impossible d'ouvrir la base de données : %v", err) + } + log.Println("Base de données SQLite initialisée") + + // ── Services de base ─────────────────────────────────────────────────── + encryptor, err := crypto.NewEncryptor(dataDir) + if err != nil { + log.Fatalf("Impossible d'initialiser le chiffrement : %v", err) + } + log.Println("Chiffrement AES-256-GCM initialisé") + + jwtManager, err := auth.NewJWTManager(dataDir) + if err != nil { + log.Fatalf("Impossible d'initialiser JWT : %v", err) + } + log.Println("Clés JWT RS256 prêtes") + + // SSH host depuis la configuration (peut être vide si pas encore installé) + sshHost, _, _ := database.GetSetting("ssh_host") + var sshAuthenticator *auth.SSHAuthenticator + if sshHost != "" { + sshAuthenticator = auth.NewSSHAuthenticator(sshHost) + } else { + sshAuthenticator = auth.NewSSHAuthenticator("") // Sera mis à jour après installation + } + + sshPool := sshpool.NewPool() + defer sshPool.Close() + + hub := websocket.NewHub() + auditLogger := audit.New(database.DB) + + // ── Chargement des modules actifs ────────────────────────────────────── + loader := modules.NewLoader(database.DB) + // Les modules sont enregistrés ici (compilés dans le binaire) + // loader.RegisterModule(dashboard.New(...)) ← à décommenter quand implémentés + if err := loader.LoadActive(); err != nil { + log.Fatalf("Erreur chargement modules : %v", err) + } + + // ── Handlers HTTP ────────────────────────────────────────────────────── + installHandler := api.NewInstallHandler(database, encryptor) + authHandler := api.NewAuthHandler(database, jwtManager, sshAuthenticator, auditLogger) + proxmoxHandler := api.NewProxmoxHandler(database, hub, auditLogger, encryptor) + updatesHandler := api.NewUpdatesHandler(database, sshPool, hub, auditLogger, encryptor) + settingsHandler := api.NewSettingsHandler(database, auditLogger) + terminalHandler := api.NewTerminalHandler(database, auditLogger, encryptor) + + // Démarrer le polling Proxmox en arrière-plan + proxmoxHandler.StartPolling() + + // ── Router Chi ───────────────────────────────────────────────────────── + r := chi.NewRouter() + + // Middlewares globaux + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(api.SecurityHeaders) + r.Use(middleware.Compress(5)) // Compression gzip + + // Limiter global (100 req/min par IP) + globalLimiter := api.NewRateLimiter(100, 60*1000000000) // 60 secondes + r.Use(api.RateLimit(globalLimiter)) + + // ── Routes publiques (sans authentification) ─────────────────────────── + r.Get("/api/health", func(w http.ResponseWriter, r *http.Request) { + api.JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + // Routes d'installation (accessibles seulement si non-installé) + r.Group(func(r chi.Router) { + r.Use(requireNotInstalled(database)) + r.Get("/api/install/status", installHandler.GetStatus) + r.Post("/api/install/test-ssh", installHandler.TestSSH) + r.Post("/api/install/test-proxmox", installHandler.TestProxmoxToken) + r.Post("/api/install/configure", installHandler.Configure) + }) + + // Status d'installation accessible toujours (pour la redirection frontend) + r.Get("/api/install/check", func(w http.ResponseWriter, r *http.Request) { + installed, _ := database.IsInstalled() + api.JSONResponse(w, http.StatusOK, map[string]bool{"installed": installed}) + }) + + // Routes d'authentification + r.Post("/api/auth/login", authHandler.Login) + r.Post("/api/auth/refresh", authHandler.Refresh) + + // ── Routes protégées (JWT requis) ────────────────────────────────────── + r.Group(func(r chi.Router) { + r.Use(api.RequireAuth(jwtManager)) + + r.Post("/api/auth/logout", authHandler.Logout) + r.Get("/api/auth/me", authHandler.Me) + r.Patch("/api/auth/preferences", authHandler.UpdatePreferences) + + // Proxmox + r.Get("/api/proxmox/resources", proxmoxHandler.GetResources) + r.Get("/api/proxmox/lxc", proxmoxHandler.GetLXC) + + // Actions Proxmox — admin uniquement + r.Group(func(r chi.Router) { + r.Use(api.RequireAdmin) + r.Post("/api/proxmox/lxc/{vmid}/start", proxmoxHandler.StartLXC) + r.Post("/api/proxmox/lxc/{vmid}/stop", proxmoxHandler.StopLXC) + }) + + // Mises à jour — admin uniquement + r.Group(func(r chi.Router) { + r.Use(api.RequireAdmin) + r.Post("/api/updates/run", updatesHandler.RunUpdate) + }) + r.Get("/api/updates/history", updatesHandler.GetHistory) + + // Paramètres — admin uniquement + r.Group(func(r chi.Router) { + r.Use(api.RequireAdmin) + r.Get("/api/settings", settingsHandler.GetAll) + r.Put("/api/settings/{key}", settingsHandler.UpdateSetting) + r.Get("/api/settings/audit", settingsHandler.GetAuditLog) + }) + + // Modules + r.Get("/api/modules", settingsHandler.GetModules) + r.Group(func(r chi.Router) { + r.Use(api.RequireAdmin) + r.Post("/api/modules/{id}/enable", settingsHandler.EnableModule) + r.Post("/api/modules/{id}/disable", settingsHandler.DisableModule) + }) + + // WebSocket — les routes WS extraient le token via query param + r.Get("/ws/proxmox", proxmoxHandler.WebSocket) + r.Get("/ws/updates/{jobId}", updatesHandler.WebSocketUpdate) + r.Get("/ws/terminal", terminalHandler.WebSocket) + }) + + // Routes enregistrées par les modules actifs + for _, route := range loader.Registry().GetRoutes() { + routeCopy := route // Capturer la variable pour la closure + if routeCopy.RequireAdmin { + r.With(api.RequireAuth(jwtManager), api.RequireAdmin).MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler) + } else { + r.With(api.RequireAuth(jwtManager)).MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler) + } + } + + // Servir les assets frontend (en production, c'est Nginx qui s'en charge) + if _, err := os.Stat("./static"); err == nil { + fs := http.FileServer(http.Dir("./static")) + r.Handle("/*", fs) + } + + // ── Démarrage du serveur ─────────────────────────────────────────────── + addr := getEnv("LISTEN_ADDR", ":3001") + log.Printf("Serveur démarré sur %s", addr) + if err := http.ListenAndServe(addr, r); err != nil { + log.Fatalf("Serveur arrêté : %v", err) + } +} + +// requireNotInstalled est un middleware qui bloque les routes d'installation si déjà installé. +func requireNotInstalled(database *db.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // La route /api/install/status reste accessible pour le check + installed, _ := database.IsInstalled() + if installed { + api.JSONError(w, "Application déjà installée", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// getEnv lit une variable d'environnement avec une valeur par défaut. +func getEnv(key, defaultValue string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultValue +} diff --git a/backend/modules/dashboard/README.md b/backend/modules/dashboard/README.md new file mode 100644 index 0000000..abaccca --- /dev/null +++ b/backend/modules/dashboard/README.md @@ -0,0 +1,44 @@ +# Module — Dashboard + +**Type**: Core (always enabled) + +Provides the main dashboard with a configurable, per-user widget grid. + +## Features + +- Drag-and-drop widget reordering (saved per user in SQLite) +- Add and remove widgets via modal +- Widget layout persisted across sessions + +## Widget Types + +| Type | Description | +|------|-------------| +| `shortcut` | Clickable link card (icon + label + URL) | +| `lxc_status` | Live status of a specific LXC container | +| `metrics` | Host CPU/RAM/disk summary | + +## API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/dashboard/widgets` | JWT | Get current user's widget layout | +| PUT | `/api/dashboard/widgets` | JWT | Save widget layout | + +## Widget Layout Format + +```json +[ + { "id": "w1", "type": "shortcut", "config": { "label": "Proxmox", "url": "https://proxmox.example.com", "icon": "server" } }, + { "id": "w2", "type": "lxc_status", "config": { "vmid": 100 } }, + { "id": "w3", "type": "metrics", "config": {} } +] +``` + +## Database + +Layouts are stored in the `user_widgets` table, keyed by user ID. + +## License + +MIT — see [LICENSE](../../LICENSE) diff --git a/backend/modules/files/README.md b/backend/modules/files/README.md new file mode 100644 index 0000000..3e382c2 --- /dev/null +++ b/backend/modules/files/README.md @@ -0,0 +1,41 @@ +# Module — Files + +**Type**: Optional (disabled by default) + +SFTP-based file browser for the Proxmox host and LXC containers. Navigate, view, edit, upload, and download files directly from the browser. + +## Planned Features + +- Directory listing with permissions, size, modification date +- File preview (text, JSON, YAML, shell scripts, logs) +- File editing via CodeMirror 6 (syntax highlighting for common formats) +- Upload and download +- Create/delete files and directories +- Navigate into LXC containers via `pct exec` or direct SFTP + +## Planned API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/files/list` | JWT | List directory contents | +| GET | `/api/files/read` | JWT | Read file content | +| PUT | `/api/files/write` | JWT+Admin | Write file content | +| POST | `/api/files/mkdir` | JWT+Admin | Create directory | +| DELETE | `/api/files/delete` | JWT+Admin | Delete file or directory | +| GET | `/api/files/download` | JWT | Download file | +| POST | `/api/files/upload` | JWT+Admin | Upload file | + +Query parameters: `path=`, `host=` + +## Status + +> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full SFTP implementation is planned for a future release. + +## Requirements + +- SSH/SFTP access to the target host +- The `ssh_host`, `ssh_username`, `ssh_password` settings must be configured + +## License + +MIT — see [LICENSE](../../LICENSE) diff --git a/backend/modules/loader.go b/backend/modules/loader.go new file mode 100644 index 0000000..76e7cd0 --- /dev/null +++ b/backend/modules/loader.go @@ -0,0 +1,150 @@ +// Package modules — Loader de modules. +// Découvre les modules disponibles, vérifie leur état en DB, et les initialise si activés. +// Un module désactivé ne fait appel à aucune de ses méthodes Register(). +package modules + +import ( + "database/sql" + "fmt" + "log" + "net/http" +) + +// Loader charge et gère les modules actifs. +type Loader struct { + db *sql.DB + registry *coreRegistry + modules []Module +} + +// NewLoader crée un Loader avec le router et la DB fournis. +func NewLoader(db *sql.DB) *Loader { + return &Loader{ + db: db, + registry: newCoreRegistry(db), + } +} + +// RegisterModule enregistre un module disponible (appelé à l'init, depuis main.go). +// Le module sera initialisé seulement s'il est activé en base. +func (l *Loader) RegisterModule(m Module) { + l.modules = append(l.modules, m) +} + +// LoadActive charge et initialise tous les modules activés en base de données. +func (l *Loader) LoadActive() error { + for _, m := range l.modules { + enabled, err := l.isEnabled(m.ID()) + if err != nil { + return fmt.Errorf("vérification module %s : %w", m.ID(), err) + } + if !enabled { + log.Printf("Module %s : désactivé, ignoré", m.ID()) + continue + } + + log.Printf("Module %s : chargement...", m.ID()) + if err := m.Register(l.registry); err != nil { + return fmt.Errorf("initialisation module %s : %w", m.ID(), err) + } + log.Printf("Module %s : chargé avec succès", m.ID()) + } + return nil +} + +// isEnabled vérifie en base de données si un module est activé. +func (l *Loader) isEnabled(id string) (bool, error) { + var enabled int + err := l.db.QueryRow(`SELECT is_enabled FROM modules WHERE id = ?`, id).Scan(&enabled) + if err == sql.ErrNoRows { + return false, nil // Module inconnu = désactivé + } + return enabled == 1, err +} + +// Registry retourne le registry partagé (pour accès par le serveur HTTP). +func (l *Loader) Registry() *coreRegistry { + return l.registry +} + +// ---- Implémentation interne du Registry ---- + +// RouteEntry décrit une route HTTP enregistrée par un module. +type RouteEntry struct { + Method string + Path string + Handler http.HandlerFunc + RequireAdmin bool +} + +type migrationEntry struct { + version int + sql string + fn MigrationFn +} + +type translationEntry struct { + lang string + keys map[string]string +} + +// coreRegistry implémente l'interface Registry. +type coreRegistry struct { + db *sql.DB + routes []RouteEntry + wsChannels map[string]WSHandler + widgets []WidgetDef + settingsTabs []SettingsTabDef + migrations []migrationEntry + translations []translationEntry +} + +func newCoreRegistry(db *sql.DB) *coreRegistry { + return &coreRegistry{ + db: db, + wsChannels: make(map[string]WSHandler), + } +} + +func (r *coreRegistry) RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool) { + r.routes = append(r.routes, RouteEntry{method, path, handler, requireAdmin}) +} + +func (r *coreRegistry) RegisterWSChannel(channel string, handler WSHandler) { + r.wsChannels[channel] = handler +} + +func (r *coreRegistry) RegisterWidget(widget WidgetDef) { + r.widgets = append(r.widgets, widget) +} + +func (r *coreRegistry) RegisterSettingsTab(tab SettingsTabDef) { + r.settingsTabs = append(r.settingsTabs, tab) +} + +func (r *coreRegistry) RegisterTranslations(lang string, keys map[string]string) { + r.translations = append(r.translations, translationEntry{lang, keys}) +} + +func (r *coreRegistry) RegisterMigration(version int, sqlStr string, fn MigrationFn) { + r.migrations = append(r.migrations, migrationEntry{version, sqlStr, fn}) +} + +func (r *coreRegistry) DB() *sql.DB { + return r.db +} + +// GetRoutes retourne les routes enregistrées par les modules. +func (r *coreRegistry) GetRoutes() []RouteEntry { + return r.routes +} + +// GetWidgets retourne les types de widgets disponibles. +func (r *coreRegistry) GetWidgets() []WidgetDef { + return r.widgets +} + +// GetSettingsTabs retourne les onglets de paramètres des modules. +func (r *coreRegistry) GetSettingsTabs() []SettingsTabDef { + return r.settingsTabs +} diff --git a/backend/modules/logs/README.md b/backend/modules/logs/README.md new file mode 100644 index 0000000..fd9196a --- /dev/null +++ b/backend/modules/logs/README.md @@ -0,0 +1,45 @@ +# Module — Logs + +**Type**: Optional (disabled by default) + +Stream and browse system logs from the Proxmox host or LXC containers in real time via WebSocket (`tail -f` equivalent). + +## Planned Features + +- Real-time log streaming via WebSocket +- Common log sources: `syslog`, `auth.log`, `kern.log`, journald +- Filter by log level (error, warning, info) +- Stop/start streaming on demand +- LXC log access via `pct exec` + +## Planned API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/logs/sources` | JWT | List available log sources | + +## Planned WebSocket Endpoint + +`GET /ws/logs/{source}?token=&host=` + +Where `source` is a log name such as `syslog`, `auth`, or `journal`. + +Message types: + +| Type | Payload | Description | +|------|---------|-------------| +| `log_line` | `{ "line": "...", "level": "info" }` | New log line | +| `log_end` | — | Stream closed (e.g. SSH disconnected) | + +## Status + +> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full implementation is planned for a future release. + +## Requirements + +- SSH access to the target host +- Read permissions on the log files (root or appropriate group) + +## License + +MIT — see [LICENSE](../../LICENSE) diff --git a/backend/modules/module.go b/backend/modules/module.go new file mode 100644 index 0000000..a624774 --- /dev/null +++ b/backend/modules/module.go @@ -0,0 +1,66 @@ +// Package modules définit le contrat d'interface pour les modules ProxmoxPanel. +// Chaque module implémente l'interface Module et s'enregistre auprès du ModuleRegistry. +package modules + +import ( + "database/sql" + "net/http" +) + +// Module est l'interface que chaque module doit implémenter. +type Module interface { + // ID retourne l'identifiant unique du module (doit correspondre à la table modules en DB). + ID() string + + // Register est appelé au chargement du module actif. + // Il reçoit le registry pour enregistrer ses routes, widgets, etc. + Register(registry Registry) error +} + +// Registry est l'interface exposée aux modules pour s'enregistrer dans le CORE. +type Registry interface { + // RegisterRoute enregistre une route HTTP dans le router principal. + RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool) + + // RegisterWSChannel enregistre un handler WebSocket pour un channel nommé. + RegisterWSChannel(channel string, handler WSHandler) + + // RegisterWidget déclare un type de widget disponible pour le dashboard. + RegisterWidget(widget WidgetDef) + + // RegisterSettingsTab ajoute un onglet dans la page paramètres. + RegisterSettingsTab(tab SettingsTabDef) + + // RegisterTranslations fusionne des clés de traduction pour une langue donnée. + RegisterTranslations(lang string, keys map[string]string) + + // RegisterMigration déclare une migration de base de données propre au module. + RegisterMigration(version int, sql string, fn MigrationFn) + + // DB retourne un accès à SQLite avec isolation par module (préfixe de tables). + DB() *sql.DB +} + +// WSHandler est un handler WebSocket pour un channel nommé. +type WSHandler func(userID int64, send chan<- []byte, recv <-chan []byte) + +// WidgetDef décrit un type de widget disponible pour le dashboard. +type WidgetDef struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + DefaultW int `json:"default_width"` + DefaultH int `json:"default_height"` +} + +// SettingsTabDef décrit un onglet de paramètres fourni par un module. +type SettingsTabDef struct { + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon"` + // Path est le chemin frontend du composant Vue à charger (lazy import). + ComponentPath string `json:"component_path"` +} + +// MigrationFn est une fonction de migration optionnelle (pour les migrations non-SQL). +type MigrationFn func(db *sql.DB) error diff --git a/backend/modules/services/README.md b/backend/modules/services/README.md new file mode 100644 index 0000000..cc5d4a4 --- /dev/null +++ b/backend/modules/services/README.md @@ -0,0 +1,55 @@ +# Module — Services + +**Type**: Optional (disabled by default) + +Manage systemd services on the Proxmox host and LXC containers. Check status, start, stop, and restart services directly from the web interface. + +## Planned Features + +- List systemd services with current status (active/inactive/failed) +- Start, stop, restart, reload actions +- View service logs (last N lines via `journalctl -u `) +- Filter by status or name +- LXC service management via `pct exec` + +## Planned API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/services` | JWT | List services and their status | +| POST | `/api/services/{name}/start` | JWT+Admin | Start a service | +| POST | `/api/services/{name}/stop` | JWT+Admin | Stop a service | +| POST | `/api/services/{name}/restart` | JWT+Admin | Restart a service | +| POST | `/api/services/{name}/reload` | JWT+Admin | Reload a service | +| GET | `/api/services/{name}/logs` | JWT | Last 100 log lines | + +Query parameter: `host=` to target a specific LXC. + +## How It Works + +Commands are executed over SSH using `systemctl`: + +```bash +systemctl status nginx +systemctl restart nginx +journalctl -u nginx -n 100 --no-pager +``` + +For LXC containers: + +```bash +pct exec -- systemctl restart nginx +``` + +## Status + +> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full implementation is planned for a future release. + +## Requirements + +- SSH access with sufficient privileges to run `systemctl` commands +- `systemd` on the target host/containers + +## License + +MIT — see [LICENSE](../../LICENSE) diff --git a/backend/modules/terminal/README.md b/backend/modules/terminal/README.md new file mode 100644 index 0000000..c18b55f --- /dev/null +++ b/backend/modules/terminal/README.md @@ -0,0 +1,54 @@ +# Module — Terminal + +**Type**: Optional (disabled by default) + +Interactive SSH terminal in the browser. Connects to the Proxmox host (or any SSH-accessible target) and opens a full PTY session via WebSocket. + +## Features + +- Full PTY support (`xterm-256color`, interactive shell) +- Responsive resizing — the terminal adjusts when the browser window is resized +- Terminal theme matches the panel's Neumorphism dark/light design +- Audit log entry on open and close + +## WebSocket Endpoint + +`GET /ws/terminal?token=&host=` + +If `host` is not specified, the SSH host configured during installation is used. + +### Message Format + +**Client → Server** (keyboard input): raw binary bytes + +**Client → Server** (resize event): JSON text frame + +```json +{ "type": "resize", "cols": 120, "rows": 40 } +``` + +**Server → Client** (terminal output): raw binary bytes + +## Frontend + +Uses [xterm.js](https://xtermjs.org/) with the following addons: + +- `@xterm/addon-fit` — auto-resize to container dimensions +- `@xterm/addon-attach` — attach xterm directly to a WebSocket + +## How It Works + +1. WebSocket connection is established and JWT is validated +2. Backend opens an SSH connection using stored credentials +3. A PTY session is requested (`xterm-256color`, initial size 80×24) +4. An interactive shell is launched +5. All data flows bidirectionally: WebSocket ↔ SSH ↔ PTY + +## Requirements + +- SSH access to the target host (password authentication) +- The `ssh_host`, `ssh_username`, `ssh_password` settings must be configured + +## License + +MIT — see [LICENSE](../../LICENSE) diff --git a/backend/modules/updates/README.md b/backend/modules/updates/README.md new file mode 100644 index 0000000..88fcc52 --- /dev/null +++ b/backend/modules/updates/README.md @@ -0,0 +1,68 @@ +# Module — Updates + +**Type**: Core (always enabled) + +Run `apt update && apt full-upgrade` on the Proxmox host or any LXC container, with real-time streaming output via WebSocket. + +## Features + +- Target: host, a specific LXC (`lxc:100`), or all LXC containers at once +- Output streamed line-by-line via WebSocket — no polling required +- Full output saved to `update_history` table in SQLite +- Admin-only action + +## API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/updates/run` | JWT+Admin | Start an update job | +| GET | `/api/updates/history` | JWT | List past update jobs (last 50) | + +### POST /api/updates/run + +```json +{ "target": "host" } +``` + +```json +{ "target": "lxc:100" } +``` + +```json +{ "target": "all" } +``` + +Response: + +```json +{ "job_id": "1710000000-ab3f9z12", "message": "Mise à jour démarrée" } +``` + +## WebSocket Streaming + +Connect to `GET /ws/updates/{jobId}?token=` to receive output in real time. + +Message types published on the channel: + +| Type | Payload | Description | +|------|---------|-------------| +| `update_output` | `{ "chunk": "..." }` | Line(s) of apt output | +| `update_done` | `{ "job_id": "..." }` | Job completed successfully | +| `update_error` | `{ "error": "..." }` | Job failed | + +## How It Works + +Updates run over SSH using the credentials configured during installation: + +- **Host**: runs `DEBIAN_FRONTEND=noninteractive apt-get update && apt-get full-upgrade -y` directly +- **LXC**: runs the same command via `pct exec -- bash -c '...'` +- **All**: iterates over `pct list` output and updates each container + +## Requirements + +- SSH access to the Proxmox host with sudo/root privileges +- `pct` available on the host (standard on Proxmox VE) + +## License + +MIT — see [LICENSE](../../LICENSE) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..25e0a0b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +# ProxmoxPanel — Docker Compose +# Démarrage : docker-compose up --build +# Accès : http://localhost (port 80) ou https://panel.geronzi.fr via Traefik + +services: + + # ── Backend Go (API + WebSocket) ──────────────────────────────────────── + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: proxmoxpanel-backend + restart: unless-stopped + expose: + - "3001" + volumes: + # Volume persistant pour SQLite, clés JWT, clé maître AES + - panel-data:/app/data + environment: + - DATA_DIR=/app/data + - LISTEN_ADDR=:3001 + - APP_ENV=production + # Pas de réseau host — le container reste isolé + # Les connexions SSH sortantes vers le host Proxmox sont autorisées via le réseau Docker + networks: + - proxmoxpanel-net + # Limite de ressources recommandées + deploy: + resources: + limits: + memory: 256M + cpus: "1.0" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # ── Frontend Vue 3 (Nginx + assets statiques) ─────────────────────────── + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: proxmoxpanel-frontend + restart: unless-stopped + ports: + # En développement local : http://localhost:80 + # En production : Traefik se charge du port 443 → ce port est juste pour accès direct + - "80:80" + depends_on: + backend: + condition: service_healthy + networks: + - proxmoxpanel-net + deploy: + resources: + limits: + memory: 64M + cpus: "0.5" + +# ── Volumes ──────────────────────────────────────────────────────────────── +volumes: + # Données persistantes : SQLite + clés cryptographiques + # NE PAS supprimer ce volume sans sauvegarder panel.db au préalable + panel-data: + name: proxmoxpanel-data + +# ── Réseaux ──────────────────────────────────────────────────────────────── +networks: + proxmoxpanel-net: + name: proxmoxpanel-net + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6967a5a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +# ── Étape 1 : Build du frontend Vue 3 + Vite ─────────────────────────────── +FROM node:22-alpine AS builder + +WORKDIR /build + +# Copier les fichiers de dépendances en premier +COPY package.json package-lock.json* ./ +RUN npm install --frozen-lockfile + +# Copier le code source et compiler +COPY . . +RUN npm run build + +# ── Étape 2 : Image Nginx pour servir le frontend ────────────────────────── +FROM nginx:1.27-alpine + +# Supprimer la config Nginx par défaut +RUN rm /etc/nginx/conf.d/default.conf + +# Copier notre config Nginx personnalisée +COPY nginx.conf /etc/nginx/nginx.conf + +# Copier les fichiers compilés par Vite +COPY --from=builder /build/dist /usr/share/nginx/html + +# Vérifier la config Nginx au build +RUN nginx -t + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b11eb63 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + ProxmoxPanel + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ac4f5a0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,73 @@ +# Configuration Nginx pour le frontend ProxmoxPanel +# Sert les fichiers statiques et proxy les requêtes API/WebSocket vers le backend Go + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logs au format JSON pour faciliter l'analyse + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + # Compression gzip pour les assets statiques + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Cache agressif pour les assets avec hash dans le nom (Vite) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Proxy des requêtes API vers le backend Go + location /api/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; # Timeout long pour les mises à jour apt + } + + # Proxy des connexions WebSocket + location /ws/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 3600s; # 1 heure pour les sessions terminal + proxy_send_timeout 3600s; + } + + # SPA : toutes les autres routes servent index.html (Vue Router) + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..63eef78 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1994 @@ +{ + "name": "proxmoxpanel-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proxmoxpanel-frontend", + "version": "1.0.0", + "dependencies": { + "@codemirror/commands": "^6.8.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.10", + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/lang-python": "^6.1.7", + "@codemirror/language": "^6.10.8", + "@codemirror/state": "^6.5.1", + "@codemirror/theme-one-dark": "^6.1.2", + "@codemirror/view": "^6.36.3", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-draggable-plus": "^0.6.0", + "vue-i18n": "^11.0.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.3", + "vite": "^6.3.3", + "vue-tsc": "^2.2.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@intlify/core-base": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz", + "integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==", + "license": "MIT", + "dependencies": { + "@intlify/devtools-types": "11.3.0", + "@intlify/message-compiler": "11.3.0", + "@intlify/shared": "11.3.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz", + "integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.3.0", + "@intlify/shared": "11.3.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz", + "integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.3.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz", + "integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", + "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@xterm/addon-attach": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.11.0.tgz", + "integrity": "sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-draggable-plus": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/vue-draggable-plus/-/vue-draggable-plus-0.6.1.tgz", + "integrity": "sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==", + "license": "MIT", + "dependencies": { + "@types/sortablejs": "^1.15.8" + }, + "peerDependencies": { + "@types/sortablejs": "^1.15.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz", + "integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.3.0", + "@intlify/devtools-types": "11.3.0", + "@intlify/shared": "11.3.0", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d06edf2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "proxmoxpanel-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.5.0", + "pinia": "^2.3.0", + "vue-i18n": "^11.0.0", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-attach": "^0.11.0", + "@codemirror/state": "^6.5.1", + "@codemirror/view": "^6.36.3", + "@codemirror/commands": "^6.8.0", + "@codemirror/language": "^6.10.8", + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.10", + "@codemirror/lang-python": "^6.1.7", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/theme-one-dark": "^6.1.2", + "vue-draggable-plus": "^0.6.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.3", + "vite": "^6.3.3", + "vue-tsc": "^2.2.10" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..23cc868 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue new file mode 100644 index 0000000..e057acf --- /dev/null +++ b/frontend/src/components/Layout.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/frontend/src/components/Navbar.vue b/frontend/src/components/Navbar.vue new file mode 100644 index 0000000..970093b --- /dev/null +++ b/frontend/src/components/Navbar.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue new file mode 100644 index 0000000..dff0fcf --- /dev/null +++ b/frontend/src/components/Sidebar.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..b79f8f1 --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,140 @@ +{ + "nav": { + "dashboard": "Dashboard", + "proxmox": "Proxmox", + "updates": "Updates", + "terminal": "Terminal", + "files": "Files", + "logs": "Logs", + "services": "Services", + "settings": "Settings", + "modules": "Modules" + }, + "navbar": { + "darkMode": "Dark mode", + "lightMode": "Light mode", + "logout": "Logout" + }, + "install": { + "subtitle": "Initial configuration of the management panel", + "step1": { "label": "General", "title": "General configuration", "desc": "Configure the instance name and public URL." }, + "step2": { "label": "SSH", "title": "SSH Connection", "desc": "Configure SSH access to the Proxmox server. These credentials will be used for authentication and management." }, + "step3": { "label": "Proxmox", "title": "Proxmox API", "desc": "Optional — Configure the API token to access Proxmox metrics." }, + "step4": { "label": "Confirm", "title": "Confirm configuration" }, + "instanceName": "Instance name", + "instanceNamePlaceholder": "My Proxmox server", + "publicUrl": "Public URL", + "publicUrlHint": "Auto-detected: {url}", + "defaultLang": "Default language", + "sshHost": "SSH host (host:port)", + "sshUsername": "SSH username", + "sshPassword": "SSH password", + "testSSH": "Test SSH connection", + "sshSuccess": "SSH connection successful!", + "sshFailed": "SSH connection failed", + "proxmoxUrl": "Proxmox URL", + "proxmoxToken": "Proxmox API token", + "proxmoxTokenHint": "Format: PVEAPIToken=user@realm!tokenid=secret", + "back": "Back", + "next": "Next", + "finish": "Complete installation", + "error": "Installation error", + "networkError": "Network error" + }, + "login": { + "subtitle": "Login with your Linux credentials", + "username": "Username", + "usernamePlaceholder": "Your Linux login", + "password": "Password", + "passwordPlaceholder": "Your password", + "submit": "Login", + "loading": "Logging in...", + "error": "Authentication error", + "hint": "Use your server's Linux credentials" + }, + "dashboard": { + "welcome": "Hello, {name}", + "addWidget": "Add widget", + "lxcStatus": "LXC Status", + "metrics": "Metrics", + "noData": "No data available", + "lxcCount": "Total LXC", + "running": "Running", + "widgetShortcut": "Shortcut", + "widgetLXC": "LXC Status", + "widgetMetrics": "Metrics" + }, + "proxmox": { + "all": "All", + "lxc": "LXC", + "vm": "VM", + "running": "Running", + "stopped": "Stopped", + "start": "Start", + "stop": "Stop", + "error": "Proxmox API error", + "liveUpdates": "Live updates", + "disconnected": "Disconnected" + }, + "updates": { + "desc": "Run apt updates on the host or LXC containers.", + "selectTarget": "Select target", + "targetHost": "Proxmox Host", + "targetAll": "All LXC", + "start": "Start update", + "running": "Updating...", + "output": "Output", + "history": "History", + "noHistory": "No updates performed", + "status": { + "running": "Running", + "success": "Success", + "error": "Error", + "pending": "Pending" + } + }, + "terminal": { + "connected": "Connected to {host}", + "disconnected": "Disconnected", + "connect": "Connect", + "reconnect": "Reconnect" + }, + "files": { + "desc": "SFTP file browser", + "moduleNotEnabled": "Module not enabled. Go to Settings → Modules to enable it." + }, + "settings": { + "general": "General", + "infrastructure": "Infrastructure", + "appearance": "Appearance", + "audit": "Audit log", + "instanceName": "Instance name", + "publicUrl": "Public URL", + "defaultLang": "Default language", + "sshHost": "SSH host", + "sshUsername": "SSH username", + "proxmoxUrl": "Proxmox URL", + "darkMode": "Dark mode", + "sidebarPosition": "Sidebar position", + "left": "Left", + "right": "Right", + "noAuditLog": "No audit log entries" + }, + "modules": { + "desc": "Manage installed modules on ProxmoxPanel.", + "enabled": "Enabled", + "disabled": "Disabled", + "enable": "Enable", + "disable": "Disable", + "coreProtected": "CORE module (cannot be disabled)", + "restartNotice": "A server restart is required to apply changes." + }, + "common": { + "refresh": "Refresh", + "save": "Save", + "saving": "Saving...", + "saved": "Saved!", + "cancel": "Cancel", + "networkError": "Network error" + } +} diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json new file mode 100644 index 0000000..db36433 --- /dev/null +++ b/frontend/src/locales/fr.json @@ -0,0 +1,140 @@ +{ + "nav": { + "dashboard": "Tableau de bord", + "proxmox": "Proxmox", + "updates": "Mises à jour", + "terminal": "Terminal", + "files": "Fichiers", + "logs": "Journaux", + "services": "Services", + "settings": "Paramètres", + "modules": "Modules" + }, + "navbar": { + "darkMode": "Mode sombre", + "lightMode": "Mode clair", + "logout": "Se déconnecter" + }, + "install": { + "subtitle": "Configuration initiale du panneau de gestion", + "step1": { "label": "Général", "title": "Configuration générale", "desc": "Configurez le nom de l'instance et l'URL publique." }, + "step2": { "label": "SSH", "title": "Connexion SSH", "desc": "Configurez l'accès SSH au serveur Proxmox. Ces identifiants seront utilisés pour l'authentification et la gestion." }, + "step3": { "label": "Proxmox", "title": "API Proxmox", "desc": "Optionnel — Configurez le token API pour accéder aux métriques Proxmox." }, + "step4": { "label": "Confirmation", "title": "Confirmer la configuration" }, + "instanceName": "Nom de l'instance", + "instanceNamePlaceholder": "Mon serveur Proxmox", + "publicUrl": "URL publique", + "publicUrlHint": "Détecté automatiquement : {url}", + "defaultLang": "Langue par défaut", + "sshHost": "Hôte SSH (host:port)", + "sshUsername": "Nom d'utilisateur SSH", + "sshPassword": "Mot de passe SSH", + "testSSH": "Tester la connexion SSH", + "sshSuccess": "Connexion SSH réussie !", + "sshFailed": "Connexion SSH échouée", + "proxmoxUrl": "URL Proxmox", + "proxmoxToken": "Token API Proxmox", + "proxmoxTokenHint": "Format : PVEAPIToken=user@realm!tokenid=secret", + "back": "Retour", + "next": "Suivant", + "finish": "Terminer l'installation", + "error": "Erreur lors de l'installation", + "networkError": "Erreur réseau" + }, + "login": { + "subtitle": "Connectez-vous avec vos identifiants Linux", + "username": "Nom d'utilisateur", + "usernamePlaceholder": "Votre login Linux", + "password": "Mot de passe", + "passwordPlaceholder": "Votre mot de passe", + "submit": "Se connecter", + "loading": "Connexion...", + "error": "Erreur d'authentification", + "hint": "Utilisez vos identifiants Linux du serveur" + }, + "dashboard": { + "welcome": "Bonjour, {name}", + "addWidget": "Ajouter un widget", + "lxcStatus": "Statut LXC", + "metrics": "Métriques", + "noData": "Données non disponibles", + "lxcCount": "LXC Total", + "running": "En cours", + "widgetShortcut": "Raccourci", + "widgetLXC": "Statut LXC", + "widgetMetrics": "Métriques" + }, + "proxmox": { + "all": "Tous", + "lxc": "LXC", + "vm": "VM", + "running": "En marche", + "stopped": "Arrêté", + "start": "Démarrer", + "stop": "Arrêter", + "error": "Erreur API Proxmox", + "liveUpdates": "Mises à jour en temps réel", + "disconnected": "Déconnecté" + }, + "updates": { + "desc": "Lancez des mises à jour apt sur le host ou les LXC.", + "selectTarget": "Sélectionner la cible", + "targetHost": "Host Proxmox", + "targetAll": "Tous les LXC", + "start": "Lancer la mise à jour", + "running": "Mise à jour en cours...", + "output": "Sortie", + "history": "Historique", + "noHistory": "Aucune mise à jour effectuée", + "status": { + "running": "En cours", + "success": "Succès", + "error": "Erreur", + "pending": "En attente" + } + }, + "terminal": { + "connected": "Connecté à {host}", + "disconnected": "Déconnecté", + "connect": "Connecter", + "reconnect": "Reconnecter" + }, + "files": { + "desc": "Navigateur de fichiers SFTP", + "moduleNotEnabled": "Module non activé. Rendez-vous dans Paramètres → Modules pour l'activer." + }, + "settings": { + "general": "Général", + "infrastructure": "Infrastructure", + "appearance": "Apparence", + "audit": "Journal d'audit", + "instanceName": "Nom de l'instance", + "publicUrl": "URL publique", + "defaultLang": "Langue par défaut", + "sshHost": "Hôte SSH", + "sshUsername": "Utilisateur SSH", + "proxmoxUrl": "URL Proxmox", + "darkMode": "Mode sombre", + "sidebarPosition": "Position de la sidebar", + "left": "Gauche", + "right": "Droite", + "noAuditLog": "Aucune entrée dans le journal" + }, + "modules": { + "desc": "Gérez les modules installés sur ProxmoxPanel.", + "enabled": "Actif", + "disabled": "Inactif", + "enable": "Activer", + "disable": "Désactiver", + "coreProtected": "Module CORE (non désactivable)", + "restartNotice": "Un redémarrage du serveur est nécessaire pour appliquer les changements." + }, + "common": { + "refresh": "Actualiser", + "save": "Sauvegarder", + "saving": "Sauvegarde...", + "saved": "Sauvegardé !", + "cancel": "Annuler", + "networkError": "Erreur réseau" + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..b2ecc37 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,37 @@ +// Point d'entrée de l'application ProxmoxPanel Frontend. +// Initialise Vue 3, Pinia, Vue Router et vue-i18n. +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { createI18n } from 'vue-i18n' + +import App from './App.vue' +import router from './router/index' + +// Imports des fichiers de traduction (locaux, pas de CDN) +import fr from './locales/fr.json' +import en from './locales/en.json' + +// Styles Neumorphism — chargés globalement +import './styles/neu.css' +import './styles/dark.css' +import './styles/light.css' + +// Déterminer la locale initiale (localStorage > défaut 'fr') +const savedLocale = localStorage.getItem('pxp_locale') || 'fr' + +// Initialisation vue-i18n +const i18n = createI18n({ + legacy: false, // Utiliser la Composition API + locale: savedLocale, + fallbackLocale: 'en', + messages: { fr, en }, +}) + +const pinia = createPinia() +const app = createApp(App) + +app.use(pinia) +app.use(router) +app.use(i18n) + +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..bbb7e17 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,128 @@ +// Configuration du routeur Vue — gère la navigation et la protection des routes. +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth.store' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + // Page d'installation (premier lancement) + { + path: '/install', + name: 'install', + component: () => import('@/views/Install.vue'), + meta: { public: true, hideLayout: true }, + }, + + // Authentification + { + path: '/login', + name: 'login', + component: () => import('@/views/Login.vue'), + meta: { public: true, hideLayout: true }, + }, + + // Application principale (protégée) + { + path: '/', + component: () => import('@/components/Layout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'dashboard', + component: () => import('@/views/Dashboard.vue'), + }, + { + path: 'proxmox', + name: 'proxmox', + component: () => import('@/views/Proxmox.vue'), + }, + { + path: 'updates', + name: 'updates', + component: () => import('@/views/Updates.vue'), + }, + { + path: 'files', + name: 'files', + component: () => import('@/views/Files.vue'), + meta: { module: 'files' }, + }, + { + path: 'terminal', + name: 'terminal', + component: () => import('@/views/Terminal.vue'), + meta: { module: 'terminal' }, + }, + { + path: 'logs', + name: 'logs', + component: () => import('@/views/Logs.vue'), + meta: { module: 'logs' }, + }, + { + path: 'services', + name: 'services', + component: () => import('@/views/Services.vue'), + meta: { module: 'services' }, + }, + { + path: 'settings', + name: 'settings', + component: () => import('@/views/Settings.vue'), + }, + { + path: 'modules', + name: 'modules', + component: () => import('@/views/Modules.vue'), + meta: { requiresAdmin: true }, + }, + ], + }, + + // Redirection 404 + { + path: '/:pathMatch(.*)*', + redirect: '/', + }, + ], +}) + +// Guard de navigation : vérification authentification et installation +router.beforeEach(async (to) => { + const authStore = useAuthStore() + + // Vérifier si l'application est installée (appel API au premier chargement) + if (!authStore.installChecked) { + await authStore.checkInstallation() + } + + // Rediriger vers l'installation si pas encore configuré + if (!authStore.isInstalled && to.name !== 'install') { + return { name: 'install' } + } + + // Si installé et route d'install → rediriger vers le dashboard + if (authStore.isInstalled && to.name === 'install') { + return { name: 'dashboard' } + } + + // Routes publiques — passer directement + if (to.meta.public) return true + + // Routes protégées — vérifier l'authentification + if (to.meta.requiresAuth || to.matched.some(r => r.meta.requiresAuth)) { + if (!authStore.isAuthenticated) { + return { name: 'login', query: { redirect: to.fullPath } } + } + } + + // Routes admin uniquement + if (to.meta.requiresAdmin && !authStore.user?.is_admin) { + return { name: 'dashboard' } + } + + return true +}) + +export default router diff --git a/frontend/src/stores/auth.store.ts b/frontend/src/stores/auth.store.ts new file mode 100644 index 0000000..0c4bef3 --- /dev/null +++ b/frontend/src/stores/auth.store.ts @@ -0,0 +1,180 @@ +// Store d'authentification — gère la session JWT, le profil utilisateur et l'état d'installation. +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface User { + id: number + username: string + is_admin: boolean + lang: string + theme: string + sidebar_position: string +} + +export const useAuthStore = defineStore('auth', () => { + // État + const user = ref(null) + const accessToken = ref(localStorage.getItem('pxp_token')) + const isInstalled = ref(false) + const installChecked = ref(false) + + // Computed + const isAuthenticated = computed(() => !!accessToken.value && !!user.value) + + // ── Actions ────────────────────────────────────────────────────────────── + + /** + * Vérifie si l'application est installée via l'API. + * Appelé une seule fois au démarrage par le router guard. + */ + async function checkInstallation(): Promise { + try { + const res = await fetch('/api/install/check') + if (res.ok) { + const data = await res.json() + isInstalled.value = data.installed + } + } catch { + // En cas d'erreur réseau, on suppose installé pour éviter une boucle + isInstalled.value = true + } finally { + installChecked.value = true + } + } + + /** + * Authentifie l'utilisateur avec ses credentials Linux. + */ + async function login(username: string, password: string): Promise { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + + if (!res.ok) { + const err = await res.json() + throw new Error(err.error || 'Erreur d\'authentification') + } + + const data = await res.json() + accessToken.value = data.access_token + localStorage.setItem('pxp_token', data.access_token) + user.value = data.user + + // Planifier le renouvellement automatique avant expiration (14 min) + scheduleRefresh(14 * 60 * 1000) + } + + /** + * Tente de renouveler le token via le cookie httpOnly (pxp_refresh). + * Appelé au démarrage de l'application. + */ + async function tryRefresh(): Promise { + const token = localStorage.getItem('pxp_token') + if (!token) return + + try { + const res = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', // Inclure le cookie httpOnly + }) + + if (res.ok) { + const data = await res.json() + accessToken.value = data.access_token + localStorage.setItem('pxp_token', data.access_token) + + // Charger le profil utilisateur + await fetchMe() + scheduleRefresh(14 * 60 * 1000) + } else { + // Refresh échoué — nettoyer la session + clearSession() + } + } catch { + clearSession() + } + } + + /** + * Charge le profil de l'utilisateur connecté. + */ + async function fetchMe(): Promise { + if (!accessToken.value) return + + const res = await fetch('/api/auth/me', { + headers: { Authorization: `Bearer ${accessToken.value}` }, + }) + + if (res.ok) { + user.value = await res.json() + } + } + + /** + * Déconnecte l'utilisateur. + */ + async function logout(): Promise { + try { + await fetch('/api/auth/logout', { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken.value}` }, + credentials: 'include', + }) + } finally { + clearSession() + } + } + + /** + * Met à jour les préférences de l'utilisateur (thème, langue, sidebar). + */ + async function updatePreferences(prefs: Partial>): Promise { + if (!accessToken.value) return + + await fetch('/api/auth/preferences', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken.value}`, + }, + body: JSON.stringify(prefs), + }) + + // Mettre à jour localement + if (user.value) { + Object.assign(user.value, prefs) + } + } + + // ── Helpers privés ──────────────────────────────────────────────────────── + + let refreshTimer: ReturnType | null = null + + function scheduleRefresh(delayMs: number): void { + if (refreshTimer) clearTimeout(refreshTimer) + refreshTimer = setTimeout(() => tryRefresh(), delayMs) + } + + function clearSession(): void { + user.value = null + accessToken.value = null + localStorage.removeItem('pxp_token') + if (refreshTimer) clearTimeout(refreshTimer) + } + + return { + user, + accessToken, + isInstalled, + installChecked, + isAuthenticated, + checkInstallation, + login, + logout, + tryRefresh, + fetchMe, + updatePreferences, + } +}) diff --git a/frontend/src/stores/ui.store.ts b/frontend/src/stores/ui.store.ts new file mode 100644 index 0000000..ee4f1f7 --- /dev/null +++ b/frontend/src/stores/ui.store.ts @@ -0,0 +1,84 @@ +// Store UI — gère le thème (dark/light) et la position de la sidebar. +// Les préférences sont persistées localement et synchronisées avec le serveur. +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export type Theme = 'dark' | 'light' +export type SidebarPosition = 'left' | 'right' + +export const useUiStore = defineStore('ui', () => { + const theme = ref('dark') + const sidebarPosition = ref('left') + const sidebarCollapsed = ref(false) + const mobileMenuOpen = ref(false) + + /** + * Initialise le thème depuis les préférences locales. + * Appelé au montage de App.vue. + */ + function initTheme(): void { + const savedTheme = localStorage.getItem('pxp_theme') as Theme | null + const savedSidebar = localStorage.getItem('pxp_sidebar') as SidebarPosition | null + + if (savedTheme === 'dark' || savedTheme === 'light') { + theme.value = savedTheme + } + if (savedSidebar === 'left' || savedSidebar === 'right') { + sidebarPosition.value = savedSidebar + } + + applyTheme(theme.value) + } + + /** + * Bascule entre thème sombre et clair. + */ + function toggleTheme(): void { + theme.value = theme.value === 'dark' ? 'light' : 'dark' + localStorage.setItem('pxp_theme', theme.value) + applyTheme(theme.value) + } + + /** + * Définit le thème explicitement. + */ + function setTheme(newTheme: Theme): void { + theme.value = newTheme + localStorage.setItem('pxp_theme', newTheme) + applyTheme(newTheme) + } + + /** + * Définit la position de la sidebar. + */ + function setSidebarPosition(pos: SidebarPosition): void { + sidebarPosition.value = pos + localStorage.setItem('pxp_sidebar', pos) + } + + /** + * Bascule l'état réduit de la sidebar. + */ + function toggleSidebarCollapse(): void { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + /** + * Applique le thème sur l'élément via data-theme. + */ + function applyTheme(t: Theme): void { + document.documentElement.setAttribute('data-theme', t) + } + + return { + theme, + sidebarPosition, + sidebarCollapsed, + mobileMenuOpen, + initTheme, + toggleTheme, + setTheme, + setSidebarPosition, + toggleSidebarCollapse, + } +}) diff --git a/frontend/src/styles/dark.css b/frontend/src/styles/dark.css new file mode 100644 index 0000000..0a1ffbf --- /dev/null +++ b/frontend/src/styles/dark.css @@ -0,0 +1,46 @@ +/* ============================================================ + ProxmoxPanel — Thème sombre (mode par défaut) + ============================================================ */ + +[data-theme="dark"], +:root { + --neu-bg: #1a1d2e; + --neu-surface: #212438; + --neu-text: #e2e6f6; + --neu-text-muted: #6b7694; + --neu-border: #2a2f4a; + + --neu-shadow-dark: #13162280; + --neu-shadow-light: #2d3356; + + --neu-primary: #6c8ef4; + --neu-primary-dim: #4a6bd4; + --neu-success: #4cbb8a; + --neu-warning: #f5a623; + --neu-danger: #f05c6b; + --neu-info: #3dbfcf; + + color-scheme: dark; +} + +/* Corps de page */ +[data-theme="dark"] body { + background-color: var(--neu-bg); + color: var(--neu-text); +} + +/* Accents spécifiques au mode sombre */ +[data-theme="dark"] .neu-card { + background: var(--neu-surface); +} + +[data-theme="dark"] .neu-input { + background: var(--neu-bg); + color: var(--neu-text); +} + +/* Sélections */ +[data-theme="dark"] ::selection { + background: rgba(108, 142, 244, 0.3); + color: var(--neu-text); +} diff --git a/frontend/src/styles/light.css b/frontend/src/styles/light.css new file mode 100644 index 0000000..1570da3 --- /dev/null +++ b/frontend/src/styles/light.css @@ -0,0 +1,70 @@ +/* ============================================================ + ProxmoxPanel — Thème clair + ============================================================ */ + +[data-theme="light"] { + --neu-bg: #e8ecf2; + --neu-surface: #eef1f8; + --neu-text: #2d3561; + --neu-text-muted: #8892b0; + --neu-border: #d4d9e8; + + --neu-shadow-dark: #c8cdd8; + --neu-shadow-light: #ffffff; + + --neu-primary: #4a6bd4; + --neu-primary-dim: #3558c0; + --neu-success: #2ea87a; + --neu-warning: #d4860e; + --neu-danger: #d43f52; + --neu-info: #1fa8bc; + + color-scheme: light; +} + +[data-theme="light"] body { + background-color: var(--neu-bg); + color: var(--neu-text); +} + +[data-theme="light"] .neu-card { + background: var(--neu-surface); + box-shadow: + 5px 5px 10px var(--neu-shadow-dark), + -5px -5px 10px var(--neu-shadow-light); +} + +[data-theme="light"] .neu-inset { + background: var(--neu-bg); + box-shadow: + inset 3px 3px 7px var(--neu-shadow-dark), + inset -3px -3px 7px var(--neu-shadow-light); +} + +[data-theme="light"] .neu-btn { + box-shadow: + 4px 4px 8px var(--neu-shadow-dark), + -4px -4px 8px var(--neu-shadow-light); +} + +[data-theme="light"] .neu-input { + background: var(--neu-bg); + color: var(--neu-text); + box-shadow: + inset 3px 3px 7px var(--neu-shadow-dark), + inset -3px -3px 7px var(--neu-shadow-light); +} + +[data-theme="light"] ::selection { + background: rgba(74, 107, 212, 0.25); + color: var(--neu-text); +} + +/* Scrollbar thème clair */ +[data-theme="light"] ::-webkit-scrollbar-track { + background: var(--neu-bg); +} + +[data-theme="light"] ::-webkit-scrollbar-thumb { + background: var(--neu-border); +} diff --git a/frontend/src/styles/neu.css b/frontend/src/styles/neu.css new file mode 100644 index 0000000..7fb3981 --- /dev/null +++ b/frontend/src/styles/neu.css @@ -0,0 +1,372 @@ +/* ============================================================================= + ProxmoxPanel — Système de design Neumorphism + Définit les classes utilitaires et les variables CSS du thème. + Utilisé par tous les composants et les modules. + ============================================================================= */ + +/* ── Variables CSS (surchargées par dark.css et light.css) ─────────────────── */ +:root { + /* Couleurs de base */ + --neu-bg: #1e2130; + --neu-surface: #252838; + --neu-text: #e0e4f0; + --neu-text-muted: #7f8899; + --neu-border: #2e3348; + + /* Ombres Neumorphism */ + --neu-shadow-dark: #161823; + --neu-shadow-light: #2a2f48; + + /* Couleurs d'accent */ + --neu-primary: #6c8ef4; + --neu-primary-dim: #4a6bd4; + --neu-success: #4cbb8a; + --neu-warning: #f5a623; + --neu-danger: #f05c6b; + --neu-info: #3dbfcf; + + /* Typographie */ + --neu-font-xs: 11px; + --neu-font-sm: 12px; + --neu-font-md: 14px; + --neu-font-lg: 16px; + --neu-font-xl: 20px; + --neu-font-2xl: 24px; + + /* Espacements */ + --neu-space-xs: 4px; + --neu-space-sm: 8px; + --neu-space-md: 16px; + --neu-space-lg: 24px; + --neu-space-xl: 32px; + + /* Rayons */ + --neu-radius-sm: 8px; + --neu-radius-md: 12px; + --neu-radius-lg: 16px; + --neu-radius-xl: 24px; + --neu-radius-full: 9999px; + + /* Transitions */ + --neu-transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + /* Sidebar */ + --sidebar-width: 240px; + --sidebar-width-collapsed: 64px; + + /* Z-index */ + --z-sidebar: 100; + --z-navbar: 200; + --z-modal: 300; + --z-toast: 400; +} + +/* ── Carte Neumorphism (élévation convexe) ─────────────────────────────────── */ +.neu-card { + background: var(--neu-surface); + border-radius: var(--neu-radius-lg); + box-shadow: + 6px 6px 12px var(--neu-shadow-dark), + -4px -4px 10px var(--neu-shadow-light); + border: 1px solid var(--neu-border); + padding: var(--neu-space-md); + color: var(--neu-text); + transition: var(--neu-transition); +} + +.neu-card--flat { + box-shadow: none; + border: 1px solid var(--neu-border); +} + +.neu-card--hover:hover { + box-shadow: + 8px 8px 16px var(--neu-shadow-dark), + -6px -6px 14px var(--neu-shadow-light); + transform: translateY(-1px); +} + +/* ── Surface enfoncée (inputs, zones de saisie) ────────────────────────────── */ +.neu-inset { + background: var(--neu-bg); + border-radius: var(--neu-radius-md); + box-shadow: + inset 3px 3px 7px var(--neu-shadow-dark), + inset -2px -2px 5px var(--neu-shadow-light); + border: 1px solid var(--neu-border); +} + +/* ── Boutons ────────────────────────────────────────────────────────────────── */ +.neu-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--neu-space-xs); + padding: var(--neu-space-sm) var(--neu-space-md); + border-radius: var(--neu-radius-md); + font-size: var(--neu-font-md); + font-weight: 500; + cursor: pointer; + border: none; + outline: none; + transition: var(--neu-transition); + text-decoration: none; + white-space: nowrap; + user-select: none; + color: var(--neu-text); + background: var(--neu-surface); + box-shadow: + 4px 4px 8px var(--neu-shadow-dark), + -3px -3px 6px var(--neu-shadow-light); +} + +.neu-btn:hover:not(:disabled) { + box-shadow: + 6px 6px 12px var(--neu-shadow-dark), + -4px -4px 8px var(--neu-shadow-light); + transform: translateY(-1px); +} + +.neu-btn:active:not(:disabled) { + box-shadow: + inset 2px 2px 5px var(--neu-shadow-dark), + inset -1px -1px 3px var(--neu-shadow-light); + transform: translateY(0); +} + +.neu-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Variantes de boutons */ +.neu-btn--primary { + background: var(--neu-primary); + color: #fff; + box-shadow: + 4px 4px 8px rgba(0,0,0,0.3), + -2px -2px 6px rgba(255,255,255,0.05); +} + +.neu-btn--primary:hover:not(:disabled) { + background: var(--neu-primary-dim); + box-shadow: + 6px 6px 12px rgba(0,0,0,0.4), + -3px -3px 8px rgba(255,255,255,0.06); +} + +.neu-btn--success { + background: var(--neu-success); + color: #fff; +} + +.neu-btn--danger { + background: var(--neu-danger); + color: #fff; +} + +.neu-btn--warning { + background: var(--neu-warning); + color: #1a1a2e; +} + +.neu-btn--sm { + padding: 4px 10px; + font-size: var(--neu-font-sm); + border-radius: var(--neu-radius-sm); +} + +.neu-btn--lg { + padding: 12px 24px; + font-size: var(--neu-font-lg); + border-radius: var(--neu-radius-lg); +} + +.neu-btn--icon { + width: 40px; + height: 40px; + padding: 0; + border-radius: var(--neu-radius-md); +} + +.neu-btn--ghost { + background: transparent; + box-shadow: none; + border: 1px solid var(--neu-border); +} + +.neu-btn--ghost:hover:not(:disabled) { + background: var(--neu-surface); + box-shadow: + 2px 2px 5px var(--neu-shadow-dark), + -1px -1px 3px var(--neu-shadow-light); +} + +/* ── Inputs ─────────────────────────────────────────────────────────────────── */ +.neu-input { + width: 100%; + padding: var(--neu-space-sm) var(--neu-space-md); + background: var(--neu-bg); + color: var(--neu-text); + border: 1px solid var(--neu-border); + border-radius: var(--neu-radius-md); + font-size: var(--neu-font-md); + outline: none; + transition: var(--neu-transition); + box-shadow: + inset 3px 3px 7px var(--neu-shadow-dark), + inset -2px -2px 5px var(--neu-shadow-light); +} + +.neu-input:focus { + border-color: var(--neu-primary); + box-shadow: + inset 3px 3px 7px var(--neu-shadow-dark), + inset -2px -2px 5px var(--neu-shadow-light), + 0 0 0 2px rgba(108, 142, 244, 0.25); +} + +.neu-input::placeholder { + color: var(--neu-text-muted); +} + +.neu-input--error { + border-color: var(--neu-danger) !important; +} + +/* ── Toggle switch ───────────────────────────────────────────────────────────── */ +.neu-toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; +} + +.neu-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.neu-toggle__slider { + position: absolute; + inset: 0; + background: var(--neu-bg); + border-radius: var(--neu-radius-full); + cursor: pointer; + transition: var(--neu-transition); + box-shadow: + inset 2px 2px 5px var(--neu-shadow-dark), + inset -1px -1px 3px var(--neu-shadow-light); +} + +.neu-toggle__slider::before { + content: ''; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + bottom: 3px; + background: var(--neu-text-muted); + border-radius: 50%; + transition: var(--neu-transition); + box-shadow: 2px 2px 4px var(--neu-shadow-dark); +} + +.neu-toggle input:checked + .neu-toggle__slider { + background: var(--neu-primary); + box-shadow: + inset 2px 2px 5px rgba(0,0,0,0.3), + inset -1px -1px 3px rgba(255,255,255,0.1); +} + +.neu-toggle input:checked + .neu-toggle__slider::before { + transform: translateX(20px); + background: #fff; +} + +/* ── Badges / Tags ───────────────────────────────────────────────────────────── */ +.neu-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--neu-radius-full); + font-size: var(--neu-font-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.neu-badge--success { background: rgba(76, 187, 138, 0.15); color: var(--neu-success); } +.neu-badge--danger { background: rgba(240, 92, 107, 0.15); color: var(--neu-danger); } +.neu-badge--warning { background: rgba(245, 166, 35, 0.15); color: var(--neu-warning); } +.neu-badge--info { background: rgba(61, 191, 207, 0.15); color: var(--neu-info); } +.neu-badge--primary { background: rgba(108, 142, 244, 0.15); color: var(--neu-primary); } + +/* ── Séparateurs ────────────────────────────────────────────────────────────── */ +.neu-divider { + height: 1px; + background: linear-gradient(90deg, transparent, var(--neu-border), transparent); + border: none; + margin: var(--neu-space-md) 0; +} + +/* ── Utilitaires de layout ──────────────────────────────────────────────────── */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.justify-center { justify-content: center; } +.gap-sm { gap: var(--neu-space-sm); } +.gap-md { gap: var(--neu-space-md); } +.gap-lg { gap: var(--neu-space-lg); } +.w-full { width: 100%; } +.h-full { height: 100%; } + +/* ── Grid responsive ────────────────────────────────────────────────────────── */ +.neu-grid { + display: grid; + gap: var(--neu-space-md); + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} + +/* ── Scrollbar personnalisée ────────────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { background: var(--neu-bg); } +::-webkit-scrollbar-thumb { + background: var(--neu-border); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { background: var(--neu-text-muted); } + +/* ── Animations ─────────────────────────────────────────────────────────────── */ +@keyframes neu-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes neu-spin { + to { transform: rotate(360deg); } +} + +.neu-loading { + animation: neu-spin 1s linear infinite; +} + +.neu-pulse { + animation: neu-pulse 2s ease-in-out infinite; +} + +/* ── Responsive ─────────────────────────────────────────────────────────────── */ +@media (max-width: 768px) { + .neu-grid { + grid-template-columns: 1fr; + } + :root { + --sidebar-width: 100%; + } +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..7be64e3 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,393 @@ + + + + + diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue new file mode 100644 index 0000000..b8a2080 --- /dev/null +++ b/frontend/src/views/Files.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/src/views/Install.vue b/frontend/src/views/Install.vue new file mode 100644 index 0000000..daf2f1d --- /dev/null +++ b/frontend/src/views/Install.vue @@ -0,0 +1,480 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..c99e37f --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue new file mode 100644 index 0000000..e8d97be --- /dev/null +++ b/frontend/src/views/Logs.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/frontend/src/views/Modules.vue b/frontend/src/views/Modules.vue new file mode 100644 index 0000000..bb7112a --- /dev/null +++ b/frontend/src/views/Modules.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/views/Proxmox.vue b/frontend/src/views/Proxmox.vue new file mode 100644 index 0000000..975adf3 --- /dev/null +++ b/frontend/src/views/Proxmox.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/frontend/src/views/Services.vue b/frontend/src/views/Services.vue new file mode 100644 index 0000000..2e2485d --- /dev/null +++ b/frontend/src/views/Services.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..db45b68 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/frontend/src/views/Terminal.vue b/frontend/src/views/Terminal.vue new file mode 100644 index 0000000..d4b849f --- /dev/null +++ b/frontend/src/views/Terminal.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/frontend/src/views/Updates.vue b/frontend/src/views/Updates.vue new file mode 100644 index 0000000..076fc91 --- /dev/null +++ b/frontend/src/views/Updates.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..66a5553 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..35cbaed --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,48 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5173, + // Proxy vers le backend Go en développement + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:3001', + ws: true, + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + // Optimiser les chunks pour de meilleures performances + rollupOptions: { + output: { + manualChunks: { + 'vue-core': ['vue', 'vue-router', 'pinia'], + 'i18n': ['vue-i18n'], + 'terminal': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-attach'], + 'editor': [ + '@codemirror/state', + '@codemirror/view', + '@codemirror/commands', + '@codemirror/language', + ], + }, + }, + }, + }, +})