From 7b77d84f687569c1960833e2e47d1340c589ad24 Mon Sep 17 00:00:00 2001 From: orohi Date: Wed, 29 Jul 2026 04:03:33 +0300 Subject: [PATCH] Add node mode with SSH auto-install and Remnawave-style manual deploy. Co-authored-by: Cursor --- Dockerfile | 6 +- README.md | 32 ++++ cmd/node/main.go | 157 +++++++++++++++++++ cmd/server/main.go | 2 +- deploy/node/docker-compose.yml | 21 +++ internal/db/db.go | 30 ++++ internal/db/nodes.go | 138 +++++++++++++++++ internal/handlers/handlers.go | 38 ++++- internal/handlers/nodes.go | 260 ++++++++++++++++++++++++++++++++ internal/models/models.go | 47 ++++++ internal/nodeclient/client.go | 44 ++++++ internal/nodeinstall/compose.go | 49 ++++++ internal/nodeinstall/ssh.go | 208 +++++++++++++++++++++++++ internal/secretbox/secretbox.go | 71 +++++++++ web/static/css/app.css | 89 +++++++++++ web/templates/dashboard.html | 39 ++++- web/templates/node_new.html | 83 ++++++++++ web/templates/node_view.html | 90 +++++++++++ web/templates/nodes.html | 73 +++++++++ web/templates/protocols.html | 1 + 20 files changed, 1470 insertions(+), 8 deletions(-) create mode 100644 cmd/node/main.go create mode 100644 deploy/node/docker-compose.yml create mode 100644 internal/db/nodes.go create mode 100644 internal/handlers/nodes.go create mode 100644 internal/nodeclient/client.go create mode 100644 internal/nodeinstall/compose.go create mode 100644 internal/nodeinstall/ssh.go create mode 100644 internal/secretbox/secretbox.go create mode 100644 web/templates/node_new.html create mode 100644 web/templates/node_view.html create mode 100644 web/templates/nodes.html diff --git a/Dockerfile b/Dockerfile index ae22022..0f9af7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,15 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/panel ./cmd/server +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/panel ./cmd/server +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/vpn-node ./cmd/node FROM alpine:3.21 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata openssh-client WORKDIR /app COPY --from=builder /out/panel /app/panel +COPY --from=builder /out/vpn-node /app/bin/vpn-node COPY web /app/web ENV APP_PORT=8080 diff --git a/README.md b/README.md index 2a8fd7d..50f9a14 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,37 @@ docker compose up -d --build --- +## Режим ноды (как Remnawave, но проще) + +Панель **не** гоняет Xray сама — VPN поднимается на отдельных серверах-нодах (как [Remnawave Node](https://docs.rw/install/remnawave-node)). + +### Чем удобнее + +| Remnawave Node | VPN Panel | +|---|---| +| Docker вручную + вставить compose | **Авто по SSH**: host/user/пароль (или ключ) → панель ставит Docker + агент | +| Или вручную | **Ручной режим** — тот же compose, если SSH недоступен | + +### Как добавить ноду + +1. Админка → **Ноды** → **Добавить ноду** +2. Укажите имя, IP/хост, SSH (`root` + пароль или ключ), порт API ноды (по умолчанию `2222`) +3. Режим: + - **Авто по SSH** — мастер подключается, ставит Docker (если нет), заливает бинарник `vpn-node`, поднимает `docker compose` + - **Вручную** — скопируйте `docker-compose.yml` и бинарник `/app/bin/vpn-node` на сервер +4. На firewall ноды откройте `NODE_PORT` **только** для IP панели +5. Нажмите **Проверить online** + +Агент слушает `NODE_PORT`, авторизация: `Authorization: Bearer `. + +Эндпоинты агента: `GET /health`, `GET|POST /api/v1/config`, `GET /api/v1/status`. + +SSH-секреты в БД шифруются AES-GCM ключом от `APP_SECRET`. + +> Панель в Docker должна иметь сетевой доступ до SSH (22) и NODE_PORT ноды. На VPS/Dokploy это обычно ок. + +--- + ## Reverse proxy + SSL (Let's Encrypt) Примеры лежат в `deploy/`. Сеть Docker: `vpn-panel-net`. @@ -193,5 +224,6 @@ go run ./cmd/server - `/` — главная - `/login` — вход - `/admin` — дашборд +- `/admin/nodes` — ноды - `/admin/protocols` — протоколы - `/health` — healthcheck diff --git a/cmd/node/main.go b/cmd/node/main.go new file mode 100644 index 0000000..1716a88 --- /dev/null +++ b/cmd/node/main.go @@ -0,0 +1,157 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const defaultVersion = "0.1.0" + +type Agent struct { + name string + secret string + version string + startedAt time.Time + mu sync.RWMutex + config map[string]any +} + +func main() { + port := env("NODE_PORT", "2222") + secret := os.Getenv("SECRET_KEY") + if secret == "" { + log.Fatal("SECRET_KEY is required") + } + name := env("NODE_NAME", "vpn-node") + version := env("AGENT_VERSION", defaultVersion) + + dataDir := env("DATA_DIR", "/var/lib/vpn-node") + _ = os.MkdirAll(dataDir, 0o755) + + a := &Agent{ + name: name, + secret: secret, + version: version, + startedAt: time.Now().UTC(), + config: map[string]any{}, + } + a.loadConfig(filepath.Join(dataDir, "config.json")) + + mux := http.NewServeMux() + mux.HandleFunc("/health", a.auth(a.health)) + mux.HandleFunc("/api/v1/config", a.auth(a.configHandler)) + mux.HandleFunc("/api/v1/status", a.auth(a.status)) + + addr := ":" + port + log.Printf("vpn-node %s listening on %s name=%s", version, addr, name) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func (a *Agent) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + token := strings.TrimPrefix(h, "Bearer ") + if token == "" { + token = r.Header.Get("X-Node-Secret") + } + if token != a.secret { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} + +func (a *Agent) health(w http.ResponseWriter, _ *http.Request) { + a.mu.RLock() + defer a.mu.RUnlock() + protocols := []string{} + if raw, ok := a.config["protocols"].([]any); ok { + for _, p := range raw { + if s, ok := p.(string); ok { + protocols = append(protocols, s) + } + } + } + writeJSON(w, map[string]any{ + "ok": true, + "name": a.name, + "version": a.version, + "uptime_sec": int64(time.Since(a.startedAt).Seconds()), + "protocols": protocols, + }) +} + +func (a *Agent) status(w http.ResponseWriter, _ *http.Request) { + a.mu.RLock() + defer a.mu.RUnlock() + writeJSON(w, map[string]any{ + "ok": true, + "name": a.name, + "version": a.version, + "config": a.config, + }) +} + +func (a *Agent) configHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + a.mu.RLock() + defer a.mu.RUnlock() + writeJSON(w, a.config) + case http.MethodPost: + var cfg map[string]any + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + a.mu.Lock() + a.config = cfg + a.mu.Unlock() + _ = a.saveConfig("/var/lib/vpn-node/config.json") + writeJSON(w, map[string]any{"ok": true}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (a *Agent) loadConfig(path string) { + b, err := os.ReadFile(path) + if err != nil { + return + } + var cfg map[string]any + if json.Unmarshal(b, &cfg) == nil { + a.config = cfg + } +} + +func (a *Agent) saveConfig(path string) error { + a.mu.RLock() + defer a.mu.RUnlock() + b, err := json.MarshalIndent(a.config, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0o600) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index f7a72da..b530dce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -34,7 +34,7 @@ func main() { } authMgr := auth.New(cfg.Secret) - srv, err := handlers.New(database, authMgr, "web/templates") + srv, err := handlers.New(database, authMgr, cfg.Secret, "web/templates") if err != nil { log.Fatalf("handlers: %v", err) } diff --git a/deploy/node/docker-compose.yml b/deploy/node/docker-compose.yml new file mode 100644 index 0000000..54176a0 --- /dev/null +++ b/deploy/node/docker-compose.yml @@ -0,0 +1,21 @@ +# Example node docker-compose (generated by panel as well) +# Place linux amd64 binary next to this file as ./vpn-node +# +# chmod +x vpn-node +# docker compose up -d + +services: + vpn-node: + container_name: vpn-panel-node + image: alpine:3.21 + restart: unless-stopped + network_mode: host + environment: + NODE_NAME: "example-node" + NODE_PORT: "2222" + SECRET_KEY: "change-me" + AGENT_VERSION: "0.1.0" + volumes: + - ./vpn-node:/usr/local/bin/vpn-node:ro + - ./data:/var/lib/vpn-node + command: ["/usr/local/bin/vpn-node"] diff --git a/internal/db/db.go b/internal/db/db.go index 0771846..8a0d9eb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -60,6 +60,28 @@ CREATE TABLE IF NOT EXISTS protocols ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + +CREATE TABLE IF NOT EXISTS nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + host TEXT NOT NULL, + ssh_port INT NOT NULL DEFAULT 22, + ssh_user TEXT NOT NULL DEFAULT 'root', + ssh_auth_type TEXT NOT NULL DEFAULT 'password', + ssh_secret_enc TEXT NOT NULL DEFAULT '', + node_port INT NOT NULL DEFAULT 2222, + secret_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + install_mode TEXT NOT NULL DEFAULT 'auto', + last_error TEXT NOT NULL DEFAULT '', + install_log TEXT NOT NULL DEFAULT '', + agent_version TEXT NOT NULL DEFAULT '', + last_seen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status); ` _, err := db.Exec(schema) return err @@ -174,5 +196,13 @@ func GetStats(db *sql.DB) (models.DashboardStats, error) { return s, err } err = db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&s.AdminsTotal) + if err != nil { + return s, err + } + err = db.QueryRow(`SELECT COUNT(*) FROM nodes`).Scan(&s.NodesTotal) + if err != nil { + return s, err + } + err = db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE status = 'online'`).Scan(&s.NodesOnline) return s, err } diff --git a/internal/db/nodes.go b/internal/db/nodes.go new file mode 100644 index 0000000..356faec --- /dev/null +++ b/internal/db/nodes.go @@ -0,0 +1,138 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/orohi/vpn-panel/internal/models" +) + +func scanNode(scanner interface { + Scan(dest ...any) error +}) (*models.Node, error) { + n := &models.Node{} + var lastSeen sql.NullTime + var status string + err := scanner.Scan( + &n.ID, &n.Name, &n.Host, &n.SSHPort, &n.SSHUser, &n.SSHAuthType, &n.SSHSecretEnc, + &n.NodePort, &n.SecretKey, &status, &n.InstallMode, &n.LastError, &n.InstallLog, + &n.AgentVersion, &lastSeen, &n.CreatedAt, &n.UpdatedAt, + ) + if err != nil { + return nil, err + } + n.Status = models.NodeStatus(status) + if lastSeen.Valid { + t := lastSeen.Time + n.LastSeenAt = &t + } + return n, nil +} + +const nodeColumns = ` +id, name, host, ssh_port, ssh_user, ssh_auth_type, ssh_secret_enc, +node_port, secret_key, status, install_mode, last_error, install_log, +agent_version, last_seen_at, created_at, updated_at` + +func CreateNode(db *sql.DB, n *models.Node) error { + if n.ID == uuid.Nil { + n.ID = uuid.New() + } + now := time.Now().UTC() + n.CreatedAt = now + n.UpdatedAt = now + if n.Status == "" { + n.Status = models.NodeStatusPending + } + _, err := db.Exec(` +INSERT INTO nodes ( + id, name, host, ssh_port, ssh_user, ssh_auth_type, ssh_secret_enc, + node_port, secret_key, status, install_mode, last_error, install_log, + agent_version, last_seen_at, created_at, updated_at +) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17 +)`, + n.ID, n.Name, n.Host, n.SSHPort, n.SSHUser, n.SSHAuthType, n.SSHSecretEnc, + n.NodePort, n.SecretKey, string(n.Status), n.InstallMode, n.LastError, n.InstallLog, + n.AgentVersion, n.LastSeenAt, n.CreatedAt, n.UpdatedAt, + ) + return err +} + +func GetNode(db *sql.DB, id uuid.UUID) (*models.Node, error) { + row := db.QueryRow(`SELECT `+nodeColumns+` FROM nodes WHERE id = $1`, id) + n, err := scanNode(row) + if err == sql.ErrNoRows { + return nil, nil + } + return n, err +} + +func ListNodes(db *sql.DB) ([]models.Node, error) { + rows, err := db.Query(`SELECT ` + nodeColumns + ` FROM nodes ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []models.Node + for rows.Next() { + n, err := scanNode(rows) + if err != nil { + return nil, err + } + list = append(list, *n) + } + return list, rows.Err() +} + +func UpdateNodeStatus(db *sql.DB, id uuid.UUID, status models.NodeStatus, lastError string) error { + _, err := db.Exec(` +UPDATE nodes +SET status = $2, last_error = $3, updated_at = NOW() +WHERE id = $1`, id, string(status), lastError) + return err +} + +func ClearNodeInstallLog(db *sql.DB, id uuid.UUID) error { + _, err := db.Exec(`UPDATE nodes SET install_log = '', updated_at = NOW() WHERE id = $1`, id) + return err +} + +func AppendNodeInstallLog(db *sql.DB, id uuid.UUID, chunk string) error { + _, err := db.Exec(` +UPDATE nodes +SET install_log = CASE + WHEN install_log = '' OR install_log IS NULL THEN $2 + ELSE install_log || E'\n' || $2 +END, updated_at = NOW() +WHERE id = $1`, id, strings.TrimRight(chunk, "\n")) + return err +} + +func MarkNodeSeen(db *sql.DB, id uuid.UUID, version string, status models.NodeStatus) error { + _, err := db.Exec(` +UPDATE nodes +SET last_seen_at = NOW(), agent_version = $2, status = $3, last_error = '', updated_at = NOW() +WHERE id = $1`, id, version, string(status)) + return err +} + +func DeleteNode(db *sql.DB, id uuid.UUID) error { + res, err := db.Exec(`DELETE FROM nodes WHERE id = $1`, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("node not found") + } + return nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 253eec9..e58dcca 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -12,23 +12,45 @@ import ( "github.com/orohi/vpn-panel/internal/auth" "github.com/orohi/vpn-panel/internal/db" + "github.com/orohi/vpn-panel/internal/secretbox" ) type Server struct { DB *sql.DB Auth *auth.Manager Templates *template.Template + SecretKey []byte } type pageData map[string]any -func New(database *sql.DB, authMgr *auth.Manager, templatesDir string) (*Server, error) { +func New(database *sql.DB, authMgr *auth.Manager, appSecret, templatesDir string) (*Server, error) { pattern := filepath.Join(templatesDir, "*.html") - tmpl, err := template.ParseGlob(pattern) + tmpl, err := template.New("").Funcs(template.FuncMap{ + "statusClass": func(s string) string { + switch s { + case "online": + return "on" + case "installing": + return "warn" + case "error": + return "err" + case "offline": + return "off" + default: + return "off" + } + }, + }).ParseGlob(pattern) if err != nil { return nil, err } - return &Server{DB: database, Auth: authMgr, Templates: tmpl}, nil + return &Server{ + DB: database, + Auth: authMgr, + Templates: tmpl, + SecretKey: secretbox.DeriveKey(appSecret), + }, nil } func (s *Server) Routes() http.Handler { @@ -44,6 +66,14 @@ func (s *Server) Routes() http.Handler { r.HandleFunc("/admin/protocols", s.Auth.RequireAuth(s.protocols)).Methods(http.MethodGet) r.HandleFunc("/admin/protocols/{id}/toggle", s.Auth.RequireAuth(s.toggleProtocol)).Methods(http.MethodPost) + r.HandleFunc("/admin/nodes", s.Auth.RequireAuth(s.nodesList)).Methods(http.MethodGet) + r.HandleFunc("/admin/nodes/new", s.Auth.RequireAuth(s.nodesNewGet)).Methods(http.MethodGet) + r.HandleFunc("/admin/nodes/new", s.Auth.RequireAuth(s.nodesCreate)).Methods(http.MethodPost) + r.HandleFunc("/admin/nodes/{id}", s.Auth.RequireAuth(s.nodeView)).Methods(http.MethodGet) + r.HandleFunc("/admin/nodes/{id}/install", s.Auth.RequireAuth(s.nodeInstall)).Methods(http.MethodPost) + r.HandleFunc("/admin/nodes/{id}/check", s.Auth.RequireAuth(s.nodeCheck)).Methods(http.MethodPost) + r.HandleFunc("/admin/nodes/{id}/delete", s.Auth.RequireAuth(s.nodeDelete)).Methods(http.MethodPost) + r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) @@ -125,11 +155,13 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { http.Error(w, "db error", http.StatusInternalServerError) return } + nodes, _ := db.ListNodes(s.DB) s.render(w, "dashboard.html", pageData{ "Title": "Админка", "UserName": s.Auth.CurrentName(r), "Stats": stats, "Protocols": protocols, + "Nodes": nodes, "Active": "dashboard", }) } diff --git a/internal/handlers/nodes.go b/internal/handlers/nodes.go new file mode 100644 index 0000000..1849141 --- /dev/null +++ b/internal/handlers/nodes.go @@ -0,0 +1,260 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/gorilla/mux" + + "github.com/orohi/vpn-panel/internal/db" + "github.com/orohi/vpn-panel/internal/models" + "github.com/orohi/vpn-panel/internal/nodeclient" + "github.com/orohi/vpn-panel/internal/nodeinstall" + "github.com/orohi/vpn-panel/internal/secretbox" +) + +func (s *Server) nodesList(w http.ResponseWriter, r *http.Request) { + nodes, err := db.ListNodes(s.DB) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + s.render(w, "nodes.html", pageData{ + "Title": "Ноды", + "UserName": s.Auth.CurrentName(r), + "Nodes": nodes, + "Active": "nodes", + "Flash": r.URL.Query().Get("ok"), + "Error": r.URL.Query().Get("err"), + }) +} + +func (s *Server) nodesNewGet(w http.ResponseWriter, r *http.Request) { + s.render(w, "node_new.html", pageData{ + "Title": "Добавить ноду", + "UserName": s.Auth.CurrentName(r), + "Active": "nodes", + "Form": map[string]any{ + "SSHPort": 22, + "NodePort": 2222, + "SSHUser": "root", + "SSHAuthType": "password", + "InstallMode": "auto", + }, + }) +} + +func (s *Server) nodesCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + host := strings.TrimSpace(r.FormValue("host")) + sshUser := strings.TrimSpace(r.FormValue("ssh_user")) + sshAuth := r.FormValue("ssh_auth_type") + sshSecret := r.FormValue("ssh_secret") + installMode := r.FormValue("install_mode") + if installMode != "manual" { + installMode = "auto" + } + if sshAuth != "key" { + sshAuth = "password" + } + sshPort, _ := strconv.Atoi(r.FormValue("ssh_port")) + if sshPort <= 0 { + sshPort = 22 + } + nodePort, _ := strconv.Atoi(r.FormValue("node_port")) + if nodePort <= 0 { + nodePort = 2222 + } + + formErr := func(msg string) { + s.render(w, "node_new.html", pageData{ + "Title": "Добавить ноду", + "UserName": s.Auth.CurrentName(r), + "Active": "nodes", + "Error": msg, + "Form": map[string]any{ + "Name": name, "Host": host, "SSHUser": sshUser, + "SSHPort": sshPort, "NodePort": nodePort, + "SSHAuthType": sshAuth, "InstallMode": installMode, + }, + }) + } + + if name == "" || host == "" { + formErr("Укажите имя и хост ноды") + return + } + if installMode == "auto" && strings.TrimSpace(sshSecret) == "" { + formErr("Для автоустановки нужен SSH пароль или ключ") + return + } + + token, err := secretbox.RandomToken(32) + if err != nil { + http.Error(w, "token error", http.StatusInternalServerError) + return + } + + enc, err := secretbox.Encrypt(s.SecretKey, sshSecret) + if err != nil { + http.Error(w, "encrypt error", http.StatusInternalServerError) + return + } + + n := &models.Node{ + ID: uuid.New(), + Name: name, + Host: host, + SSHPort: sshPort, + SSHUser: sshUser, + SSHAuthType: sshAuth, + SSHSecretEnc: enc, + NodePort: nodePort, + SecretKey: token, + Status: models.NodeStatusPending, + InstallMode: installMode, + } + if err := db.CreateNode(s.DB, n); err != nil { + log.Printf("create node: %v", err) + formErr("Не удалось сохранить ноду") + return + } + + if installMode == "auto" { + go s.runAutoInstall(n.ID) + http.Redirect(w, r, "/admin/nodes/"+n.ID.String()+"?ok=install_started", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/nodes/"+n.ID.String()+"?ok=created", http.StatusSeeOther) +} + +func (s *Server) nodeView(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + n, err := db.GetNode(s.DB, id) + if err != nil || n == nil { + http.NotFound(w, r) + return + } + s.render(w, "node_view.html", pageData{ + "Title": n.Name, + "UserName": s.Auth.CurrentName(r), + "Active": "nodes", + "Node": n, + "Compose": nodeinstall.ComposeYAML(n), + "ManualScript": nodeinstall.ManualInstallScript(n), + "Flash": r.URL.Query().Get("ok"), + "Error": r.URL.Query().Get("err"), + }) +} + +func (s *Server) nodeInstall(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + n, err := db.GetNode(s.DB, id) + if err != nil || n == nil { + http.NotFound(w, r) + return + } + if n.Status == models.NodeStatusInstalling { + http.Redirect(w, r, "/admin/nodes/"+id.String()+"?err=already_installing", http.StatusSeeOther) + return + } + go s.runAutoInstall(id) + http.Redirect(w, r, "/admin/nodes/"+id.String()+"?ok=install_started", http.StatusSeeOther) +} + +func (s *Server) nodeCheck(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + n, err := db.GetNode(s.DB, id) + if err != nil || n == nil { + http.NotFound(w, r) + return + } + hr, err := nodeclient.Health(n) + if err != nil { + _ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusOffline, err.Error()) + http.Redirect(w, r, "/admin/nodes/"+id.String()+"?err=offline", http.StatusSeeOther) + return + } + _ = db.MarkNodeSeen(s.DB, id, hr.Version, models.NodeStatusOnline) + http.Redirect(w, r, "/admin/nodes/"+id.String()+"?ok=online", http.StatusSeeOther) +} + +func (s *Server) nodeDelete(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if err := db.DeleteNode(s.DB, id); err != nil { + http.Redirect(w, r, "/admin/nodes?err=delete", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/nodes?ok=deleted", http.StatusSeeOther) +} + +func (s *Server) runAutoInstall(id uuid.UUID) { + n, err := db.GetNode(s.DB, id) + if err != nil || n == nil { + return + } + _ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusInstalling, "") + _ = db.ClearNodeInstallLog(s.DB, id) + + logFn := func(line string) { + _ = db.AppendNodeInstallLog(s.DB, id, time.Now().UTC().Format("15:04:05")+" "+line) + } + + secret, err := secretbox.Decrypt(s.SecretKey, n.SSHSecretEnc) + if err != nil { + _ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusError, "decrypt ssh secret: "+err.Error()) + return + } + if secret == "" { + _ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusError, "SSH credentials empty") + return + } + + bin, err := nodeinstall.LoadAgentBinary() + if err != nil { + _ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusError, err.Error()) + logFn(err.Error()) + return + } + + if err := nodeinstall.Provision(n, secret, bin, logFn); err != nil { + _ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusError, err.Error()) + logFn("ERROR: " + err.Error()) + return + } + + // wait briefly then probe health + time.Sleep(3 * time.Second) + if hr, err := nodeclient.Health(n); err == nil { + _ = db.MarkNodeSeen(s.DB, id, hr.Version, models.NodeStatusOnline) + logFn("health OK, node online") + return + } + _ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusOffline, "agent started but health check failed (check firewall NODE_PORT)") + logFn("agent up, waiting for health on NODE_PORT — open firewall from panel IP") +} diff --git a/internal/models/models.go b/internal/models/models.go index 611f550..e7fe510 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -27,8 +27,55 @@ type Protocol struct { UpdatedAt time.Time `json:"updated_at"` } +type NodeStatus string + +const ( + NodeStatusPending NodeStatus = "pending" + NodeStatusInstalling NodeStatus = "installing" + NodeStatusOnline NodeStatus = "online" + NodeStatusOffline NodeStatus = "offline" + NodeStatusError NodeStatus = "error" +) + +type Node struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + SSHPort int `json:"ssh_port"` + SSHUser string `json:"ssh_user"` + SSHAuthType string `json:"ssh_auth_type"` // password | key + SSHSecretEnc string `json:"-"` + NodePort int `json:"node_port"` + SecretKey string `json:"-"` + Status NodeStatus `json:"status"` + InstallMode string `json:"install_mode"` // auto | manual + LastError string `json:"last_error"` + InstallLog string `json:"install_log"` + AgentVersion string `json:"agent_version"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (n Node) StatusLabel() string { + switch n.Status { + case NodeStatusOnline: + return "Online" + case NodeStatusInstalling: + return "Установка" + case NodeStatusOffline: + return "Offline" + case NodeStatusError: + return "Ошибка" + default: + return "Ожидание" + } +} + type DashboardStats struct { ProtocolsTotal int ProtocolsEnabled int AdminsTotal int + NodesTotal int + NodesOnline int } diff --git a/internal/nodeclient/client.go b/internal/nodeclient/client.go new file mode 100644 index 0000000..c28a3ae --- /dev/null +++ b/internal/nodeclient/client.go @@ -0,0 +1,44 @@ +package nodeclient + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/orohi/vpn-panel/internal/models" +) + +type HealthResponse struct { + OK bool `json:"ok"` + Name string `json:"name"` + Version string `json:"version"` + Uptime int64 `json:"uptime_sec"` + Protocols []string `json:"protocols"` +} + +func Health(n *models.Node) (*HealthResponse, error) { + url := fmt.Sprintf("http://%s:%d/health", n.Host, n.NodePort) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+n.SecretKey) + + client := &http.Client{Timeout: 8 * time.Second} + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d: %s", res.StatusCode, string(body)) + } + var hr HealthResponse + if err := json.Unmarshal(body, &hr); err != nil { + return nil, err + } + return &hr, nil +} diff --git a/internal/nodeinstall/compose.go b/internal/nodeinstall/compose.go new file mode 100644 index 0000000..9dae669 --- /dev/null +++ b/internal/nodeinstall/compose.go @@ -0,0 +1,49 @@ +package nodeinstall + +import ( + "fmt" + "strings" + + "github.com/orohi/vpn-panel/internal/models" +) + +const AgentVersion = "0.1.0" + +func ComposeYAML(n *models.Node) string { + return fmt.Sprintf(`services: + vpn-node: + container_name: vpn-panel-node + image: alpine:3.21 + restart: unless-stopped + network_mode: host + environment: + NODE_NAME: %q + NODE_PORT: %q + SECRET_KEY: %q + AGENT_VERSION: %q + volumes: + - ./vpn-node:/usr/local/bin/vpn-node:ro + - ./data:/var/lib/vpn-node + command: ["/usr/local/bin/vpn-node"] +`, n.Name, fmt.Sprintf("%d", n.NodePort), n.SecretKey, AgentVersion) +} + +func ManualInstallScript(n *models.Node) string { + compose := ComposeYAML(n) + var b strings.Builder + b.WriteString("#!/usr/bin/env bash\n") + b.WriteString("set -euo pipefail\n\n") + b.WriteString("sudo curl -fsSL https://get.docker.com | sh\n") + b.WriteString("sudo mkdir -p /opt/vpn-panel-node/data\n") + b.WriteString("cd /opt/vpn-panel-node\n\n") + b.WriteString("cat > docker-compose.yml <<'EOF'\n") + b.WriteString(compose) + b.WriteString("EOF\n\n") + b.WriteString("# Скопируйте бинарник vpn-node (linux amd64) в /opt/vpn-panel-node/vpn-node\n") + b.WriteString("# chmod +x /opt/vpn-panel-node/vpn-node\n") + b.WriteString("# docker compose up -d && docker compose logs -f -t\n") + b.WriteString("\necho \"NODE_PORT=") + b.WriteString(fmt.Sprintf("%d", n.NodePort)) + b.WriteString(" — откройте его только для IP панели\"\n") + return b.String() +} diff --git a/internal/nodeinstall/ssh.go b/internal/nodeinstall/ssh.go new file mode 100644 index 0000000..2537475 --- /dev/null +++ b/internal/nodeinstall/ssh.go @@ -0,0 +1,208 @@ +package nodeinstall + +import ( + "bytes" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/orohi/vpn-panel/internal/models" +) + +type LogFunc func(line string) + +type SSHConfig struct { + Host string + Port int + User string + AuthType string // password | key + Secret string +} + +func (c SSHConfig) dial() (*ssh.Client, error) { + var auth []ssh.AuthMethod + switch c.AuthType { + case "key": + signer, err := ssh.ParsePrivateKey([]byte(c.Secret)) + if err != nil { + return nil, fmt.Errorf("parse ssh key: %w", err) + } + auth = append(auth, ssh.PublicKeys(signer)) + default: + auth = append(auth, ssh.Password(c.Secret)) + } + + cfg := &ssh.ClientConfig{ + User: c.User, + Auth: auth, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // bootstrap only; harden later with known_hosts + Timeout: 20 * time.Second, + } + addr := net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port)) + return ssh.Dial("tcp", addr, cfg) +} + +func run(client *ssh.Client, cmd string, log LogFunc) (string, error) { + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + if log != nil { + log("$ " + cmd) + } + err = session.Run(cmd) + out := strings.TrimSpace(stdout.String() + "\n" + stderr.String()) + if log != nil && out != "" { + log(out) + } + if err != nil { + return out, fmt.Errorf("%w: %s", err, out) + } + return out, nil +} + +func writeRemoteFile(client *ssh.Client, remotePath, content string, mode os.FileMode, log LogFunc) error { + dir := filepath.ToSlash(filepath.Dir(remotePath)) + if _, err := run(client, fmt.Sprintf("mkdir -p %q", dir), log); err != nil { + return err + } + + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + + stdin, err := session.StdinPipe() + if err != nil { + return err + } + + base := filepath.Base(remotePath) + go func() { + defer stdin.Close() + fmt.Fprintf(stdin, "C%04o %d %s\n", mode, len(content), base) + _, _ = io.WriteString(stdin, content) + fmt.Fprint(stdin, "\x00") + }() + + if log != nil { + log(fmt.Sprintf("upload %s (%d bytes)", remotePath, len(content))) + } + cmd := fmt.Sprintf("scp -t %q", dir) + if err := session.Run(cmd); err != nil { + return fmt.Errorf("scp %s: %w", remotePath, err) + } + return nil +} + +func uploadBinary(client *ssh.Client, remotePath string, data []byte, log LogFunc) error { + dir := filepath.ToSlash(filepath.Dir(remotePath)) + if _, err := run(client, fmt.Sprintf("mkdir -p %q", dir), log); err != nil { + return err + } + + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + + stdin, err := session.StdinPipe() + if err != nil { + return err + } + + base := filepath.Base(remotePath) + go func() { + defer stdin.Close() + fmt.Fprintf(stdin, "C0755 %d %s\n", len(data), base) + _, _ = stdin.Write(data) + fmt.Fprint(stdin, "\x00") + }() + + if log != nil { + log(fmt.Sprintf("upload binary %s (%d bytes)", remotePath, len(data))) + } + if err := session.Run(fmt.Sprintf("scp -t %q", dir)); err != nil { + return fmt.Errorf("scp binary: %w", err) + } + return nil +} + +// Provision installs Docker (if needed), uploads agent binary + compose, starts the node. +func Provision(n *models.Node, sshSecret string, agentBinary []byte, log LogFunc) error { + if len(agentBinary) == 0 { + return fmt.Errorf("agent binary is empty — build cmd/node for linux/amd64") + } + client, err := SSHConfig{ + Host: n.Host, Port: n.SSHPort, User: n.SSHUser, + AuthType: n.SSHAuthType, Secret: sshSecret, + }.dial() + if err != nil { + return fmt.Errorf("ssh connect: %w", err) + } + defer client.Close() + + if log != nil { + log("SSH connected") + } + + if _, err := run(client, `command -v docker >/dev/null 2>&1 || (curl -fsSL https://get.docker.com | sh)`, log); err != nil { + return fmt.Errorf("install docker: %w", err) + } + if _, err := run(client, `sudo mkdir -p /opt/vpn-panel-node/data && sudo chown -R $(whoami):$(whoami) /opt/vpn-panel-node || true`, log); err != nil { + return fmt.Errorf("prepare dir: %w", err) + } + + if err := uploadBinary(client, "/opt/vpn-panel-node/vpn-node", agentBinary, log); err != nil { + return err + } + if err := writeRemoteFile(client, "/opt/vpn-panel-node/docker-compose.yml", ComposeYAML(n), 0644, log); err != nil { + return err + } + + if _, err := run(client, `cd /opt/vpn-panel-node && chmod +x vpn-node && (docker compose up -d --force-recreate || docker-compose up -d --force-recreate)`, log); err != nil { + return fmt.Errorf("docker compose up: %w", err) + } + + if log != nil { + log(fmt.Sprintf("node agent started on :%d", n.NodePort)) + log("firewall: allow NODE_PORT only from panel IP") + } + return nil +} + +func FindAgentBinary() (string, error) { + candidates := []string{ + "/app/bin/vpn-node", + "bin/vpn-node", + "bin/vpn-node-linux-amd64", + "vpn-node", + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && !st.IsDir() && st.Size() > 0 { + return c, nil + } + } + return "", fmt.Errorf("vpn-node binary not found (looked in %s)", strings.Join(candidates, ", ")) +} + +func LoadAgentBinary() ([]byte, error) { + path, err := FindAgentBinary() + if err != nil { + return nil, err + } + return os.ReadFile(path) +} diff --git a/internal/secretbox/secretbox.go b/internal/secretbox/secretbox.go new file mode 100644 index 0000000..c2b825a --- /dev/null +++ b/internal/secretbox/secretbox.go @@ -0,0 +1,71 @@ +package secretbox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" +) + +func DeriveKey(secret string) []byte { + sum := sha256.Sum256([]byte(secret)) + return sum[:] +} + +func Encrypt(key []byte, plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + out := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(out), nil +} + +func Decrypt(key []byte, encoded string) (string, error) { + if encoded == "" { + return "", nil + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("ciphertext too short") + } + nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func RandomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/web/static/css/app.css b/web/static/css/app.css index 487278e..4b8aa43 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -276,6 +276,93 @@ input:focus { } .badge.on { background: rgba(61,214,198,0.15); color: var(--ok); } .badge.off { background: rgba(122,135,156,0.18); color: var(--off); } +.badge.warn { background: rgba(255, 196, 87, 0.16); color: #ffc457; } +.badge.err { background: rgba(255,107,122,0.15); color: #ff8a96; } + +.alert.ok { + background: rgba(61,214,198,0.12); + border: 1px solid rgba(61,214,198,0.35); + color: #9af0e6; +} + +.row-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.stats-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } + +.form-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.25rem; + max-width: 760px; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.85rem 1rem; + margin-bottom: 0.85rem; +} +textarea, select { + width: 100%; + border: 1px solid var(--line); + background: rgba(0,0,0,0.25); + color: var(--text); + border-radius: 10px; + padding: 0.75rem 0.9rem; + font: inherit; +} +textarea { resize: vertical; font-family: var(--mono); font-size: 0.85rem; } +.hint { color: var(--muted); font-size: 0.85rem; margin: -0.35rem 0 1rem; } +.mode-pick { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.85rem 1rem; + margin: 0 0 1.25rem; +} +.mode-pick legend { padding: 0 0.35rem; color: var(--muted); } +.radio { + display: flex; + gap: 0.65rem; + align-items: flex-start; + margin: 0.55rem 0; + color: var(--text); +} +.radio input { width: auto; margin-top: 0.25rem; } + +.code-block { + background: rgba(0,0,0,0.35); + border: 1px solid var(--line); + border-radius: 12px; + padding: 1rem; + overflow: auto; + font-family: var(--mono); + font-size: 0.82rem; + line-height: 1.45; + white-space: pre-wrap; +} +.code-block.log { max-height: 320px; } +.steps { color: var(--muted); padding-left: 1.2rem; } +.steps li { margin: 0.35rem 0; } +.empty-state { + border: 1px dashed var(--line); + border-radius: var(--radius); + padding: 2rem; + text-align: center; +} +.meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; +} +.panel-section { margin-top: 1.75rem; } +.panel-section h2 { margin: 0 0 0.75rem; font-size: 1.1rem; } + .table-wrap { background: var(--surface); @@ -326,6 +413,8 @@ input:focus { .sidebar nav { grid-auto-flow: column; grid-auto-columns: max-content; overflow-x: auto; } .sidebar-foot { display: flex; align-items: center; justify-content: space-between; } .stats { grid-template-columns: 1fr; } + .stats-5 { grid-template-columns: 1fr 1fr; } + .form-grid, .meta-grid { grid-template-columns: 1fr; } .admin-main { padding: 1.25rem; } .home-top, .home-hero { padding-left: 1.25rem; padding-right: 1.25rem; } } diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index b74ba5a..7fb4fc5 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -14,6 +14,7 @@ VPN Panel