Add node mode with SSH auto-install and Remnawave-style manual deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user