From ee5f0363253769a951ca53f405cd8166a9b9cc9c Mon Sep 17 00:00:00 2001 From: xieyao Date: Wed, 17 Jun 2026 03:50:23 +0800 Subject: [PATCH] initial: ZeroTier-like P2P mesh VPN server with multi-tenant Web UI --- .gitignore | 10 + Makefile | 43 + build.bat | 9 + build.sh | 22 + cmd/zeromesh-agent/main.go | 46 + cmd/zeromesh-cli/main.go | 36 + cmd/zeromesh-controller/main.go | 94 ++ cmd/zeromesh-relay/main.go | 62 ++ config.yaml | 55 + data/controller.identity | 1 + go.mod | 49 + go.sum | 115 +++ internal/agent/agent.go | 324 ++++++ internal/config/config.go | 142 +++ internal/controller/controller.go | 192 ++++ internal/database/database.go | 84 ++ internal/handler/admin.go | 62 ++ internal/handler/auth.go | 87 ++ internal/handler/context.go | 23 + internal/handler/log.go | 75 ++ internal/handler/network.go | 143 +++ internal/handler/node.go | 81 ++ internal/identity/identity.go | 142 +++ internal/middleware/cors.go | 29 + internal/middleware/jwt.go | 58 ++ internal/middleware/logger.go | 97 ++ internal/middleware/ratelimit.go | 64 ++ internal/model/models.go | 101 ++ internal/repository/log.go | 111 ++ internal/repository/network.go | 82 ++ internal/repository/node.go | 75 ++ internal/repository/user.go | 58 ++ internal/router/router.go | 124 +++ internal/service/auth.go | 113 +++ internal/service/log.go | 54 + internal/service/network.go | 163 +++ internal/service/node.go | 89 ++ internal/service/user.go | 25 + internal/tap/tap.go | 24 + internal/tap/tap_linux.go | 71 ++ internal/tap/tap_stub.go | 21 + internal/task/task.go | 50 + internal/vl1/noise.go | 103 ++ internal/vl1/packet.go | 117 +++ internal/vl1/peer.go | 193 ++++ internal/vl1/transport.go | 112 +++ internal/vl2/arp.go | 120 +++ internal/vl2/frame.go | 92 ++ internal/vl2/mac.go | 19 + internal/vl2/network.go | 38 + internal/vl2/switch.go | 153 +++ main.go | 133 +++ test.sh | 11 + web/index.html | 12 + web/package-lock.json | 1455 +++++++++++++++++++++++++++ web/package.json | 20 + web/src/App.vue | 91 ++ web/src/api/index.js | 53 + web/src/main.js | 7 + web/src/router/index.js | 101 ++ web/src/views/Dashboard.vue | 230 +++++ web/src/views/Docs.vue | 715 +++++++++++++ web/src/views/Init.vue | 190 ++++ web/src/views/Layout.vue | 276 +++++ web/src/views/Login.vue | 262 +++++ web/src/views/Logs.vue | 408 ++++++++ web/src/views/NetworkCreate.vue | 141 +++ web/src/views/Networks.vue | 306 ++++++ web/src/views/Nodes.vue | 294 ++++++ web/src/views/UserDashboard.vue | 119 +++ web/src/views/UserNetworkDetail.vue | 101 ++ web/src/views/UserNetworks.vue | 107 ++ web/src/views/UserNodes.vue | 215 ++++ web/vite.config.js | 17 + 74 files changed, 9517 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 build.bat create mode 100755 build.sh create mode 100644 cmd/zeromesh-agent/main.go create mode 100644 cmd/zeromesh-cli/main.go create mode 100644 cmd/zeromesh-controller/main.go create mode 100644 cmd/zeromesh-relay/main.go create mode 100644 config.yaml create mode 100644 data/controller.identity create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/agent/agent.go create mode 100644 internal/config/config.go create mode 100644 internal/controller/controller.go create mode 100644 internal/database/database.go create mode 100644 internal/handler/admin.go create mode 100644 internal/handler/auth.go create mode 100644 internal/handler/context.go create mode 100644 internal/handler/log.go create mode 100644 internal/handler/network.go create mode 100644 internal/handler/node.go create mode 100644 internal/identity/identity.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/jwt.go create mode 100644 internal/middleware/logger.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/model/models.go create mode 100644 internal/repository/log.go create mode 100644 internal/repository/network.go create mode 100644 internal/repository/node.go create mode 100644 internal/repository/user.go create mode 100644 internal/router/router.go create mode 100644 internal/service/auth.go create mode 100644 internal/service/log.go create mode 100644 internal/service/network.go create mode 100644 internal/service/node.go create mode 100644 internal/service/user.go create mode 100644 internal/tap/tap.go create mode 100644 internal/tap/tap_linux.go create mode 100644 internal/tap/tap_stub.go create mode 100644 internal/task/task.go create mode 100644 internal/vl1/noise.go create mode 100644 internal/vl1/packet.go create mode 100644 internal/vl1/peer.go create mode 100644 internal/vl1/transport.go create mode 100644 internal/vl2/arp.go create mode 100644 internal/vl2/frame.go create mode 100644 internal/vl2/mac.go create mode 100644 internal/vl2/network.go create mode 100644 internal/vl2/switch.go create mode 100644 main.go create mode 100755 test.sh create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.vue create mode 100644 web/src/api/index.js create mode 100644 web/src/main.js create mode 100644 web/src/router/index.js create mode 100644 web/src/views/Dashboard.vue create mode 100644 web/src/views/Docs.vue create mode 100644 web/src/views/Init.vue create mode 100644 web/src/views/Layout.vue create mode 100644 web/src/views/Login.vue create mode 100644 web/src/views/Logs.vue create mode 100644 web/src/views/NetworkCreate.vue create mode 100644 web/src/views/Networks.vue create mode 100644 web/src/views/Nodes.vue create mode 100644 web/src/views/UserDashboard.vue create mode 100644 web/src/views/UserNetworkDetail.vue create mode 100644 web/src/views/UserNetworks.vue create mode 100644 web/src/views/UserNodes.vue create mode 100644 web/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f694b3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +data/*.db +web/node_modules/ +web/dist/ +uploads/ +*.exe +*.test +*.out +.idea/ +.vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ab0805 --- /dev/null +++ b/Makefile @@ -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/ diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..9f8bc89 --- /dev/null +++ b/build.bat @@ -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. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..698d4a4 --- /dev/null +++ b/build.sh @@ -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." diff --git a/cmd/zeromesh-agent/main.go b/cmd/zeromesh-agent/main.go new file mode 100644 index 0000000..b659f40 --- /dev/null +++ b/cmd/zeromesh-agent/main.go @@ -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() +} diff --git a/cmd/zeromesh-cli/main.go b/cmd/zeromesh-cli/main.go new file mode 100644 index 0000000..8c1fccb --- /dev/null +++ b/cmd/zeromesh-cli/main.go @@ -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") + } +} diff --git a/cmd/zeromesh-controller/main.go b/cmd/zeromesh-controller/main.go new file mode 100644 index 0000000..e7a594e --- /dev/null +++ b/cmd/zeromesh-controller/main.go @@ -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) + } +} diff --git a/cmd/zeromesh-relay/main.go b/cmd/zeromesh-relay/main.go new file mode 100644 index 0000000..65e3cb1 --- /dev/null +++ b/cmd/zeromesh-relay/main.go @@ -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() +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..49834ad --- /dev/null +++ b/config.yaml @@ -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 diff --git a/data/controller.identity b/data/controller.identity new file mode 100644 index 0000000..9a7e456 --- /dev/null +++ b/data/controller.identity @@ -0,0 +1 @@ +{"public_key":"blqsO1TyrzRWGA4Ndhhwh/WFx50jIrr/x4z05vXm5aI=","private_key":"eZZVD6q+YrOy/pk+IBbPm8X4QyrvIF4FpUyh6t95jkduWqw7VPKvNFYYDg12GHCH9YXHnSMiuv/HjPTm9eblog==","address":"b412579ffd"} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f4bb6ee --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0fb3f43 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..272cb94 --- /dev/null +++ b/internal/agent/agent.go @@ -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() + } + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..25a21e0 --- /dev/null +++ b/internal/config/config.go @@ -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 "" +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go new file mode 100644 index 0000000..7e0895a --- /dev/null +++ b/internal/controller/controller.go @@ -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, + }) +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..7966950 --- /dev/null +++ b/internal/database/database.go @@ -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") +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..eaf3b24 --- /dev/null +++ b/internal/handler/admin.go @@ -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), + }) +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..8ecdf1c --- /dev/null +++ b/internal/handler/auth.go @@ -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}) +} diff --git a/internal/handler/context.go b/internal/handler/context.go new file mode 100644 index 0000000..81ea52e --- /dev/null +++ b/internal/handler/context.go @@ -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" +} diff --git a/internal/handler/log.go b/internal/handler/log.go new file mode 100644 index 0000000..3d07782 --- /dev/null +++ b/internal/handler/log.go @@ -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"}) +} diff --git a/internal/handler/network.go b/internal/handler/network.go new file mode 100644 index 0000000..34cc3fd --- /dev/null +++ b/internal/handler/network.go @@ -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"}) +} diff --git a/internal/handler/node.go b/internal/handler/node.go new file mode 100644 index 0000000..4f38cc2 --- /dev/null +++ b/internal/handler/node.go @@ -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"}) +} diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 0000000..587fed8 --- /dev/null +++ b/internal/identity/identity.go @@ -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 +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..08067ef --- /dev/null +++ b/internal/middleware/cors.go @@ -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() + } +} diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go new file mode 100644 index 0000000..756983b --- /dev/null +++ b/internal/middleware/jwt.go @@ -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)) +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..77db32f --- /dev/null +++ b/internal/middleware/logger.go @@ -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) +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..198d0b0 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -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 +} diff --git a/internal/model/models.go b/internal/model/models.go new file mode 100644 index 0000000..49ddb45 --- /dev/null +++ b/internal/model/models.go @@ -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"` +} diff --git a/internal/repository/log.go b/internal/repository/log.go new file mode 100644 index 0000000..0200446 --- /dev/null +++ b/internal/repository/log.go @@ -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 +} diff --git a/internal/repository/network.go b/internal/repository/network.go new file mode 100644 index 0000000..ee23c33 --- /dev/null +++ b/internal/repository/network.go @@ -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 +} diff --git a/internal/repository/node.go b/internal/repository/node.go new file mode 100644 index 0000000..4ed806c --- /dev/null +++ b/internal/repository/node.go @@ -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 +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..74916fc --- /dev/null +++ b/internal/repository/user.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..a19599d --- /dev/null +++ b/internal/router/router.go @@ -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 +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..e08cb1a --- /dev/null +++ b/internal/service/auth.go @@ -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() +} + + diff --git a/internal/service/log.go b/internal/service/log.go new file mode 100644 index 0000000..a5cb890 --- /dev/null +++ b/internal/service/log.go @@ -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() +} diff --git a/internal/service/network.go b/internal/service/network.go new file mode 100644 index 0000000..1af9b26 --- /dev/null +++ b/internal/service/network.go @@ -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 +} diff --git a/internal/service/node.go b/internal/service/node.go new file mode 100644 index 0000000..d2f0721 --- /dev/null +++ b/internal/service/node.go @@ -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 +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..ceb1377 --- /dev/null +++ b/internal/service/user.go @@ -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 +} diff --git a/internal/tap/tap.go b/internal/tap/tap.go new file mode 100644 index 0000000..a66ae29 --- /dev/null +++ b/internal/tap/tap.go @@ -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) +} diff --git a/internal/tap/tap_linux.go b/internal/tap/tap_linux.go new file mode 100644 index 0000000..ce10a64 --- /dev/null +++ b/internal/tap/tap_linux.go @@ -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) +} diff --git a/internal/tap/tap_stub.go b/internal/tap/tap_stub.go new file mode 100644 index 0000000..ffd9bdd --- /dev/null +++ b/internal/tap/tap_stub.go @@ -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") +} diff --git a/internal/task/task.go b/internal/task/task.go new file mode 100644 index 0000000..685e0ea --- /dev/null +++ b/internal/task/task.go @@ -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) + } +} diff --git a/internal/vl1/noise.go b/internal/vl1/noise.go new file mode 100644 index 0000000..bc156eb --- /dev/null +++ b/internal/vl1/noise.go @@ -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 +} diff --git a/internal/vl1/packet.go b/internal/vl1/packet.go new file mode 100644 index 0000000..8c67087 --- /dev/null +++ b/internal/vl1/packet.go @@ -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 +} diff --git a/internal/vl1/peer.go b/internal/vl1/peer.go new file mode 100644 index 0000000..d9800f4 --- /dev/null +++ b/internal/vl1/peer.go @@ -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) + } + } +} diff --git a/internal/vl1/transport.go b/internal/vl1/transport.go new file mode 100644 index 0000000..bfe557e --- /dev/null +++ b/internal/vl1/transport.go @@ -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) +} diff --git a/internal/vl2/arp.go b/internal/vl2/arp.go new file mode 100644 index 0000000..fc574e9 --- /dev/null +++ b/internal/vl2/arp.go @@ -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) + } + } +} diff --git a/internal/vl2/frame.go b/internal/vl2/frame.go new file mode 100644 index 0000000..5550d26 --- /dev/null +++ b/internal/vl2/frame.go @@ -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) +} diff --git a/internal/vl2/mac.go b/internal/vl2/mac.go new file mode 100644 index 0000000..bcc4f51 --- /dev/null +++ b/internal/vl2/mac.go @@ -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 +} diff --git a/internal/vl2/network.go b/internal/vl2/network.go new file mode 100644 index 0000000..2a48cc5 --- /dev/null +++ b/internal/vl2/network.go @@ -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, + } +} diff --git a/internal/vl2/switch.go b/internal/vl2/switch.go new file mode 100644 index 0000000..f895410 --- /dev/null +++ b/internal/vl2/switch.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cb81719 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..a8e9629 --- /dev/null +++ b/test.sh @@ -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." diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..84575ef --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + ZeroMesh 管理控制台 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..548bfc0 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1455 @@ +{ + "name": "zeromesh-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zeromesh-web", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..69aac1f --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..63aadc5 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,91 @@ + + + diff --git a/web/src/api/index.js b/web/src/api/index.js new file mode 100644 index 0000000..2920b17 --- /dev/null +++ b/web/src/api/index.js @@ -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 }), +} diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..efe493a --- /dev/null +++ b/web/src/main.js @@ -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') diff --git a/web/src/router/index.js b/web/src/router/index.js new file mode 100644 index 0000000..a0c28a4 --- /dev/null +++ b/web/src/router/index.js @@ -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 diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue new file mode 100644 index 0000000..5fb3b13 --- /dev/null +++ b/web/src/views/Dashboard.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/web/src/views/Docs.vue b/web/src/views/Docs.vue new file mode 100644 index 0000000..797638c --- /dev/null +++ b/web/src/views/Docs.vue @@ -0,0 +1,715 @@ + + + + + diff --git a/web/src/views/Init.vue b/web/src/views/Init.vue new file mode 100644 index 0000000..ea0e1e1 --- /dev/null +++ b/web/src/views/Init.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/web/src/views/Layout.vue b/web/src/views/Layout.vue new file mode 100644 index 0000000..b599821 --- /dev/null +++ b/web/src/views/Layout.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue new file mode 100644 index 0000000..844570f --- /dev/null +++ b/web/src/views/Login.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/web/src/views/Logs.vue b/web/src/views/Logs.vue new file mode 100644 index 0000000..36b299f --- /dev/null +++ b/web/src/views/Logs.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/web/src/views/NetworkCreate.vue b/web/src/views/NetworkCreate.vue new file mode 100644 index 0000000..78a37d5 --- /dev/null +++ b/web/src/views/NetworkCreate.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/web/src/views/Networks.vue b/web/src/views/Networks.vue new file mode 100644 index 0000000..3f9fe80 --- /dev/null +++ b/web/src/views/Networks.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/web/src/views/Nodes.vue b/web/src/views/Nodes.vue new file mode 100644 index 0000000..e1fe233 --- /dev/null +++ b/web/src/views/Nodes.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/web/src/views/UserDashboard.vue b/web/src/views/UserDashboard.vue new file mode 100644 index 0000000..47bba0e --- /dev/null +++ b/web/src/views/UserDashboard.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/web/src/views/UserNetworkDetail.vue b/web/src/views/UserNetworkDetail.vue new file mode 100644 index 0000000..4b4ea93 --- /dev/null +++ b/web/src/views/UserNetworkDetail.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/web/src/views/UserNetworks.vue b/web/src/views/UserNetworks.vue new file mode 100644 index 0000000..daf5d42 --- /dev/null +++ b/web/src/views/UserNetworks.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/web/src/views/UserNodes.vue b/web/src/views/UserNodes.vue new file mode 100644 index 0000000..1d4ac1b --- /dev/null +++ b/web/src/views/UserNodes.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..c787ae0 --- /dev/null +++ b/web/vite.config.js @@ -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', + }, + }, +})