Add node mode with SSH auto-install and Remnawave-style manual deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 04:03:33 +03:00
co-authored by Cursor
parent 93bbbf8ce8
commit 7b77d84f68
20 changed files with 1470 additions and 8 deletions
+4 -2
View File
@@ -7,13 +7,15 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata RUN apk add --no-cache ca-certificates tzdata openssh-client
WORKDIR /app WORKDIR /app
COPY --from=builder /out/panel /app/panel COPY --from=builder /out/panel /app/panel
COPY --from=builder /out/vpn-node /app/bin/vpn-node
COPY web /app/web COPY web /app/web
ENV APP_PORT=8080 ENV APP_PORT=8080
+32
View File
@@ -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 <SECRET_KEY>`.
Эндпоинты агента: `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) ## Reverse proxy + SSL (Let's Encrypt)
Примеры лежат в `deploy/`. Сеть Docker: `vpn-panel-net`. Примеры лежат в `deploy/`. Сеть Docker: `vpn-panel-net`.
@@ -193,5 +224,6 @@ go run ./cmd/server
- `/` — главная - `/` — главная
- `/login` — вход - `/login` — вход
- `/admin` — дашборд - `/admin` — дашборд
- `/admin/nodes` — ноды
- `/admin/protocols` — протоколы - `/admin/protocols` — протоколы
- `/health` — healthcheck - `/health` — healthcheck
+157
View File
@@ -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)
}
+1 -1
View File
@@ -34,7 +34,7 @@ func main() {
} }
authMgr := auth.New(cfg.Secret) 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 { if err != nil {
log.Fatalf("handlers: %v", err) log.Fatalf("handlers: %v", err)
} }
+21
View File
@@ -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"]
+30
View File
@@ -60,6 +60,28 @@ CREATE TABLE IF NOT EXISTS protocols (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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) _, err := db.Exec(schema)
return err return err
@@ -174,5 +196,13 @@ func GetStats(db *sql.DB) (models.DashboardStats, error) {
return s, err return s, err
} }
err = db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&s.AdminsTotal) 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 return s, err
} }
+138
View File
@@ -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
}
+35 -3
View File
@@ -12,23 +12,45 @@ import (
"github.com/orohi/vpn-panel/internal/auth" "github.com/orohi/vpn-panel/internal/auth"
"github.com/orohi/vpn-panel/internal/db" "github.com/orohi/vpn-panel/internal/db"
"github.com/orohi/vpn-panel/internal/secretbox"
) )
type Server struct { type Server struct {
DB *sql.DB DB *sql.DB
Auth *auth.Manager Auth *auth.Manager
Templates *template.Template Templates *template.Template
SecretKey []byte
} }
type pageData map[string]any 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") 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 { if err != nil {
return nil, err 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 { 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", 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/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) { r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok")) _, _ = 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) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
nodes, _ := db.ListNodes(s.DB)
s.render(w, "dashboard.html", pageData{ s.render(w, "dashboard.html", pageData{
"Title": "Админка", "Title": "Админка",
"UserName": s.Auth.CurrentName(r), "UserName": s.Auth.CurrentName(r),
"Stats": stats, "Stats": stats,
"Protocols": protocols, "Protocols": protocols,
"Nodes": nodes,
"Active": "dashboard", "Active": "dashboard",
}) })
} }
+260
View File
@@ -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")
}
+47
View File
@@ -27,8 +27,55 @@ type Protocol struct {
UpdatedAt time.Time `json:"updated_at"` 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 { type DashboardStats struct {
ProtocolsTotal int ProtocolsTotal int
ProtocolsEnabled int ProtocolsEnabled int
AdminsTotal int AdminsTotal int
NodesTotal int
NodesOnline int
} }
+44
View File
@@ -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
}
+49
View File
@@ -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()
}
+208
View File
@@ -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)
}
+71
View File
@@ -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
}
+89
View File
@@ -276,6 +276,93 @@ input:focus {
} }
.badge.on { background: rgba(61,214,198,0.15); color: var(--ok); } .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.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 { .table-wrap {
background: var(--surface); background: var(--surface);
@@ -326,6 +413,8 @@ input:focus {
.sidebar nav { grid-auto-flow: column; grid-auto-columns: max-content; overflow-x: auto; } .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; } .sidebar-foot { display: flex; align-items: center; justify-content: space-between; }
.stats { grid-template-columns: 1fr; } .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; } .admin-main { padding: 1.25rem; }
.home-top, .home-hero { padding-left: 1.25rem; padding-right: 1.25rem; } .home-top, .home-hero { padding-left: 1.25rem; padding-right: 1.25rem; }
} }
+37 -2
View File
@@ -14,6 +14,7 @@
<a class="brand" href="/admin">VPN Panel</a> <a class="brand" href="/admin">VPN Panel</a>
<nav> <nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a> <a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a> <a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav> </nav>
<div class="sidebar-foot"> <div class="sidebar-foot">
@@ -25,10 +26,18 @@
<main class="admin-main"> <main class="admin-main">
<header class="admin-header"> <header class="admin-header">
<h1>Обзор</h1> <h1>Обзор</h1>
<p class="muted">Состояние панели и активные протоколы</p> <p class="muted">Панель, ноды и протоколы</p>
</header> </header>
<section class="stats"> <section class="stats stats-5">
<div class="stat">
<span class="stat-label">Ноды</span>
<span class="stat-value">{{.Stats.NodesTotal}}</span>
</div>
<div class="stat">
<span class="stat-label">Online</span>
<span class="stat-value accent">{{.Stats.NodesOnline}}</span>
</div>
<div class="stat"> <div class="stat">
<span class="stat-label">Протоколов</span> <span class="stat-label">Протоколов</span>
<span class="stat-value">{{.Stats.ProtocolsTotal}}</span> <span class="stat-value">{{.Stats.ProtocolsTotal}}</span>
@@ -43,6 +52,32 @@
</div> </div>
</section> </section>
<section class="panel-section">
<div class="section-head">
<h2>Ноды</h2>
<a href="/admin/nodes" class="btn btn-ghost btn-sm">Все ноды</a>
</div>
{{if .Nodes}}
<div class="proto-grid">
{{range .Nodes}}
<article class="proto-card">
<div class="proto-top">
<h3><a href="/admin/nodes/{{.ID}}">{{.Name}}</a></h3>
<span class="badge {{statusClass (printf "%s" .Status)}}">{{.StatusLabel}}</span>
</div>
<p>{{.Host}}:{{.NodePort}}</p>
<div class="proto-meta">
<code>{{.InstallMode}}</code>
<span>{{if .AgentVersion}}v{{.AgentVersion}}{{else}}—{{end}}</span>
</div>
</article>
{{end}}
</div>
{{else}}
<p class="muted">Ноды не добавлены. <a href="/admin/nodes/new">Добавить</a></p>
{{end}}
</section>
<section class="panel-section"> <section class="panel-section">
<div class="section-head"> <div class="section-head">
<h2>Протоколы</h2> <h2>Протоколы</h2>
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="page-admin">
<aside class="sidebar">
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
<span class="user-chip">{{.UserName}}</span>
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
</div>
</aside>
<main class="admin-main">
<header class="admin-header">
<h1>Новая нода</h1>
<p class="muted">Авторежим: укажите SSH — мастер поставит Docker и агент. Ручной: получите docker-compose как в Remnawave.</p>
</header>
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
<form class="form-card" method="POST" action="/admin/nodes/new" autocomplete="off">
<div class="form-grid">
<label>Имя
<input name="name" required value="{{.Form.Name}}" placeholder="eu-1">
</label>
<label>Хост / IP
<input name="host" required value="{{.Form.Host}}" placeholder="203.0.113.10">
</label>
<label>SSH порт
<input name="ssh_port" type="number" value="{{.Form.SSHPort}}">
</label>
<label>SSH пользователь
<input name="ssh_user" value="{{.Form.SSHUser}}" placeholder="root">
</label>
<label>Тип SSH
<select name="ssh_auth_type">
<option value="password" {{if eq .Form.SSHAuthType "password"}}selected{{end}}>Пароль</option>
<option value="key" {{if eq .Form.SSHAuthType "key"}}selected{{end}}>Приватный ключ</option>
</select>
</label>
<label>Node API порт
<input name="node_port" type="number" value="{{.Form.NodePort}}">
</label>
</div>
<label>SSH пароль или приватный ключ
<textarea name="ssh_secret" rows="4" placeholder="пароль или содержимое id_rsa"></textarea>
</label>
<p class="hint">Хранится зашифрованным (AES-GCM от APP_SECRET). Для ручного режима можно оставить пустым.</p>
<fieldset class="mode-pick">
<legend>Режим установки</legend>
<label class="radio">
<input type="radio" name="install_mode" value="auto" {{if ne .Form.InstallMode "manual"}}checked{{end}}>
<span><strong>Авто по SSH</strong> — панель сама ставит Docker + агент</span>
</label>
<label class="radio">
<input type="radio" name="install_mode" value="manual" {{if eq .Form.InstallMode "manual"}}checked{{end}}>
<span><strong>Вручную</strong> — скопировать docker-compose (как Remnawave Node)</span>
</label>
</fieldset>
<div class="cta-row">
<button class="btn btn-primary" type="submit">Создать</button>
<a class="btn btn-ghost" href="/admin/nodes">Отмена</a>
</div>
</form>
</main>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
{{if eq (printf "%s" .Node.Status) "installing"}}
<meta http-equiv="refresh" content="4">
{{end}}
</head>
<body class="page-admin">
<aside class="sidebar">
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
<span class="user-chip">{{.UserName}}</span>
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
</div>
</aside>
<main class="admin-main">
<header class="admin-header row-head">
<div>
<h1>{{.Node.Name}}</h1>
<p class="muted"><code>{{.Node.Host}}</code> · API :{{.Node.NodePort}} · {{.Node.InstallMode}}</p>
</div>
<span class="badge {{statusClass (printf "%s" .Node.Status)}}">{{.Node.StatusLabel}}</span>
</header>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
{{if .Node.LastError}}<div class="alert">{{.Node.LastError}}</div>{{end}}
<div class="cta-row" style="margin-bottom:1.25rem">
<form method="POST" action="/admin/nodes/{{.Node.ID}}/install">
<button class="btn btn-primary" type="submit">Установить / переустановить по SSH</button>
</form>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/check">
<button class="btn btn-ghost" type="submit">Проверить online</button>
</form>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/delete" onsubmit="return confirm('Удалить ноду из панели?');">
<button class="btn btn-ghost" type="submit">Удалить</button>
</form>
<a class="btn btn-ghost" href="/admin/nodes">К списку</a>
</div>
<section class="panel-section">
<h2>Важно</h2>
<p class="muted">Откройте порт <strong>{{.Node.NodePort}}</strong> на ноде <em>только</em> для IP панели (как NODE_PORT в Remnawave).</p>
</section>
<section class="panel-section">
<h2>docker-compose.yml</h2>
<pre class="code-block">{{.Compose}}</pre>
</section>
<section class="panel-section">
<h2>Ручная установка ( Remnawave-style )</h2>
<ol class="steps">
<li>На сервере ноды: <code>sudo curl -fsSL https://get.docker.com | sh</code></li>
<li><code>mkdir -p /opt/vpn-panel-node && cd /opt/vpn-panel-node</code></li>
<li>Скопируйте бинарник <code>vpn-node</code> (linux amd64) из образа панели <code>/app/bin/vpn-node</code></li>
<li>Вставьте compose ниже / выше и выполните <code>docker compose up -d</code></li>
</ol>
<pre class="code-block">{{.ManualScript}}</pre>
</section>
{{if .Node.InstallLog}}
<section class="panel-section">
<h2>Лог установки</h2>
<pre class="code-block log">{{.Node.InstallLog}}</pre>
</section>
{{end}}
<section class="panel-section meta-grid">
<div><span class="stat-label">Агент</span><div>{{if .Node.AgentVersion}}{{.Node.AgentVersion}}{{else}}—{{end}}</div></div>
<div><span class="stat-label">Last seen</span><div>{{if .Node.LastSeenAt}}{{.Node.LastSeenAt}}{{else}}—{{end}}</div></div>
<div><span class="stat-label">Создана</span><div>{{.Node.CreatedAt}}</div></div>
</section>
</main>
</body>
</html>
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="page-admin">
<aside class="sidebar">
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
<span class="user-chip">{{.UserName}}</span>
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
</div>
</aside>
<main class="admin-main">
<header class="admin-header row-head">
<div>
<h1>Ноды</h1>
<p class="muted">Удалённые серверы: автоустановка по SSH или ручной docker compose</p>
</div>
<a class="btn btn-primary" href="/admin/nodes/new">+ Добавить ноду</a>
</header>
{{if .Flash}}<div class="alert ok">Готово: {{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert">Ошибка: {{.Error}}</div>{{end}}
{{if .Nodes}}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Имя</th>
<th>Хост</th>
<th>API порт</th>
<th>Режим</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Nodes}}
<tr>
<td><strong><a href="/admin/nodes/{{.ID}}">{{.Name}}</a></strong></td>
<td><code>{{.Host}}</code></td>
<td>{{.NodePort}}</td>
<td>{{.InstallMode}}</td>
<td><span class="badge {{statusClass (printf "%s" .Status)}}">{{.StatusLabel}}</span></td>
<td class="actions"><a class="btn btn-sm btn-ghost" href="/admin/nodes/{{.ID}}">Открыть</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<p>Нод пока нет. Добавьте сервер — панель сама поставит Docker и агент (или выдаст compose как Remnawave).</p>
<a class="btn btn-primary" href="/admin/nodes/new">Добавить первую ноду</a>
</div>
{{end}}
</main>
</body>
</html>
+1
View File
@@ -14,6 +14,7 @@
<a class="brand" href="/admin">VPN Panel</a> <a class="brand" href="/admin">VPN Panel</a>
<nav> <nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a> <a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a> <a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav> </nav>
<div class="sidebar-foot"> <div class="sidebar-foot">