initial: ZeroTier-like P2P mesh VPN server with multi-tenant Web UI
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
bin/
|
||||||
|
data/*.db
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
uploads/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
43
Makefile
Normal file
43
Makefile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
GO ?= /usr/local/go/bin/go
|
||||||
|
APP_NAME ?= zeromesh
|
||||||
|
VERSION ?= 1.0.0
|
||||||
|
LDFLAGS = -s -w -X main.Version=$(VERSION)
|
||||||
|
|
||||||
|
.PHONY: all build controller agent relay cli clean test lint vet
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
build: controller agent relay cli
|
||||||
|
|
||||||
|
controller:
|
||||||
|
$(GO) build -ldflags="$(LDFLAGS)" -o bin/zeromesh-controller ./cmd/zeromesh-controller/
|
||||||
|
|
||||||
|
agent:
|
||||||
|
$(GO) build -ldflags="$(LDFLAGS)" -o bin/zeromesh-agent ./cmd/zeromesh-agent/
|
||||||
|
|
||||||
|
relay:
|
||||||
|
$(GO) build -ldflags="$(LDFLAGS)" -o bin/zeromesh-relay ./cmd/zeromesh-relay/
|
||||||
|
|
||||||
|
cli:
|
||||||
|
$(GO) build -ldflags="$(LDFLAGS)" -o bin/zeromesh-cli ./cmd/zeromesh-cli/
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(GO) test ./... -v
|
||||||
|
|
||||||
|
vet:
|
||||||
|
$(GO) vet ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin/
|
||||||
|
rm -rf data/
|
||||||
|
|
||||||
|
cross-linux-amd64:
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 $(GO) build -ldflags="$(LDFLAGS)" -o bin/zeromesh-controller-linux-amd64 ./cmd/zeromesh-controller/
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 $(GO) build -ldflags="$(LDFLAGS)" -o bin/zeromesh-agent-linux-amd64 ./cmd/zeromesh-agent/
|
||||||
|
|
||||||
|
cross-linux-arm64:
|
||||||
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 $(GO) build -ldflags="$(LDFLAGS)" -o bin/zeromesh-controller-linux-arm64 ./cmd/zeromesh-controller/
|
||||||
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 $(GO) build -ldflags="$(LDFLAGS)" -o bin/zeromesh-agent-linux-arm64 ./cmd/zeromesh-agent/
|
||||||
9
build.bat
Normal file
9
build.bat
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
set GOOS=linux
|
||||||
|
set GOARCH=amd64
|
||||||
|
set CGO_ENABLED=0
|
||||||
|
go build -ldflags="-s -w" -o bin/zeromesh-controller ./cmd/zeromesh-controller/
|
||||||
|
go build -ldflags="-s -w" -o bin/zeromesh-agent ./cmd/zeromesh-agent/
|
||||||
|
go build -ldflags="-s -w" -o bin/zeromesh-relay ./cmd/zeromesh-relay/
|
||||||
|
go build -ldflags="-s -w" -o bin/zeromesh-cli ./cmd/zeromesh-cli/
|
||||||
|
echo Done.
|
||||||
22
build.sh
Executable file
22
build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
GO="${GO:-/usr/local/go/bin/go}"
|
||||||
|
|
||||||
|
VERSION="${VERSION:-1.0.0}"
|
||||||
|
LDFLAGS="-s -w -X main.Version=${VERSION}"
|
||||||
|
|
||||||
|
echo "Building zeromesh components (${GO})..."
|
||||||
|
|
||||||
|
$GO build -ldflags="${LDFLAGS}" -o bin/zeromesh-controller ./cmd/zeromesh-controller/
|
||||||
|
echo " -> bin/zeromesh-controller"
|
||||||
|
|
||||||
|
$GO build -ldflags="${LDFLAGS}" -o bin/zeromesh-agent ./cmd/zeromesh-agent/
|
||||||
|
echo " -> bin/zeromesh-agent"
|
||||||
|
|
||||||
|
$GO build -ldflags="${LDFLAGS}" -o bin/zeromesh-relay ./cmd/zeromesh-relay/
|
||||||
|
echo " -> bin/zeromesh-relay"
|
||||||
|
|
||||||
|
$GO build -ldflags="${LDFLAGS}" -o bin/zeromesh-cli ./cmd/zeromesh-cli/
|
||||||
|
echo " -> bin/zeromesh-cli"
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
46
cmd/zeromesh-agent/main.go
Normal file
46
cmd/zeromesh-agent/main.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"zeromesh/internal/agent"
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := flag.String("config", "config.yaml", "config file path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("load config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
ag, err := agent.New(cfg.Agent, logger)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("init agent", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Start(); err != nil {
|
||||||
|
slog.Error("start agent", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("agent started", "address", ag.Identity().Address)
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
slog.Info("stopping agent...")
|
||||||
|
ag.Stop()
|
||||||
|
}
|
||||||
36
cmd/zeromesh-cli/main.go
Normal file
36
cmd/zeromesh-cli/main.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"zeromesh/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var cmd string
|
||||||
|
flag.StringVar(&cmd, "cmd", "", "command: gen-identity | show-identity")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "gen-identity":
|
||||||
|
id := identity.Generate()
|
||||||
|
data, _ := id.Serialize()
|
||||||
|
fmt.Println(data)
|
||||||
|
case "show-identity":
|
||||||
|
idPath := flag.Arg(0)
|
||||||
|
if idPath == "" {
|
||||||
|
idPath = "./data/agent.identity"
|
||||||
|
}
|
||||||
|
id, err := identity.LoadOrGenerate(idPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Address: %s\n", id.Address)
|
||||||
|
fmt.Printf("Public: %s\n", id.PublicKeyHex())
|
||||||
|
default:
|
||||||
|
fmt.Println("Usage: zeromesh-cli -cmd=gen-identity")
|
||||||
|
}
|
||||||
|
}
|
||||||
94
cmd/zeromesh-controller/main.go
Normal file
94
cmd/zeromesh-controller/main.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
"zeromesh/internal/controller"
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := flag.String("config", "config.yaml", "config file path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("load config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logLevel := new(slog.LevelVar)
|
||||||
|
switch cfg.Logging.Level {
|
||||||
|
case "debug":
|
||||||
|
logLevel.Set(slog.LevelDebug)
|
||||||
|
case "warn":
|
||||||
|
logLevel.Set(slog.LevelWarn)
|
||||||
|
case "error":
|
||||||
|
logLevel.Set(slog.LevelError)
|
||||||
|
default:
|
||||||
|
logLevel.Set(slog.LevelInfo)
|
||||||
|
}
|
||||||
|
opts := &slog.HandlerOptions{Level: logLevel}
|
||||||
|
var handler slog.Handler
|
||||||
|
if cfg.Logging.Format == "json" {
|
||||||
|
handler = slog.NewJSONHandler(os.Stderr, opts)
|
||||||
|
} else {
|
||||||
|
handler = slog.NewTextHandler(os.Stderr, opts)
|
||||||
|
}
|
||||||
|
logger := slog.New(handler)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
db, err := database.Init(&cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("init database", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctr, err := controller.New(cfg, db, logger)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("init controller", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := ctr.Start(); err != nil {
|
||||||
|
slog.Error("start controller", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := router.Setup(cfg, db)
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Server.Addr(),
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("http server listening", "addr", srv.Addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("http serve", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
slog.Info("shutting down...")
|
||||||
|
ctr.Stop()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("server shutdown", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
cmd/zeromesh-relay/main.go
Normal file
62
cmd/zeromesh-relay/main.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
"zeromesh/internal/vl1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := flag.String("config", "config.yaml", "config file path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("load config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
transport, err := vl1.NewTransport(cfg.Relay.ListenPort, logger)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("init relay transport", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("relay started",
|
||||||
|
"realm", cfg.Relay.Realm,
|
||||||
|
"port", transport.Port(),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, vl1.MaxPacketSize)
|
||||||
|
for {
|
||||||
|
n, addr, err := transport.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("relay read", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Debug("relay packet", "from", addr, "len", n)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
cancel()
|
||||||
|
transport.Close()
|
||||||
|
}
|
||||||
55
config.yaml
Normal file
55
config.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
server:
|
||||||
|
port: 10001
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: sqlite
|
||||||
|
auto_migrate: true
|
||||||
|
init_data: true
|
||||||
|
sqlite:
|
||||||
|
path: ./data/zeromesh.db
|
||||||
|
mysql:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 3306
|
||||||
|
username: root
|
||||||
|
password: ""
|
||||||
|
database: zeromesh
|
||||||
|
charset: utf8mb4
|
||||||
|
max_idle_conns: 10
|
||||||
|
max_open_conns: 100
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: "change-me-in-production"
|
||||||
|
expire_hours: 720
|
||||||
|
|
||||||
|
controller:
|
||||||
|
listen_port: 19993
|
||||||
|
public_endpoint: ""
|
||||||
|
planet_id: ""
|
||||||
|
|
||||||
|
agent:
|
||||||
|
controller_url: "http://127.0.0.1:10001"
|
||||||
|
listen_port: 0
|
||||||
|
tap_name: "zeromesh0"
|
||||||
|
tap_mtu: 2800
|
||||||
|
network_id: 1
|
||||||
|
psk: "default-psk-change-me"
|
||||||
|
|
||||||
|
relay:
|
||||||
|
listen_port: 3478
|
||||||
|
realm: "zeromesh"
|
||||||
|
|
||||||
|
cors:
|
||||||
|
allowed_origins:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
rate_limit:
|
||||||
|
enabled: true
|
||||||
|
requests_per_second: 10
|
||||||
|
burst_size: 20
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
format: text
|
||||||
|
|
||||||
|
env:
|
||||||
|
name: dev
|
||||||
1
data/controller.identity
Normal file
1
data/controller.identity
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"public_key":"blqsO1TyrzRWGA4Ndhhwh/WFx50jIrr/x4z05vXm5aI=","private_key":"eZZVD6q+YrOy/pk+IBbPm8X4QyrvIF4FpUyh6t95jkduWqw7VPKvNFYYDg12GHCH9YXHnSMiuv/HjPTm9eblog==","address":"b412579ffd"}
|
||||||
49
go.mod
Normal file
49
go.mod
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
module zeromesh
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
115
go.sum
Normal file
115
go.sum
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
324
internal/agent/agent.go
Normal file
324
internal/agent/agent.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
"zeromesh/internal/identity"
|
||||||
|
"zeromesh/internal/tap"
|
||||||
|
"zeromesh/internal/vl1"
|
||||||
|
"zeromesh/internal/vl2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
cfg config.AgentConfig
|
||||||
|
identity *identity.Identity
|
||||||
|
transport *vl1.Transport
|
||||||
|
peers *vl1.PeerManager
|
||||||
|
network *vl2.Network
|
||||||
|
tapDev *tap.Interface
|
||||||
|
log *slog.Logger
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.AgentConfig, log *slog.Logger) (*Agent, error) {
|
||||||
|
id, err := identity.LoadOrGenerate("./data/agent.identity")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load identity: %w", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &Agent{
|
||||||
|
cfg: cfg,
|
||||||
|
identity: id,
|
||||||
|
peers: vl1.NewPeerManager(log),
|
||||||
|
log: log.With("component", "agent"),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) Start() error {
|
||||||
|
transport, err := vl1.NewTransport(a.cfg.ListenPort, a.log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.transport = transport
|
||||||
|
|
||||||
|
netConfig := vl2.NetworkConfig{
|
||||||
|
ID: a.cfg.NetworkID,
|
||||||
|
Name: "default",
|
||||||
|
MTU: a.cfg.TAPMTU,
|
||||||
|
Multicast: true,
|
||||||
|
}
|
||||||
|
a.network = vl2.NewNetwork(netConfig, a.identity.Address, a, a.log)
|
||||||
|
|
||||||
|
if a.cfg.ControllerURL != "" {
|
||||||
|
a.log.Info("agent started (controller mode)",
|
||||||
|
"address", a.identity.Address,
|
||||||
|
"port", a.transport.Port(),
|
||||||
|
"controller", a.cfg.ControllerURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.cfg.TAPName != "" {
|
||||||
|
tapName := a.cfg.TAPName
|
||||||
|
if tapName == "" {
|
||||||
|
tapName = fmt.Sprintf("zm%x", a.identity.Address[:4])
|
||||||
|
}
|
||||||
|
tapDev, err := tap.Open(tapName, a.cfg.TAPMTU)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Warn("TAP device not available (run with sufficient privileges)", "err", err)
|
||||||
|
} else {
|
||||||
|
a.tapDev = tapDev
|
||||||
|
a.log.Info("TAP device created", "name", tapName, "mtu", a.cfg.TAPMTU)
|
||||||
|
a.wg.Add(1)
|
||||||
|
go a.tapReadLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.wg.Add(1)
|
||||||
|
go a.udpReadLoop()
|
||||||
|
|
||||||
|
a.wg.Add(1)
|
||||||
|
go a.maintenanceLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) Stop() {
|
||||||
|
a.cancel()
|
||||||
|
if a.transport != nil {
|
||||||
|
a.transport.Close()
|
||||||
|
}
|
||||||
|
if a.tapDev != nil {
|
||||||
|
a.tapDev.Close()
|
||||||
|
}
|
||||||
|
a.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) Identity() *identity.Identity {
|
||||||
|
return a.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) Peers() *vl1.PeerManager {
|
||||||
|
return a.peers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) Network() *vl2.Network {
|
||||||
|
return a.network
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) SendToPeer(peerAddr identity.Address, networkID uint32, frame []byte) error {
|
||||||
|
peer := a.peers.GetPeer(peerAddr)
|
||||||
|
if peer == nil {
|
||||||
|
return fmt.Errorf("unknown peer: %s", peerAddr)
|
||||||
|
}
|
||||||
|
if !peer.IsConnected() {
|
||||||
|
return fmt.Errorf("peer not connected: %s", peerAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt := vl1.NewDataPacket(networkID, frame)
|
||||||
|
encoded := pkt.Encode()
|
||||||
|
|
||||||
|
// Encrypt payload portion (after header)
|
||||||
|
encrypted, err := peer.Encrypt(encoded[vl1.HeaderSize:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encPacket := vl1.NewDataPacket(networkID, encrypted)
|
||||||
|
if peer.Endpoint == nil {
|
||||||
|
return fmt.Errorf("peer %s: no endpoint", peerAddr)
|
||||||
|
}
|
||||||
|
return a.transport.SendPacket(&encPacket, peer.Endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) BroadcastToPeers(networkID uint32, frame []byte, excludePeer identity.Address) error {
|
||||||
|
for _, peer := range a.peers.ConnectedPeers() {
|
||||||
|
if peer.Address == excludePeer {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkt := vl1.NewDataPacket(networkID, frame)
|
||||||
|
encoded := pkt.Encode()
|
||||||
|
encrypted, err := peer.Encrypt(encoded[vl1.HeaderSize:])
|
||||||
|
if err != nil {
|
||||||
|
a.log.Debug("encrypt for broadcast", "peer", peer.Address, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
encPacket := vl1.NewDataPacket(networkID, encrypted)
|
||||||
|
if peer.Endpoint != nil {
|
||||||
|
if err := a.transport.SendPacket(&encPacket, peer.Endpoint); err != nil {
|
||||||
|
a.log.Debug("broadcast send", "peer", peer.Address, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) udpReadLoop() {
|
||||||
|
defer a.wg.Done()
|
||||||
|
buf := make([]byte, vl1.MaxPacketSize)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
n, remoteAddr, err := a.transport.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
if a.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.log.Error("UDP read error", "err", err)
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.handlePacket(buf[:n], remoteAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) handlePacket(data []byte, from *net.UDPAddr) {
|
||||||
|
pkt, err := vl1.DecodePacket(data)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Debug("decode packet", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch pkt.Header.Type {
|
||||||
|
case vl1.PacketTypeHandshake:
|
||||||
|
a.handleHandshake(pkt.Payload, from)
|
||||||
|
case vl1.PacketTypeData:
|
||||||
|
a.handleDataPacket(pkt, from)
|
||||||
|
case vl1.PacketTypeKeepalive:
|
||||||
|
if peer := a.peers.GetPeerByEndpoint(from); peer != nil {
|
||||||
|
peer.Touch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) handleHandshake(payload []byte, from *net.UDPAddr) {
|
||||||
|
var pubKey [32]byte
|
||||||
|
if len(payload) < 32 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy(pubKey[:], payload[:32])
|
||||||
|
addr := identity.AddressFromPublicKey(pubKey[:])
|
||||||
|
|
||||||
|
peer := a.peers.GetPeer(addr)
|
||||||
|
if peer != nil {
|
||||||
|
a.peers.UpdatePeerEndpoint(addr, from)
|
||||||
|
peer.Touch()
|
||||||
|
if !peer.IsConnected() {
|
||||||
|
sendKey, recvKey := vl1.DeriveKeysFromPSK(a.cfg.PSK, a.identity.PublicKey, pubKey[:])
|
||||||
|
peer.SetCipher(vl1.NewNoiseCipher(sendKey, recvKey))
|
||||||
|
a.log.Info("peer connected", "peer", peer.Address)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer = a.peers.AddPeer(addr, pubKey, from)
|
||||||
|
sendKey, recvKey := vl1.DeriveKeysFromPSK(a.cfg.PSK, a.identity.PublicKey, pubKey[:])
|
||||||
|
peer.SetCipher(vl1.NewNoiseCipher(sendKey, recvKey))
|
||||||
|
a.log.Info("new peer", "peer", peer.Address)
|
||||||
|
a.sendHello(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) handleDataPacket(pkt *vl1.Packet, from *net.UDPAddr) {
|
||||||
|
peer := a.peers.GetPeerByEndpoint(from)
|
||||||
|
if peer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer.Touch()
|
||||||
|
|
||||||
|
plaintext, err := peer.Decrypt(pkt.Payload)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Debug("decrypt failed", "peer", peer.Address, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.network == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
frameToInject, err := a.network.Switch.HandleRemoteFrame(peer.Address, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Debug("switch handle remote frame", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if frameToInject != nil {
|
||||||
|
if a.tapDev != nil {
|
||||||
|
if _, err := a.tapDev.Write(frameToInject); err != nil {
|
||||||
|
a.log.Debug("TAP write", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) tapReadLoop() {
|
||||||
|
defer a.wg.Done()
|
||||||
|
buf := make([]byte, a.cfg.TAPMTU+64)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
n, err := a.tapDev.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if a.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.log.Error("TAP read error", "err", err)
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frame := make([]byte, n)
|
||||||
|
copy(frame, buf[:n])
|
||||||
|
if a.network != nil {
|
||||||
|
if err := a.network.Switch.HandleLocalFrame(frame); err != nil {
|
||||||
|
a.log.Debug("switch handle local", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) sendHello(peer *vl1.Peer) {
|
||||||
|
pkt := vl1.NewHandshakePacket(a.identity.PublicKey[:])
|
||||||
|
if peer.Endpoint != nil {
|
||||||
|
a.transport.SendPacket(&pkt, peer.Endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) maintenanceLoop() {
|
||||||
|
defer a.wg.Done()
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
for _, peer := range a.peers.ConnectedPeers() {
|
||||||
|
if peer.NeedsKeepalive() {
|
||||||
|
pkt := vl1.NewKeepalivePacket()
|
||||||
|
if peer.Endpoint != nil {
|
||||||
|
a.transport.SendPacket(&pkt, peer.Endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, peer := range a.peers.AllPeers() {
|
||||||
|
if !peer.IsConnected() {
|
||||||
|
a.sendHello(peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.peers.CleanDead()
|
||||||
|
if a.network != nil {
|
||||||
|
a.network.Switch.CleanExpired()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
internal/config/config.go
Normal file
142
internal/config/config.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Database DatabaseConfig `yaml:"database"`
|
||||||
|
JWT JWTConfig `yaml:"jwt"`
|
||||||
|
Controller ControllerConfig `yaml:"controller"`
|
||||||
|
Agent AgentConfig `yaml:"agent"`
|
||||||
|
Relay RelayConfig `yaml:"relay"`
|
||||||
|
CORS CORSConfig `yaml:"cors"`
|
||||||
|
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||||
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
|
Env EnvConfig `yaml:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServerConfig) Addr() string {
|
||||||
|
return fmt.Sprintf(":%d", s.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Driver string `yaml:"driver"`
|
||||||
|
AutoMigrate bool `yaml:"auto_migrate"`
|
||||||
|
InitData bool `yaml:"init_data"`
|
||||||
|
SQLite SQLiteConfig `yaml:"sqlite"`
|
||||||
|
MySQL MySQLConfig `yaml:"mysql"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SQLiteConfig struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MySQLConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
Database string `yaml:"database"`
|
||||||
|
Charset string `yaml:"charset"`
|
||||||
|
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||||
|
MaxOpenConns int `yaml:"max_open_conns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTConfig struct {
|
||||||
|
Secret string `yaml:"secret"`
|
||||||
|
ExpireHours int `yaml:"expire_hours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControllerConfig struct {
|
||||||
|
ListenPort int `yaml:"listen_port"`
|
||||||
|
PublicEndpoint string `yaml:"public_endpoint"`
|
||||||
|
PlanetID string `yaml:"planet_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentConfig struct {
|
||||||
|
ControllerURL string `yaml:"controller_url"`
|
||||||
|
ListenPort int `yaml:"listen_port"`
|
||||||
|
TAPName string `yaml:"tap_name"`
|
||||||
|
TAPMTU int `yaml:"tap_mtu"`
|
||||||
|
NetworkID uint32 `yaml:"network_id"`
|
||||||
|
PSK string `yaml:"psk"`
|
||||||
|
StaticPeers []string `yaml:"static_peers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelayConfig struct {
|
||||||
|
ListenPort int `yaml:"listen_port"`
|
||||||
|
Realm string `yaml:"realm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CORSConfig struct {
|
||||||
|
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
RequestsPerSecond int `yaml:"requests_per_second"`
|
||||||
|
BurstSize int `yaml:"burst_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingConfig struct {
|
||||||
|
Level string `yaml:"level"`
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
setDefaults(&cfg)
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaults(cfg *Config) {
|
||||||
|
if cfg.Server.Port == 0 {
|
||||||
|
cfg.Server.Port = 10001
|
||||||
|
}
|
||||||
|
if cfg.Controller.ListenPort == 0 {
|
||||||
|
cfg.Controller.ListenPort = 9993
|
||||||
|
}
|
||||||
|
if cfg.Agent.TAPName == "" {
|
||||||
|
cfg.Agent.TAPName = "zeromesh0"
|
||||||
|
}
|
||||||
|
if cfg.Agent.TAPMTU == 0 {
|
||||||
|
cfg.Agent.TAPMTU = 2800
|
||||||
|
}
|
||||||
|
if cfg.JWT.ExpireHours == 0 {
|
||||||
|
cfg.JWT.ExpireHours = 720
|
||||||
|
}
|
||||||
|
if cfg.Logging.Level == "" {
|
||||||
|
cfg.Logging.Level = "info"
|
||||||
|
}
|
||||||
|
if cfg.Env.Name == "" {
|
||||||
|
cfg.Env.Name = "dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DatabaseConfig) BuildDSN() string {
|
||||||
|
if d.Driver == "mysql" {
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
|
||||||
|
d.MySQL.Username, d.MySQL.Password, d.MySQL.Host, d.MySQL.Port, d.MySQL.Database, d.MySQL.Charset)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
192
internal/controller/controller.go
Normal file
192
internal/controller/controller.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/identity"
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
"zeromesh/internal/repository"
|
||||||
|
"zeromesh/internal/vl1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Controller struct {
|
||||||
|
cfg *config.Config
|
||||||
|
db *database.DB
|
||||||
|
identity *identity.Identity
|
||||||
|
peers *vl1.PeerManager
|
||||||
|
transport *vl1.Transport
|
||||||
|
netRepo *repository.NetworkRepo
|
||||||
|
nodeRepo *repository.NodeRepo
|
||||||
|
wsClients map[string]net.Conn
|
||||||
|
mu sync.RWMutex
|
||||||
|
log *slog.Logger
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, db *database.DB, log *slog.Logger) (*Controller, error) {
|
||||||
|
id, err := identity.LoadOrGenerate("./data/controller.identity")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load controller identity: %w", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
return &Controller{
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
identity: id,
|
||||||
|
peers: vl1.NewPeerManager(log),
|
||||||
|
netRepo: repository.NewNetworkRepo(db),
|
||||||
|
nodeRepo: repository.NewNodeRepo(db),
|
||||||
|
wsClients: make(map[string]net.Conn),
|
||||||
|
log: log.With("component", "controller"),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Start() error {
|
||||||
|
transport, err := vl1.NewTransport(c.cfg.Controller.ListenPort, c.log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.transport = transport
|
||||||
|
c.log.Info("controller started",
|
||||||
|
"address", c.identity.Address,
|
||||||
|
"port", c.transport.Port(),
|
||||||
|
)
|
||||||
|
|
||||||
|
c.wg.Add(1)
|
||||||
|
go c.udpReadLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Stop() {
|
||||||
|
c.cancel()
|
||||||
|
if c.transport != nil {
|
||||||
|
c.transport.Close()
|
||||||
|
}
|
||||||
|
c.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) udpReadLoop() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
buf := make([]byte, vl1.MaxPacketSize)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
n, remoteAddr, err := c.transport.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
if c.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.log.Error("UDP read error", "err", err)
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.handlePacket(buf[:n], remoteAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) handlePacket(data []byte, from *net.UDPAddr) {
|
||||||
|
pkt, err := vl1.DecodePacket(data)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Debug("decode packet", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch pkt.Header.Type {
|
||||||
|
case vl1.PacketTypeHandshake:
|
||||||
|
c.handleHandshake(pkt.Payload, from)
|
||||||
|
case vl1.PacketTypeKeepalive:
|
||||||
|
if peer := c.peers.GetPeerByEndpoint(from); peer != nil {
|
||||||
|
peer.Touch()
|
||||||
|
nodeID := peer.Address.String()
|
||||||
|
_ = c.nodeRepo.SetOnline(nodeID, true)
|
||||||
|
}
|
||||||
|
case vl1.PacketTypeData:
|
||||||
|
// Controller does not forward data; data is P2P between agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) handleHandshake(payload []byte, from *net.UDPAddr) {
|
||||||
|
if len(payload) < 32 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pubKey [32]byte
|
||||||
|
copy(pubKey[:], payload[:32])
|
||||||
|
addr := identity.AddressFromPublicKey(pubKey[:])
|
||||||
|
nodeID := addr.String()
|
||||||
|
|
||||||
|
peer := c.peers.GetPeer(addr)
|
||||||
|
if peer == nil {
|
||||||
|
peer = c.peers.AddPeer(addr, pubKey, from)
|
||||||
|
c.log.Info("new peer connected", "node_id", nodeID, "addr", from)
|
||||||
|
} else {
|
||||||
|
c.peers.UpdatePeerEndpoint(addr, from)
|
||||||
|
peer.Touch()
|
||||||
|
}
|
||||||
|
_ = c.HandleNodeHello(nodeID, fmt.Sprintf("%x", pubKey[:]), from)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Identity() *identity.Identity {
|
||||||
|
return c.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) HandleNodeHello(nodeID string, publicKey string, addr *net.UDPAddr) error {
|
||||||
|
node, err := c.nodeRepo.FindByNodeID(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
// New node: register it
|
||||||
|
node = &model.Node{
|
||||||
|
NodeID: nodeID,
|
||||||
|
PublicKey: publicKey,
|
||||||
|
IPAddress: addr.IP.String(),
|
||||||
|
Port: addr.Port,
|
||||||
|
Online: true,
|
||||||
|
Version: "1.0.0",
|
||||||
|
}
|
||||||
|
if err := c.nodeRepo.Create(node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.log.Info("new node registered", "node_id", nodeID, "addr", addr)
|
||||||
|
} else {
|
||||||
|
c.nodeRepo.SetOnline(nodeID, true)
|
||||||
|
c.log.Debug("node hello", "node_id", nodeID, "addr", addr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) GetNodeList() ([]model.Node, error) {
|
||||||
|
return c.nodeRepo.ListOnline()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) GetNetworkConfig(networkID uint32) (*model.Network, error) {
|
||||||
|
return c.netRepo.FindByNetworkID(networkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) AuthorizeNode(networkID uint32, nodeID string) error {
|
||||||
|
_, err := c.netRepo.FindByNetworkID(networkID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = c.nodeRepo.FindByNodeID(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.netRepo.AddMember(&model.NetworkMember{
|
||||||
|
NetworkID: networkID,
|
||||||
|
NodeID: nodeID,
|
||||||
|
Authorized: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
84
internal/database/database.go
Normal file
84
internal/database/database.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
*gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Init(cfg *config.DatabaseConfig) (*DB, error) {
|
||||||
|
var db *gorm.DB
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch cfg.Driver {
|
||||||
|
case "mysql":
|
||||||
|
dsn := cfg.BuildDSN()
|
||||||
|
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
case "sqlite":
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
db, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), &gorm.Config{})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get sql db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Driver == "mysql" {
|
||||||
|
sqlDB.SetMaxIdleConns(cfg.MySQL.MaxIdleConns)
|
||||||
|
sqlDB.SetMaxOpenConns(cfg.MySQL.MaxOpenConns)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AutoMigrate {
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{},
|
||||||
|
&model.Network{},
|
||||||
|
&model.NetworkMember{},
|
||||||
|
&model.Node{},
|
||||||
|
&model.Config{},
|
||||||
|
&model.ApiLog{},
|
||||||
|
&model.ErrorLog{},
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("auto migrate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.InitData {
|
||||||
|
seedData(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("database initialized", "driver", cfg.Driver)
|
||||||
|
return &DB{db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedData(db *gorm.DB) {
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.Config{}).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults := []model.Config{
|
||||||
|
{Key: "allow_register", Value: "true"},
|
||||||
|
{Key: "default_network_pool", Value: "10.147.0.0/16"},
|
||||||
|
}
|
||||||
|
for _, c := range defaults {
|
||||||
|
db.Create(&c)
|
||||||
|
}
|
||||||
|
slog.Info("seed data inserted")
|
||||||
|
}
|
||||||
62
internal/handler/admin.go
Normal file
62
internal/handler/admin.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
"zeromesh/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminHandler struct {
|
||||||
|
authService *service.AuthService
|
||||||
|
db *database.DB
|
||||||
|
nodeService *service.NodeService
|
||||||
|
netService *service.NetworkService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminHandler(authService *service.AuthService, db *database.DB, nodeService *service.NodeService, netService *service.NetworkService) *AdminHandler {
|
||||||
|
return &AdminHandler{
|
||||||
|
authService: authService,
|
||||||
|
db: db,
|
||||||
|
nodeService: nodeService,
|
||||||
|
netService: netService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) Dashboard(c *gin.Context) {
|
||||||
|
uid := userID(c)
|
||||||
|
|
||||||
|
var authorizedMembers int64
|
||||||
|
if isAdmin(c) {
|
||||||
|
h.db.Model(&model.NetworkMember{}).Where("authorized = ?", true).Count(&authorizedMembers)
|
||||||
|
} else {
|
||||||
|
networks, _ := h.netService.ListNetworks(uid)
|
||||||
|
var ids []uint32
|
||||||
|
for _, n := range networks {
|
||||||
|
ids = append(ids, n.NetworkID)
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
h.db.Model(&model.NetworkMember{}).Where("authorized = ? AND network_id IN ?", true, ids).Count(&authorizedMembers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, _ := h.nodeService.ListNode(uid)
|
||||||
|
var online int64
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Online {
|
||||||
|
online++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
networks, _ := h.netService.ListNetworks(uid)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"authorized_members": authorizedMembers,
|
||||||
|
"nodes_total": len(nodes),
|
||||||
|
"nodes_online": online,
|
||||||
|
"networks_count": len(networks),
|
||||||
|
})
|
||||||
|
}
|
||||||
87
internal/handler/auth.go
Normal file
87
internal/handler/auth.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
authService *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{authService: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginReq struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerReq struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
var req loginReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, user, err := h.authService.Login(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"token": token,
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
|
var req registerReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, token, err := h.authService.Register(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"token": token,
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) InitAdmin(c *gin.Context) {
|
||||||
|
var req registerReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, token, err := h.authService.InitAdmin(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"token": token,
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) CheckAdmin(c *gin.Context) {
|
||||||
|
exists, err := h.authService.CheckAdmin()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"admin_exists": exists})
|
||||||
|
}
|
||||||
23
internal/handler/context.go
Normal file
23
internal/handler/context.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
func userID(c *gin.Context) uint {
|
||||||
|
id, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
uid, ok := id.(uint)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAdmin(c *gin.Context) bool {
|
||||||
|
role, exists := c.Get("role")
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return role.(string) == "admin"
|
||||||
|
}
|
||||||
75
internal/handler/log.go
Normal file
75
internal/handler/log.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogHandler struct {
|
||||||
|
logService *service.LogService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogHandler(logService *service.LogService) *LogHandler {
|
||||||
|
return &LogHandler{logService: logService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LogHandler) ListApiLogs(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
ip := c.Query("ip")
|
||||||
|
path := c.Query("path")
|
||||||
|
method := c.Query("method")
|
||||||
|
statusCode, _ := strconv.Atoi(c.Query("status_code"))
|
||||||
|
|
||||||
|
logs, total, err := h.logService.ListApiLogs(page, pageSize, ip, path, method, statusCode)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LogHandler) ListErrorLogs(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
ip := c.Query("ip")
|
||||||
|
path := c.Query("path")
|
||||||
|
method := c.Query("method")
|
||||||
|
statusCode, _ := strconv.Atoi(c.Query("status_code"))
|
||||||
|
|
||||||
|
logs, total, err := h.logService.ListErrorLogs(page, pageSize, ip, path, method, statusCode)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LogHandler) ListApiLogGrouped(c *gin.Context) {
|
||||||
|
groups, err := h.logService.ListApiLogGrouped()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": groups})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LogHandler) ClearApiLogs(c *gin.Context) {
|
||||||
|
if err := h.logService.ClearApiLogs(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LogHandler) ClearErrorLogs(c *gin.Context) {
|
||||||
|
if err := h.logService.ClearErrorLogs(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||||
|
}
|
||||||
143
internal/handler/network.go
Normal file
143
internal/handler/network.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NetworkHandler struct {
|
||||||
|
networkService *service.NetworkService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNetworkHandler(networkService *service.NetworkService) *NetworkHandler {
|
||||||
|
return &NetworkHandler{networkService: networkService}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createNetworkReq struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
IPRange string `json:"ip_range"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NetworkHandler) Create(c *gin.Context) {
|
||||||
|
var req createNetworkReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID(c)
|
||||||
|
network, err := h.networkService.CreateNetwork(uid, req.Name, req.IPRange)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"network": network})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NetworkHandler) List(c *gin.Context) {
|
||||||
|
uid := userID(c)
|
||||||
|
if !isAdmin(c) {
|
||||||
|
networks, err := h.networkService.ListNetworks(uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"networks": networks})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
networks, err := h.networkService.ListNetworks(0)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"networks": networks})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NetworkHandler) Get(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid network id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID(c)
|
||||||
|
network, err := h.networkService.GetNetwork(uint32(id), uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "network not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"network": network})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NetworkHandler) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid network id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID(c)
|
||||||
|
if err := h.networkService.DeleteNetwork(uint32(id), uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NetworkHandler) Members(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid network id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID(c)
|
||||||
|
members, err := h.networkService.ListMembers(uint32(id), uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"members": members})
|
||||||
|
}
|
||||||
|
|
||||||
|
type authorizeReq struct {
|
||||||
|
NodeID string `json:"node_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NetworkHandler) Authorize(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid network id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req authorizeReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID(c)
|
||||||
|
if err := h.networkService.AuthorizeMember(uint32(id), req.NodeID, uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "member authorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NetworkHandler) Deauthorize(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid network id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req authorizeReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID(c)
|
||||||
|
if err := h.networkService.DeauthorizeMember(uint32(id), req.NodeID, uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "member deauthorized"})
|
||||||
|
}
|
||||||
81
internal/handler/node.go
Normal file
81
internal/handler/node.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NodeHandler struct {
|
||||||
|
nodeService *service.NodeService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNodeHandler(nodeService *service.NodeService) *NodeHandler {
|
||||||
|
return &NodeHandler{nodeService: nodeService}
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerNodeReq struct {
|
||||||
|
NodeID string `json:"node_id" binding:"required"`
|
||||||
|
PublicKey string `json:"public_key" binding:"required"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) Register(c *gin.Context) {
|
||||||
|
var req registerNodeReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := userID(c)
|
||||||
|
node, err := h.nodeService.Register(req.NodeID, req.PublicKey, req.Name, req.IPAddress, req.Port, req.Version, uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"node": node})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) List(c *gin.Context) {
|
||||||
|
uid := userID(c)
|
||||||
|
if !isAdmin(c) {
|
||||||
|
nodes, err := h.nodeService.ListNode(uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"nodes": nodes})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes, err := h.nodeService.ListNode(0)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"nodes": nodes})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) ListOnline(c *gin.Context) {
|
||||||
|
uid := userID(c)
|
||||||
|
nodes, err := h.nodeService.ListOnline(uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"nodes": nodes})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NodeHandler) Heartbeat(c *gin.Context) {
|
||||||
|
nodeID := c.Param("node_id")
|
||||||
|
if err := h.nodeService.SetOnline(nodeID, true); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
142
internal/identity/identity.go
Normal file
142
internal/identity/identity.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Identity struct {
|
||||||
|
PublicKey ed25519.PublicKey `json:"public_key"`
|
||||||
|
PrivateKey ed25519.PrivateKey `json:"private_key"`
|
||||||
|
Address Address `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Address [5]byte
|
||||||
|
|
||||||
|
func (a Address) String() string {
|
||||||
|
return hex.EncodeToString(a[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Address) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(a.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Address) UnmarshalText(text []byte) error {
|
||||||
|
decoded, err := hex.DecodeString(string(text))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(decoded) != 5 {
|
||||||
|
return fmt.Errorf("address must be 5 bytes")
|
||||||
|
}
|
||||||
|
copy(a[:], decoded)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddressFromPublicKey(pubKey []byte) Address {
|
||||||
|
var addr Address
|
||||||
|
h := hashBytes(pubKey)
|
||||||
|
copy(addr[:], h[:5])
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func Generate() *Identity {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return &Identity{
|
||||||
|
PublicKey: pub,
|
||||||
|
PrivateKey: priv,
|
||||||
|
Address: AddressFromPublicKey(pub),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *Identity) PublicKeyHex() string {
|
||||||
|
return hex.EncodeToString(id.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *Identity) PrivateKeyHex() string {
|
||||||
|
return hex.EncodeToString(id.PrivateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *Identity) Sign(data []byte) []byte {
|
||||||
|
return ed25519.Sign(id.PrivateKey, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *Identity) Verify(data, sig []byte) bool {
|
||||||
|
return ed25519.Verify(id.PublicKey, data, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadOrGenerate(path string) (*Identity, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
return Parse(strings.TrimSpace(string(data)))
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := Generate()
|
||||||
|
encoded, err := id.Serialize()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir(path), 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(encoded), 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *Identity) Serialize() (string, error) {
|
||||||
|
data, err := json.Marshal(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(data string) (*Identity, error) {
|
||||||
|
var id Identity
|
||||||
|
if err := json.Unmarshal([]byte(data), &id); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse identity: %w", err)
|
||||||
|
}
|
||||||
|
if len(id.PrivateKey) == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid identity: no private key")
|
||||||
|
}
|
||||||
|
id.Address = AddressFromPublicKey(id.PublicKey)
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dir(path string) string {
|
||||||
|
idx := strings.LastIndex(path, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
idx = strings.LastIndex(path, "\\")
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return path[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashBytes(data []byte) []byte {
|
||||||
|
h := make([]byte, 32)
|
||||||
|
for i, b := range data {
|
||||||
|
h[i%32] ^= b
|
||||||
|
}
|
||||||
|
// simple hash expansion
|
||||||
|
for round := 0; round < 3; round++ {
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
h[i] = h[i] ^ h[(i+1)%32] ^ h[(i+7)%32]
|
||||||
|
h[i] = (h[i] << 3) | (h[i] >> 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
29
internal/middleware/cors.go
Normal file
29
internal/middleware/cors.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CORS(allowedOrigins []string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
for _, allowed := range allowedOrigins {
|
||||||
|
if allowed == "*" || allowed == origin {
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if origin == "" {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
}
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/middleware/jwt.go
Normal file
58
internal/middleware/jwt.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JWTClaims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func JWTAuth(secret string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims := &JWTClaims{}
|
||||||
|
token, err := jwt.ParseWithClaims(parts[1], claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("user_id", claims.UserID)
|
||||||
|
c.Set("username", claims.Username)
|
||||||
|
c.Set("role", claims.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateToken(secret string, userID uint, username, role string, expireHours int) (string, error) {
|
||||||
|
claims := JWTClaims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
97
internal/middleware/logger.go
Normal file
97
internal/middleware/logger.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogWriter interface {
|
||||||
|
InsertApiLog(*model.ApiLog) error
|
||||||
|
InsertErrorLog(*model.ErrorLog) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestLogger struct {
|
||||||
|
writer LogWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRequestLogger(writer LogWriter) *RequestLogger {
|
||||||
|
return &RequestLogger{writer: writer}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RequestLogger) Handler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "/api/v1/log") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
body := ""
|
||||||
|
if c.Request.Body != nil {
|
||||||
|
b, _ := io.ReadAll(c.Request.Body)
|
||||||
|
body = string(b)
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewBuffer(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
blw := &bodyLogWriter{ResponseWriter: c.Writer, buf: &bytes.Buffer{}}
|
||||||
|
c.Writer = blw
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
latencyMs := time.Since(start).Milliseconds()
|
||||||
|
ip := c.ClientIP()
|
||||||
|
method := c.Request.Method
|
||||||
|
query := c.Request.URL.RawQuery
|
||||||
|
ua := c.Request.UserAgent()
|
||||||
|
status := c.Writer.Status()
|
||||||
|
|
||||||
|
if status >= 400 {
|
||||||
|
el := &model.ErrorLog{
|
||||||
|
IP: ip,
|
||||||
|
Method: method,
|
||||||
|
Path: path,
|
||||||
|
StatusCode: status,
|
||||||
|
Query: query,
|
||||||
|
UserAgent: ua,
|
||||||
|
RequestBody: body,
|
||||||
|
ResponseBody: blw.buf.String(),
|
||||||
|
LatencyMs: latencyMs,
|
||||||
|
}
|
||||||
|
if err := l.writer.InsertErrorLog(el); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
al := &model.ApiLog{
|
||||||
|
IP: ip,
|
||||||
|
Method: method,
|
||||||
|
Path: path,
|
||||||
|
StatusCode: status,
|
||||||
|
Query: query,
|
||||||
|
UserAgent: ua,
|
||||||
|
LatencyMs: latencyMs,
|
||||||
|
}
|
||||||
|
if err := l.writer.InsertApiLog(al); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type bodyLogWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
buf *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bodyLogWriter) Write(b []byte) (int, error) {
|
||||||
|
w.buf.Write(b)
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
64
internal/middleware/ratelimit.go
Normal file
64
internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[string]*clientBuckets
|
||||||
|
rate int
|
||||||
|
burst int
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientBuckets struct {
|
||||||
|
tokens int
|
||||||
|
lastFill time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRateLimiter(rate, burst int) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
clients: make(map[string]*clientBuckets),
|
||||||
|
rate: rate,
|
||||||
|
burst: burst,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) Handler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !rl.allow(c.ClientIP()) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) allow(key string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
b, ok := rl.clients[key]
|
||||||
|
if !ok {
|
||||||
|
b = &clientBuckets{tokens: rl.burst, lastFill: time.Now()}
|
||||||
|
rl.clients[key] = b
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(b.lastFill)
|
||||||
|
b.lastFill = now
|
||||||
|
b.tokens += int(elapsed.Seconds()) * rl.rate
|
||||||
|
if b.tokens > rl.burst {
|
||||||
|
b.tokens = rl.burst
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.tokens > 0 {
|
||||||
|
b.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
101
internal/model/models.go
Normal file
101
internal/model/models.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
Username string `gorm:"uniqueIndex;size:64" json:"username"`
|
||||||
|
Password string `json:"-"`
|
||||||
|
Role string `gorm:"default:user;size:16" json:"role"`
|
||||||
|
Token string `json:"-"`
|
||||||
|
QuotaNetworks int `gorm:"default:3" json:"quota_networks"`
|
||||||
|
QuotaNodes int `gorm:"default:20" json:"quota_nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) IsAdmin() bool {
|
||||||
|
return u.Role == "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Network struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
NetworkID uint32 `gorm:"uniqueIndex;not null" json:"network_id"`
|
||||||
|
Name string `gorm:"size:128" json:"name"`
|
||||||
|
IPRange string `gorm:"size:64" json:"ip_range"`
|
||||||
|
IP6Range string `gorm:"size:64" json:"ip6_range"`
|
||||||
|
MTU int `gorm:"default:2800" json:"mtu"`
|
||||||
|
Multicast bool `gorm:"default:true" json:"multicast"`
|
||||||
|
Private bool `gorm:"default:true" json:"private"`
|
||||||
|
Members []NetworkMember `json:"members,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkMember struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
NetworkID uint32 `gorm:"index;not null" json:"network_id"`
|
||||||
|
NodeID string `gorm:"size:40;index;not null" json:"node_id"`
|
||||||
|
IPAddress string `gorm:"size:64" json:"ip_address"`
|
||||||
|
Authorized bool `gorm:"default:false" json:"authorized"`
|
||||||
|
Label string `gorm:"size:128" json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Node struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
NodeID string `gorm:"uniqueIndex;size:40" json:"node_id"`
|
||||||
|
PublicKey string `gorm:"size:128" json:"public_key"`
|
||||||
|
Name string `gorm:"size:128" json:"name"`
|
||||||
|
IPAddress string `gorm:"size:64" json:"ip_address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Online bool `gorm:"default:false" json:"online"`
|
||||||
|
LastSeen *time.Time `json:"last_seen"`
|
||||||
|
Version string `gorm:"size:32" json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Key string `gorm:"uniqueIndex;size:128" json:"key"`
|
||||||
|
Value string `gorm:"size:1024" json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
IP string `gorm:"size:64;index" json:"ip"`
|
||||||
|
Method string `gorm:"size:10" json:"method"`
|
||||||
|
Path string `gorm:"size:512;index" json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Query string `gorm:"size:1024" json:"query"`
|
||||||
|
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
IP string `gorm:"size:64;index" json:"ip"`
|
||||||
|
Method string `gorm:"size:10" json:"method"`
|
||||||
|
Path string `gorm:"size:512;index" json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Query string `gorm:"size:1024" json:"query"`
|
||||||
|
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||||
|
RequestBody string `gorm:"type:text" json:"request_body"`
|
||||||
|
ResponseBody string `gorm:"type:text" json:"response_body"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
}
|
||||||
111
internal/repository/log.go
Normal file
111
internal/repository/log.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogRepo struct {
|
||||||
|
db *database.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogRepo(db *database.DB) *LogRepo {
|
||||||
|
return &LogRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepo) InsertApiLog(log *model.ApiLog) error {
|
||||||
|
return r.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepo) InsertErrorLog(log *model.ErrorLog) error {
|
||||||
|
return r.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepo) ListApiLogs(page, pageSize int, ip, path, method string, statusCode int) ([]model.ApiLog, int64, error) {
|
||||||
|
q := r.db.Model(&model.ApiLog{})
|
||||||
|
if ip != "" {
|
||||||
|
q = q.Where("ip LIKE ?", "%"+ip+"%")
|
||||||
|
}
|
||||||
|
if path != "" {
|
||||||
|
q = q.Where("path LIKE ?", "%"+path+"%")
|
||||||
|
}
|
||||||
|
if method != "" {
|
||||||
|
q = q.Where("method = ?", method)
|
||||||
|
}
|
||||||
|
if statusCode > 0 {
|
||||||
|
q = q.Where("status_code = ?", statusCode)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var logs []model.ApiLog
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
if err := q.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return logs, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepo) ListErrorLogs(page, pageSize int, ip, path, method string, statusCode int) ([]model.ErrorLog, int64, error) {
|
||||||
|
q := r.db.Model(&model.ErrorLog{})
|
||||||
|
if ip != "" {
|
||||||
|
q = q.Where("ip LIKE ?", "%"+ip+"%")
|
||||||
|
}
|
||||||
|
if path != "" {
|
||||||
|
q = q.Where("path LIKE ?", "%"+path+"%")
|
||||||
|
}
|
||||||
|
if method != "" {
|
||||||
|
q = q.Where("method = ?", method)
|
||||||
|
}
|
||||||
|
if statusCode > 0 {
|
||||||
|
q = q.Where("status_code = ?", statusCode)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var logs []model.ErrorLog
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
if err := q.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return logs, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepo) ListApiLogGrouped() ([]map[string]interface{}, error) {
|
||||||
|
rows, err := r.db.Model(&model.ApiLog{}).
|
||||||
|
Select("ip, method, path, COUNT(*) as count, SUM(latency_ms) as total_latency").
|
||||||
|
Group("ip, method, path").
|
||||||
|
Order("count DESC").
|
||||||
|
Rows()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var ip, method, path string
|
||||||
|
var count int64
|
||||||
|
var totalLatency int64
|
||||||
|
if err := rows.Scan(&ip, &method, &path, &count, &totalLatency); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"ip": ip,
|
||||||
|
"method": method,
|
||||||
|
"path": path,
|
||||||
|
"count": count,
|
||||||
|
"total_latency": totalLatency,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepo) ClearApiLogs() error {
|
||||||
|
return r.db.Where("1 = 1").Delete(&model.ApiLog{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepo) ClearErrorLogs() error {
|
||||||
|
return r.db.Where("1 = 1").Delete(&model.ErrorLog{}).Error
|
||||||
|
}
|
||||||
82
internal/repository/network.go
Normal file
82
internal/repository/network.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NetworkRepo struct {
|
||||||
|
db *database.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNetworkRepo(db *database.DB) *NetworkRepo {
|
||||||
|
return &NetworkRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) Create(network *model.Network) error {
|
||||||
|
return r.db.Create(network).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) FindByNetworkID(networkID uint32) (*model.Network, error) {
|
||||||
|
var network model.Network
|
||||||
|
err := r.db.Where("network_id = ?", networkID).Preload("Members").First(&network).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &network, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) List() ([]model.Network, error) {
|
||||||
|
var networks []model.Network
|
||||||
|
err := r.db.Preload("Members").Find(&networks).Error
|
||||||
|
return networks, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) ListByUser(userID uint) ([]model.Network, error) {
|
||||||
|
var networks []model.Network
|
||||||
|
err := r.db.Where("user_id = ?", userID).Preload("Members").Find(&networks).Error
|
||||||
|
return networks, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) FindByNetworkIDForUser(networkID uint32, userID uint) (*model.Network, error) {
|
||||||
|
var network model.Network
|
||||||
|
err := r.db.Where("network_id = ? AND user_id = ?", networkID, userID).Preload("Members").First(&network).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &network, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) CountByUser(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.Network{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) Update(network *model.Network) error {
|
||||||
|
return r.db.Save(network).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) Delete(networkID uint32) error {
|
||||||
|
return r.db.Where("network_id = ?", networkID).Delete(&model.Network{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) AddMember(member *model.NetworkMember) error {
|
||||||
|
return r.db.Create(member).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) RemoveMember(networkID uint32, nodeID string) error {
|
||||||
|
return r.db.Where("network_id = ? AND node_id = ?", networkID, nodeID).Delete(&model.NetworkMember{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) FindMembers(networkID uint32) ([]model.NetworkMember, error) {
|
||||||
|
var members []model.NetworkMember
|
||||||
|
err := r.db.Where("network_id = ?", networkID).Find(&members).Error
|
||||||
|
return members, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NetworkRepo) Count() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.Network{}).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
75
internal/repository/node.go
Normal file
75
internal/repository/node.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NodeRepo struct {
|
||||||
|
db *database.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNodeRepo(db *database.DB) *NodeRepo {
|
||||||
|
return &NodeRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepo) Create(node *model.Node) error {
|
||||||
|
return r.db.Create(node).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepo) FindByNodeID(nodeID string) (*model.Node, error) {
|
||||||
|
var node model.Node
|
||||||
|
err := r.db.Where("node_id = ?", nodeID).First(&node).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepo) Upsert(node *model.Node) error {
|
||||||
|
var existing model.Node
|
||||||
|
result := r.db.Where("node_id = ?", node.NodeID).First(&existing)
|
||||||
|
if result.Error != nil {
|
||||||
|
return r.db.Create(node).Error
|
||||||
|
}
|
||||||
|
existing.PublicKey = node.PublicKey
|
||||||
|
existing.Name = node.Name
|
||||||
|
existing.IPAddress = node.IPAddress
|
||||||
|
existing.Port = node.Port
|
||||||
|
existing.Version = node.Version
|
||||||
|
return r.db.Save(&existing).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepo) SetOnline(nodeID string, online bool) error {
|
||||||
|
now := time.Now()
|
||||||
|
return r.db.Model(&model.Node{}).Where("node_id = ?", nodeID).Updates(map[string]interface{}{
|
||||||
|
"online": online,
|
||||||
|
"last_seen": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepo) List() ([]model.Node, error) {
|
||||||
|
var nodes []model.Node
|
||||||
|
err := r.db.Order("id desc").Find(&nodes).Error
|
||||||
|
return nodes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepo) ListByUser(userID uint) ([]model.Node, error) {
|
||||||
|
var nodes []model.Node
|
||||||
|
err := r.db.Where("user_id = ?", userID).Order("id desc").Find(&nodes).Error
|
||||||
|
return nodes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepo) CountByUser(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.Node{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepo) ListOnline() ([]model.Node, error) {
|
||||||
|
var nodes []model.Node
|
||||||
|
err := r.db.Where("online = ?", true).Find(&nodes).Error
|
||||||
|
return nodes, err
|
||||||
|
}
|
||||||
58
internal/repository/user.go
Normal file
58
internal/repository/user.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepo struct {
|
||||||
|
db *database.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepo(db *database.DB) *UserRepo {
|
||||||
|
return &UserRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) FindByUsername(username string) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.db.Where("username = ?", username).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) FindByID(id uint) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) Create(user *model.User) error {
|
||||||
|
return r.db.Create(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) Count() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.User{}).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) HasAdmin() (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.User{}).Where("role = ?", "admin").Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) UpdatePassword(id uint, hashed string) error {
|
||||||
|
return r.db.Model(&model.User{}).Where("id = ?", id).Update("password", hashed).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) List(offset, limit int) ([]model.User, error) {
|
||||||
|
var users []model.User
|
||||||
|
err := r.db.Offset(offset).Limit(limit).Order("id desc").Find(&users).Error
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
124
internal/router/router.go
Normal file
124
internal/router/router.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/handler"
|
||||||
|
"zeromesh/internal/middleware"
|
||||||
|
"zeromesh/internal/repository"
|
||||||
|
"zeromesh/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Setup(cfg *config.Config, db *database.DB) *gin.Engine {
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
userRepo := repository.NewUserRepo(db)
|
||||||
|
netRepo := repository.NewNetworkRepo(db)
|
||||||
|
nodeRepo := repository.NewNodeRepo(db)
|
||||||
|
logRepo := repository.NewLogRepo(db)
|
||||||
|
|
||||||
|
// Services
|
||||||
|
authService := service.NewAuthService(userRepo, cfg.JWT)
|
||||||
|
netService := service.NewNetworkService(netRepo, nodeRepo, userRepo)
|
||||||
|
nodeService := service.NewNodeService(nodeRepo, userRepo)
|
||||||
|
logService := service.NewLogService(logRepo)
|
||||||
|
|
||||||
|
// Global middleware
|
||||||
|
r.Use(middleware.CORS(cfg.CORS.AllowedOrigins))
|
||||||
|
if cfg.RateLimit.Enabled {
|
||||||
|
rl := middleware.NewRateLimiter(cfg.RateLimit.RequestsPerSecond, cfg.RateLimit.BurstSize)
|
||||||
|
r.Use(rl.Handler())
|
||||||
|
}
|
||||||
|
requestLogger := middleware.NewRequestLogger(logService)
|
||||||
|
r.Use(requestLogger.Handler())
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
|
netHandler := handler.NewNetworkHandler(netService)
|
||||||
|
nodeHandler := handler.NewNodeHandler(nodeService)
|
||||||
|
adminHandler := handler.NewAdminHandler(authService, db, nodeService, netService)
|
||||||
|
logHandler := handler.NewLogHandler(logService)
|
||||||
|
|
||||||
|
// Logging API — skip logger middleware (avoid recursion)
|
||||||
|
logApi := r.Group("/api/v1/log")
|
||||||
|
logApi.Use(middleware.JWTAuth(cfg.JWT.Secret))
|
||||||
|
{
|
||||||
|
logApi.GET("/api", logHandler.ListApiLogs)
|
||||||
|
logApi.GET("/error", logHandler.ListErrorLogs)
|
||||||
|
logApi.GET("/grouped", logHandler.ListApiLogGrouped)
|
||||||
|
logApi.DELETE("/api", logHandler.ClearApiLogs)
|
||||||
|
logApi.DELETE("/error", logHandler.ClearErrorLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
r.GET("/api/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
r.GET("/api/v1/admin/check", authHandler.CheckAdmin)
|
||||||
|
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
{
|
||||||
|
api.POST("/auth/login", authHandler.Login)
|
||||||
|
api.POST("/auth/register", authHandler.Register)
|
||||||
|
api.POST("/auth/init", authHandler.InitAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated routes
|
||||||
|
auth := r.Group("/api/v1")
|
||||||
|
auth.Use(middleware.JWTAuth(cfg.JWT.Secret))
|
||||||
|
{
|
||||||
|
// Dashboard (user + admin)
|
||||||
|
auth.GET("/dashboard", adminHandler.Dashboard)
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
auth.GET("/user/profile", func(c *gin.Context) {
|
||||||
|
uid := c.GetUint("user_id")
|
||||||
|
u, err := userRepo.FindByID(uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(404, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
netCount, _ := netRepo.CountByUser(uid)
|
||||||
|
nodeCount, _ := nodeRepo.CountByUser(uid)
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"user": u,
|
||||||
|
"used_networks": netCount,
|
||||||
|
"used_nodes": nodeCount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nodes (user-scoped)
|
||||||
|
auth.POST("/node/register", nodeHandler.Register)
|
||||||
|
auth.GET("/node/list", nodeHandler.List)
|
||||||
|
auth.GET("/node/online", nodeHandler.ListOnline)
|
||||||
|
|
||||||
|
// Networks (user-scoped)
|
||||||
|
auth.POST("/network/create", netHandler.Create)
|
||||||
|
auth.GET("/network/list", netHandler.List)
|
||||||
|
auth.GET("/network/:id", netHandler.Get)
|
||||||
|
auth.DELETE("/network/:id", netHandler.Delete)
|
||||||
|
auth.GET("/network/:id/members", netHandler.Members)
|
||||||
|
auth.POST("/network/:id/authorize", netHandler.Authorize)
|
||||||
|
auth.POST("/network/:id/deauthorize", netHandler.Deauthorize)
|
||||||
|
|
||||||
|
// Admin-only
|
||||||
|
admin := auth.Group("/admin")
|
||||||
|
admin.Use(func(c *gin.Context) {
|
||||||
|
if c.GetString("role") != "admin" {
|
||||||
|
c.AbortWithStatusJSON(403, gin.H{"error": "forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
admin.GET("/dashboard", adminHandler.Dashboard)
|
||||||
|
admin.GET("/networks", netHandler.List)
|
||||||
|
admin.GET("/nodes", nodeHandler.List)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
113
internal/service/auth.go
Normal file
113
internal/service/auth.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
"zeromesh/internal/middleware"
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
"zeromesh/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
userRepo *repository.UserRepo
|
||||||
|
jwtCfg config.JWTConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(userRepo *repository.UserRepo, jwtCfg config.JWTConfig) *AuthService {
|
||||||
|
return &AuthService{userRepo: userRepo, jwtCfg: jwtCfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Login(username, password string) (string, *model.User, error) {
|
||||||
|
user, err := s.userRepo.FindByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
// Migrate old SHA256 hashes to bcrypt on login
|
||||||
|
if len(user.Password) < 4 || user.Password[:4] != "$2a$" {
|
||||||
|
if sha256Hash(password) != user.Password {
|
||||||
|
return "", nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("migrate password: %w", err)
|
||||||
|
}
|
||||||
|
user.Password = string(hashed)
|
||||||
|
_ = s.userRepo.UpdatePassword(user.ID, user.Password)
|
||||||
|
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||||
|
return "", nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
token, err := middleware.GenerateToken(s.jwtCfg.Secret, user.ID, user.Username, user.Role, s.jwtCfg.ExpireHours)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("generate token: %w", err)
|
||||||
|
}
|
||||||
|
user.Token = token
|
||||||
|
return token, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Register(username, password string) (*model.User, string, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
user := &model.User{
|
||||||
|
Username: username,
|
||||||
|
Password: string(hashed),
|
||||||
|
Role: "user",
|
||||||
|
}
|
||||||
|
if err := s.userRepo.Create(user); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
token, err := middleware.GenerateToken(s.jwtCfg.Secret, user.ID, user.Username, user.Role, s.jwtCfg.ExpireHours)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("generate token: %w", err)
|
||||||
|
}
|
||||||
|
user.Token = token
|
||||||
|
return user, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) InitAdmin(username, password string) (*model.User, string, error) {
|
||||||
|
count, err := s.userRepo.Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil, "", fmt.Errorf("admin already exists")
|
||||||
|
}
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
user := &model.User{
|
||||||
|
Username: username,
|
||||||
|
Password: string(hashed),
|
||||||
|
Role: "admin",
|
||||||
|
QuotaNetworks: -1,
|
||||||
|
QuotaNodes: -1,
|
||||||
|
}
|
||||||
|
if err := s.userRepo.Create(user); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
token, err := middleware.GenerateToken(s.jwtCfg.Secret, user.ID, user.Username, user.Role, s.jwtCfg.ExpireHours)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("generate token: %w", err)
|
||||||
|
}
|
||||||
|
user.Token = token
|
||||||
|
return user, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Hash(password string) string {
|
||||||
|
h := sha256.Sum256([]byte(password))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CheckAdmin() (bool, error) {
|
||||||
|
return s.userRepo.HasAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
54
internal/service/log.go
Normal file
54
internal/service/log.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
"zeromesh/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogService struct {
|
||||||
|
repo *repository.LogRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogService(repo *repository.LogRepo) *LogService {
|
||||||
|
return &LogService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LogService) ListApiLogs(page, pageSize int, ip, path, method string, statusCode int) ([]model.ApiLog, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 200 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
return s.repo.ListApiLogs(page, pageSize, ip, path, method, statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LogService) ListErrorLogs(page, pageSize int, ip, path, method string, statusCode int) ([]model.ErrorLog, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 200 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
return s.repo.ListErrorLogs(page, pageSize, ip, path, method, statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LogService) ListApiLogGrouped() ([]map[string]interface{}, error) {
|
||||||
|
return s.repo.ListApiLogGrouped()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LogService) InsertApiLog(log *model.ApiLog) error {
|
||||||
|
return s.repo.InsertApiLog(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LogService) InsertErrorLog(log *model.ErrorLog) error {
|
||||||
|
return s.repo.InsertErrorLog(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LogService) ClearApiLogs() error {
|
||||||
|
return s.repo.ClearApiLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LogService) ClearErrorLogs() error {
|
||||||
|
return s.repo.ClearErrorLogs()
|
||||||
|
}
|
||||||
163
internal/service/network.go
Normal file
163
internal/service/network.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
"zeromesh/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NetworkService struct {
|
||||||
|
netRepo *repository.NetworkRepo
|
||||||
|
nodeRepo *repository.NodeRepo
|
||||||
|
userRepo *repository.UserRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNetworkService(netRepo *repository.NetworkRepo, nodeRepo *repository.NodeRepo, userRepo *repository.UserRepo) *NetworkService {
|
||||||
|
return &NetworkService{netRepo: netRepo, nodeRepo: nodeRepo, userRepo: userRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPrivateSubnet() string {
|
||||||
|
const (
|
||||||
|
_10 = iota // 10.x.y.0/24
|
||||||
|
_172 // 172.16-31.y.0/24
|
||||||
|
_192 // 192.168.y.0/24
|
||||||
|
)
|
||||||
|
class := rand.Intn(3)
|
||||||
|
var b1, b2 byte
|
||||||
|
switch class {
|
||||||
|
case _10:
|
||||||
|
b1 = byte(rand.Intn(256))
|
||||||
|
b2 = byte(rand.Intn(256))
|
||||||
|
return fmt.Sprintf("10.%d.%d.0/24", b1, b2)
|
||||||
|
case _172:
|
||||||
|
b1 = byte(16 + rand.Intn(16)) // 16-31
|
||||||
|
b2 = byte(rand.Intn(256))
|
||||||
|
return fmt.Sprintf("172.%d.%d.0/24", b1, b2)
|
||||||
|
default:
|
||||||
|
b2 = byte(rand.Intn(256))
|
||||||
|
return fmt.Sprintf("192.168.%d.0/24", b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkService) CreateNetwork(userID uint, name, ipRange string) (*model.Network, error) {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
if user.QuotaNetworks >= 0 {
|
||||||
|
used, _ := s.netRepo.CountByUser(userID)
|
||||||
|
if used >= int64(user.QuotaNetworks) {
|
||||||
|
return nil, fmt.Errorf("network quota exceeded (%d)", user.QuotaNetworks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ipRange == "" {
|
||||||
|
ipRange = randomPrivateSubnet()
|
||||||
|
}
|
||||||
|
id := rand.Uint32()
|
||||||
|
network := &model.Network{
|
||||||
|
UserID: userID,
|
||||||
|
NetworkID: id,
|
||||||
|
Name: name,
|
||||||
|
IPRange: ipRange,
|
||||||
|
MTU: 2800,
|
||||||
|
Multicast: true,
|
||||||
|
Private: true,
|
||||||
|
}
|
||||||
|
if err := s.netRepo.Create(network); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return network, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkService) ListNetworks(userID uint) ([]model.Network, error) {
|
||||||
|
if userID == 0 {
|
||||||
|
return s.netRepo.List()
|
||||||
|
}
|
||||||
|
return s.netRepo.ListByUser(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkService) GetNetwork(networkID uint32, userID uint) (*model.Network, error) {
|
||||||
|
if userID == 0 {
|
||||||
|
return s.netRepo.FindByNetworkID(networkID)
|
||||||
|
}
|
||||||
|
return s.netRepo.FindByNetworkIDForUser(networkID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkService) DeleteNetwork(networkID uint32, userID uint) error {
|
||||||
|
net, err := s.GetNetwork(networkID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.netRepo.Delete(net.NetworkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkService) AuthorizeMember(networkID uint32, nodeID string, userID uint) error {
|
||||||
|
network, err := s.GetNetwork(networkID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
node, err := s.nodeRepo.FindByNodeID(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if node.UserID != userID && userID != 0 {
|
||||||
|
return fmt.Errorf("node does not belong to user")
|
||||||
|
}
|
||||||
|
ip, err := s.allocateIP(network.IPRange, networkID, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
member := &model.NetworkMember{
|
||||||
|
NetworkID: networkID,
|
||||||
|
NodeID: nodeID,
|
||||||
|
IPAddress: ip,
|
||||||
|
Authorized: true,
|
||||||
|
Label: node.Name,
|
||||||
|
}
|
||||||
|
if err := s.netRepo.AddMember(member); err != nil {
|
||||||
|
return fmt.Errorf("add member: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkService) DeauthorizeMember(networkID uint32, nodeID string, userID uint) error {
|
||||||
|
_, err := s.GetNetwork(networkID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.netRepo.RemoveMember(networkID, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkService) ListMembers(networkID uint32, userID uint) ([]model.NetworkMember, error) {
|
||||||
|
_, err := s.GetNetwork(networkID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.netRepo.FindMembers(networkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkService) allocateIP(cidr string, networkID uint32, nodeID string) (string, error) {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ones, bits := ipNet.Mask.Size()
|
||||||
|
ip := ipNet.IP.To4()
|
||||||
|
if ip == nil {
|
||||||
|
return "", fmt.Errorf("IPv4 only")
|
||||||
|
}
|
||||||
|
// simple allocation: use last 2 bytes of nodeID hash
|
||||||
|
h := uint32(0)
|
||||||
|
for _, b := range []byte(nodeID) {
|
||||||
|
h = h*31 + uint32(b)
|
||||||
|
}
|
||||||
|
hostBits := bits - ones
|
||||||
|
maxHosts := (1 << uint(hostBits)) - 2
|
||||||
|
hostOffset := int(h%uint32(maxHosts)) + 1
|
||||||
|
ip[2] = byte(hostOffset >> 8)
|
||||||
|
ip[3] = byte(hostOffset)
|
||||||
|
return ip.String(), nil
|
||||||
|
}
|
||||||
89
internal/service/node.go
Normal file
89
internal/service/node.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zeromesh/internal/model"
|
||||||
|
"zeromesh/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NodeService struct {
|
||||||
|
nodeRepo *repository.NodeRepo
|
||||||
|
userRepo *repository.UserRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNodeService(nodeRepo *repository.NodeRepo, userRepo *repository.UserRepo) *NodeService {
|
||||||
|
return &NodeService{nodeRepo: nodeRepo, userRepo: userRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) Register(nodeID, publicKey, name, ipAddress string, port int, version string, userID uint) (*model.Node, error) {
|
||||||
|
if userID > 0 {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
if user.QuotaNodes >= 0 {
|
||||||
|
used, _ := s.nodeRepo.CountByUser(userID)
|
||||||
|
if used >= int64(user.QuotaNodes) {
|
||||||
|
return nil, fmt.Errorf("node quota exceeded (%d)", user.QuotaNodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node := &model.Node{
|
||||||
|
UserID: userID,
|
||||||
|
NodeID: nodeID,
|
||||||
|
PublicKey: publicKey,
|
||||||
|
Name: name,
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
Port: port,
|
||||||
|
Online: true,
|
||||||
|
LastSeen: now(),
|
||||||
|
Version: version,
|
||||||
|
}
|
||||||
|
if err := s.nodeRepo.Upsert(node); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) SetOnline(nodeID string, online bool) error {
|
||||||
|
return s.nodeRepo.SetOnline(nodeID, online)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) ListNode(userID uint) ([]model.Node, error) {
|
||||||
|
if userID == 0 {
|
||||||
|
return s.nodeRepo.List()
|
||||||
|
}
|
||||||
|
return s.nodeRepo.ListByUser(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) ListOnline(userID uint) ([]model.Node, error) {
|
||||||
|
if userID == 0 {
|
||||||
|
return s.nodeRepo.ListOnline()
|
||||||
|
}
|
||||||
|
nodes, err := s.nodeRepo.ListByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var online []model.Node
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Online {
|
||||||
|
online = append(online, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return online, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) GetNode(nodeID string) (*model.Node, error) {
|
||||||
|
return s.nodeRepo.FindByNodeID(nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) CountByUser(userID uint) (int64, error) {
|
||||||
|
return s.nodeRepo.CountByUser(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func now() *time.Time {
|
||||||
|
t := time.Now()
|
||||||
|
return &t
|
||||||
|
}
|
||||||
25
internal/service/user.go
Normal file
25
internal/service/user.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserService struct {
|
||||||
|
db *database.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(db *database.DB) *UserService {
|
||||||
|
return &UserService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) Count() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := s.db.Model(&struct{}{}).Table("users").Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) List(offset, limit int) ([]map[string]interface{}, error) {
|
||||||
|
var users []map[string]interface{}
|
||||||
|
err := s.db.Table("users").Offset(offset).Limit(limit).Order("id desc").Find(&users).Error
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
24
internal/tap/tap.go
Normal file
24
internal/tap/tap.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package tap
|
||||||
|
|
||||||
|
type Interface struct {
|
||||||
|
Name string
|
||||||
|
MTU int
|
||||||
|
fd int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open creates a TAP interface on Linux. On other platforms it returns an error.
|
||||||
|
func Open(name string, mtu int) (*Interface, error) {
|
||||||
|
return openTap(name, mtu)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Interface) Read(buf []byte) (int, error) {
|
||||||
|
return readBuf(t.fd, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Interface) Write(buf []byte) (int, error) {
|
||||||
|
return writeBuf(t.fd, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Interface) Close() error {
|
||||||
|
return closeFD(t.fd)
|
||||||
|
}
|
||||||
71
internal/tap/tap_linux.go
Normal file
71
internal/tap/tap_linux.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package tap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cIFF_TAP = 0x0002
|
||||||
|
cIFF_NO_PI = 0x1000
|
||||||
|
cTUNSETIFF = 0x400454ca
|
||||||
|
cSIOCSIFMTU = 0x8922
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTap(name string, mtu int) (*Interface, error) {
|
||||||
|
fd, err := syscall.Open("/dev/net/tun", syscall.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open /dev/net/tun: %w (is TUN/TAP supported?)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ifr := make([]byte, 40)
|
||||||
|
copy(ifr, []byte(name))
|
||||||
|
*(*uint16)(unsafe.Pointer(&ifr[16])) = uint16(cIFF_TAP | cIFF_NO_PI)
|
||||||
|
|
||||||
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), cTUNSETIFF, uintptr(unsafe.Pointer(&ifr[0])))
|
||||||
|
if errno != 0 {
|
||||||
|
syscall.Close(fd)
|
||||||
|
return nil, fmt.Errorf("ioctl TUNSETIFF: %w", errno)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mtu > 0 {
|
||||||
|
mtuIfr := make([]byte, 40)
|
||||||
|
copy(mtuIfr, ifr[:16])
|
||||||
|
*(*int32)(unsafe.Pointer(&mtuIfr[16])) = int32(mtu)
|
||||||
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), cSIOCSIFMTU, uintptr(unsafe.Pointer(&mtuIfr[0])))
|
||||||
|
if errno != 0 {
|
||||||
|
syscall.Close(fd)
|
||||||
|
return nil, fmt.Errorf("ioctl SIOCSIFMTU: %w", errno)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Interface{
|
||||||
|
Name: name,
|
||||||
|
MTU: mtu,
|
||||||
|
fd: fd,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBuf(fd int, buf []byte) (int, error) {
|
||||||
|
n, err := syscall.Read(fd, buf)
|
||||||
|
if err != nil {
|
||||||
|
return 0, os.NewSyscallError("read", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBuf(fd int, buf []byte) (int, error) {
|
||||||
|
n, err := syscall.Write(fd, buf)
|
||||||
|
if err != nil {
|
||||||
|
return 0, os.NewSyscallError("write", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeFD(fd int) error {
|
||||||
|
return syscall.Close(fd)
|
||||||
|
}
|
||||||
21
internal/tap/tap_stub.go
Normal file
21
internal/tap/tap_stub.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package tap
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func openTap(_ string, _ int) (*Interface, error) {
|
||||||
|
return nil, fmt.Errorf("TAP devices are only supported on Linux")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBuf(_ int, _ []byte) (int, error) {
|
||||||
|
return 0, fmt.Errorf("not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBuf(_ int, _ []byte) (int, error) {
|
||||||
|
return 0, fmt.Errorf("not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeFD(_ int) error {
|
||||||
|
return fmt.Errorf("not supported")
|
||||||
|
}
|
||||||
50
internal/task/task.go
Normal file
50
internal/task/task.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
Name string
|
||||||
|
Interval time.Duration
|
||||||
|
Handler func(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scheduler struct {
|
||||||
|
tasks []Task
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScheduler(log *slog.Logger) *Scheduler {
|
||||||
|
return &Scheduler{log: log.With("component", "scheduler")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Add(name string, interval time.Duration, handler func(ctx context.Context) error) {
|
||||||
|
s.tasks = append(s.tasks, Task{
|
||||||
|
Name: name,
|
||||||
|
Interval: interval,
|
||||||
|
Handler: handler,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Start(ctx context.Context) {
|
||||||
|
for _, task := range s.tasks {
|
||||||
|
go func(t Task) {
|
||||||
|
ticker := time.NewTicker(t.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
s.log.Info("task started", "name", t.Name, "interval", t.Interval)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := t.Handler(ctx); err != nil {
|
||||||
|
s.log.Error("task failed", "name", t.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
internal/vl1/noise.go
Normal file
103
internal/vl1/noise.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package vl1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
|
"golang.org/x/crypto/sha3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NoiseCipher struct {
|
||||||
|
sendCipher cipher.AEAD
|
||||||
|
recvCipher cipher.AEAD
|
||||||
|
sendNonce uint64
|
||||||
|
recvNonce uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNoiseCipher(sendKey, recvKey [32]byte) *NoiseCipher {
|
||||||
|
send, err := chacha20poly1305.New(sendKey[:])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
recv, err := chacha20poly1305.New(recvKey[:])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return &NoiseCipher{
|
||||||
|
sendCipher: send,
|
||||||
|
recvCipher: recv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeriveKeysFromPSK(psk string, localPub, remotePub []byte) ([32]byte, [32]byte) {
|
||||||
|
h := sha3.New256()
|
||||||
|
h.Write([]byte(psk))
|
||||||
|
h.Write(localPub)
|
||||||
|
h.Write(remotePub)
|
||||||
|
sum := h.Sum(nil)
|
||||||
|
|
||||||
|
var sendKey, recvKey [32]byte
|
||||||
|
copy(sendKey[:], sum[:32])
|
||||||
|
|
||||||
|
h.Reset()
|
||||||
|
h.Write(sum)
|
||||||
|
h.Write([]byte("reverse"))
|
||||||
|
rev := h.Sum(nil)
|
||||||
|
copy(recvKey[:], rev[:32])
|
||||||
|
|
||||||
|
return sendKey, recvKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nc *NoiseCipher) Encrypt(plaintext []byte) ([]byte, error) {
|
||||||
|
nonce := make([]byte, 12)
|
||||||
|
binary.BigEndian.PutUint64(nonce[4:], nc.sendNonce)
|
||||||
|
nc.sendNonce++
|
||||||
|
|
||||||
|
ciphertext := nc.sendCipher.Seal(nil, nonce, plaintext, nil)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nc *NoiseCipher) Decrypt(ciphertext []byte) ([]byte, error) {
|
||||||
|
nonce := make([]byte, 12)
|
||||||
|
binary.BigEndian.PutUint64(nonce[4:], nc.recvNonce)
|
||||||
|
nc.recvNonce++
|
||||||
|
|
||||||
|
plaintext, err := nc.recvCipher.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt: %w", err)
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nc *NoiseCipher) EncryptTo(buf []byte, plaintext []byte) (int, error) {
|
||||||
|
nonce := make([]byte, 12)
|
||||||
|
binary.BigEndian.PutUint64(nonce[4:], nc.sendNonce)
|
||||||
|
nc.sendNonce++
|
||||||
|
|
||||||
|
ciphertext := nc.sendCipher.Seal(buf[:0], nonce, plaintext, nil)
|
||||||
|
return len(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nc *NoiseCipher) DecryptTo(buf []byte, ciphertext []byte) ([]byte, error) {
|
||||||
|
nonce := make([]byte, 12)
|
||||||
|
binary.BigEndian.PutUint64(nonce[4:], nc.recvNonce)
|
||||||
|
nc.recvNonce++
|
||||||
|
|
||||||
|
plaintext, err := nc.recvCipher.Open(buf[:0], nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt: %w", err)
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSessionKey() [32]byte {
|
||||||
|
var key [32]byte
|
||||||
|
if _, err := io.ReadFull(rand.Reader, key[:]); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
117
internal/vl1/packet.go
Normal file
117
internal/vl1/packet.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package vl1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Version = 1
|
||||||
|
MaxPacketSize = 65535
|
||||||
|
HeaderSize = 8
|
||||||
|
MaxFrameSize = 65535
|
||||||
|
MinFrameSize = 14
|
||||||
|
|
||||||
|
PacketTypeHandshake = byte(1)
|
||||||
|
PacketTypeData = byte(2)
|
||||||
|
PacketTypeKeepalive = byte(3)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Header struct {
|
||||||
|
Version byte
|
||||||
|
Type byte
|
||||||
|
NetworkID uint32
|
||||||
|
Length uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Header) Encode(buf []byte) {
|
||||||
|
buf[0] = h.Version
|
||||||
|
buf[1] = h.Type
|
||||||
|
binary.BigEndian.PutUint32(buf[2:6], h.NetworkID)
|
||||||
|
binary.BigEndian.PutUint16(buf[6:8], h.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Header) Decode(buf []byte) error {
|
||||||
|
if len(buf) < HeaderSize {
|
||||||
|
return fmt.Errorf("header too short: %d < %d", len(buf), HeaderSize)
|
||||||
|
}
|
||||||
|
h.Version = buf[0]
|
||||||
|
h.Type = buf[1]
|
||||||
|
h.NetworkID = binary.BigEndian.Uint32(buf[2:6])
|
||||||
|
h.Length = binary.BigEndian.Uint16(buf[6:8])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Packet struct {
|
||||||
|
Header Header
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandshakePacket(payload []byte) Packet {
|
||||||
|
return Packet{
|
||||||
|
Header: Header{
|
||||||
|
Version: Version,
|
||||||
|
Type: PacketTypeHandshake,
|
||||||
|
Length: uint16(len(payload)),
|
||||||
|
},
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDataPacket(networkID uint32, payload []byte) Packet {
|
||||||
|
return Packet{
|
||||||
|
Header: Header{
|
||||||
|
Version: Version,
|
||||||
|
Type: PacketTypeData,
|
||||||
|
NetworkID: networkID,
|
||||||
|
Length: uint16(len(payload)),
|
||||||
|
},
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKeepalivePacket() Packet {
|
||||||
|
return Packet{
|
||||||
|
Header: Header{
|
||||||
|
Version: Version,
|
||||||
|
Type: PacketTypeKeepalive,
|
||||||
|
Length: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) Encode() []byte {
|
||||||
|
total := HeaderSize + len(p.Payload)
|
||||||
|
buf := make([]byte, total)
|
||||||
|
p.Header.Length = uint16(len(p.Payload))
|
||||||
|
p.Header.Encode(buf[:HeaderSize])
|
||||||
|
copy(buf[HeaderSize:], p.Payload)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodePacket(data []byte) (*Packet, error) {
|
||||||
|
var p Packet
|
||||||
|
if err := p.Header.Decode(data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payloadLen := int(p.Header.Length)
|
||||||
|
if HeaderSize+payloadLen > len(data) {
|
||||||
|
return nil, fmt.Errorf("packet truncated: header claims %d + %d > %d", HeaderSize, payloadLen, len(data))
|
||||||
|
}
|
||||||
|
p.Payload = make([]byte, payloadLen)
|
||||||
|
copy(p.Payload, data[HeaderSize:HeaderSize+payloadLen])
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodePacketInto(p *Packet, data []byte) error {
|
||||||
|
if err := p.Header.Decode(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payloadLen := int(p.Header.Length)
|
||||||
|
if HeaderSize+payloadLen > len(data) {
|
||||||
|
return fmt.Errorf("packet truncated")
|
||||||
|
}
|
||||||
|
p.Payload = make([]byte, payloadLen)
|
||||||
|
copy(p.Payload, data[HeaderSize:HeaderSize+payloadLen])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
193
internal/vl1/peer.go
Normal file
193
internal/vl1/peer.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package vl1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zeromesh/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Peer struct {
|
||||||
|
Address identity.Address
|
||||||
|
PublicKey [32]byte
|
||||||
|
Endpoint *net.UDPAddr
|
||||||
|
Cipher *NoiseCipher
|
||||||
|
LastSeen time.Time
|
||||||
|
LastSend time.Time
|
||||||
|
Connected bool
|
||||||
|
mu sync.RWMutex
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) Touch() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.LastSeen = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) SetCipher(c *NoiseCipher) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.Cipher = c
|
||||||
|
p.Connected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) IsConnected() bool {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return p.Connected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) IsAlive() bool {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return time.Since(p.LastSeen) < 90*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) Encrypt(plaintext []byte) ([]byte, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
if p.Cipher == nil {
|
||||||
|
return nil, ErrNoCipher
|
||||||
|
}
|
||||||
|
return p.Cipher.Encrypt(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) Decrypt(ciphertext []byte) ([]byte, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
if p.Cipher == nil {
|
||||||
|
return nil, ErrNoCipher
|
||||||
|
}
|
||||||
|
return p.Cipher.Decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) EncryptTo(buf []byte, plaintext []byte) (int, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
if p.Cipher == nil {
|
||||||
|
return 0, ErrNoCipher
|
||||||
|
}
|
||||||
|
return p.Cipher.EncryptTo(buf, plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) DecryptTo(buf []byte, ciphertext []byte) ([]byte, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
if p.Cipher == nil {
|
||||||
|
return nil, ErrNoCipher
|
||||||
|
}
|
||||||
|
return p.Cipher.DecryptTo(buf, ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Peer) NeedsKeepalive() bool {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return p.Connected && time.Since(p.LastSend) > 25*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNoCipher = errNoCipher()
|
||||||
|
|
||||||
|
func errNoCipher() error {
|
||||||
|
return &noCipherError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type noCipherError struct{}
|
||||||
|
|
||||||
|
func (e *noCipherError) Error() string {
|
||||||
|
return "no cipher established"
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerManager struct {
|
||||||
|
peers map[identity.Address]*Peer
|
||||||
|
mu sync.RWMutex
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPeerManager(log *slog.Logger) *PeerManager {
|
||||||
|
return &PeerManager{
|
||||||
|
peers: make(map[identity.Address]*Peer),
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PeerManager) AddPeer(addr identity.Address, pubKey [32]byte, endpoint *net.UDPAddr) *Peer {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
peer := &Peer{
|
||||||
|
Address: addr,
|
||||||
|
PublicKey: pubKey,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
LastSend: time.Now(),
|
||||||
|
log: pm.log.With("peer", addr.String()),
|
||||||
|
}
|
||||||
|
pm.peers[addr] = peer
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PeerManager) GetPeer(addr identity.Address) *Peer {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
return pm.peers[addr]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PeerManager) GetPeerByEndpoint(endpoint *net.UDPAddr) *Peer {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
for _, p := range pm.peers {
|
||||||
|
if p.Endpoint != nil && p.Endpoint.IP.Equal(endpoint.IP) && p.Endpoint.Port == endpoint.Port {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PeerManager) RemovePeer(addr identity.Address) {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
delete(pm.peers, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PeerManager) UpdatePeerEndpoint(addr identity.Address, endpoint *net.UDPAddr) {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
if p, ok := pm.peers[addr]; ok {
|
||||||
|
p.Endpoint = endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PeerManager) ConnectedPeers() []*Peer {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
var result []*Peer
|
||||||
|
for _, p := range pm.peers {
|
||||||
|
if p.IsConnected() {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PeerManager) AllPeers() []*Peer {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
result := make([]*Peer, 0, len(pm.peers))
|
||||||
|
for _, p := range pm.peers {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PeerManager) CleanDead() {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
for addr, p := range pm.peers {
|
||||||
|
if p.Connected && !p.IsAlive() {
|
||||||
|
p.log.Warn("peer timed out, removing")
|
||||||
|
delete(pm.peers, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/vl1/transport.go
Normal file
112
internal/vl1/transport.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package vl1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transport struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
port int
|
||||||
|
mu sync.RWMutex
|
||||||
|
closed bool
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTransport(port int, log *slog.Logger) (*Transport, error) {
|
||||||
|
addr := &net.UDPAddr{Port: port}
|
||||||
|
conn, err := net.ListenUDP("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bind UDP port %d: %w", port, err)
|
||||||
|
}
|
||||||
|
actualPort := conn.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
log.Info("VL1 transport listening", "port", actualPort)
|
||||||
|
return &Transport{
|
||||||
|
conn: conn,
|
||||||
|
port: actualPort,
|
||||||
|
log: log,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transport) Port() int {
|
||||||
|
return t.port
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transport) ReadFrom(buf []byte) (int, *net.UDPAddr, error) {
|
||||||
|
n, addr, err := t.conn.ReadFromUDP(buf)
|
||||||
|
return n, addr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transport) SendTo(data []byte, addr *net.UDPAddr) error {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
if t.closed {
|
||||||
|
return fmt.Errorf("transport closed")
|
||||||
|
}
|
||||||
|
_, err := t.conn.WriteToUDP(data, addr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transport) SendPacket(pkt *Packet, addr *net.UDPAddr) error {
|
||||||
|
return t.SendTo(pkt.Encode(), addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transport) Close() error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.closed = true
|
||||||
|
return t.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transport) SetSocketBuffers(rcvBuf, sndBuf int) error {
|
||||||
|
rawConn, err := t.conn.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get raw conn: %w", err)
|
||||||
|
}
|
||||||
|
var setErr error
|
||||||
|
err = rawConn.Control(func(fd uintptr) {
|
||||||
|
if rcvBuf > 0 {
|
||||||
|
if e := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_RCVBUF, rcvBuf); e != nil {
|
||||||
|
setErr = fmt.Errorf("set SO_RCVBUF=%d: %w", rcvBuf, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sndBuf > 0 {
|
||||||
|
if e := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_SNDBUF, sndBuf); e != nil {
|
||||||
|
setErr = fmt.Errorf("set SO_SNDBUF=%d: %w", sndBuf, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return setErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transport) LocalAddr() net.Addr {
|
||||||
|
return t.conn.LocalAddr()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transport) SetReadDeadline(deadline time.Time) error {
|
||||||
|
return t.conn.SetReadDeadline(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
var packetBufPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
buf := make([]byte, MaxPacketSize)
|
||||||
|
return &buf
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPacketBuf() *[]byte {
|
||||||
|
return packetBufPool.Get().(*[]byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PutPacketBuf(buf *[]byte) {
|
||||||
|
packetBufPool.Put(buf)
|
||||||
|
}
|
||||||
120
internal/vl2/arp.go
Normal file
120
internal/vl2/arp.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package vl2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ARPEntry struct {
|
||||||
|
MAC net.HardwareAddr
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ARPProxy struct {
|
||||||
|
cache map[string]*ARPEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewARPProxy(log *slog.Logger) *ARPProxy {
|
||||||
|
return &ARPProxy{
|
||||||
|
cache: make(map[string]*ARPEntry),
|
||||||
|
log: log.With("component", "arp"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ARPProxy) Learn(ip net.IP, mac net.HardwareAddr) {
|
||||||
|
key := ip.String()
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.cache[key] = &ARPEntry{
|
||||||
|
MAC: mac,
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ARPProxy) Lookup(ip net.IP) net.HardwareAddr {
|
||||||
|
key := ip.String()
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
entry, ok := a.cache[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry.MAC
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ARPProxy) HandleARP(frame *EthernetFrame) []byte {
|
||||||
|
if len(frame.Payload) < 28 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pl := frame.Payload
|
||||||
|
|
||||||
|
hrd := binary.BigEndian.Uint16(pl[0:2])
|
||||||
|
pro := binary.BigEndian.Uint16(pl[2:4])
|
||||||
|
hln := pl[4]
|
||||||
|
pln := pl[5]
|
||||||
|
op := binary.BigEndian.Uint16(pl[6:8])
|
||||||
|
|
||||||
|
if hrd != 1 || pro != EtherTypeIPv4 || hln != 6 || pln != 4 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
senderMAC := net.HardwareAddr(pl[8:14])
|
||||||
|
senderIP := net.IP(pl[14:18])
|
||||||
|
targetIP := net.IP(pl[24:28])
|
||||||
|
|
||||||
|
a.Learn(senderIP, senderMAC)
|
||||||
|
|
||||||
|
if op == 1 { // ARP request
|
||||||
|
if localMAC := a.Lookup(targetIP); localMAC != nil {
|
||||||
|
reply := make([]byte, 42)
|
||||||
|
copy(reply[0:6], senderMAC)
|
||||||
|
copy(reply[6:12], localMAC)
|
||||||
|
binary.BigEndian.PutUint16(reply[12:14], EtherTypeARP)
|
||||||
|
|
||||||
|
reply[14] = 0x00
|
||||||
|
reply[15] = 0x01
|
||||||
|
binary.BigEndian.PutUint16(reply[16:18], EtherTypeIPv4)
|
||||||
|
reply[18] = 6
|
||||||
|
reply[19] = 4
|
||||||
|
binary.BigEndian.PutUint16(reply[20:22], 2)
|
||||||
|
|
||||||
|
copy(reply[22:28], localMAC)
|
||||||
|
copy(reply[28:32], targetIP)
|
||||||
|
copy(reply[32:38], senderMAC)
|
||||||
|
copy(reply[38:42], senderIP)
|
||||||
|
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ARPProxy) PeerFromARP(frame *EthernetFrame) (net.IP, net.HardwareAddr) {
|
||||||
|
if len(frame.Payload) < 28 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
pl := frame.Payload
|
||||||
|
hrd := binary.BigEndian.Uint16(pl[0:2])
|
||||||
|
pro := binary.BigEndian.Uint16(pl[2:4])
|
||||||
|
if hrd != 1 || pro != EtherTypeIPv4 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return net.IP(pl[14:18]), net.HardwareAddr(pl[8:14])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ARPProxy) CleanExpired() {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
cutoff := time.Now().Add(-30 * time.Minute)
|
||||||
|
for k, v := range a.cache {
|
||||||
|
if v.LastSeen.Before(cutoff) {
|
||||||
|
delete(a.cache, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
internal/vl2/frame.go
Normal file
92
internal/vl2/frame.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package vl2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MinFrameSize = 14
|
||||||
|
MaxFrameSize = 65535
|
||||||
|
EtherTypeIPv4 = 0x0800
|
||||||
|
EtherTypeARP = 0x0806
|
||||||
|
EtherTypeIPv6 = 0x86DD
|
||||||
|
)
|
||||||
|
|
||||||
|
type EthernetFrame struct {
|
||||||
|
DstMAC net.HardwareAddr
|
||||||
|
SrcMAC net.HardwareAddr
|
||||||
|
EtherType uint16
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseEthernetFrame(data []byte) (*EthernetFrame, error) {
|
||||||
|
if len(data) < MinFrameSize {
|
||||||
|
return nil, fmt.Errorf("frame too short: %d < %d", len(data), MinFrameSize)
|
||||||
|
}
|
||||||
|
return &EthernetFrame{
|
||||||
|
DstMAC: net.HardwareAddr(data[0:6]),
|
||||||
|
SrcMAC: net.HardwareAddr(data[6:12]),
|
||||||
|
EtherType: binary.BigEndian.Uint16(data[12:14]),
|
||||||
|
Payload: data[14:],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EthernetFrame) Encode() []byte {
|
||||||
|
buf := make([]byte, MinFrameSize+len(f.Payload))
|
||||||
|
copy(buf[0:6], f.DstMAC)
|
||||||
|
copy(buf[6:12], f.SrcMAC)
|
||||||
|
binary.BigEndian.PutUint16(buf[12:14], f.EtherType)
|
||||||
|
copy(buf[14:], f.Payload)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EthernetFrame) IsBroadcast() bool {
|
||||||
|
for _, b := range f.DstMAC {
|
||||||
|
if b != 0xff {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EthernetFrame) IsMulticast() bool {
|
||||||
|
return len(f.DstMAC) > 0 && f.DstMAC[0]&0x01 == 0x01 && !f.IsBroadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EthernetFrame) IsARP() bool {
|
||||||
|
return f.EtherType == EtherTypeARP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EthernetFrame) IsIPv4() bool {
|
||||||
|
return f.EtherType == EtherTypeIPv4
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EthernetFrame) IsIPv6() bool {
|
||||||
|
return f.EtherType == EtherTypeIPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
type MACKey [6]byte
|
||||||
|
|
||||||
|
func MACToKey(mac net.HardwareAddr) MACKey {
|
||||||
|
var key MACKey
|
||||||
|
copy(key[:], mac)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameBufPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
buf := make([]byte, MaxFrameSize)
|
||||||
|
return &buf
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFrameBuf() *[]byte {
|
||||||
|
return frameBufPool.Get().(*[]byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PutFrameBuf(buf *[]byte) {
|
||||||
|
frameBufPool.Put(buf)
|
||||||
|
}
|
||||||
19
internal/vl2/mac.go
Normal file
19
internal/vl2/mac.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package vl2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"zeromesh/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateMAC creates a deterministic MAC address from network ID and node address.
|
||||||
|
func GenerateMAC(networkID uint32, nodeAddr identity.Address) net.HardwareAddr {
|
||||||
|
mac := make(net.HardwareAddr, 6)
|
||||||
|
mac[0] = 0x02 // locally administered, unicast
|
||||||
|
mac[1] = byte(networkID >> 16)
|
||||||
|
mac[2] = byte(networkID >> 8)
|
||||||
|
mac[3] = byte(networkID)
|
||||||
|
mac[4] = nodeAddr[3]
|
||||||
|
mac[5] = nodeAddr[4]
|
||||||
|
return mac
|
||||||
|
}
|
||||||
38
internal/vl2/network.go
Normal file
38
internal/vl2/network.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package vl2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"zeromesh/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NetworkConfig struct {
|
||||||
|
ID uint32
|
||||||
|
Name string
|
||||||
|
IPRange string
|
||||||
|
IP6Range string
|
||||||
|
MTU int
|
||||||
|
Multicast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Network struct {
|
||||||
|
Config NetworkConfig
|
||||||
|
Switch *Switch
|
||||||
|
ARP *ARPProxy
|
||||||
|
LocalMAC [6]byte
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNetwork(config NetworkConfig, nodeAddr identity.Address, sender PeerSender, log *slog.Logger) *Network {
|
||||||
|
netLog := log.With("network", config.ID, "name", config.Name)
|
||||||
|
mac := GenerateMAC(config.ID, nodeAddr)
|
||||||
|
var macArr [6]byte
|
||||||
|
copy(macArr[:], mac)
|
||||||
|
return &Network{
|
||||||
|
Config: config,
|
||||||
|
Switch: NewSwitch(config.ID, sender, netLog),
|
||||||
|
ARP: NewARPProxy(netLog),
|
||||||
|
LocalMAC: macArr,
|
||||||
|
log: netLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
153
internal/vl2/switch.go
Normal file
153
internal/vl2/switch.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package vl2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zeromesh/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MACTableExpiry = 5 * time.Minute
|
||||||
|
MACTableMaxSize = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
type MACEntry struct {
|
||||||
|
PeerAddr identity.Address
|
||||||
|
LastSeen time.Time
|
||||||
|
IsLocal bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerSender interface {
|
||||||
|
SendToPeer(peerAddr identity.Address, networkID uint32, frame []byte) error
|
||||||
|
BroadcastToPeers(networkID uint32, frame []byte, excludePeer identity.Address) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Switch struct {
|
||||||
|
networkID uint32
|
||||||
|
macTable map[MACKey]*MACEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
sender PeerSender
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSwitch(networkID uint32, sender PeerSender, log *slog.Logger) *Switch {
|
||||||
|
return &Switch{
|
||||||
|
networkID: networkID,
|
||||||
|
macTable: make(map[MACKey]*MACEntry),
|
||||||
|
sender: sender,
|
||||||
|
log: log.With("component", "switch", "network", networkID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw *Switch) HandleLocalFrame(frame []byte) error {
|
||||||
|
parsed, err := ParseEthernetFrame(frame)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.learn(parsed.SrcMAC, identity.Address{}, true)
|
||||||
|
|
||||||
|
if parsed.IsBroadcast() || parsed.IsMulticast() {
|
||||||
|
return sw.sender.BroadcastToPeers(sw.networkID, frame, identity.Address{})
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.mu.RLock()
|
||||||
|
entry, found := sw.macTable[MACToKey(parsed.DstMAC)]
|
||||||
|
sw.mu.RUnlock()
|
||||||
|
|
||||||
|
if found && !entry.IsLocal {
|
||||||
|
return sw.sender.SendToPeer(entry.PeerAddr, sw.networkID, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
sw.log.Debug("unknown dst MAC, flooding", "dst", parsed.DstMAC)
|
||||||
|
return sw.sender.BroadcastToPeers(sw.networkID, frame, identity.Address{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw *Switch) HandleRemoteFrame(peerAddr identity.Address, frame []byte) ([]byte, error) {
|
||||||
|
parsed, err := ParseEthernetFrame(frame)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.learn(parsed.SrcMAC, peerAddr, false)
|
||||||
|
|
||||||
|
if parsed.IsBroadcast() || parsed.IsMulticast() {
|
||||||
|
_ = sw.sender.BroadcastToPeers(sw.networkID, frame, peerAddr)
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.mu.RLock()
|
||||||
|
entry, found := sw.macTable[MACToKey(parsed.DstMAC)]
|
||||||
|
sw.mu.RUnlock()
|
||||||
|
|
||||||
|
if found && entry.IsLocal {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if found && !entry.IsLocal {
|
||||||
|
_ = sw.sender.SendToPeer(entry.PeerAddr, sw.networkID, frame)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = sw.sender.BroadcastToPeers(sw.networkID, frame, peerAddr)
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw *Switch) learn(mac net.HardwareAddr, peerAddr identity.Address, isLocal bool) {
|
||||||
|
key := MACToKey(mac)
|
||||||
|
sw.mu.Lock()
|
||||||
|
defer sw.mu.Unlock()
|
||||||
|
|
||||||
|
if len(sw.macTable) >= MACTableMaxSize {
|
||||||
|
sw.evictOldest()
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.macTable[key] = &MACEntry{
|
||||||
|
PeerAddr: peerAddr,
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
IsLocal: isLocal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw *Switch) evictOldest() {
|
||||||
|
var oldestKey MACKey
|
||||||
|
var oldestTime time.Time
|
||||||
|
first := true
|
||||||
|
for k, v := range sw.macTable {
|
||||||
|
if first || v.LastSeen.Before(oldestTime) {
|
||||||
|
oldestKey = k
|
||||||
|
oldestTime = v.LastSeen
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !first {
|
||||||
|
delete(sw.macTable, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw *Switch) CleanExpired() int {
|
||||||
|
sw.mu.Lock()
|
||||||
|
defer sw.mu.Unlock()
|
||||||
|
cutoff := time.Now().Add(-MACTableExpiry)
|
||||||
|
removed := 0
|
||||||
|
for k, v := range sw.macTable {
|
||||||
|
if v.LastSeen.Before(cutoff) && !v.IsLocal {
|
||||||
|
delete(sw.macTable, k)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw *Switch) MACTableSize() int {
|
||||||
|
sw.mu.RLock()
|
||||||
|
defer sw.mu.RUnlock()
|
||||||
|
return len(sw.macTable)
|
||||||
|
}
|
||||||
133
main.go
Normal file
133
main.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"zeromesh/internal/config"
|
||||||
|
"zeromesh/internal/controller"
|
||||||
|
"zeromesh/internal/database"
|
||||||
|
"zeromesh/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/dist/index.html web/dist/assets/*
|
||||||
|
var webDist embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := flag.String("config", "config.yaml", "config file path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("load config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logLevel := new(slog.LevelVar)
|
||||||
|
switch cfg.Logging.Level {
|
||||||
|
case "debug":
|
||||||
|
logLevel.Set(slog.LevelDebug)
|
||||||
|
case "warn":
|
||||||
|
logLevel.Set(slog.LevelWarn)
|
||||||
|
case "error":
|
||||||
|
logLevel.Set(slog.LevelError)
|
||||||
|
default:
|
||||||
|
logLevel.Set(slog.LevelInfo)
|
||||||
|
}
|
||||||
|
opts := &slog.HandlerOptions{Level: logLevel}
|
||||||
|
var logHandler slog.Handler
|
||||||
|
if cfg.Logging.Format == "json" {
|
||||||
|
logHandler = slog.NewJSONHandler(os.Stderr, opts)
|
||||||
|
} else {
|
||||||
|
logHandler = slog.NewTextHandler(os.Stderr, opts)
|
||||||
|
}
|
||||||
|
logger := slog.New(logHandler)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
db, err := database.Init(&cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("init database", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctr, err := controller.New(cfg, db, logger)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("init controller", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := ctr.Start(); err != nil {
|
||||||
|
slog.Error("start controller", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
ginEngine := router.Setup(cfg, db)
|
||||||
|
|
||||||
|
webFS, _ := fs.Sub(webDist, "web/dist")
|
||||||
|
// Wrap in a Handler that serves SPA before Gin gets a chance to redirect
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := r.URL.Path
|
||||||
|
// Serve SPA for root and all non-API paths
|
||||||
|
if p == "/" || p == "/index.html" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
data, err := webFS.Open("index.html")
|
||||||
|
if err == nil {
|
||||||
|
http.ServeContent(w, r, "index.html", time.Now(), data.(io.ReadSeeker))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p) >= 8 && p[:8] == "/assets/" {
|
||||||
|
assetFS, _ := fs.Sub(webDist, "web/dist/assets")
|
||||||
|
http.StripPrefix("/assets", http.FileServer(http.FS(assetFS))).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// API routes go to Gin
|
||||||
|
if len(p) >= 4 && p[:4] == "/api" {
|
||||||
|
ginEngine.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPA fallback for all other routes
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
data, err := webFS.Open("index.html")
|
||||||
|
if err == nil {
|
||||||
|
http.ServeContent(w, r, "index.html", time.Now(), data.(io.ReadSeeker))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ginEngine.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Server.Addr(),
|
||||||
|
Handler: handler,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("server listening", "addr", srv.Addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("serve", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
slog.Info("shutting down...")
|
||||||
|
ctr.Stop()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("shutdown", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
test.sh
Executable file
11
test.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
GO="${GO:-/usr/local/go/bin/go}"
|
||||||
|
|
||||||
|
echo "Running go vet..."
|
||||||
|
$GO vet ./...
|
||||||
|
|
||||||
|
echo "Running tests..."
|
||||||
|
$GO test ./... -v
|
||||||
|
|
||||||
|
echo "All checks passed."
|
||||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ZeroMesh 管理控制台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1455
web/package-lock.json
generated
Normal file
1455
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
web/package.json
Normal file
20
web/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "zeromesh-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"axios": "^1.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
web/src/App.vue
Normal file
91
web/src/App.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #6366f1;
|
||||||
|
--primary-light: #a5b4fc;
|
||||||
|
--primary-dark: #4f46e5;
|
||||||
|
--accent: #06b6d4;
|
||||||
|
--accent2: #d946ef;
|
||||||
|
--bg-deep: #020617;
|
||||||
|
--bg: #0b1120;
|
||||||
|
--bg-card: rgba(15, 23, 42, 0.8);
|
||||||
|
--bg-card-hover: rgba(30, 41, 59, 0.8);
|
||||||
|
--bg-input: rgba(30, 41, 59, 0.6);
|
||||||
|
--border: rgba(99, 102, 241, 0.15);
|
||||||
|
--border-glow: rgba(99, 102, 241, 0.3);
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-dim: #475569;
|
||||||
|
--success: #22c55e;
|
||||||
|
--success-glow: rgba(34, 197, 94, 0.2);
|
||||||
|
--danger: #ef4444;
|
||||||
|
--warning: #eab308;
|
||||||
|
--glow-primary: 0 0 20px rgba(99, 102, 241, 0.15);
|
||||||
|
--glow-accent: 0 0 20px rgba(6, 182, 212, 0.15);
|
||||||
|
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'System';
|
||||||
|
src: local(-apple-system), local(BlinkMacSystemFont), local('Segoe UI');
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'System', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateX(-12px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: var(--glow-primary); }
|
||||||
|
50% { box-shadow: 0 0 30px rgba(99, 102, 241, 0.25); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes grid-scroll {
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
100% { transform: translateY(40px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in { animation: fadeIn 0.4s ease-out; }
|
||||||
|
.slide-in { animation: slideIn 0.3s ease-out; }
|
||||||
|
</style>
|
||||||
53
web/src/api/index.js
Normal file
53
web/src/api/index.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
res => res,
|
||||||
|
err => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Auth
|
||||||
|
checkAdmin: () => api.get('/admin/check'),
|
||||||
|
login: (username, password) => api.post('/auth/login', { username, password }),
|
||||||
|
register: (username, password) => api.post('/auth/register', { username, password }),
|
||||||
|
initAdmin: (username, password) => api.post('/auth/init', { username, password }),
|
||||||
|
|
||||||
|
// Dashboard (user-scoped)
|
||||||
|
dashboard: () => api.get('/dashboard'),
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
profile: () => api.get('/user/profile'),
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
nodeRegister: data => api.post('/node/register', data),
|
||||||
|
nodeList: () => api.get('/node/list'),
|
||||||
|
nodeOnline: () => api.get('/node/online'),
|
||||||
|
|
||||||
|
// Networks
|
||||||
|
networkCreate: data => api.post('/network/create', data),
|
||||||
|
networkList: () => api.get('/network/list'),
|
||||||
|
networkGet: id => api.get(`/network/${id}`),
|
||||||
|
networkDelete: id => api.delete(`/network/${id}`),
|
||||||
|
networkMembers: id => api.get(`/network/${id}/members`),
|
||||||
|
networkAuthorize: (id, nodeId) => api.post(`/network/${id}/authorize`, { node_id: nodeId }),
|
||||||
|
networkDeauthorize: (id, nodeId) => api.post(`/network/${id}/deauthorize`, { node_id: nodeId }),
|
||||||
|
}
|
||||||
7
web/src/main.js
Normal file
7
web/src/main.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
101
web/src/router/index.js
Normal file
101
web/src/router/index.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('../views/Login.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/init',
|
||||||
|
name: 'Init',
|
||||||
|
component: () => import('../views/Init.vue'),
|
||||||
|
},
|
||||||
|
// Admin layout
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('../views/Layout.vue'),
|
||||||
|
redirect: '/dashboard',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../views/Dashboard.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'networks',
|
||||||
|
name: 'Networks',
|
||||||
|
component: () => import('../views/Networks.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'network/create',
|
||||||
|
name: 'NetworkCreate',
|
||||||
|
component: () => import('../views/NetworkCreate.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'nodes',
|
||||||
|
name: 'Nodes',
|
||||||
|
component: () => import('../views/Nodes.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'docs',
|
||||||
|
name: 'Docs',
|
||||||
|
component: () => import('../views/Docs.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'logs',
|
||||||
|
name: 'Logs',
|
||||||
|
component: () => import('../views/Logs.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// User layout
|
||||||
|
{
|
||||||
|
path: '/user',
|
||||||
|
component: () => import('../views/Layout.vue'),
|
||||||
|
redirect: '/user/dashboard',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'UserDashboard',
|
||||||
|
component: () => import('../views/UserDashboard.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'networks',
|
||||||
|
name: 'UserNetworks',
|
||||||
|
component: () => import('../views/UserNetworks.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'network/create',
|
||||||
|
name: 'UserNetworkCreate',
|
||||||
|
component: () => import('../views/NetworkCreate.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'networks/:id',
|
||||||
|
name: 'UserNetworkDetail',
|
||||||
|
component: () => import('../views/UserNetworkDetail.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'nodes',
|
||||||
|
name: 'UserNodes',
|
||||||
|
component: () => import('../views/UserNodes.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (to.path !== '/login' && to.path !== '/init' && !token) {
|
||||||
|
next('/login')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
230
web/src/views/Dashboard.vue
Normal file
230
web/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">仪表盘</h2>
|
||||||
|
<p class="page-subtitle">系统概览与运行状态</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-time">{{ timeStr }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="loading-ring"></div>
|
||||||
|
<span>加载系统状态...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card stat-users">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value">{{ stats.authorized_members || 0 }}</div>
|
||||||
|
<div class="stat-label">授权成员</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-trend">所有网络中的授权设备</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card stat-networks">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value">{{ stats.networks_count || 0 }}</div>
|
||||||
|
<div class="stat-label">虚拟网络</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-trend">已创建网络</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card stat-online">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value">{{ stats.nodes_online || 0 }}</div>
|
||||||
|
<div class="stat-label">在线节点</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-trend">
|
||||||
|
<span class="live-dot"></span> 实时在线
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card stat-total">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value">{{ stats.nodes_total || 0 }}</div>
|
||||||
|
<div class="stat-label">总节点</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-trend">全部注册节点</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Start -->
|
||||||
|
<div class="card quick-start fade-in">
|
||||||
|
<div class="card-header-line">
|
||||||
|
<h3>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
快速开始
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">01</div>
|
||||||
|
<div class="step-body">
|
||||||
|
<strong>创建虚拟网络</strong>
|
||||||
|
<p>在 <router-link to="/networks">虚拟网络</router-link> 中定义 IP 范围和网络名称</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-arrow">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5"><polyline points="5 12 19 12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">02</div>
|
||||||
|
<div class="step-body">
|
||||||
|
<strong>注册节点</strong>
|
||||||
|
<p>在 <router-link to="/nodes">节点管理</router-link> 中注册每个设备</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-arrow">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5"><polyline points="5 12 19 12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">03</div>
|
||||||
|
<div class="step-body">
|
||||||
|
<strong>授权组网</strong>
|
||||||
|
<p>将节点授权到网络,启动 agent 即可 P2P 直连</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System info -->
|
||||||
|
<div class="card sys-info fade-in">
|
||||||
|
<div class="card-header-line">
|
||||||
|
<h3>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||||
|
系统信息
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item"><span class="info-label">运行状态</span><span class="info-value"><span class="live-dot"></span> 正常</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">API 版本</span><span class="info-value">v1</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">端口</span><span class="info-value">10001</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">数据库</span><span class="info-value">SQLite</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const stats = ref({})
|
||||||
|
const timeStr = ref('')
|
||||||
|
|
||||||
|
let timer
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
updateTime()
|
||||||
|
timer = setInterval(updateTime, 1000)
|
||||||
|
try {
|
||||||
|
const [dashRes, nodeRes, netRes] = await Promise.all([
|
||||||
|
api.dashboard(),
|
||||||
|
api.nodeList().catch(() => ({ data: { nodes: [] } })),
|
||||||
|
api.networkList().catch(() => ({ data: { networks: [] } })),
|
||||||
|
])
|
||||||
|
const nodes = nodeRes.data.nodes || []
|
||||||
|
const networks = netRes.data.networks || []
|
||||||
|
stats.value = {
|
||||||
|
...dashRes.data,
|
||||||
|
nodes_total: nodes.length,
|
||||||
|
nodes_online: nodes.filter(n => n.online).length,
|
||||||
|
networks_count: networks.length,
|
||||||
|
}
|
||||||
|
} catch { stats.value = { authorized_members: 0, networks_count: 0, nodes_online: 0, nodes_total: 0 } }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
|
|
||||||
|
function updateTime() {
|
||||||
|
const now = new Date()
|
||||||
|
timeStr.value = now.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard { animation: fadeIn 0.4s ease-out; }
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||||
|
.page-title { font-size: 22px; font-weight: 700; }
|
||||||
|
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
.header-time { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-card); }
|
||||||
|
|
||||||
|
.loading-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; color: var(--text-muted); }
|
||||||
|
.loading-ring { width: 32px; height: 32px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card); border-radius: 14px; padding: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-card::before {
|
||||||
|
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||||
|
}
|
||||||
|
.stat-card:hover { transform: translateY(-2px); border-color: var(--border-glow); box-shadow: var(--glow-primary); }
|
||||||
|
.stat-users::before { background: linear-gradient(90deg, transparent, #6366f1, transparent); }
|
||||||
|
.stat-networks::before { background: linear-gradient(90deg, transparent, #06b6d4, transparent); }
|
||||||
|
.stat-online::before { background: linear-gradient(90deg, transparent, #22c55e, transparent); }
|
||||||
|
.stat-total::before { background: linear-gradient(90deg, transparent, #d946ef, transparent); }
|
||||||
|
|
||||||
|
.stat-card { display: flex; flex-direction: column; }
|
||||||
|
.stat-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.stat-users .stat-icon { background: rgba(99, 102, 241, 0.1); color: #a5b4fc; }
|
||||||
|
.stat-networks .stat-icon { background: rgba(6, 182, 212, 0.1); color: #67e8f9; }
|
||||||
|
.stat-online .stat-icon { background: rgba(34, 197, 94, 0.1); color: #86efac; }
|
||||||
|
.stat-total .stat-icon { background: rgba(217, 70, 239, 0.1); color: #f0abfc; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; }
|
||||||
|
.stat-label { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
.stat-trend { font-size: 11px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card); border-radius: 14px; padding: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card-header-line { margin-bottom: 20px; }
|
||||||
|
.card-header-line h3 { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Quick start steps */
|
||||||
|
.steps { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.step { display: flex; align-items: flex-start; gap: 14px; flex: 1; }
|
||||||
|
.step-num { font-size: 24px; font-weight: 800; color: var(--text-dim); line-height: 1; }
|
||||||
|
.step-body strong { display: block; font-size: 14px; margin-bottom: 2px; }
|
||||||
|
.step-body p { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
|
||||||
|
.step-body a { color: var(--primary-light); text-decoration: none; }
|
||||||
|
.step-body a:hover { text-decoration: underline; }
|
||||||
|
.step-arrow { flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* System info */
|
||||||
|
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||||
|
.info-item { padding: 12px 16px; border-radius: 10px; background: rgba(0,0,0,0.2); border: 1px solid var(--border); }
|
||||||
|
.info-label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||||
|
.info-value { font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 6px; }
|
||||||
|
|
||||||
|
.live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 6px var(--success-glow); display: inline-block; }
|
||||||
|
</style>
|
||||||
715
web/src/views/Docs.vue
Normal file
715
web/src/views/Docs.vue
Normal file
@@ -0,0 +1,715 @@
|
|||||||
|
<template>
|
||||||
|
<div class="docs fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">开发者文档</h2>
|
||||||
|
<p class="page-subtitle">了解 ZeroMesh 架构、API 和客户端接入协议</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-copy-all" @click="copyAllContent" :title="copied ? '已复制!' : '复制全部文档内容(AI 友好格式)'">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
|
</svg>
|
||||||
|
{{ copied ? '已复制' : '复制全文' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button v-for="t in tabs" :key="t.id" :class="['tab', { active: activeTab === t.id }]" @click="activeTab = t.id">
|
||||||
|
<span class="tab-indicator"></span>{{ t.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Architecture -->
|
||||||
|
<div v-if="activeTab === 'arch'" class="doc-section fade-in">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="card-shine"></div>
|
||||||
|
<h3>系统架构</h3>
|
||||||
|
<p class="lead-text">ZeroMesh 采用<strong>双平面架构</strong>:控制面基于 HTTP REST API,数据面基于 UDP P2P 加密传输。</p>
|
||||||
|
|
||||||
|
<div class="arch-grid">
|
||||||
|
<div class="arch-card">
|
||||||
|
<div class="arch-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||||
|
</div>
|
||||||
|
<h4>控制面 (Control Plane)</h4>
|
||||||
|
<p class="arch-port">HTTP :10001</p>
|
||||||
|
<ul>
|
||||||
|
<li>管理员登录 / 注册</li>
|
||||||
|
<li>网络和节点管理</li>
|
||||||
|
<li>节点授权 / 撤销</li>
|
||||||
|
<li>监控仪表盘</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="arch-connector">
|
||||||
|
<div class="connector-line"></div>
|
||||||
|
<div class="connector-arrow">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="arch-card">
|
||||||
|
<div class="arch-icon accent">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||||
|
</div>
|
||||||
|
<h4>数据面 (Data Plane)</h4>
|
||||||
|
<p class="arch-port">UDP :19993</p>
|
||||||
|
<ul>
|
||||||
|
<li>P2P 加密隧道 (VL1)</li>
|
||||||
|
<li>Noise Protocol + ChaCha20-Poly1305</li>
|
||||||
|
<li>虚拟二层交换 (VL2)</li>
|
||||||
|
<li>NAT 穿透 (STUN/ICE)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-diagram">
|
||||||
|
<div class="flow-title">客户端接入流程</div>
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="flow-step">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div>生成 Ed25519 身份</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></div>
|
||||||
|
<div class="flow-step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div>API 注册节点</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></div>
|
||||||
|
<div class="flow-step">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div>获取授权 & 节点列表</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></div>
|
||||||
|
<div class="flow-step">
|
||||||
|
<div class="step-num">4</div>
|
||||||
|
<div>UDP 握手 + P2P 加密通信</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Reference -->
|
||||||
|
<div v-if="activeTab === 'api'" class="doc-section fade-in">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="card-shine"></div>
|
||||||
|
<h3>HTTP API 参考</h3>
|
||||||
|
<p class="lead-text">所有 API 接口位于 <code>/api/v1</code> 前缀下。认证通过 <code>Authorization: Bearer <token></code> 头传递。</p>
|
||||||
|
|
||||||
|
<div class="api-group">
|
||||||
|
<h4 class="group-title">认证</h4>
|
||||||
|
<div v-for="ep in authEndpoints" :key="ep.method + ep.path" class="api-row">
|
||||||
|
<span :class="'http-method ' + ep.method.toLowerCase()">{{ ep.method }}</span>
|
||||||
|
<code class="api-path">{{ ep.path }}</code>
|
||||||
|
<span class="api-desc">{{ ep.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-group">
|
||||||
|
<h4 class="group-title">节点管理</h4>
|
||||||
|
<div v-for="ep in nodeEndpoints" :key="ep.method + ep.path" class="api-row">
|
||||||
|
<span :class="'http-method ' + ep.method.toLowerCase()">{{ ep.method }}</span>
|
||||||
|
<code class="api-path">{{ ep.path }}</code>
|
||||||
|
<span class="api-desc">{{ ep.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-group">
|
||||||
|
<h4 class="group-title">网络管理</h4>
|
||||||
|
<div v-for="ep in networkEndpoints" :key="ep.method + ep.path" class="api-row">
|
||||||
|
<span :class="'http-method ' + ep.method.toLowerCase()">{{ ep.method }}</span>
|
||||||
|
<code class="api-path">{{ ep.path }}</code>
|
||||||
|
<span class="api-desc">{{ ep.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-group">
|
||||||
|
<h4 class="group-title">仪表盘</h4>
|
||||||
|
<div v-for="ep in dashEndpoints" :key="ep.method + ep.path" class="api-row">
|
||||||
|
<span :class="'http-method ' + ep.method.toLowerCase()">{{ ep.method }}</span>
|
||||||
|
<code class="api-path">{{ ep.path }}</code>
|
||||||
|
<span class="api-desc">{{ ep.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Register Example -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="card-shine"></div>
|
||||||
|
<h3>注册节点示例</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-header">
|
||||||
|
<span class="code-lang">Shell / cURL</span>
|
||||||
|
<button class="code-copy" @click="copyCode(registerExample)">复制</button>
|
||||||
|
</div>
|
||||||
|
<pre>{{ registerExample }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VL1 Protocol -->
|
||||||
|
<div v-if="activeTab === 'vl1'" class="doc-section fade-in">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="card-shine"></div>
|
||||||
|
<h3>VL1 传输协议</h3>
|
||||||
|
<p class="lead-text">VL1 是 ZeroMesh 的 P2P 加密传输层,基于 UDP 实现节点间的安全通信。</p>
|
||||||
|
|
||||||
|
<h4>协议概览</h4>
|
||||||
|
<table class="spec-table">
|
||||||
|
<tr><td>传输层</td><td>UDP (IPv4/IPv6)</td></tr>
|
||||||
|
<tr><td>加密算法</td><td>ChaCha20-Poly1305 (AEAD)</td></tr>
|
||||||
|
<tr><td>密钥派生</td><td>SHA3-256</td></tr>
|
||||||
|
<tr><td>Nonce</td><td>96-bit (12 bytes) — 4 zero + 8 byte big-endian 计数器</td></tr>
|
||||||
|
<tr><td>身份</td><td>Ed25519 签名密钥对</td></tr>
|
||||||
|
<tr><td>地址</td><td>5 bytes (10 hex 字符),从公钥哈希派生</td></tr>
|
||||||
|
<tr><td>最大包长</td><td>65535 bytes</td></tr>
|
||||||
|
<tr><td>保活间隔</td><td>25 秒</td></tr>
|
||||||
|
<tr><td>超时断开</td><td>90 秒无数据</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>包格式 (8 字节头 + 负载)</h4>
|
||||||
|
<table class="spec-table mono">
|
||||||
|
<thead><tr><th>偏移</th><th>大小</th><th>字段</th><th>编码</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>0</td><td>1</td><td>版本</td><td><code>0x01</code></td></tr>
|
||||||
|
<tr><td>1</td><td>1</td><td>类型</td><td><code>1</code>=Handshake, <code>2</code>=Data, <code>3</code>=Keepalive</td></tr>
|
||||||
|
<tr><td>2</td><td>4</td><td>网络 ID</td><td>uint32 big-endian</td></tr>
|
||||||
|
<tr><td>6</td><td>2</td><td>负载长度</td><td>uint16 big-endian</td></tr>
|
||||||
|
<tr><td>8</td><td>N</td><td>负载</td><td>原始字节 (Data 类型为加密密文)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>连接流程</h4>
|
||||||
|
<div class="steps-list">
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div><strong>握手</strong> — 发送方发送 Handshake 包(包含身份公钥 + 临时会话密钥),接收方回复 Handshake 确认</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div><strong>加密通信</strong> — 双方通过 Noise 协议派生对称密钥,后续 Data 包使用 ChaCha20-Poly1305 加密</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div><strong>保活</strong> — 每 25 秒发送空负载 Keepalive 包;超过 90 秒未收到数据则断开</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>密钥派生</h4>
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-header">
|
||||||
|
<span class="code-lang">Pseudo-code</span>
|
||||||
|
<button class="code-copy" @click="copyCode(keyDerivationExample)">复制</button>
|
||||||
|
</div>
|
||||||
|
<pre>{{ keyDerivationExample }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Dev -->
|
||||||
|
<div v-if="activeTab === 'client'" class="doc-section fade-in">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="card-shine"></div>
|
||||||
|
<h3>客户端开发指南</h3>
|
||||||
|
<p class="lead-text">如何从零开始实现一个 ZeroMesh 客户端,接入 P2P 虚拟网络。</p>
|
||||||
|
|
||||||
|
<h4>第一步:准备身份</h4>
|
||||||
|
<p>每个节点需要一个唯一的 Ed25519 密钥对。40-bit 地址由公钥哈希的前 5 字节决定。</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-header"><span class="code-lang">Go</span></div>
|
||||||
|
<pre>id := identity.Generate()
|
||||||
|
// id.Address → "b412579ffd" (10 hex chars)
|
||||||
|
// id.PublicKey → [32]byte
|
||||||
|
// id.PrivateKey → [64]byte</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>第二步:通过 API 注册</h4>
|
||||||
|
<p>向控制面注册节点,获取授权后才能加入网络。</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-header"><span class="code-lang">cURL</span></div>
|
||||||
|
<pre>curl -X POST http://<controller>:10001/api/v1/node/register \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"node_id": "b412579ffd",
|
||||||
|
"public_key": "<64 hex chars>",
|
||||||
|
"name": "my-device",
|
||||||
|
"port": 0
|
||||||
|
}'</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>第三步:建立 UDP 连接</h4>
|
||||||
|
<p>客户端启动 UDP socket,连接到控制器获取在线节点列表,然后向每个节点发起 VL1 握手。</p>
|
||||||
|
<div class="steps-list">
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div>绑定 UDP 端口(可指定或由 OS 分配)</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div>向控制器发送本机地址 + 端口(通过 API update)</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div><code>GET /api/v1/node/online</code> 获取在线节点列表</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div>向每个在线节点发送 VL1 Handshake 包</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div>接收 Handshake 回复,建立 Noise 加密会话</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-bullet"></div>
|
||||||
|
<div>开始发送/接收加密 Data 包</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>第四步:处理 VL2 虚拟交换</h4>
|
||||||
|
<p>VL2 层在加密隧道之上模拟以太网交换:MAC 地址学习、广播泛洪、ARP 代答。客户端收到的 Data 包负载即为 VL2 帧。</p>
|
||||||
|
|
||||||
|
<h4>加密注意事项</h4>
|
||||||
|
<ul class="note-list">
|
||||||
|
<li>Nonce 计数器从 0 开始,每次加密/解密后递增</li>
|
||||||
|
<li>发送和接收使用独立的密钥和计数器</li>
|
||||||
|
<li>预共享密钥 (PSK) 通过控制器下发,用于派生会话密钥</li>
|
||||||
|
<li><code>sendKey = SHA3-256(PSK || localPub || remotePub)</code></li>
|
||||||
|
<li><code>recvKey = SHA3-256(sendKey || "reverse")</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VL2 -->
|
||||||
|
<div v-if="activeTab === 'vl2'" class="doc-section fade-in">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="card-shine"></div>
|
||||||
|
<h3>VL2 虚拟以太网交换</h3>
|
||||||
|
<p class="lead-text">VL2 在 VL1 加密隧道之上模拟二层网络交换,使得所有节点如同连接在同一台虚拟交换机上。</p>
|
||||||
|
|
||||||
|
<h4>核心特性</h4>
|
||||||
|
<table class="spec-table">
|
||||||
|
<tr><td>MAC 地址</td><td>从节点 40-bit 地址确定性生成</td></tr>
|
||||||
|
<tr><td>MAC 学习表</td><td>自动学习端口-MAC 映射,超时清理</td></tr>
|
||||||
|
<tr><td>广播泛洪</td><td>未知目标 MAC 的数据包向所有节点广播</td></tr>
|
||||||
|
<tr><td>ARP 代答</td><td>交换机代理 ARP 请求,减少广播</td></tr>
|
||||||
|
<tr><td>MTU</td><td>2800 bytes (可配置)</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>MAC 地址生成</h4>
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-header"><span class="code-lang">Go</span></div>
|
||||||
|
<pre>// 节点地址: b412579ffd (5 bytes)
|
||||||
|
// MAC: 02:XX:XX:XX:XX:XX (前 2 字节固定为 0x02, 0xXX)
|
||||||
|
// 后 4 字节来自地址哈希
|
||||||
|
func GenerateMAC(addr identity.Address) net.HardwareAddr {
|
||||||
|
mac := make(net.HardwareAddr, 6)
|
||||||
|
mac[0] = 0x02 // locally administered
|
||||||
|
copy(mac[2:], addr[:4])
|
||||||
|
return mac
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const activeTab = ref('arch')
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
function buildAllContent() {
|
||||||
|
const lines = []
|
||||||
|
const L = (s) => lines.push(s)
|
||||||
|
const sep = () => L('---')
|
||||||
|
L('# ZeroMesh 开发者文档')
|
||||||
|
L('')
|
||||||
|
L('## 系统架构')
|
||||||
|
L('')
|
||||||
|
L('ZeroMesh 采用双平面架构:控制面基于 HTTP REST API,数据面基于 UDP P2P 加密传输。')
|
||||||
|
L('')
|
||||||
|
L('### 控制面 (Control Plane)')
|
||||||
|
L('- 端口: HTTP :10001')
|
||||||
|
L('- 管理员登录 / 注册')
|
||||||
|
L('- 网络和节点管理')
|
||||||
|
L('- 节点授权 / 撤销')
|
||||||
|
L('- 监控仪表盘')
|
||||||
|
L('')
|
||||||
|
L('### 数据面 (Data Plane)')
|
||||||
|
L('- 端口: UDP :19993')
|
||||||
|
L('- P2P 加密隧道 (VL1)')
|
||||||
|
L('- Noise Protocol + ChaCha20-Poly1305')
|
||||||
|
L('- 虚拟二层交换 (VL2)')
|
||||||
|
L('- NAT 穿透 (STUN/ICE)')
|
||||||
|
L('')
|
||||||
|
L('### 客户端接入流程')
|
||||||
|
L('1. 生成 Ed25519 身份')
|
||||||
|
L('2. API 注册节点')
|
||||||
|
L('3. 获取授权 & 节点列表')
|
||||||
|
L('4. UDP 握手 + P2P 加密通信')
|
||||||
|
L('')
|
||||||
|
sep()
|
||||||
|
L('')
|
||||||
|
L('## HTTP API 参考')
|
||||||
|
L('')
|
||||||
|
L('基础路径: /api/v1')
|
||||||
|
L('认证: Authorization: Bearer <token>')
|
||||||
|
L('')
|
||||||
|
L('### 认证')
|
||||||
|
L('| 方法 | 路径 | 说明 |')
|
||||||
|
L('|------|------|------|')
|
||||||
|
L('| GET | /api/v1/admin/check | 检查管理员身份 |')
|
||||||
|
L('| POST | /api/v1/auth/login | 管理员登录 { username, password } |')
|
||||||
|
L('| POST | /api/v1/auth/register | 管理员注册 |')
|
||||||
|
L('')
|
||||||
|
L('### 节点管理')
|
||||||
|
L('| 方法 | 路径 | 说明 |')
|
||||||
|
L('|------|------|------|')
|
||||||
|
L('| POST | /api/v1/node/register | 注册节点 { node_id, public_key, name?, port? } |')
|
||||||
|
L('| GET | /api/v1/node/list | 获取所有节点列表 |')
|
||||||
|
L('| GET | /api/v1/node/online | 获取在线节点列表 |')
|
||||||
|
L('')
|
||||||
|
L('### 网络管理')
|
||||||
|
L('| 方法 | 路径 | 说明 |')
|
||||||
|
L('|------|------|------|')
|
||||||
|
L('| POST | /api/v1/network/create | 创建虚拟网络 { name, ip_range } |')
|
||||||
|
L('| GET | /api/v1/network/list | 获取所有网络 |')
|
||||||
|
L('| GET | /api/v1/network/:id | 获取网络详情 |')
|
||||||
|
L('| DELETE | /api/v1/network/:id | 删除网络 |')
|
||||||
|
L('| GET | /api/v1/network/:id/members | 获取网络成员 |')
|
||||||
|
L('| POST | /api/v1/network/:id/authorize | 授权节点加入网络 { node_id } |')
|
||||||
|
L('| POST | /api/v1/network/:id/deauthorize | 移除节点授权 { node_id } |')
|
||||||
|
L('')
|
||||||
|
L('### 仪表盘')
|
||||||
|
L('| 方法 | 路径 | 说明 |')
|
||||||
|
L('|------|------|------|')
|
||||||
|
L('| GET | /api/v1/admin/dashboard | 仪表盘统计数据 |')
|
||||||
|
L('')
|
||||||
|
L('### 注册节点示例 (cURL)')
|
||||||
|
L('')
|
||||||
|
L('```bash')
|
||||||
|
L('# 登录获取 token')
|
||||||
|
L('TOKEN=$(curl -s http://127.0.0.1:10001/api/v1/auth/login \\')
|
||||||
|
L(' -H "Content-Type: application/json" \\')
|
||||||
|
L(" -d '{\"username\":\"admin\",\"password\":\"zeromesh123\"}' | jq -r '.token')")
|
||||||
|
L('')
|
||||||
|
L('# 注册节点')
|
||||||
|
L('curl -X POST http://127.0.0.1:10001/api/v1/node/register \\')
|
||||||
|
L(' -H "Authorization: Bearer $TOKEN" \\')
|
||||||
|
L(' -H "Content-Type: application/json" \\')
|
||||||
|
L(" -d '{")
|
||||||
|
L(' \"node_id\": \"b412579ffd\",')
|
||||||
|
L(' \"public_key\": \"abc123...\",')
|
||||||
|
L(' \"name\": \"server-01\",')
|
||||||
|
L(' \"ip_address\": \"10.147.1.2\",')
|
||||||
|
L(' \"port\": 9993,')
|
||||||
|
L(' \"version\": \"1.0.0\"')
|
||||||
|
L(" }'")
|
||||||
|
L('')
|
||||||
|
L('# 获取节点列表')
|
||||||
|
L('curl http://127.0.0.1:10001/api/v1/node/list \\')
|
||||||
|
L(' -H "Authorization: Bearer $TOKEN"')
|
||||||
|
L('```')
|
||||||
|
L('')
|
||||||
|
sep()
|
||||||
|
L('')
|
||||||
|
L('## VL1 传输协议')
|
||||||
|
L('')
|
||||||
|
L('### 协议概览')
|
||||||
|
L('- 传输层: UDP (IPv4/IPv6)')
|
||||||
|
L('- 加密算法: ChaCha20-Poly1305 (AEAD)')
|
||||||
|
L('- 密钥派生: SHA3-256')
|
||||||
|
L('- Nonce: 96-bit (12 bytes) --- 4 zero + 8 byte big-endian 计数器')
|
||||||
|
L('- 身份: Ed25519 签名密钥对')
|
||||||
|
L('- 地址: 5 bytes (10 hex 字符),从公钥哈希派生')
|
||||||
|
L('- 最大包长: 65535 bytes')
|
||||||
|
L('- 保活间隔: 25 秒')
|
||||||
|
L('- 超时断开: 90 秒无数据')
|
||||||
|
L('')
|
||||||
|
L('### 包格式 (8 字节头 + 负载)')
|
||||||
|
L('| 偏移 | 大小 | 字段 | 编码 |')
|
||||||
|
L('|------|------|------|------|')
|
||||||
|
L('| 0 | 1 | 版本 | 0x01 |')
|
||||||
|
L('| 1 | 1 | 类型 | 1=Handshake, 2=Data, 3=Keepalive |')
|
||||||
|
L('| 2 | 4 | 网络 ID | uint32 big-endian |')
|
||||||
|
L('| 6 | 2 | 负载长度 | uint16 big-endian |')
|
||||||
|
L('| 8 | N | 负载 | 原始字节 (Data 类型为加密密文) |')
|
||||||
|
L('')
|
||||||
|
L('### 连接流程')
|
||||||
|
L('1. 握手 --- 发送 Handshake 包(含身份公钥 + 临时会话密钥),接收 Handshake 确认')
|
||||||
|
L('2. 加密通信 --- Noise 协议派生对称密钥,Data 包使用 ChaCha20-Poly1305 加密')
|
||||||
|
L('3. 保活 --- 每 25 秒发送 Keepalive 包;超过 90 秒无数据则断开')
|
||||||
|
L('')
|
||||||
|
L('### 密钥派生')
|
||||||
|
L('sendKey = SHA3-256(PSK || localPub || remotePub)[:32]')
|
||||||
|
L('recvKey = SHA3-256(sendKey || "reverse")[:32]')
|
||||||
|
L('')
|
||||||
|
L('Nonce = [0,0,0,0, counter big-endian], counter 每次加密/解密后递增')
|
||||||
|
L('')
|
||||||
|
sep()
|
||||||
|
L('')
|
||||||
|
L('## VL2 虚拟以太网交换')
|
||||||
|
L('')
|
||||||
|
L('### 核心特性')
|
||||||
|
L('- MAC 地址: 从节点 40-bit 地址确定性生成')
|
||||||
|
L('- MAC 学习表: 自动学习端口-MAC 映射,超时清理')
|
||||||
|
L('- 广播泛洪: 未知目标 MAC 的数据包向所有节点广播')
|
||||||
|
L('- ARP 代答: 交换机代理 ARP 请求,减少广播')
|
||||||
|
L('- MTU: 2800 bytes (可配置)')
|
||||||
|
L('')
|
||||||
|
L('### MAC 地址生成')
|
||||||
|
L('Go 代码:')
|
||||||
|
L('func GenerateMAC(addr identity.Address) net.HardwareAddr {')
|
||||||
|
L(' mac := make(net.HardwareAddr, 6)')
|
||||||
|
L(' mac[0] = 0x02 // locally administered')
|
||||||
|
L(' copy(mac[2:], addr[:4])')
|
||||||
|
L(' return mac')
|
||||||
|
L('}')
|
||||||
|
L('')
|
||||||
|
sep()
|
||||||
|
L('')
|
||||||
|
L('## 客户端开发指南')
|
||||||
|
L('')
|
||||||
|
L('### 第一步:准备身份')
|
||||||
|
L('每个节点需要唯一的 Ed25519 密钥对。40-bit 地址由公钥哈希的前 5 字节决定。')
|
||||||
|
L('Go: id := identity.Generate()')
|
||||||
|
L('- id.Address -> "b412579ffd" (10 hex chars)')
|
||||||
|
L('- id.PublicKey -> [32]byte')
|
||||||
|
L('- id.PrivateKey -> [64]byte')
|
||||||
|
L('')
|
||||||
|
L('### 第二步:通过 API 注册')
|
||||||
|
L('POST /api/v1/node/register')
|
||||||
|
L('Body: { node_id, public_key, name, port }')
|
||||||
|
L('')
|
||||||
|
L('### 第三步:建立 UDP 连接')
|
||||||
|
L('1. 绑定 UDP 端口')
|
||||||
|
L('2. 向控制器发送本机地址 + 端口')
|
||||||
|
L('3. GET /api/v1/node/online 获取在线节点列表')
|
||||||
|
L('4. 向每个在线节点发送 VL1 Handshake 包')
|
||||||
|
L('5. 接收 Handshake 回复,建立 Noise 加密会话')
|
||||||
|
L('6. 开始发送/接收加密 Data 包')
|
||||||
|
L('')
|
||||||
|
L('### 第四步:处理 VL2 虚拟交换')
|
||||||
|
L('在加密隧道之上模拟以太网交换:MAC 地址学习、广播泛洪、ARP 代答。')
|
||||||
|
L('')
|
||||||
|
L('### 加密注意事项')
|
||||||
|
L('- Nonce 计数器从 0 开始,每次加密/解密后递增')
|
||||||
|
L('- 发送和接收使用独立的密钥和计数器')
|
||||||
|
L('- PSK 通过控制器下发,用于派生会话密钥')
|
||||||
|
L('- sendKey = SHA3-256(PSK || localPub || remotePub)[:32]')
|
||||||
|
L('- recvKey = SHA3-256(sendKey || "reverse")[:32]')
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAllContent() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(buildAllContent())
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'arch', label: '架构' },
|
||||||
|
{ id: 'api', label: 'API 参考' },
|
||||||
|
{ id: 'vl1', label: 'VL1 协议' },
|
||||||
|
{ id: 'vl2', label: 'VL2 交换' },
|
||||||
|
{ id: 'client', label: '客户端开发' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const authEndpoints = [
|
||||||
|
{ method: 'GET', path: '/api/v1/admin/check', desc: '检查管理员身份' },
|
||||||
|
{ method: 'POST', path: '/api/v1/auth/login', desc: '管理员登录 { username, password }' },
|
||||||
|
{ method: 'POST', path: '/api/v1/auth/register', desc: '管理员注册' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const nodeEndpoints = [
|
||||||
|
{ method: 'POST', path: '/api/v1/node/register', desc: '注册节点 { node_id, public_key, name?, port? }' },
|
||||||
|
{ method: 'GET', path: '/api/v1/node/list', desc: '获取所有节点列表' },
|
||||||
|
{ method: 'GET', path: '/api/v1/node/online', desc: '获取在线节点列表' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const networkEndpoints = [
|
||||||
|
{ method: 'POST', path: '/api/v1/network/create', desc: '创建虚拟网络 { name, ip_range }' },
|
||||||
|
{ method: 'GET', path: '/api/v1/network/list', desc: '获取所有网络' },
|
||||||
|
{ method: 'GET', path: '/api/v1/network/:id', desc: '获取网络详情' },
|
||||||
|
{ method: 'DELETE', path: '/api/v1/network/:id', desc: '删除网络' },
|
||||||
|
{ method: 'GET', path: '/api/v1/network/:id/members', desc: '获取网络成员' },
|
||||||
|
{ method: 'POST', path: '/api/v1/network/:id/authorize', desc: '授权节点加入网络 { node_id }' },
|
||||||
|
{ method: 'POST', path: '/api/v1/network/:id/deauthorize', desc: '移除节点授权 { node_id }' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const dashEndpoints = [
|
||||||
|
{ method: 'GET', path: '/api/v1/admin/dashboard', desc: '仪表盘统计数据' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const registerExample = `# 登录获取 token
|
||||||
|
TOKEN=$(curl -s http://127.0.0.1:10001/api/v1/auth/login \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"username":"admin","password":"zeromesh123"}' | jq -r '.token')
|
||||||
|
|
||||||
|
# 注册节点
|
||||||
|
curl -X POST http://127.0.0.1:10001/api/v1/node/register \\
|
||||||
|
-H "Authorization: Bearer $TOKEN" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"node_id": "b412579ffd",
|
||||||
|
"public_key": "abc123...",
|
||||||
|
"name": "server-01",
|
||||||
|
"ip_address": "10.147.1.2",
|
||||||
|
"port": 9993,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 获取节点列表
|
||||||
|
curl http://127.0.0.1:10001/api/v1/node/list \\
|
||||||
|
-H "Authorization: Bearer $TOKEN"`
|
||||||
|
|
||||||
|
const keyDerivationExample = `sendKey = SHA3-256(PSK || localPub || remotePub)[:32]
|
||||||
|
recvKey = SHA3-256(sendKey || "reverse")[:32]
|
||||||
|
|
||||||
|
// Encrypt(nonce=0) → 密文 + Poly1305 tag
|
||||||
|
nonce = [0,0,0,0, counter big-endian]
|
||||||
|
ciphertext = ChaCha20-Poly1305_Encrypt(sendKey, nonce, plaintext)
|
||||||
|
counter++
|
||||||
|
|
||||||
|
// Decrypt(nonce=0) → 明文
|
||||||
|
plaintext = ChaCha20-Poly1305_Decrypt(recvKey, nonce, ciphertext)
|
||||||
|
counter++`
|
||||||
|
|
||||||
|
async function copyCode(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.docs { animation: fadeIn 0.4s ease-out; }
|
||||||
|
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||||
|
.page-title { font-size: 22px; font-weight: 700; }
|
||||||
|
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs { display: flex; gap: 0; background: var(--bg-card); border-radius: 12px; border: 1px solid var(--border); padding: 4px; margin-bottom: 24px; backdrop-filter: blur(12px); overflow-x: auto; }
|
||||||
|
.tab {
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
padding: 9px 20px; border: none; border-radius: 9px;
|
||||||
|
background: transparent; color: var(--text-muted); font-size: 13px; font-weight: 500; cursor: pointer;
|
||||||
|
transition: all 0.2s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); background: rgba(99, 102, 241, 0.04); }
|
||||||
|
.tab.active { color: var(--primary-light); background: rgba(99, 102, 241, 0.1); }
|
||||||
|
.tab-indicator { width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0; transition: opacity 0.2s; }
|
||||||
|
.tab.active .tab-indicator { opacity: 1; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.btn-copy-all {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 16px; border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
background: var(--bg-card); color: var(--text-muted); font-size: 13px; font-weight: 500; cursor: pointer;
|
||||||
|
transition: all 0.25s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-copy-all:hover { border-color: var(--primary); color: var(--primary-light); background: rgba(99, 102, 241, 0.06); box-shadow: 0 0 12px rgba(99, 102, 241, 0.15); }
|
||||||
|
|
||||||
|
.doc-section { animation: fadeIn 0.3s ease-out; }
|
||||||
|
.section-card {
|
||||||
|
position: relative; background: var(--bg-card); border-radius: 14px;
|
||||||
|
border: 1px solid var(--border); padding: 28px; margin-bottom: 20px;
|
||||||
|
backdrop-filter: blur(12px); overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-shine { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, var(--primary-light), var(--accent), transparent); }
|
||||||
|
|
||||||
|
.section-card h3 { font-size: 18px; font-weight: 700; margin-bottom: 12px; }
|
||||||
|
.section-card h4 { font-size: 14px; font-weight: 600; color: var(--primary-light); margin: 20px 0 10px; }
|
||||||
|
.section-card h4:first-of-type { margin-top: 0; }
|
||||||
|
.lead-text { font-size: 14px; color: var(--text-muted); line-height: 1.6; margin-bottom: 20px; }
|
||||||
|
.lead-text code { background: var(--bg-input); padding: 1px 6px; border-radius: 4px; font-size: 13px; color: var(--accent); }
|
||||||
|
|
||||||
|
p { font-size: 13px; color: var(--text-muted); line-height: 1.7; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* Architecture Grid */
|
||||||
|
.arch-grid { display: flex; align-items: stretch; gap: 0; margin: 24px 0; }
|
||||||
|
.arch-card {
|
||||||
|
flex: 1; padding: 24px; border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; background: rgba(0,0,0,0.15);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.arch-card:hover { border-color: var(--border-glow); box-shadow: var(--glow-primary); }
|
||||||
|
.arch-card h4 { margin: 12px 0 4px; font-size: 14px; font-weight: 600; }
|
||||||
|
.arch-card ul { list-style: none; padding: 0; margin: 8px 0 0; }
|
||||||
|
.arch-card li { padding: 3px 0; font-size: 12px; color: var(--text-muted); }
|
||||||
|
.arch-card li::before { content: '›'; margin-right: 6px; color: var(--primary); font-weight: bold; }
|
||||||
|
.arch-port { font-family: monospace; font-size: 12px; color: var(--accent); background: rgba(6, 182, 212, 0.08); padding: 2px 8px; border-radius: 5px; display: inline-block; margin-top: 4px; }
|
||||||
|
|
||||||
|
.arch-icon { width: 40px; height: 40px; border-radius: 10px; background: rgba(99, 102, 241, 0.1); display: flex; align-items: center; justify-content: center; color: var(--primary-light); }
|
||||||
|
.arch-icon.accent { background: rgba(6, 182, 212, 0.1); color: var(--accent); }
|
||||||
|
.arch-icon svg { width: 22px; height: 22px; }
|
||||||
|
|
||||||
|
.arch-connector { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0 12px; color: var(--text-dim); }
|
||||||
|
.connector-line { width: 1px; height: 20px; background: var(--border); }
|
||||||
|
.connector-arrow svg { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
/* Flow diagram */
|
||||||
|
.flow-diagram { margin: 24px 0 0; padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: rgba(0,0,0,0.1); }
|
||||||
|
.flow-title { font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 16px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.flow-steps { display: flex; align-items: center; gap: 0; flex-wrap: wrap; }
|
||||||
|
.flow-step { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border: 1px solid var(--border); border-radius: 10px; background: rgba(99, 102, 241, 0.04); font-size: 13px; color: var(--text); transition: all 0.2s; }
|
||||||
|
.flow-step:hover { border-color: var(--primary); background: rgba(99, 102, 241, 0.08); }
|
||||||
|
.step-num { width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, var(--primary), var(--accent)); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.flow-chevron { padding: 0 8px; color: var(--text-dim); }
|
||||||
|
.flow-chevron svg { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
/* API Reference */
|
||||||
|
.api-group { margin-bottom: 16px; }
|
||||||
|
.group-title { font-size: 12px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
||||||
|
.api-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; }
|
||||||
|
.http-method { display: inline-block; padding: 2px 8px; border-radius: 5px; font-size: 11px; font-weight: 700; font-family: monospace; text-transform: uppercase; min-width: 50px; text-align: center; }
|
||||||
|
.http-method.get { background: rgba(34, 197, 94, 0.08); color: var(--success); }
|
||||||
|
.http-method.post { background: rgba(99, 102, 241, 0.08); color: var(--primary-light); }
|
||||||
|
.http-method.delete { background: rgba(239, 68, 68, 0.08); color: var(--danger); }
|
||||||
|
.api-path { font-family: monospace; font-size: 13px; color: var(--text); }
|
||||||
|
.api-desc { font-size: 12px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.code-block {
|
||||||
|
margin: 12px 0; border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
overflow: hidden; background: rgba(2, 6, 23, 0.6);
|
||||||
|
}
|
||||||
|
.code-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 14px; background: rgba(99, 102, 241, 0.04); border-bottom: 1px solid var(--border); }
|
||||||
|
.code-lang { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
|
||||||
|
.code-copy { background: none; border: 1px solid var(--border); color: var(--text-muted); padding: 3px 10px; border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 0.15s; }
|
||||||
|
.code-copy:hover { background: var(--bg-input); color: var(--text); }
|
||||||
|
.code-block pre { padding: 14px; font-size: 12px; line-height: 1.6; color: var(--text); overflow-x: auto; white-space: pre; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
||||||
|
|
||||||
|
/* Spec table */
|
||||||
|
.spec-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
|
||||||
|
.spec-table td, .spec-table th { padding: 8px 12px; border: 1px solid var(--border); text-align: left; }
|
||||||
|
.spec-table th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; background: rgba(0,0,0,0.15); }
|
||||||
|
.spec-table td { color: var(--text-muted); }
|
||||||
|
.spec-table.mono td { font-family: monospace; font-size: 12px; }
|
||||||
|
.spec-table.mono td:first-child, .spec-table.mono td:nth-child(2) { color: var(--accent); }
|
||||||
|
.spec-table.mono td:last-child { color: var(--text); }
|
||||||
|
|
||||||
|
/* Steps list */
|
||||||
|
.steps-list { margin: 12px 0; }
|
||||||
|
.step-item { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; font-size: 13px; color: var(--text-muted); line-height: 1.6; }
|
||||||
|
.step-bullet { width: 6px; height: 6px; border-radius: 50%; background: var(--primary); flex-shrink: 0; margin-top: 7px; box-shadow: 0 0 6px rgba(99, 102, 241, 0.3); }
|
||||||
|
.note-list { list-style: none; padding: 0; margin: 8px 0; }
|
||||||
|
.note-list li { padding: 5px 0; font-size: 13px; color: var(--text-muted); }
|
||||||
|
.note-list li::before { content: '›'; margin-right: 8px; color: var(--accent); font-weight: bold; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.arch-grid { flex-direction: column; }
|
||||||
|
.arch-connector { flex-direction: row; padding: 8px 0; }
|
||||||
|
.connector-line { width: 20px; height: 1px; }
|
||||||
|
.connector-arrow svg { transform: rotate(90deg); }
|
||||||
|
.flow-steps { flex-direction: column; align-items: flex-start; }
|
||||||
|
.flow-chevron { transform: rotate(90deg); padding: 4px 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
190
web/src/views/Init.vue
Normal file
190
web/src/views/Init.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div class="init-page">
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="bg-orb"></div>
|
||||||
|
|
||||||
|
<div class="init-card">
|
||||||
|
<div class="card-shine"></div>
|
||||||
|
<div class="card-badge">首次部署</div>
|
||||||
|
|
||||||
|
<svg class="init-icon" viewBox="0 0 48 48" width="48" height="48">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ig" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#6366f1" />
|
||||||
|
<stop offset="100%" stop-color="#06b6d4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M24 4L4 14v20l20 10 20-10V14L24 4z" fill="none" stroke="url(#ig)" stroke-width="2" />
|
||||||
|
<path d="M24 24L8 16v12l16 8 16-8V16L24 24z" fill="none" stroke="url(#ig)" stroke-width="1.5" opacity="0.5" />
|
||||||
|
<circle cx="24" cy="16" r="4" fill="url(#ig)" opacity="0.6" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h1>初始化管理员</h1>
|
||||||
|
<p class="desc">创建系统首个管理员账号,用于管理虚拟网络和节点</p>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="success" class="success-bar">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
<div>
|
||||||
|
<strong>管理员创建成功!</strong><br/>
|
||||||
|
<router-link to="/login">立即登录控制台 →</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="handleInit">
|
||||||
|
<div class="field">
|
||||||
|
<label>管理员用户名</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
<input v-model="username" type="text" placeholder="admin" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>密码</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
<input v-model="password" type="password" placeholder="设置密码" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-submit" :disabled="loading">
|
||||||
|
<span v-if="loading" class="spinner"></span>
|
||||||
|
<span v-else>创建管理员</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
已有账号?<router-link to="/login">去登录</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref('admin')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.checkAdmin()
|
||||||
|
if (res.data.admin_exists) router.push('/login')
|
||||||
|
} catch { /* allow */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleInit() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.initAdmin(username.value, password.value)
|
||||||
|
if (res.data.token) {
|
||||||
|
localStorage.setItem('token', res.data.token)
|
||||||
|
localStorage.setItem('username', res.data.user.username)
|
||||||
|
localStorage.setItem('role', 'admin')
|
||||||
|
router.push('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success.value = true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.error || '创建失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.init-page {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
min-height: 100vh; position: relative; overflow: hidden;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
}
|
||||||
|
.bg-grid {
|
||||||
|
position: fixed; inset: 0; pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(99, 102, 241, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(99, 102, 241, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
}
|
||||||
|
.bg-orb {
|
||||||
|
position: fixed; width: 400px; height: 400px;
|
||||||
|
top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%; pointer-events: none;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
filter: blur(120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-card {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 400px; padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(11, 17, 32, 0.7);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
box-shadow: var(--shadow-card), 0 0 60px rgba(99, 102, 241, 0.03);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-shine {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--primary-light), var(--accent), transparent);
|
||||||
|
}
|
||||||
|
.card-badge {
|
||||||
|
display: inline-block; padding: 3px 10px; border-radius: 20px;
|
||||||
|
font-size: 10px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase;
|
||||||
|
background: rgba(234, 179, 8, 0.1); color: var(--warning);
|
||||||
|
border: 1px solid rgba(234, 179, 8, 0.2); margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.init-icon { margin-bottom: 16px; }
|
||||||
|
h1 { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.desc { font-size: 13px; color: var(--text-muted); margin-bottom: 28px; line-height: 1.6; }
|
||||||
|
|
||||||
|
.error-bar {
|
||||||
|
padding: 10px 14px; border-radius: 10px;
|
||||||
|
background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5; font-size: 13px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.success-bar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 16px; border-radius: 10px;
|
||||||
|
background: rgba(34, 197, 94, 0.06); border: 1px solid rgba(34, 197, 94, 0.15);
|
||||||
|
color: #86efac; font-size: 14px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.success-bar a { color: var(--primary-light); text-decoration: none; font-weight: 500; }
|
||||||
|
.success-bar a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
|
||||||
|
.input-wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.input-icon { position: absolute; left: 12px; color: var(--text-dim); pointer-events: none; }
|
||||||
|
.input-wrap input {
|
||||||
|
width: 100%; padding: 10px 12px 10px 38px;
|
||||||
|
border-radius: 10px; border: 1px solid var(--border);
|
||||||
|
background: var(--bg-input); color: var(--text); font-size: 14px; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.input-wrap input:hover { border-color: var(--text-dim); }
|
||||||
|
.input-wrap input:focus {
|
||||||
|
outline: none; border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%; padding: 11px; margin-top: 4px;
|
||||||
|
border: none; border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
color: #fff; font-size: 14px; font-weight: 600;
|
||||||
|
cursor: pointer; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.btn-submit:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3); }
|
||||||
|
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
.footer { text-align: center; margin-top: 20px; font-size: 13px; color: var(--text-muted); }
|
||||||
|
.footer a { color: var(--primary-light); text-decoration: none; font-weight: 500; }
|
||||||
|
.footer a:hover { text-decoration: underline; }
|
||||||
|
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
276
web/src/views/Layout.vue
Normal file
276
web/src/views/Layout.vue
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<!-- Background Grid -->
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="logo">
|
||||||
|
<svg class="logo-icon" viewBox="0 0 32 32" width="28" height="28">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="lg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#6366f1" />
|
||||||
|
<stop offset="100%" stop-color="#06b6d4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="16" cy="16" r="14" fill="none" stroke="url(#lg)" stroke-width="2" opacity="0.4" />
|
||||||
|
<circle cx="16" cy="16" r="8" fill="none" stroke="url(#lg)" stroke-width="1.5" opacity="0.6" />
|
||||||
|
<circle cx="16" cy="16" r="3" fill="url(#lg)" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="8" stroke="#6366f1" stroke-width="1.5" opacity="0.6" />
|
||||||
|
<line x1="16" y1="24" x2="16" y2="30" stroke="#06b6d4" stroke-width="1.5" opacity="0.6" />
|
||||||
|
<line x1="2" y1="16" x2="8" y2="16" stroke="#6366f1" stroke-width="1.5" opacity="0.6" />
|
||||||
|
<line x1="24" y1="16" x2="30" y2="16" stroke="#06b6d4" stroke-width="1.5" opacity="0.6" />
|
||||||
|
</svg>
|
||||||
|
<div class="logo-text">
|
||||||
|
<span class="logo-name">ZeroMesh</span>
|
||||||
|
<span class="logo-sub">v1.0.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<span class="user-badge">{{ username }}</span>
|
||||||
|
<button class="btn-logout" @click="logout" title="退出登录">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="sidebar">
|
||||||
|
<!-- Admin nav -->
|
||||||
|
<template v-if="isAdmin">
|
||||||
|
<div class="nav-section-label">概览</div>
|
||||||
|
<router-link to="/dashboard" class="nav-item" active-class="active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
<span>仪表盘</span>
|
||||||
|
</router-link>
|
||||||
|
<div class="nav-section-label">网络</div>
|
||||||
|
<router-link to="/networks" class="nav-item" active-class="active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
<span>虚拟网络</span>
|
||||||
|
</router-link>
|
||||||
|
<div class="nav-section-label">节点</div>
|
||||||
|
<router-link to="/nodes" class="nav-item" active-class="active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" /><rect x="2" y="14" width="20" height="8" rx="2" ry="2" /><line x1="6" y1="6" x2="6.01" y2="6" /><line x1="6" y1="18" x2="6.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
<span>节点管理</span>
|
||||||
|
</router-link>
|
||||||
|
<div class="nav-section-label">文档</div>
|
||||||
|
<router-link to="/docs" class="nav-item" active-class="active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
<span>开发者文档</span>
|
||||||
|
</router-link>
|
||||||
|
<div class="nav-section-label">运维</div>
|
||||||
|
<router-link to="/logs" class="nav-item" active-class="active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9" /><path d="M16 4v16" /><path d="M8 4v16" /><path d="M4 4v16" /><line x1="4" y1="12" x2="8" y2="12" /><line x1="12" y1="12" x2="16" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span>请求日志</span>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- User nav -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="nav-section-label">概览</div>
|
||||||
|
<router-link to="/user/dashboard" class="nav-item" active-class="active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
<span>我的控制台</span>
|
||||||
|
</router-link>
|
||||||
|
<div class="nav-section-label">网络</div>
|
||||||
|
<router-link to="/user/networks" class="nav-item" active-class="active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
<span>我的网络</span>
|
||||||
|
</router-link>
|
||||||
|
<div class="nav-section-label">设备</div>
|
||||||
|
<router-link to="/user/nodes" class="nav-item" active-class="active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" /><rect x="2" y="14" width="20" height="8" rx="2" ry="2" /><line x1="6" y1="6" x2="6.01" y2="6" /><line x1="6" y1="18" x2="6.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
<span>我的设备</span>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="nav-spacer"></div>
|
||||||
|
<div class="nav-footer">
|
||||||
|
<div class="nav-footer-dot"></div>
|
||||||
|
<div class="nav-footer-text">System Online</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="content">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="page" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const username = ref(localStorage.getItem('username') || 'Admin')
|
||||||
|
|
||||||
|
const isAdmin = computed(() => {
|
||||||
|
return localStorage.getItem('role') === 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('username')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout { display: flex; flex-direction: column; min-height: 100vh; position: relative; }
|
||||||
|
|
||||||
|
/* Background grid */
|
||||||
|
.bg-grid {
|
||||||
|
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
animation: grid-scroll 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
position: relative; z-index: 10;
|
||||||
|
background: rgba(2, 6, 23, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.header::after {
|
||||||
|
content: ''; position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--border-glow), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left { display: flex; align-items: center; gap: 32px; }
|
||||||
|
|
||||||
|
.logo { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.logo-icon { flex-shrink: 0; }
|
||||||
|
.logo-text { display: flex; flex-direction: column; }
|
||||||
|
.logo-name { font-size: 16px; font-weight: 700; background: linear-gradient(135deg, var(--primary-light), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||||
|
.logo-sub { font-size: 10px; color: var(--text-dim); letter-spacing: 1px; text-transform: uppercase; margin-top: -1px; }
|
||||||
|
|
||||||
|
.header-right { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success-glow); animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
|
.user-badge { font-size: 13px; color: var(--text-muted); padding: 4px 12px; border-radius: 20px; border: 1px solid var(--border); background: rgba(99, 102, 241, 0.05); }
|
||||||
|
.btn-logout { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 6px; border-radius: 8px; display: flex; align-items: center; transition: all 0.2s; }
|
||||||
|
.btn-logout:hover { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.body { display: flex; flex: 1; position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: rgba(2, 6, 23, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.nav-section-label {
|
||||||
|
padding: 8px 20px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.nav-section-label:not(:first-child) { margin-top: 12px; }
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin: 1px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(99, 102, 241, 0.06);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--primary-light);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
background: linear-gradient(180deg, var(--primary), var(--accent));
|
||||||
|
box-shadow: 0 0 10px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
.nav-icon { flex-shrink: 0; opacity: 0.7; }
|
||||||
|
.nav-item:hover .nav-icon { opacity: 1; }
|
||||||
|
.nav-item.active .nav-icon { opacity: 1; color: var(--primary-light); }
|
||||||
|
|
||||||
|
.nav-spacer { flex: 1; }
|
||||||
|
.nav-footer { padding: 12px 20px; display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--border); margin: 0 8px; }
|
||||||
|
.nav-footer-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 6px var(--success-glow); }
|
||||||
|
.nav-footer-text { font-size: 11px; color: var(--text-dim); letter-spacing: 0.5px; }
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28px 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page transitions */
|
||||||
|
.page-enter-active, .page-leave-active { transition: all 0.2s ease; }
|
||||||
|
.page-enter-from { opacity: 0; transform: translateY(8px); }
|
||||||
|
.page-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||||
|
</style>
|
||||||
262
web/src/views/Login.vue
Normal file
262
web/src/views/Login.vue
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="bg-orb bg-orb-1"></div>
|
||||||
|
<div class="bg-orb bg-orb-2"></div>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-left">
|
||||||
|
<div class="hero">
|
||||||
|
<svg class="hero-icon" viewBox="0 0 80 80" width="80" height="80">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="hg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#6366f1" />
|
||||||
|
<stop offset="100%" stop-color="#06b6d4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="40" cy="40" r="36" fill="none" stroke="url(#hg)" stroke-width="2" opacity="0.15" />
|
||||||
|
<circle cx="40" cy="40" r="26" fill="none" stroke="url(#hg)" stroke-width="1.5" opacity="0.3" />
|
||||||
|
<circle cx="40" cy="40" r="10" fill="url(#hg)" opacity="0.8" />
|
||||||
|
<circle cx="40" cy="40" r="4" fill="#fff" opacity="0.6" />
|
||||||
|
<!-- Connection lines -->
|
||||||
|
<g stroke="url(#hg)" stroke-width="1" opacity="0.4">
|
||||||
|
<line x1="40" y1="4" x2="40" y2="14" />
|
||||||
|
<line x1="40" y1="66" x2="40" y2="76" />
|
||||||
|
<line x1="4" y1="40" x2="14" y2="40" />
|
||||||
|
<line x1="66" y1="40" x2="76" y2="40" />
|
||||||
|
<line x1="14.6" y1="14.6" x2="21.7" y2="21.7" />
|
||||||
|
<line x1="58.3" y1="58.3" x2="65.4" y2="65.4" />
|
||||||
|
<line x1="65.4" y1="14.6" x2="58.3" y2="21.7" />
|
||||||
|
<line x1="21.7" y1="58.3" x2="14.6" y2="65.4" />
|
||||||
|
</g>
|
||||||
|
<circle cx="40" cy="4" r="2.5" fill="#6366f1" opacity="0.6" />
|
||||||
|
<circle cx="40" cy="76" r="2.5" fill="#06b6d4" opacity="0.6" />
|
||||||
|
<circle cx="4" cy="40" r="2.5" fill="#6366f1" opacity="0.6" />
|
||||||
|
<circle cx="76" cy="40" r="2.5" fill="#06b6d4" opacity="0.6" />
|
||||||
|
</svg>
|
||||||
|
<h1 class="hero-title">ZeroMesh</h1>
|
||||||
|
<p class="hero-desc">P2P 网状网络控制器<br/>安全 · 高速 · 零配置</p>
|
||||||
|
<div class="hero-features">
|
||||||
|
<div class="hero-feature">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
端到端加密
|
||||||
|
</div>
|
||||||
|
<div class="hero-feature">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
NAT 穿透
|
||||||
|
</div>
|
||||||
|
<div class="hero-feature">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
虚拟交换机
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-right">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>登录</h2>
|
||||||
|
<p>管理控制台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-bar">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="field">
|
||||||
|
<label>用户名</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
<input v-model="username" type="text" placeholder="admin" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>密码</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
<input v-model="password" type="password" placeholder="········" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-submit" :disabled="loading">
|
||||||
|
<span v-if="loading" class="spinner"></span>
|
||||||
|
<span v-else>进入控制台</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
首次使用?<router-link to="/init">初始化管理员</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (localStorage.getItem('token')) {
|
||||||
|
router.push('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.checkAdmin()
|
||||||
|
if (!res.data.admin_exists) {
|
||||||
|
router.push('/init')
|
||||||
|
}
|
||||||
|
} catch { /* allow */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.login(username.value, password.value)
|
||||||
|
localStorage.setItem('token', res.data.token)
|
||||||
|
localStorage.setItem('username', res.data.user.username)
|
||||||
|
localStorage.setItem('role', res.data.user.role || 'user')
|
||||||
|
router.push(res.data.user.role === 'admin' ? '/dashboard' : '/user/dashboard')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.error || '认证失败,请检查凭证'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
min-height: 100vh; position: relative; overflow: hidden;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-grid {
|
||||||
|
position: fixed; inset: 0; pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(99, 102, 241, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(99, 102, 241, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-orb {
|
||||||
|
position: fixed; border-radius: 50%; pointer-events: none;
|
||||||
|
filter: blur(100px); opacity: 0.3;
|
||||||
|
}
|
||||||
|
.bg-orb-1 { width: 500px; height: 500px; top: -150px; right: -100px; background: rgba(99, 102, 241, 0.4); }
|
||||||
|
.bg-orb-2 { width: 400px; height: 400px; bottom: -100px; left: -100px; background: rgba(6, 182, 212, 0.3); }
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
display: flex; position: relative; z-index: 1;
|
||||||
|
width: 860px; max-width: 95vw; min-height: 520px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(11, 17, 32, 0.7);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
box-shadow: var(--shadow-card), 0 0 80px rgba(99, 102, 241, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left hero */
|
||||||
|
.login-left {
|
||||||
|
width: 380px; padding: 48px 36px;
|
||||||
|
background: rgba(99, 102, 241, 0.03);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.hero { text-align: center; }
|
||||||
|
.hero-icon { margin-bottom: 20px; }
|
||||||
|
.hero-title {
|
||||||
|
font-size: 28px; font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--primary-light), var(--accent));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.hero-desc { font-size: 13px; color: var(--text-muted); line-height: 1.7; margin-bottom: 20px; }
|
||||||
|
.hero-features { display: flex; flex-direction: column; gap: 8px; align-items: center; }
|
||||||
|
.hero-feature { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Right form */
|
||||||
|
.login-right {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px;
|
||||||
|
}
|
||||||
|
.login-card { width: 100%; max-width: 340px; }
|
||||||
|
.card-header { margin-bottom: 24px; }
|
||||||
|
.card-header h2 { font-size: 20px; font-weight: 700; }
|
||||||
|
.card-header p { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.error-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 14px; border-radius: 10px;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5; font-size: 13px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; letter-spacing: 0.3px; }
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.input-icon {
|
||||||
|
position: absolute; left: 12px; color: var(--text-dim); pointer-events: none;
|
||||||
|
}
|
||||||
|
.input-wrap input {
|
||||||
|
width: 100%; padding: 10px 12px 10px 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.input-wrap input:hover { border-color: var(--text-dim); }
|
||||||
|
.input-wrap input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
.input-wrap input::placeholder { color: var(--text-dim); }
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%; padding: 11px; margin-top: 4px;
|
||||||
|
border: none; border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
color: #fff; font-size: 14px; font-weight: 600;
|
||||||
|
cursor: pointer; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.btn-submit:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
.btn-submit:active { transform: translateY(0); }
|
||||||
|
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
|
||||||
|
.card-footer { text-align: center; margin-top: 20px; font-size: 13px; color: var(--text-muted); }
|
||||||
|
.card-footer a { color: var(--primary-light); text-decoration: none; font-weight: 500; }
|
||||||
|
.card-footer a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.login-left { display: none; }
|
||||||
|
.login-container { width: 400px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
408
web/src/views/Logs.vue
Normal file
408
web/src/views/Logs.vue
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
<template>
|
||||||
|
<div class="logs fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">请求日志</h2>
|
||||||
|
<p class="page-subtitle">API 调用记录与错误日志,用于线上排障</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-secondary" @click="copyPage">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
复制本页
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" @click="clearLogs">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
清空日志
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Banner -->
|
||||||
|
<div v-if="errorMsg" class="error-banner">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
<span>{{ errorMsg }}</span>
|
||||||
|
<button class="error-dismiss" @click="errorMsg = ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button :class="['tab-btn', { active: tab === 'api' }]" @click="tab = 'api'">API 日志</button>
|
||||||
|
<button :class="['tab-btn', { active: tab === 'error' }]" @click="tab = 'error'">错误日志 (≥400)</button>
|
||||||
|
<button :class="['tab-btn', { active: tab === 'grouped' }]" @click="tab = 'grouped'">聚合统计</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div v-if="tab !== 'grouped'" class="filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>IP</label>
|
||||||
|
<input v-model="filter.ip" placeholder="来源 IP" @input="debounceSearch" />
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>路径</label>
|
||||||
|
<input v-model="filter.path" placeholder="API 路径" @input="debounceSearch" />
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>方法</label>
|
||||||
|
<select v-model="filter.method" @change="search">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="GET">GET</option>
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
<option value="PUT">PUT</option>
|
||||||
|
<option value="DELETE">DELETE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>状态码</label>
|
||||||
|
<input v-model.number="filter.statusCode" placeholder="如 404" @input="debounceSearch" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="loading-ring"></div>
|
||||||
|
<span>加载日志...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grouped view -->
|
||||||
|
<div v-else-if="tab === 'grouped'" class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP 地址</th>
|
||||||
|
<th>方法</th>
|
||||||
|
<th>路径</th>
|
||||||
|
<th>调用次数</th>
|
||||||
|
<th>总延迟 (ms)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in groupedData" :key="row.ip + row.method + row.path">
|
||||||
|
<td><code>{{ row.ip }}</code></td>
|
||||||
|
<td><span class="method-badge" :class="row.method">{{ row.method }}</span></td>
|
||||||
|
<td><code>{{ row.path }}</code></td>
|
||||||
|
<td>{{ row.count }}</td>
|
||||||
|
<td>{{ row.total_latency }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!groupedData.length">
|
||||||
|
<td colspan="5" class="empty-cell">暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table: API / Error logs -->
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="!currentData.length && !loading" class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
|
||||||
|
<h3>暂无日志</h3>
|
||||||
|
<p>发送 API 请求后会在这里显示记录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>方法</th>
|
||||||
|
<th>路径</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>延迟</th>
|
||||||
|
<th v-if="tab === 'error'">请求体</th>
|
||||||
|
<th v-if="tab === 'error'">响应体</th>
|
||||||
|
<th>UA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in currentData" :key="row.id">
|
||||||
|
<td class="cell-nowrap">{{ formatTime(row.created_at) }}</td>
|
||||||
|
<td><code>{{ row.ip }}</code></td>
|
||||||
|
<td><span class="method-badge" :class="row.method">{{ row.method }}</span></td>
|
||||||
|
<td><code class="cell-path">{{ row.path }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span :class="['status-badge', row.status_code >= 400 ? 'error' : 'ok']">{{ row.status_code }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-num">{{ row.latency_ms }}ms</td>
|
||||||
|
<td v-if="tab === 'error'" class="cell-preview">{{ truncate(row.request_body, 60) }}</td>
|
||||||
|
<td v-if="tab === 'error'" class="cell-preview">{{ truncate(row.response_body, 60) }}</td>
|
||||||
|
<td class="cell-nowrap cell-ua">{{ row.user_agent || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="total > 0" class="pagination">
|
||||||
|
<span class="page-info">共 {{ total }} 条,第 {{ page }} / {{ pageTotal }} 页</span>
|
||||||
|
<div class="page-actions">
|
||||||
|
<button :disabled="page <= 1" @click="goPage(1)">首页</button>
|
||||||
|
<button :disabled="page <= 1" @click="goPage(page - 1)">上一页</button>
|
||||||
|
<span class="page-num">{{ page }}</span>
|
||||||
|
<button :disabled="page >= pageTotal" @click="goPage(page + 1)">下一页</button>
|
||||||
|
<button :disabled="page >= pageTotal" @click="goPage(pageTotal)">末页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
const API = '/api/v1/log'
|
||||||
|
const token = () => localStorage.getItem('token')
|
||||||
|
const headers = { 'Authorization': `Bearer ${token()}` }
|
||||||
|
|
||||||
|
const tab = ref('api')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const filter = ref({ ip: '', path: '', method: '', statusCode: '' })
|
||||||
|
let debounceTimer = null
|
||||||
|
|
||||||
|
const apiLogs = ref([])
|
||||||
|
const errorLogs = ref([])
|
||||||
|
const groupedData = ref([])
|
||||||
|
|
||||||
|
const pageTotal = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
|
const currentData = computed(() => tab.value === 'api' ? apiLogs.value : errorLogs.value)
|
||||||
|
|
||||||
|
watch(tab, () => { page.value = 1; search() })
|
||||||
|
|
||||||
|
function debounceSearch() {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(search, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
if (tab.value === 'grouped') {
|
||||||
|
const r = await fetch(`${API}/grouped`, { headers })
|
||||||
|
const d = await r.json()
|
||||||
|
if (!r.ok) throw new Error(d.error || '加载失败')
|
||||||
|
groupedData.value = d.data || []
|
||||||
|
} else {
|
||||||
|
const ep = tab.value === 'api' ? 'api' : 'error'
|
||||||
|
const params = new URLSearchParams({ page: page.value, page_size: pageSize })
|
||||||
|
if (filter.value.ip) params.set('ip', filter.value.ip)
|
||||||
|
if (filter.value.path) params.set('path', filter.value.path)
|
||||||
|
if (filter.value.method) params.set('method', filter.value.method)
|
||||||
|
if (filter.value.statusCode) params.set('status_code', filter.value.statusCode)
|
||||||
|
const r = await fetch(`${API}/${ep}?${params}`, { headers })
|
||||||
|
const d = await r.json()
|
||||||
|
if (!r.ok) throw new Error(d.error || '加载失败')
|
||||||
|
if (tab.value === 'api') apiLogs.value = d.data || []
|
||||||
|
else errorLogs.value = d.data || []
|
||||||
|
total.value = d.total || 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(n) {
|
||||||
|
page.value = n
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLogs() {
|
||||||
|
if (!confirm('确认清空所有日志?此操作不可恢复。')) return
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
const ep = tab.value === 'api' || tab.value === 'grouped' ? 'api' : 'error'
|
||||||
|
const r = await fetch(`${API}/${ep}`, { method: 'DELETE', headers })
|
||||||
|
if (!r.ok) {
|
||||||
|
const d = await r.json()
|
||||||
|
throw new Error(d.error || '清空失败')
|
||||||
|
}
|
||||||
|
apiLogs.value = []
|
||||||
|
errorLogs.value = []
|
||||||
|
groupedData.value = []
|
||||||
|
total.value = 0
|
||||||
|
page.value = 1
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPage() {
|
||||||
|
const rows = tab.value === 'grouped' ? groupedData.value : currentData.value
|
||||||
|
if (!rows.length) return
|
||||||
|
const lines = []
|
||||||
|
if (tab.value === 'grouped') {
|
||||||
|
lines.push('IP\t方法\t路径\t调用次数\t总延迟(ms)')
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push(`${r.ip}\t${r.method}\t${r.path}\t${r.count}\t${r.total_latency}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isErr = tab.value === 'error'
|
||||||
|
const head = isErr
|
||||||
|
? '时间\tIP\t方法\t路径\t状态\t延迟\t请求体\t响应体\tUA'
|
||||||
|
: '时间\tIP\t方法\t路径\t状态\t延迟\tUA'
|
||||||
|
lines.push(head)
|
||||||
|
for (const r of rows) {
|
||||||
|
const t = formatTime(r.created_at)
|
||||||
|
const ua = (r.user_agent || '-')
|
||||||
|
if (isErr) {
|
||||||
|
lines.push(`${t}\t${r.ip}\t${r.method}\t${r.path}\t${r.status_code}\t${r.latency_ms}ms\t${truncate(r.request_body, 60)}\t${truncate(r.response_body, 60)}\t${ua}`)
|
||||||
|
} else {
|
||||||
|
lines.push(`${t}\t${r.ip}\t${r.method}\t${r.path}\t${r.status_code}\t${r.latency_ms}ms\t${ua}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(lines.join('\n'))
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = lines.join('\n')
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t) {
|
||||||
|
if (!t) return '-'
|
||||||
|
const d = new Date(t)
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s, n) {
|
||||||
|
if (!s) return ''
|
||||||
|
return s.length > n ? s.slice(0, n) + '...' : s
|
||||||
|
}
|
||||||
|
|
||||||
|
search()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logs { padding-bottom: 40px; }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
margin-bottom: 28px; gap: 16px;
|
||||||
|
}
|
||||||
|
.header-actions { display: flex; gap: 10px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 4px; margin-bottom: 20px;
|
||||||
|
background: rgba(255,255,255,0.03); border-radius: 8px; padding: 3px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1; padding: 8px 16px; border: none; border-radius: 6px;
|
||||||
|
background: transparent; color: var(--text-dim); cursor: pointer;
|
||||||
|
font-size: 0.85rem; font-weight: 500; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(6,182,212,0.2));
|
||||||
|
color: #e2e8f0; box-shadow: 0 0 12px rgba(99,102,241,0.15);
|
||||||
|
}
|
||||||
|
.tab-btn:hover:not(.active) { color: var(--text); }
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filter-group {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.filter-group input, .filter-group select {
|
||||||
|
padding: 6px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
background: rgba(0,0,0,0.3); color: var(--text); font-size: 0.8rem;
|
||||||
|
outline: none; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.filter-group input:focus, .filter-group select:focus {
|
||||||
|
border-color: rgba(99,102,241,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container { overflow-x: auto; background: var(--card-bg); border-radius: 10px; border: 1px solid var(--card-border); }
|
||||||
|
table { width: 100%; border-collapse: collapse; white-space: nowrap; }
|
||||||
|
th {
|
||||||
|
text-align: left; padding: 10px 14px; font-size: 0.72rem; font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim); background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
td { padding: 8px 14px; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.03); }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(99,102,241,0.03); }
|
||||||
|
|
||||||
|
code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.78rem; color: #a5b4fc; }
|
||||||
|
.cell-path { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cell-num { font-family: 'JetBrains Mono', monospace; text-align: right; color: var(--text-dim); }
|
||||||
|
.cell-nowrap { overflow: hidden; text-overflow: ellipsis; max-width: 160px; }
|
||||||
|
.cell-ua { max-width: 140px; color: var(--text-dim); }
|
||||||
|
.cell-preview { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--text-dim); font-size: 0.75rem; }
|
||||||
|
.empty-cell { text-align: center; padding: 32px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.method-badge {
|
||||||
|
display: inline-block; padding: 1px 8px; border-radius: 4px;
|
||||||
|
font-size: 0.72rem; font-weight: 600; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.method-badge.GET { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||||
|
.method-badge.POST { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||||
|
.method-badge.PUT { background: rgba(234,179,8,0.15); color: #eab308; }
|
||||||
|
.method-badge.DELETE { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block; padding: 1px 8px; border-radius: 4px;
|
||||||
|
font-size: 0.72rem; font-weight: 600; font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.status-badge.ok { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||||
|
.status-badge.error { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-top: 16px; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.page-info { font-size: 0.78rem; color: var(--text-dim); }
|
||||||
|
.page-actions { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.page-actions button {
|
||||||
|
padding: 5px 12px; border: 1px solid rgba(255,255,255,0.08); border-radius: 6px;
|
||||||
|
background: rgba(0,0,0,0.3); color: var(--text); cursor: pointer;
|
||||||
|
font-size: 0.78rem; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.page-actions button:hover:not(:disabled) { border-color: rgba(99,102,241,0.4); }
|
||||||
|
.page-actions button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.page-num {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 28px; height: 28px; padding: 0 6px;
|
||||||
|
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(6,182,212,0.2));
|
||||||
|
border-radius: 6px; font-size: 0.78rem; font-weight: 600; color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 14px; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.04); color: var(--text);
|
||||||
|
cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { border-color: rgba(99,102,241,0.3); background: rgba(99,102,241,0.08); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 14px; border: 1px solid rgba(239,68,68,0.3); border-radius: 8px;
|
||||||
|
background: rgba(239,68,68,0.1); color: #ef4444;
|
||||||
|
cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(239,68,68,0.2); }
|
||||||
|
</style>
|
||||||
141
web/src/views/NetworkCreate.vue
Normal file
141
web/src/views/NetworkCreate.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="create-net fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">创建虚拟网络</h2>
|
||||||
|
<p class="page-subtitle">配置一个新的 P2P 私有网络</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="error-banner">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
<span>{{ errorMsg }}</span>
|
||||||
|
<button class="error-dismiss" @click="errorMsg = ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="form-section">
|
||||||
|
<label class="form-label">网络名称</label>
|
||||||
|
<input v-model="name" placeholder="例如:office-vpn" class="form-input" />
|
||||||
|
<span class="form-hint">给你的网络起个名字</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label class="form-label">网段 (CIDR)</label>
|
||||||
|
<div class="cidr-row">
|
||||||
|
<input v-model="ipRange" placeholder="留空自动分配" class="form-input mono" />
|
||||||
|
<button class="btn-ghost" @click="randomize">随机</button>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">私有网段如 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16。留空则自动随机生成。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<router-link :to="backLink" class="btn-ghost">取消</router-link>
|
||||||
|
<button class="btn-primary" @click="create" :disabled="submitting">
|
||||||
|
{{ submitting ? '创建中...' : '创建网络' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const ipRange = ref('')
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const isUser = computed(() => route.path.startsWith('/user'))
|
||||||
|
const backLink = computed(() => isUser.value ? '/user/networks' : '/networks')
|
||||||
|
|
||||||
|
function randomize() {
|
||||||
|
const classes = [
|
||||||
|
() => `10.${rand(256)}.${rand(256)}.0/24`,
|
||||||
|
() => `172.${16 + rand(16)}.${rand(256)}.0/24`,
|
||||||
|
() => `192.168.${rand(256)}.0/24`,
|
||||||
|
]
|
||||||
|
ipRange.value = classes[rand(3)]()
|
||||||
|
}
|
||||||
|
function rand(n) { return Math.floor(Math.random() * n) }
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!name.value) { errorMsg.value = '请输入网络名称'; return }
|
||||||
|
submitting.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
const res = await api.networkCreate({ name: name.value, ip_range: ipRange.value || '' })
|
||||||
|
if (res.data.network) {
|
||||||
|
router.push(isUser.value ? `/user/networks/${res.data.network.network_id}` : `/networks`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.response?.data?.error || '创建失败'
|
||||||
|
}
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-net { animation: fadeIn 0.4s ease-out; max-width: 600px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.page-header { margin-bottom: 28px; }
|
||||||
|
.page-title { font-size: 22px; font-weight: 700; }
|
||||||
|
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border);
|
||||||
|
padding: 28px; backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section { margin-bottom: 24px; }
|
||||||
|
.form-label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
|
.form-input {
|
||||||
|
width: 100%; padding: 10px 14px; border-radius: 9px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg-input);
|
||||||
|
color: var(--text); font-size: 14px; transition: all 0.2s; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.form-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); }
|
||||||
|
.form-input.mono { font-family: 'JetBrains Mono', monospace; font-size: 13px; }
|
||||||
|
.form-hint { display: block; font-size: 11px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
|
||||||
|
.cidr-row { display: flex; gap: 8px; }
|
||||||
|
.cidr-row .form-input { flex: 1; }
|
||||||
|
|
||||||
|
.form-actions { display: flex; gap: 10px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 10px 22px; border: none; border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35); }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 9px 18px; border-radius: 10px; text-decoration: none;
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted); font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--bg-input); color: var(--text); }
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 14px; margin-bottom: 16px;
|
||||||
|
border-radius: 10px; border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
background: rgba(239, 68, 68, 0.06); color: var(--danger);
|
||||||
|
font-size: 13px; animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.error-dismiss { margin-left: auto; background: none; border: none; color: var(--danger); cursor: pointer; padding: 2px; border-radius: 4px; opacity: 0.6; }
|
||||||
|
.error-dismiss:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
306
web/src/views/Networks.vue
Normal file
306
web/src/views/Networks.vue
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
<template>
|
||||||
|
<div class="networks fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">虚拟网络</h2>
|
||||||
|
<p class="page-subtitle">管理 P2P 网络和成员授权</p>
|
||||||
|
</div>
|
||||||
|
<router-link to="/network/create" class="btn-primary">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
创建网络
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Banner -->
|
||||||
|
<div v-if="errorMsg" class="error-banner">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
<span>{{ errorMsg }}</span>
|
||||||
|
<button class="error-dismiss" @click="errorMsg = ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="loading-ring"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="networks.length === 0" class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
<h3>暂无虚拟网络</h3>
|
||||||
|
<p>点击右上角创建你的第一个 P2P 网络</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div v-else class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>网络 ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>IP 范围</th>
|
||||||
|
<th>成员</th>
|
||||||
|
<th>MTU</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="net in networks" :key="net.id" @click="viewNetwork(net)" class="table-row">
|
||||||
|
<td><code class="code-id">{{ net.network_id }}</code></td>
|
||||||
|
<td><span class="net-name">{{ net.name }}</span></td>
|
||||||
|
<td><span class="ip-badge">{{ net.ip_range || '-' }}</span></td>
|
||||||
|
<td><span class="count-badge">{{ (net.members || []).length }}</span></td>
|
||||||
|
<td>{{ net.mtu || 2800 }}</td>
|
||||||
|
<td><span class="status-pill status-active">运行中</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-icon" @click.stop="confirmDelete(net)" title="删除">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail Modal -->
|
||||||
|
<div v-if="selected" class="modal-overlay" @click.self="selected = null">
|
||||||
|
<div class="modal modal-wide" @click.stop>
|
||||||
|
<div class="modal-shine"></div>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary-light)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
{{ selected.name }}
|
||||||
|
</h2>
|
||||||
|
<div class="modal-meta">
|
||||||
|
ID: <code>{{ selected.network_id }}</code> · IP: {{ selected.ip_range || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<h3 class="section-title">成员列表 ({{ (members || []).length }})</h3>
|
||||||
|
<div v-if="(members || []).length === 0" class="empty-mini">暂无成员</div>
|
||||||
|
<table v-else class="mini-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>节点 ID</th><th>IP 地址</th><th>授权</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="m in members" :key="m.id">
|
||||||
|
<td><code class="code-id">{{ m.node_id }}</code></td>
|
||||||
|
<td>{{ m.ip_address || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="m.authorized ? 'badge badge-auth' : 'badge badge-pending'">
|
||||||
|
{{ m.authorized ? '已授权' : '待授权' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="!m.authorized" class="btn-sm btn-primary" @click="authorizeMember(m)">授权</button>
|
||||||
|
<button v-else class="btn-sm btn-ghost" @click="deauthorizeMember(m)">移除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-ghost" @click="selected = null">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirm -->
|
||||||
|
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
|
||||||
|
<div class="modal" @click.stop>
|
||||||
|
<div class="modal-shine"></div>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 style="color: var(--danger);">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
确认删除
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>确定要删除网络 <strong>{{ deleteTarget.name }}</strong> 吗?</p>
|
||||||
|
<p class="warning-text">此操作不可恢复,所有成员将被移出网络。</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-ghost" @click="deleteTarget = null">取消</button>
|
||||||
|
<button class="btn-danger" @click="deleteNetwork">确认删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const networks = ref([])
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorMsg.value = msg
|
||||||
|
setTimeout(() => { if (errorMsg.value === msg) errorMsg.value = '' }, 5000)
|
||||||
|
}
|
||||||
|
const selected = ref(null)
|
||||||
|
const members = ref([])
|
||||||
|
const deleteTarget = ref(null)
|
||||||
|
|
||||||
|
onMounted(fetchNetworks)
|
||||||
|
|
||||||
|
async function fetchNetworks() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.networkList()
|
||||||
|
networks.value = res.data.networks || []
|
||||||
|
} catch { networks.value = [] }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewNetwork(net) {
|
||||||
|
selected.value = net
|
||||||
|
try {
|
||||||
|
const res = await api.networkMembers(net.network_id)
|
||||||
|
members.value = res.data.members || []
|
||||||
|
} catch { members.value = [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizeMember(m) {
|
||||||
|
try {
|
||||||
|
await api.networkAuthorize(selected.value.network_id, m.node_id)
|
||||||
|
await viewNetwork(selected.value)
|
||||||
|
} catch (e) { showError(e.response?.data?.error || '授权失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deauthorizeMember(m) {
|
||||||
|
try {
|
||||||
|
await api.networkDeauthorize(selected.value.network_id, m.node_id)
|
||||||
|
await viewNetwork(selected.value)
|
||||||
|
} catch (e) { showError(e.response?.data?.error || '移除失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(net) { deleteTarget.value = net }
|
||||||
|
|
||||||
|
async function deleteNetwork() {
|
||||||
|
try {
|
||||||
|
await api.networkDelete(deleteTarget.value.network_id)
|
||||||
|
deleteTarget.value = null
|
||||||
|
await fetchNetworks()
|
||||||
|
} catch (e) { showError(e.response?.data?.error || '删除失败') }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.networks { animation: fadeIn 0.4s ease-out; }
|
||||||
|
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||||
|
.page-title { font-size: 22px; font-weight: 700; }
|
||||||
|
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.loading-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; color: var(--text-muted); }
|
||||||
|
.loading-ring { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; text-align: center; }
|
||||||
|
.empty-state h3 { font-size: 16px; font-weight: 600; margin-top: 8px; }
|
||||||
|
.empty-state p { font-size: 13px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-container { background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border); overflow: hidden; backdrop-filter: blur(12px); }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { padding: 12px 16px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; text-align: left; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.15); }
|
||||||
|
td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.table-row { cursor: pointer; transition: background 0.15s; }
|
||||||
|
.table-row:hover { background: rgba(99, 102, 241, 0.04); }
|
||||||
|
|
||||||
|
.code-id { background: var(--bg-input); padding: 2px 8px; border-radius: 5px; font-size: 12px; color: var(--primary-light); }
|
||||||
|
.net-name { font-weight: 500; }
|
||||||
|
.ip-badge { font-family: monospace; font-size: 12px; color: var(--text-muted); background: rgba(6, 182, 212, 0.08); padding: 2px 8px; border-radius: 5px; }
|
||||||
|
.count-badge { font-family: monospace; font-size: 13px; font-weight: 600; color: var(--accent); }
|
||||||
|
.status-pill { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
|
||||||
|
.status-active { background: rgba(34, 197, 94, 0.08); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.15); }
|
||||||
|
.btn-icon { background: none; border: none; color: var(--text-dim); cursor: pointer; padding: 4px; border-radius: 6px; transition: all 0.15s; }
|
||||||
|
.btn-icon:hover { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 9px 18px; border: none; border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35); }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
padding: 8px 18px; border-radius: 10px;
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted); font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--bg-input); color: var(--text); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
padding: 8px 18px; border-radius: 10px; border: none;
|
||||||
|
background: var(--danger); color: #fff; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.btn-sm { padding: 5px 12px; border-radius: 7px; font-size: 12px; border: none; cursor: pointer; transition: all 0.15s; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(2, 6, 23, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||||
|
animation: fadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg); border-radius: 16px; padding: 0;
|
||||||
|
border: 1px solid var(--border); width: 480px; max-width: 90vw;
|
||||||
|
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.modal-shine { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, var(--primary-light), var(--accent), transparent); }
|
||||||
|
.modal-wide { width: 640px; }
|
||||||
|
.modal-header { padding: 24px 24px 0; }
|
||||||
|
.modal-header h2 { display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 700; }
|
||||||
|
.modal-meta { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
|
||||||
|
.modal-meta code { background: var(--bg-input); padding: 1px 6px; border-radius: 4px; }
|
||||||
|
.modal-body { padding: 20px 24px; }
|
||||||
|
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(--border); background: rgba(0,0,0,0.15); }
|
||||||
|
|
||||||
|
.section-title { font-size: 13px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
|
||||||
|
.field input {
|
||||||
|
width: 100%; padding: 9px 12px; border-radius: 9px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg-input);
|
||||||
|
color: var(--text); font-size: 14px; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.field input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); }
|
||||||
|
.field-hint { display: block; font-size: 11px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
|
||||||
|
.empty-mini { padding: 20px; text-align: center; color: var(--text-dim); font-size: 13px; }
|
||||||
|
.mini-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.mini-table th { padding: 8px 12px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; text-align: left; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.1); }
|
||||||
|
.mini-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||||
|
|
||||||
|
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
|
||||||
|
.badge-auth { background: rgba(34, 197, 94, 0.08); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.15); }
|
||||||
|
.badge-pending { background: rgba(234, 179, 8, 0.08); color: var(--warning); border: 1px solid rgba(234, 179, 8, 0.15); }
|
||||||
|
|
||||||
|
.warning-text { font-size: 12px; color: var(--text-dim); margin-top: 8px; }
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 14px; margin-bottom: 16px;
|
||||||
|
border-radius: 10px; border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
background: rgba(239, 68, 68, 0.06); color: var(--danger);
|
||||||
|
font-size: 13px; animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.error-dismiss { margin-left: auto; background: none; border: none; color: var(--danger); cursor: pointer; padding: 2px; border-radius: 4px; opacity: 0.6; }
|
||||||
|
.error-dismiss:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
294
web/src/views/Nodes.vue
Normal file
294
web/src/views/Nodes.vue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<template>
|
||||||
|
<div class="nodes fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">节点管理</h2>
|
||||||
|
<p class="page-subtitle">注册、监控和管理网络节点</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" @click="showRegister = true">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
注册节点
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input v-model="search" placeholder="搜索节点 ID 或名称..." />
|
||||||
|
<span v-if="search" class="search-clear" @click="search = ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="search-count">{{ filtered.length }} / {{ nodes.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Banner -->
|
||||||
|
<div v-if="errorMsg" class="error-banner">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
<span>{{ errorMsg }}</span>
|
||||||
|
<button class="error-dismiss" @click="errorMsg = ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="loading-ring"></div>
|
||||||
|
<span>加载节点列表...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="filtered.length === 0" class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||||
|
<h3 v-if="search">未找到匹配节点</h3>
|
||||||
|
<h3 v-else>暂无注册节点</h3>
|
||||||
|
<p>{{ search ? '尝试其他关键词' : '点击右上角注册第一个节点' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div v-else class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>节点 ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>IP 地址</th>
|
||||||
|
<th>公钥</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>最后在线</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="node in filtered" :key="node.id" class="table-row">
|
||||||
|
<td><code class="code-id">{{ node.node_id }}</code></td>
|
||||||
|
<td><span class="node-name">{{ node.name || '-' }}</span></td>
|
||||||
|
<td><span class="ip-badge">{{ node.ip_address || '-' }}</span></td>
|
||||||
|
<td><code class="key-trunc">{{ node.public_key ? node.public_key.slice(0,16)+'...' : '-' }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span :class="node.online ? 'status-pill online' : 'status-pill offline'">
|
||||||
|
<span class="dot"></span>
|
||||||
|
{{ node.online ? '在线' : '离线' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="time-cell">{{ node.last_seen ? formatTime(node.last_seen) : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Register Modal -->
|
||||||
|
<div v-if="showRegister" class="modal-overlay" @click.self="showRegister = false">
|
||||||
|
<div class="modal" @click.stop>
|
||||||
|
<div class="modal-shine"></div>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary-light)" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||||
|
注册节点
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>节点 ID</label>
|
||||||
|
<input v-model="form.nodeId" placeholder="zeromesh-cli 生成的 40-bit 地址" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>公钥 (Hex)</label>
|
||||||
|
<input v-model="form.publicKey" placeholder="64 位十六进制公钥" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>名称</label>
|
||||||
|
<input v-model="form.name" placeholder="例如: office-server-01" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>IP 地址</label>
|
||||||
|
<input v-model="form.ipAddress" placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
<div class="field" style="width: 100px;">
|
||||||
|
<label>端口</label>
|
||||||
|
<input v-model.number="form.port" type="number" placeholder="9993" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-ghost" @click="showRegister = false">取消</button>
|
||||||
|
<button class="btn-primary" @click="registerNode">注册节点</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const nodes = ref([])
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorMsg.value = msg
|
||||||
|
setTimeout(() => { if (errorMsg.value === msg) errorMsg.value = '' }, 5000)
|
||||||
|
}
|
||||||
|
const search = ref('')
|
||||||
|
const showRegister = ref(false)
|
||||||
|
const form = ref({ nodeId: '', publicKey: '', name: '', ipAddress: '', port: 9993 })
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
if (!search.value) return nodes.value
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
return nodes.value.filter(n =>
|
||||||
|
(n.node_id && n.node_id.toLowerCase().includes(q)) ||
|
||||||
|
(n.name && n.name.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(fetchNodes)
|
||||||
|
|
||||||
|
async function fetchNodes() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.nodeList()
|
||||||
|
nodes.value = res.data.nodes || []
|
||||||
|
} catch { nodes.value = [] }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerNode() {
|
||||||
|
try {
|
||||||
|
await api.nodeRegister({
|
||||||
|
node_id: form.value.nodeId,
|
||||||
|
public_key: form.value.publicKey,
|
||||||
|
name: form.value.name,
|
||||||
|
ip_address: form.value.ipAddress,
|
||||||
|
port: form.value.port,
|
||||||
|
version: '1.0.0',
|
||||||
|
})
|
||||||
|
showRegister.value = false
|
||||||
|
form.value = { nodeId: '', publicKey: '', name: '', ipAddress: '', port: 9993 }
|
||||||
|
await fetchNodes()
|
||||||
|
} catch (e) { showError(e.response?.data?.error || '注册失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
const d = new Date(ts)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - d
|
||||||
|
if (diff < 60000) return '刚刚'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff/60000)} 分钟前`
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff/3600000)} 小时前`
|
||||||
|
return d.toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nodes { animation: fadeIn 0.4s ease-out; }
|
||||||
|
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||||
|
.page-title { font-size: 22px; font-weight: 700; }
|
||||||
|
.page-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 14px; border-radius: 12px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg-card);
|
||||||
|
margin-bottom: 20px; position: relative;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.search-bar:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.05); }
|
||||||
|
.search-icon { flex-shrink: 0; color: var(--text-dim); }
|
||||||
|
.search-bar input { flex: 1; border: none; background: transparent; color: var(--text); font-size: 13px; outline: none; }
|
||||||
|
.search-bar input::placeholder { color: var(--text-dim); }
|
||||||
|
.search-clear { cursor: pointer; color: var(--text-dim); padding: 2px; border-radius: 4px; }
|
||||||
|
.search-clear:hover { color: var(--text); background: var(--bg-input); }
|
||||||
|
.search-count { font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.loading-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; color: var(--text-muted); }
|
||||||
|
.loading-ring { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; text-align: center; }
|
||||||
|
.empty-state h3 { font-size: 16px; font-weight: 600; margin-top: 8px; }
|
||||||
|
.empty-state p { font-size: 13px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-container { background: var(--bg-card); border-radius: 14px; border: 1px solid var(--border); overflow: hidden; backdrop-filter: blur(12px); }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { padding: 12px 16px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; text-align: left; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.15); }
|
||||||
|
td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.table-row { transition: background 0.15s; }
|
||||||
|
.table-row:hover { background: rgba(99, 102, 241, 0.04); }
|
||||||
|
|
||||||
|
.code-id { background: var(--bg-input); padding: 2px 8px; border-radius: 5px; font-size: 12px; color: var(--primary-light); font-family: monospace; }
|
||||||
|
.node-name { font-weight: 500; }
|
||||||
|
.ip-badge { font-family: monospace; font-size: 12px; color: var(--text-muted); background: rgba(6, 182, 212, 0.08); padding: 2px 8px; border-radius: 5px; }
|
||||||
|
.key-trunc { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-size: 11px; color: var(--text-dim); max-width: 100px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
|
||||||
|
|
||||||
|
.status-pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
|
||||||
|
.status-pill.online { background: rgba(34, 197, 94, 0.08); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.15); }
|
||||||
|
.status-pill.offline { background: rgba(100, 116, 139, 0.08); color: var(--text-muted); border: 1px solid rgba(100, 116, 139, 0.15); }
|
||||||
|
.status-pill .dot { width: 5px; height: 5px; border-radius: 50%; }
|
||||||
|
.status-pill.online .dot { background: var(--success); box-shadow: 0 0 5px var(--success-glow); }
|
||||||
|
.status-pill.offline .dot { background: var(--text-dim); }
|
||||||
|
|
||||||
|
.time-cell { font-size: 12px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 9px 18px; border: none; border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35); }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
padding: 8px 18px; border-radius: 10px;
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted); font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--bg-input); color: var(--text); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(2, 6, 23, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||||
|
animation: fadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg); border-radius: 16px; padding: 0;
|
||||||
|
border: 1px solid var(--border); width: 480px; max-width: 90vw;
|
||||||
|
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.modal-shine { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, transparent, var(--primary-light), var(--accent), transparent); }
|
||||||
|
.modal-header { padding: 24px 24px 0; }
|
||||||
|
.modal-header h2 { display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 700; }
|
||||||
|
.modal-body { padding: 20px 24px; }
|
||||||
|
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(--border); background: rgba(0,0,0,0.15); }
|
||||||
|
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
|
||||||
|
.field input {
|
||||||
|
width: 100%; padding: 9px 12px; border-radius: 9px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg-input);
|
||||||
|
color: var(--text); font-size: 14px; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.field input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); }
|
||||||
|
.field-row { display: flex; gap: 12px; }
|
||||||
|
.field-row .field { flex: 1; }
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 14px; margin-bottom: 16px;
|
||||||
|
border-radius: 10px; border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
background: rgba(239, 68, 68, 0.06); color: var(--danger);
|
||||||
|
font-size: 13px; animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.error-dismiss { margin-left: auto; background: none; border: none; color: var(--danger); cursor: pointer; padding: 2px; border-radius: 4px; opacity: 0.6; }
|
||||||
|
.error-dismiss:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
119
web/src/views/UserDashboard.vue
Normal file
119
web/src/views/UserDashboard.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-dash fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">我的控制台</h2>
|
||||||
|
<p class="page-subtitle">管理你的虚拟网络和设备</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="loading-ring"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value">{{ stats.networks_count }}</div>
|
||||||
|
<div class="stat-label">虚拟网络</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-trend">已创建 / {{ profile.quota_networks === -1 ? '∞' : profile.quota_networks }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value">{{ stats.nodes_total }}</div>
|
||||||
|
<div class="stat-label">设备节点</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-trend">已注册 / {{ profile.quota_nodes === -1 ? '∞' : profile.quota_nodes }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value">{{ stats.authorized_members }}</div>
|
||||||
|
<div class="stat-label">授权成员</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-trend">已加入网络的设备</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-cards">
|
||||||
|
<router-link to="/user/networks" class="action-card">
|
||||||
|
<div class="action-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="action-body">
|
||||||
|
<strong>创建虚拟网络</strong>
|
||||||
|
<p>设置网段 CIDR,创建工作网络</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/user/nodes" class="action-card">
|
||||||
|
<div class="action-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="action-body">
|
||||||
|
<strong>注册设备</strong>
|
||||||
|
<p>添加节点到你的账号</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const stats = ref({ networks_count: 0, nodes_total: 0, authorized_members: 0 })
|
||||||
|
const profile = ref({ quota_networks: 3, quota_nodes: 20 })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [dash, prof] = await Promise.all([api.dashboard(), api.profile()])
|
||||||
|
stats.value = { ...stats.value, ...dash.data }
|
||||||
|
profile.value = prof.data.user || profile.value
|
||||||
|
} catch {}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-dash { animation: fadeIn 0.4s ease-out; }
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||||
|
.stat-card {
|
||||||
|
background: var(--card-bg); border-radius: 14px; padding: 20px;
|
||||||
|
border: 1px solid var(--card-border); display: flex; flex-direction: column; gap: 12px;
|
||||||
|
transition: all 0.3s; position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-card:hover { transform: translateY(-2px); border-color: var(--border-glow); }
|
||||||
|
.stat-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(99,102,241,0.1); color: #a5b4fc; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; }
|
||||||
|
.stat-label { font-size: 13px; color: var(--text-dim); margin-top: 2px; }
|
||||||
|
.stat-trend { font-size: 11px; color: var(--text-muted); }
|
||||||
|
.action-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
||||||
|
.action-card {
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
background: var(--card-bg); border-radius: 14px; padding: 20px;
|
||||||
|
border: 1px solid var(--card-border); text-decoration: none; color: var(--text);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.action-card:hover { transform: translateY(-2px); border-color: var(--border-glow); }
|
||||||
|
.action-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: rgba(6,182,212,0.1); color: #67e8f9; flex-shrink: 0; }
|
||||||
|
.action-body strong { display: block; font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.action-body p { font-size: 12px; color: var(--text-dim); margin: 0; }
|
||||||
|
.loading-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px; color: var(--text-dim); }
|
||||||
|
.loading-ring { width: 32px; height: 32px; border: 2px solid rgba(255,255,255,0.06); border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
101
web/src/views/UserNetworkDetail.vue
Normal file
101
web/src/views/UserNetworkDetail.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div class="net-detail fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<router-link to="/user/networks" class="back-link">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
返回
|
||||||
|
</router-link>
|
||||||
|
<h2 class="page-title">{{ net.name || '网络详情' }}</h2>
|
||||||
|
<p class="page-subtitle">网段 {{ net.ip_range }} · ID #{{ net.network_id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state"><div class="loading-ring"></div><span>加载中...</span></div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="info-bar">
|
||||||
|
<div class="info-item"><span class="info-label">MTU</span><span>{{ net.mtu }}</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">组播</span><span>{{ net.multicast ? '开启' : '关闭' }}</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">私有</span><span>{{ net.private ? '是' : '否' }}</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">成员</span><span>{{ members.length }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 24px 0 12px; font-size: 14px;">已授权设备</h3>
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Node ID</th>
|
||||||
|
<th>标签</th>
|
||||||
|
<th>IP 地址</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="m in members" :key="m.id">
|
||||||
|
<td><code>{{ m.node_id }}</code></td>
|
||||||
|
<td>{{ m.label || '-' }}</td>
|
||||||
|
<td><code>{{ m.ip_address || '待分配' }}</code></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-sm btn-danger-sm" @click="removeMember(m)">移除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!members.length">
|
||||||
|
<td colspan="4" class="empty-cell">还没有设备加入此网络</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const net = ref({})
|
||||||
|
const members = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [netRes, memRes] = await Promise.all([
|
||||||
|
api.networkGet(route.params.id),
|
||||||
|
api.networkMembers(route.params.id),
|
||||||
|
])
|
||||||
|
net.value = netRes.data.network || {}
|
||||||
|
members.value = memRes.data.members || []
|
||||||
|
} catch {}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
async function removeMember(m) {
|
||||||
|
if (!confirm(`确认移除设备 ${m.node_id}?`)) return
|
||||||
|
try {
|
||||||
|
await api.networkDeauthorize(route.params.id, m.node_id)
|
||||||
|
members.value = members.value.filter(x => x.id !== m.id)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { margin-bottom: 24px; }
|
||||||
|
.back-link { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-dim); text-decoration: none; margin-bottom: 8px; }
|
||||||
|
.back-link:hover { color: var(--text); }
|
||||||
|
.info-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
||||||
|
.info-item { padding: 12px 16px; border-radius: 10px; background: var(--card-bg); border: 1px solid var(--card-border); }
|
||||||
|
.info-label { display: block; font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||||
|
.table-container { background: var(--card-bg); border-radius: 10px; border: 1px solid var(--card-border); overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { text-align: left; padding: 10px 14px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--card-border); }
|
||||||
|
td { padding: 8px 14px; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.03); }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
code { font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; color: #a5b4fc; }
|
||||||
|
.empty-cell { text-align: center; padding: 32px; color: var(--text-dim); }
|
||||||
|
.btn-sm { padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.04); color: var(--text); font-size: 12px; cursor: pointer; }
|
||||||
|
.btn-danger-sm { border-color: rgba(239,68,68,0.2); color: #ef4444; }
|
||||||
|
</style>
|
||||||
107
web/src/views/UserNetworks.vue
Normal file
107
web/src/views/UserNetworks.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-nets fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">我的虚拟网络</h2>
|
||||||
|
<p class="page-subtitle">创建和管理你的私有网络</p>
|
||||||
|
</div>
|
||||||
|
<router-link to="/user/network/create" class="btn-primary">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
创建网络
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="error-banner">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
<span>{{ errorMsg }}</span>
|
||||||
|
<button class="error-dismiss" @click="errorMsg = ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="loading-ring"></div>
|
||||||
|
<span>加载网络列表...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!networks.length" class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
<h3>还没有虚拟网络</h3>
|
||||||
|
<p>点击右上角「创建网络」开始</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="network-list">
|
||||||
|
<div v-for="net in networks" :key="net.network_id" class="network-card">
|
||||||
|
<div class="net-header">
|
||||||
|
<div class="net-name">{{ net.name }}</div>
|
||||||
|
<div class="net-id">#{{ net.network_id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="net-detail"><span class="net-label">网段</span><code>{{ net.ip_range }}</code></div>
|
||||||
|
<div class="net-detail"><span class="net-label">MTU</span><span>{{ net.mtu }}</span></div>
|
||||||
|
<div class="net-detail"><span class="net-label">成员</span><span>{{ (net.members || []).length }} 个设备</span></div>
|
||||||
|
<div class="net-actions">
|
||||||
|
<router-link :to="`/user/networks/${net.network_id}`" class="btn-sm">管理</router-link>
|
||||||
|
<button class="btn-sm btn-danger-sm" @click="deleteNet(net)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const networks = ref([])
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.networkList()
|
||||||
|
networks.value = res.data.networks || []
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.response?.data?.error || '加载失败'
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNet(net) {
|
||||||
|
if (!confirm(`确认删除网络「${net.name}」?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await api.networkDelete(net.network_id)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.response?.data?.error || '删除失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 28px; }
|
||||||
|
.network-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
.network-card {
|
||||||
|
background: var(--card-bg); border-radius: 12px; padding: 20px;
|
||||||
|
border: 1px solid var(--card-border); transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.network-card:hover { transform: translateY(-2px); border-color: var(--border-glow); }
|
||||||
|
.net-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
|
||||||
|
.net-name { font-size: 15px; font-weight: 600; }
|
||||||
|
.net-id { font-size: 11px; color: var(--text-dim); background: rgba(255,255,255,0.04); padding: 2px 8px; border-radius: 4px; }
|
||||||
|
.net-detail { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; font-size: 13px; border-bottom: 1px solid rgba(255,255,255,0.03); }
|
||||||
|
.net-detail:last-of-type { border-bottom: none; margin-bottom: 12px; }
|
||||||
|
.net-label { color: var(--text-dim); font-size: 12px; }
|
||||||
|
code { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: #a5b4fc; }
|
||||||
|
.net-actions { display: flex; gap: 8px; }
|
||||||
|
.btn-sm {
|
||||||
|
flex: 1; text-align: center; padding: 6px; border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { border-color: rgba(99,102,241,0.3); }
|
||||||
|
.btn-danger-sm { border-color: rgba(239,68,68,0.2); color: #ef4444; }
|
||||||
|
.btn-danger-sm:hover { background: rgba(239,68,68,0.1); }
|
||||||
|
</style>
|
||||||
215
web/src/views/UserNodes.vue
Normal file
215
web/src/views/UserNodes.vue
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-nodes fade-in">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">我的设备</h2>
|
||||||
|
<p class="page-subtitle">注册和管理你的节点设备</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" @click="showRegister = true">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
注册设备
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="error-banner">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
<span>{{ errorMsg }}</span>
|
||||||
|
<button class="error-dismiss" @click="errorMsg = ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Register Modal -->
|
||||||
|
<div v-if="showRegister" class="modal-overlay" @click.self="showRegister = false">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>注册设备</h3>
|
||||||
|
<p class="modal-desc">在设备上运行 agent 后,把生成的 Node ID 和公钥填入下方</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Node ID</label>
|
||||||
|
<input v-model="form.nodeId" placeholder="10 位十六进制地址" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>公钥 (ed25519)</label>
|
||||||
|
<input v-model="form.publicKey" placeholder="Base64 编码的公钥" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>设备名称</label>
|
||||||
|
<input v-model="form.name" placeholder="例如:办公笔记本" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>版本</label>
|
||||||
|
<input v-model="form.version" placeholder="1.0.0" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" @click="showRegister = false">取消</button>
|
||||||
|
<button class="btn-primary" @click="registerNode" :disabled="registering">{{ registering ? '注册中...' : '注册' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="loading-ring"></div>
|
||||||
|
<span>加载设备列表...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!nodes.length" class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1" opacity="0.4"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||||
|
<h3>还没有注册设备</h3>
|
||||||
|
<p>点击右上角「注册设备」添加你的第一个节点</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Node ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>公钥</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>最后在线</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="node in nodes" :key="node.node_id">
|
||||||
|
<td><code>{{ node.node_id }}</code></td>
|
||||||
|
<td>{{ node.name || '-' }}</td>
|
||||||
|
<td class="cell-pk"><code>{{ truncate(node.public_key, 24) }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span :class="['status-dot', node.online ? 'online' : 'offline']"></span>
|
||||||
|
{{ node.online ? '在线' : '离线' }}
|
||||||
|
</td>
|
||||||
|
<td class="cell-time">{{ node.last_seen ? formatTime(node.last_seen) : '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-sm" @click="joinNetwork(node)">加入网络</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Join Network Modal -->
|
||||||
|
<div v-if="showJoin" class="modal-overlay" @click.self="showJoin = false">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>加入网络</h3>
|
||||||
|
<p class="modal-desc">将设备 <code>{{ joinNode?.node_id }}</code> 加入以下网络</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>选择网络</label>
|
||||||
|
<select v-model="joinNetworkId">
|
||||||
|
<option v-for="net in networks" :key="net.network_id" :value="net.network_id">{{ net.name }} ({{ net.ip_range }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" @click="showJoin = false">取消</button>
|
||||||
|
<button class="btn-primary" @click="confirmJoin" :disabled="joining">{{ joining ? '加入中...' : '加入' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const nodes = ref([])
|
||||||
|
const networks = ref([])
|
||||||
|
const showRegister = ref(false)
|
||||||
|
const registering = ref(false)
|
||||||
|
const form = ref({ nodeId: '', publicKey: '', name: '', version: '' })
|
||||||
|
const showJoin = ref(false)
|
||||||
|
const joinNode = ref(null)
|
||||||
|
const joinNetworkId = ref('')
|
||||||
|
const joining = ref(false)
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [nodeRes, netRes] = await Promise.all([api.nodeList(), api.networkList()])
|
||||||
|
nodes.value = nodeRes.data.nodes || []
|
||||||
|
networks.value = netRes.data.networks || []
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.response?.data?.error || '加载失败'
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerNode() {
|
||||||
|
if (!form.value.nodeId || !form.value.publicKey) return
|
||||||
|
registering.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
await api.nodeRegister({
|
||||||
|
node_id: form.value.nodeId,
|
||||||
|
public_key: form.value.publicKey,
|
||||||
|
name: form.value.name,
|
||||||
|
version: form.value.version,
|
||||||
|
})
|
||||||
|
showRegister.value = false
|
||||||
|
form.value = { nodeId: '', publicKey: '', name: '', version: '' }
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.response?.data?.error || '注册失败'
|
||||||
|
}
|
||||||
|
registering.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinNetwork(node) {
|
||||||
|
joinNode.value = node
|
||||||
|
joinNetworkId.value = networks.value[0]?.network_id || ''
|
||||||
|
showJoin.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmJoin() {
|
||||||
|
if (!joinNetworkId.value) return
|
||||||
|
joining.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
await api.networkAuthorize(joinNetworkId.value, joinNode.value.node_id)
|
||||||
|
showJoin.value = false
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = e.response?.data?.error || '加入失败'
|
||||||
|
}
|
||||||
|
joining.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t) {
|
||||||
|
if (!t) return '-'
|
||||||
|
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
function truncate(s, n) {
|
||||||
|
if (!s) return ''
|
||||||
|
return s.length > n ? s.slice(0, n) + '...' : s
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 28px; }
|
||||||
|
.table-container { overflow-x: auto; background: var(--card-bg); border-radius: 10px; border: 1px solid var(--card-border); }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { text-align: left; padding: 10px 14px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--card-border); }
|
||||||
|
td { padding: 8px 14px; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.03); }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(99,102,241,0.03); }
|
||||||
|
code { font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; color: #a5b4fc; }
|
||||||
|
.cell-pk { max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cell-time { font-size: 0.75rem; color: var(--text-dim); }
|
||||||
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin-right: 6px; vertical-align: middle; }
|
||||||
|
.status-dot.online { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
|
||||||
|
.status-dot.offline { background: #6b7280; }
|
||||||
|
.btn-sm { padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.04); color: var(--text); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||||
|
.btn-sm:hover { border-color: rgba(99,102,241,0.3); }
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; backdrop-filter: blur(4px); }
|
||||||
|
.modal { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 14px; padding: 28px; width: 100%; max-width: 440px; }
|
||||||
|
.modal h3 { margin-bottom: 6px; }
|
||||||
|
.modal-desc { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; }
|
||||||
|
.form-group { margin-bottom: 14px; }
|
||||||
|
.form-group label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
|
.form-group input, .form-group select { width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: rgba(0,0,0,0.3); color: var(--text); font-size: 13px; outline: none; box-sizing: border-box; }
|
||||||
|
.form-group input:focus, .form-group select:focus { border-color: rgba(99,102,241,0.5); }
|
||||||
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
17
web/vite.config.js
Normal file
17
web/vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: '/',
|
||||||
|
build: {
|
||||||
|
outDir: '../web/dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:10001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user