diff options
94 files changed, 18117 insertions, 0 deletions
diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..a0c36e3 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "vpnem-local", + "interface": { + "displayName": "vpnem Local Plugins" + }, + "plugins": [ + { + "name": "claude-migration", + "source": { + "source": "local", + "path": "./plugins/claude-migration" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ab7a93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +data/rules/*.srs +data/rules/*.txt +*.exe +*.dll +/vpnem-server +/vpnem-client +/build/ +/server +/client +cmd/client/build/bin/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9aa0a7f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /build +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o vpnem-server ./cmd/server + +FROM alpine:3.21 +RUN apk add --no-cache wget ca-certificates openssh-client sshpass +WORKDIR /opt/vpnem +COPY --from=builder /build/vpnem-server . +COPY data/ ./data/ +COPY scripts/update-rulesets.sh ./scripts/ +RUN chmod +x scripts/update-rulesets.sh && mkdir -p data/rules + +EXPOSE 8090 +CMD ["./vpnem-server", "-addr", ":8090", "-data", "./data"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..20c3d88 --- /dev/null +++ b/Makefile @@ -0,0 +1,139 @@ +GOPATH ?= $(HOME)/.local/share/go +WAILS := $(GOPATH)/bin/wails +SERVER := root@178.20.40.99 +RELEASES_DIR := /opt/vpnem/data/releases +export GOPATH + +.PHONY: all server client-linux client-windows installer vpnemctl test test-control test-hy2 clean deploy release release-assets release-verify install-ruleset-timer + +all: server client-linux + +# --- Server --- + +server: + go build -o build/vpnem-server ./cmd/server + +# --- Client --- + +client-linux: + cd cmd/client && $(WAILS) build -platform linux/amd64 -o vpnem -tags with_gvisor,with_utls,with_quic + cp cmd/client/build/bin/vpnem build/vpnem-linux-amd64 + +client-windows: + cd cmd/client && CC=x86_64-w64-mingw32-gcc $(WAILS) build -platform windows/amd64 -o vpnem.exe -tags with_gvisor,with_utls,with_quic + cp cmd/client/build/bin/vpnem.exe build/vpnem-windows-amd64.exe + +installer: + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-H windowsgui" -o build/vpnem-installer.exe ./cmd/installer + +vpnemctl: + go build -o build/vpnemctl ./cmd/vpnemctl + +# --- Test --- + +test: + go test ./... -v + +test-control: + go test ./internal/control -v + +test-hy2: + go test ./internal/config/ -run TestBuildConfigHysteria2 -v + go test ./internal/control/ -run TestHysteria2Bundle -v + +.PHONY: test-multi +test-multi: + go test ./internal/config/ -run "TestBuildConfig(VLESSReality|Hysteria2|SplitRealityHysteria2)" -v + go test ./internal/control/ -run "Test(RenderRuntimeBundleReality|Hysteria2Bundle|RenderRuntimeBundleMultiProtocol)" -v + go test ./internal/sync/ -run "TestCatalogToServers(MultiProtocolNode)?" -v + +# --- Deploy: server code only --- + +deploy: + rsync -avz --exclude '.git' --exclude 'build' --exclude '*.srs' --exclude '.claude' --exclude '.env' --exclude 'VPNUI_ADMIN_TOKEN.txt' \ + ./ $(SERVER):/opt/vpnem/ + ssh $(SERVER) "cd /opt/vpnem && docker compose up -d --build" + @echo "Verify: curl https://vpn.em-sysadmin.xyz/health" + +# --- Release: build all + deploy binaries + bump version --- + +release-assets: client-linux client-windows installer + rm -f build/vpnem-linux-amd64 + cp cmd/client/build/bin/vpnem build/vpnem-linux-amd64 + test -f build/sing-box.exe + test -f build/wintun.dll + scp build/vpnem-linux-amd64 build/vpnem-windows-amd64.exe build/vpnem-installer.exe build/sing-box.exe build/wintun.dll \ + $(SERVER):$(RELEASES_DIR)/ + scp data/version.json $(SERVER):/opt/vpnem/data/version.json + +release-verify: + @echo "" + @echo "Verifying health..." + @ok=0; \ + for attempt in 1 2 3 4 5; do \ + if curl -fSs https://vpn.em-sysadmin.xyz/health; then \ + ok=1; \ + break; \ + fi; \ + echo "retry $$attempt for /health"; \ + sleep 2; \ + done; \ + if [ "$$ok" -ne 1 ]; then \ + echo "release verify failed for /health"; \ + exit 1; \ + fi + @echo "" + @echo "Verifying version..." + @ok=0; \ + for attempt in 1 2 3 4 5; do \ + if curl -fSs https://vpn.em-sysadmin.xyz/api/v1/version; then \ + ok=1; \ + break; \ + fi; \ + echo "retry $$attempt for /api/v1/version"; \ + sleep 2; \ + done; \ + if [ "$$ok" -ne 1 ]; then \ + echo "release verify failed for /api/v1/version"; \ + exit 1; \ + fi + @echo "" + @echo "Verifying release assets..." + @for url in \ + https://vpn.em-sysadmin.xyz/releases/vpnem-windows-amd64.exe \ + https://vpn.em-sysadmin.xyz/releases/vpnem-installer.exe \ + https://vpn.em-sysadmin.xyz/releases/sing-box.exe \ + https://vpn.em-sysadmin.xyz/releases/wintun.dll; do \ + ok=0; \ + for attempt in 1 2 3 4 5; do \ + if curl -fSs -I "$$url" | sed -n '1,5p'; then \ + ok=1; \ + break; \ + fi; \ + echo "retry $$attempt for $$url"; \ + sleep 2; \ + done; \ + if [ "$$ok" -ne 1 ]; then \ + echo "release verify failed for $$url"; \ + exit 1; \ + fi; \ + echo ""; \ + done + +release: release-assets + rsync -avz --exclude '.git' --exclude 'build' --exclude '*.srs' --exclude '.claude' --exclude '.env' --exclude 'VPNUI_ADMIN_TOKEN.txt' \ + ./ $(SERVER):/opt/vpnem/ + ssh $(SERVER) "cd /opt/vpnem && docker compose up -d --build" + $(MAKE) release-verify + @echo "Release deployed." + +install-ruleset-timer: + chmod +x scripts/install-ruleset-timer.sh + rsync -avz deploy/systemd/vpnem-rulesets-update.service deploy/systemd/vpnem-rulesets-update.timer scripts/install-ruleset-timer.sh \ + $(SERVER):/opt/vpnem/ + ssh $(SERVER) "cd /opt/vpnem && ./scripts/install-ruleset-timer.sh" + +# --- Clean --- + +clean: + rm -rf build/ cmd/client/build/ diff --git a/cmd/client/app.go b/cmd/client/app.go new file mode 100644 index 0000000..6d26318 --- /dev/null +++ b/cmd/client/app.go @@ -0,0 +1,729 @@ +package main + +import ( + "context" + "fmt" + "log" + "math/rand" + "net/http" + "os" + "os/exec" + "runtime" + "time" + + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "vpnem/internal/config" + "vpnem/internal/engine" + "vpnem/internal/models" + "vpnem/internal/state" + syncpkg "vpnem/internal/sync" +) + +const Version = "2.0.11" + +// App is the Wails backend. +type App struct { + ctx context.Context + engine *engine.Engine + watchdog *engine.Watchdog + fetcher *syncpkg.Fetcher + updater *syncpkg.Updater + state *state.Store + log *engine.RingLog + + catalog *models.CatalogV2 + servers []models.Server + ruleSets []models.RuleSet + policy *models.RoutingPolicy + + recommendedServerIP string + recommendedNodeID string + recommendReason string + studioClients int +} + +// NewApp creates a new App instance. +func NewApp(dataDir, apiURL string) *App { + eng := engine.New(dataDir) + fetcher := syncpkg.NewFetcher(apiURL) + rl := engine.NewRingLog(200, dataDir) + return &App{ + engine: eng, + watchdog: engine.NewWatchdog(eng, engine.DefaultWatchdogConfig()), + fetcher: fetcher, + updater: syncpkg.NewUpdater(fetcher, Version, dataDir), + state: state.NewStore(dataDir), + log: rl, + } +} + +// startup is called when the app starts. Must not block — Wails UI won't render until this returns. +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + _ = a.state.Load() + a.logEvent("vpnem " + Version + " started") + + // Sync + recommendation synchronously — UI won't render until done + if err := a.Sync(); err != nil { + a.logEvent("initial sync failed: " + err.Error()) + a.reportError("initial sync failed: " + err.Error()) + } else { + a.logEvent("initial sync ok") + } + + // Fetch recommendation synchronously — blocks until ready + a.fetchRecommendation() + + // Periodic refresh in background + go func() { + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := a.Sync(); err != nil { + a.logEvent("sync failed: " + err.Error()) + } + a.fetchRecommendation() + case <-ctx.Done(): + return + } + } + }() +} + +// shutdown is called when the app closes. +func (a *App) shutdown(ctx context.Context) { + a.logEvent("shutting down") + a.watchdog.StopWatching() + if err := a.engine.Stop(); err != nil { + a.logEvent("engine stop error: " + err.Error()) + } + // Fallback: kill any orphaned sing-box + killSingbox() + _ = a.state.Save() + a.logEvent("shutdown complete") + a.log.Close() +} + +func killSingbox() { + if runtime.GOOS == "windows" { + exec.Command("taskkill", "/F", "/IM", "sing-box.exe").Run() + } else { + exec.Command("pkill", "-f", "sing-box").Run() + } +} + +func (a *App) logEvent(msg string) { + line := time.Now().Format("15:04:05") + " " + msg + a.log.Add(line) + log.Println(msg) +} + +// --- Wails bindings --- + +// Sync fetches servers and rulesets from the API. +func (a *App) Sync() error { + catalog, err := a.fetcher.FetchCatalog() + if err != nil { + return fmt.Errorf("sync catalog: %w", err) + } + a.catalog = catalog + a.servers = syncpkg.CatalogToServers(catalog) + + rsResp, err := a.fetcher.FetchRuleSets() + if err != nil { + return fmt.Errorf("sync rulesets: %w", err) + } + + dataDir := a.state.DataDir() + a.ruleSets, err = a.fetcher.DownloadRuleSets(rsResp.RuleSets, dataDir) + if err != nil { + return fmt.Errorf("download rule-sets: %w", err) + } + + policy, err := a.fetcher.FetchRoutingPolicy() + if err != nil { + return fmt.Errorf("sync routing policy: %w", err) + } + a.policy = policy + + a.state.SetLastSync(time.Now()) + _ = a.state.Save() + a.logEvent(fmt.Sprintf("synced: %d servers, %d rulesets, routing policy %s", len(a.servers), len(a.ruleSets), a.policy.Version)) + + // Notify frontend to refresh + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "synced") + } + return nil +} + +func (a *App) applyProfile(serverTag, modeName string, logAction string) error { + server := a.findServer(serverTag) + if server == nil { + return fmt.Errorf("server not found: %s", serverTag) + } + mode := config.ModeByName(modeName) + if mode == nil { + return fmt.Errorf("mode not found: %s", modeName) + } + + serverIPs := syncpkg.ServerIPs(a.servers) + activeRuleSets := a.activeRuleSets(*mode) + customBypass := a.state.GetCustomBypass() + localProxyPort, err := engine.ResolveLocalProxyPort() + if err != nil { + return fmt.Errorf("allocate local proxy port: %w", err) + } + + a.logEvent(logAction + ": " + serverTag + " [" + modeName + "]") + + // Flush DNS cache before applying a new profile (Windows caches poisoned responses). + flushDNS() + + if err := a.engine.RestartFull(*server, *mode, activeRuleSets, serverIPs, customBypass, localProxyPort, a.policy); err != nil { + a.logEvent(logAction + " failed: " + err.Error()) + return err + } + + a.watchdog.StartWatching(*server, *mode, activeRuleSets, serverIPs, customBypass, localProxyPort, a.policy) + a.state.SetServer(serverTag) + a.state.SetMode(modeName) + a.state.SetLocalProxyPort(localProxyPort) + _ = a.state.Save() + a.logEvent("connected: " + serverTag) + + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "connected", serverTag) + } + + // Report connection to server for recommendation tracking + go a.ReportConnection(serverTag, server.Server, a.extractNodeID(serverTag)) + + go a.validateConnection() + return nil +} + +// Connect starts the VPN with the given server and mode. +func (a *App) Connect(serverTag, modeName string) error { + return a.applyProfile(serverTag, modeName, "connecting") +} + +// ApplyProfile switches the active server/mode live when connected. +// If the engine is not running yet, it only persists the selection for the next connect. +func (a *App) ApplyProfile(serverTag, modeName string) error { + server := a.findServer(serverTag) + if server == nil { + return fmt.Errorf("server not found: %s", serverTag) + } + mode := config.ModeByName(modeName) + if mode == nil { + return fmt.Errorf("mode not found: %s", modeName) + } + + current := a.state.Get() + if !a.engine.IsRunning() { + a.state.SetServer(serverTag) + a.state.SetMode(modeName) + _ = a.state.Save() + a.logEvent("profile selected: " + serverTag + " [" + modeName + "]") + return nil + } + + if current.SelectedServer == serverTag && current.SelectedMode == modeName { + return nil + } + + a.watchdog.StopWatching() + if err := a.applyProfile(serverTag, modeName, "switching profile"); err != nil { + return err + } + + a.state.SetServer(serverTag) + a.state.SetMode(modeName) + _ = a.state.Save() + return nil +} + +func flushDNS() { + if runtime.GOOS == "windows" { + exec.Command("ipconfig", "/flushdns").Run() + log.Println("DNS cache flushed") + } +} + +func (a *App) validateConnection() { + time.Sleep(3 * time.Second) + if !a.engine.IsRunning() { + return + } + + localProxyPort := a.engine.LocalProxyPort() + if localProxyPort == 0 { + localProxyPort = a.state.Get().LocalProxyPort + } + client, err := engine.HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 10*time.Second) + if err != nil { + a.logEvent("validation setup failed — " + err.Error()) + return + } + + mode := config.ModeByName(a.state.Get().SelectedMode) + requiresExitIP := mode != nil && engine.ModeRequiresExitIP(*mode) + + ip := getExitIP(client) + switch { + case ip != "": + a.logEvent("exit IP: " + ip) + case requiresExitIP: + a.logEvent("WARNING: could not verify exit IP") + default: + a.logEvent("validation: exit IP skipped for direct-final mode") + } + + statusCode, err := engine.ProbeBlockedSite(localProxyPort, engine.DefaultBlockedSiteProbeURL, 10*time.Second) + if err == nil { + a.logEvent(fmt.Sprintf("validation: rutracker.org → %d OK", statusCode)) + } else { + a.logEvent("validation: rutracker.org FAILED — " + err.Error()) + } +} + +// Disconnect stops the VPN and clears system proxy. +func (a *App) Disconnect() error { + // Report disconnect before stopping + if a.engine.IsRunning() { + st := a.state.Get() + server := a.findServer(st.SelectedServer) + if server != nil { + nodeID := a.extractNodeID(st.SelectedServer) + go a.ReportDisconnect(server.Server, nodeID) + } + } + + a.watchdog.StopWatching() + clearSystemProxy() + a.logEvent("disconnected") + return a.engine.Stop() +} + +// SetSystemProxy sets Windows system SOCKS5 proxy directly (no TUN needed). +// Fallback for when TUN/sing-box doesn't work with browser. +func (a *App) SetSystemProxy(serverTag string) error { + localProxyPort := a.engine.LocalProxyPort() + if localProxyPort == 0 { + localProxyPort = a.state.Get().LocalProxyPort + } + if !a.engine.IsRunning() || localProxyPort == 0 { + return fmt.Errorf("connect first to start the local proxy") + } + addr := fmt.Sprintf("%s:%d", config.LocalProxyHost, localProxyPort) + if runtime.GOOS == "windows" { + // Route Windows system proxy through the local SOCKS5 inbound. + exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f").Run() + exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + "/v", "ProxyServer", "/t", "REG_SZ", "/d", "socks="+addr, "/f").Run() + a.logEvent("system proxy set to local SOCKS5 inbound: " + addr) + } + return nil +} + +func clearSystemProxy() { + if runtime.GOOS == "windows" { + exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f").Run() + log.Println("system proxy cleared") + } +} + +// GetServers returns the server list grouped by region. +func (a *App) GetServers() []models.Server { + if a.servers == nil { + return []models.Server{} + } + return a.servers +} + +// GetRecommendedServerTag returns the tag of the recommended MULTI server. +// Only MULTI tags are returned (vless-reality + hysteria2). SOCKS5-only tags are never recommended. +// If the recommended IP doesn't match any MULTI server, falls back to first MULTI. +func (a *App) GetRecommendedServerTag() string { + // If we have a recommended IP, try to find a MULTI server for it + if a.recommendedServerIP != "" && len(a.servers) > 0 { + // Find MULTI server matching recommended IP + for _, s := range a.servers { + if s.Server == a.recommendedServerIP && isMultiServer(s) { + a.logEvent("GetRecommendedServerTag: MATCH " + s.Tag) + return s.Tag + } + } + a.logEvent("GetRecommendedServerTag: no MULTI match for IP " + a.recommendedServerIP) + } + + // Fallback: pick first MULTI server from the list + for _, s := range a.servers { + if isMultiServer(s) { + a.logEvent("GetRecommendedServerTag: fallback to " + s.Tag) + return s.Tag + } + } + + a.logEvent("GetRecommendedServerTag: no MULTI servers found") + return "" +} + +// isMultiServer checks if a server is a MULTI node (split TCP/UDP routing). +func isMultiServer(s models.Server) bool { + return s.Type == "multi" || len(s.Companions) > 0 +} + +// IsServerRecommended checks if a given server tag matches the recommendation. +func (a *App) IsServerRecommended(tag string) bool { + recTag := a.GetRecommendedServerTag() + return recTag != "" && tag == recTag +} + +// GetRecommendationReason returns the human-readable reason for the recommendation. +func (a *App) GetRecommendationReason() string { + return a.recommendReason +} + +// GetCatalog returns the current canonical server catalog. +func (a *App) GetCatalog() *models.CatalogV2 { + if a.catalog == nil { + return &models.CatalogV2{Version: "uninitialized", Nodes: []models.CatalogNode{}} + } + return a.catalog +} + +// GetModes returns all available mode names. +func (a *App) GetModes() []string { + return config.ModeNames() +} + +// GetStatus returns the current connection status. +func (a *App) GetStatus() map[string]any { + st := a.state.Get() + localProxyPort := a.engine.LocalProxyPort() + if localProxyPort == 0 { + localProxyPort = st.LocalProxyPort + } + return map[string]any{ + "connected": a.engine.IsRunning(), + "server": st.SelectedServer, + "mode": st.SelectedMode, + "lastSync": st.LastSync, + "autoConnect": st.AutoConnect, + "localProxyHost": config.LocalProxyHost, + "localProxyPort": localProxyPort, + "localProxyURL": fmt.Sprintf("%s:%d", config.LocalProxyHost, localProxyPort), + "localProxyScheme": "socks5", + } +} + +// GetExitIP checks the actual exit IP through the proxy. +func (a *App) GetExitIP() string { + localProxyPort := a.engine.LocalProxyPort() + if localProxyPort == 0 { + localProxyPort = a.state.Get().LocalProxyPort + } + client, err := engine.HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 5*time.Second) + if err != nil { + return "" + } + return getExitIP(client) +} + +func getExitIP(client *http.Client) string { + resp, err := client.Get("http://ifconfig.me/ip") + if err != nil { + return "" + } + defer resp.Body.Close() + buf := make([]byte, 64) + n, _ := resp.Body.Read(buf) + return string(buf[:n]) +} + +// SetAutoConnect updates the auto-connect setting. +func (a *App) SetAutoConnect(v bool) { + a.state.SetAutoConnect(v) + _ = a.state.Save() +} + +// GetRuleSets returns all rule-sets with their enabled status. +func (a *App) GetRuleSets() []map[string]any { + result := make([]map[string]any, 0) + for _, rs := range a.ruleSets { + enabled := !rs.Optional || a.state.IsRuleSetEnabled(rs.Tag) + result = append(result, map[string]any{ + "tag": rs.Tag, + "description": rs.Description, + "type": rs.Type, + "optional": rs.Optional, + "enabled": enabled, + }) + } + return result +} + +// SetRuleSetEnabled enables or disables an optional rule-set. +func (a *App) SetRuleSetEnabled(tag string, enabled bool) { + a.state.SetRuleSetEnabled(tag, enabled) + _ = a.state.Save() +} + +// GetBypassProcesses returns default + custom bypass processes. +func (a *App) GetBypassProcesses() map[string]any { + policy := config.EffectiveRoutingPolicy(a.policy) + return map[string]any{ + "default": policy.AlwaysDirectProcesses, + "custom": a.state.GetCustomBypass(), + } +} + +// AddBypassProcess adds a custom bypass process. +func (a *App) AddBypassProcess(name string) { + current := a.state.GetCustomBypass() + for _, p := range current { + if p == name { + return + } + } + a.state.SetCustomBypass(append(current, name)) + _ = a.state.Save() +} + +// RemoveBypassProcess removes a custom bypass process. +func (a *App) RemoveBypassProcess(name string) { + current := a.state.GetCustomBypass() + var filtered []string + for _, p := range current { + if p != name { + filtered = append(filtered, p) + } + } + a.state.SetCustomBypass(filtered) + _ = a.state.Save() +} + +// MeasureLatency pings all servers and returns sorted results. +func (a *App) MeasureLatency() []syncpkg.LatencyResult { + a.logEvent("measuring latency...") + results := syncpkg.MeasureLatency(a.servers, 3*time.Second) + for _, r := range results { + if r.Latency >= 0 { + a.logEvent(fmt.Sprintf(" %s: %dms", r.Tag, r.Latency)) + } + } + return results +} + +// GetLogs returns the last N log lines. +func (a *App) GetLogs() []string { + return a.log.Lines() +} + +// GetGeneratedConfig returns the current sing-box config JSON for diagnostics. +func (a *App) GetGeneratedConfig() string { + path := a.engine.ConfigPath() + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(data) +} + +// CheckUpdate checks if a new version is available. +func (a *App) CheckUpdate() (*syncpkg.UpdateInfo, error) { + return a.updater.Check() +} + +// DownloadUpdate downloads the new binary. Returns "restart_pending" on success. +// The caller should then shut down Wails gracefully — the OS will restart the app. +func (a *App) DownloadUpdate() (string, error) { + result, err := a.updater.Download() + if err != nil { + a.logEvent("update download failed: " + err.Error()) + return "", err + } + if result == "restart_pending" { + a.logEvent("update installed, restarting...") + // Shut down Wails gracefully — the OS Scheduled Task will relaunch + go func() { + time.Sleep(2 * time.Second) + wailsRuntime.Quit(a.ctx) + }() + } + return result, nil +} + +// RandomNLServer picks a random non-RU server tag. +func (a *App) RandomNLServer() string { + var candidates []string + for _, s := range a.servers { + if s.Region != "RU" { + candidates = append(candidates, s.Tag) + } + } + if len(candidates) == 0 { + return "" + } + return candidates[rand.Intn(len(candidates))] +} + +// fetchRecommendation fetches a recommended server from the API. +// Server auto-detects client real IP from X-Forwarded-For header. +func (a *App) fetchRecommendation() { + a.logEvent("fetchRecommendation: starting") + + // Check if we have a recent recommendation + recServer, recNodeID, recTime := a.state.GetRecommendation() + if recServer != "" && time.Since(recTime) < 15*time.Minute { + a.recommendedServerIP = recServer + a.recommendedNodeID = recNodeID + a.recommendReason = "cached" + a.logEvent("fetchRecommendation: using cached " + recServer) + return + } + + // Server detects client IP via X-Forwarded-For + rec, err := a.fetcher.GetRecommendation() + if err != nil { + a.logEvent("fetchRecommendation: error — " + err.Error()) + // Fallback: keep stale recommendation if available + if recServer != "" { + a.recommendedServerIP = recServer + a.recommendedNodeID = recNodeID + a.recommendReason = "stale (server unreachable)" + a.logEvent("fetchRecommendation: using stale " + recServer) + } + return + } + + if rec.RecommendedServerIP == "" { + a.logEvent("fetchRecommendation: empty recommendation from server") + return + } + + a.recommendedServerIP = rec.RecommendedServerIP + a.recommendedNodeID = rec.RecommendedNodeID + a.recommendReason = rec.Reason + if rec.IsRebalance { + a.recommendReason += " (ребалансировка)" + } + + a.state.SetRecommendation(rec.RecommendedServerIP, rec.RecommendedNodeID) + _ = a.state.Save() + + a.logEvent("fetchRecommendation: set " + rec.RecommendedServerIP + " — " + rec.Reason) + if rec.LoadInfo != "" { + a.logEvent(" load: " + rec.LoadInfo) + } +} + +// GetRecommendation returns the current recommendation data. +func (a *App) GetRecommendation() map[string]any { + return map[string]any{ + "server_ip": a.recommendedServerIP, + "node_id": a.recommendedNodeID, + "reason": a.recommendReason, + "studio_clients": a.studioClients, + } +} + +// ReportConnection sends a connect notification to the server. +// Server auto-detects client real IP from X-Forwarded-For. +func (a *App) ReportConnection(serverTag, serverIP, nodeID string) { + a.logEvent("report connect: " + serverTag + " → " + serverIP) + + resp, err := a.fetcher.ReportConnect(serverIP, nodeID) + if err != nil { + a.logEvent("report connect error: " + err.Error()) + return + } + + // Update recommendation if server returned a different one + if resp.RecommendedServerIP != "" && resp.RecommendedServerIP != a.recommendedServerIP { + old := a.recommendedServerIP + a.recommendedServerIP = resp.RecommendedServerIP + a.recommendedNodeID = resp.RecommendedNodeID + a.recommendReason = resp.Reason + if resp.IsRebalance { + a.recommendReason += " (ребалансировка)" + } + a.state.SetRecommendation(resp.RecommendedServerIP, resp.RecommendedNodeID) + _ = a.state.Save() + a.logEvent("recommendation updated: " + old + " → " + resp.RecommendedServerIP) + } +} + +// ReportDisconnect notifies the server of disconnection. +func (a *App) ReportDisconnect(serverIP, nodeID string) { + a.logEvent("report disconnect: " + serverIP) + if err := a.fetcher.ReportDisconnect(serverIP, nodeID); err != nil { + a.logEvent("report disconnect error: " + err.Error()) + } +} + +func (a *App) extractNodeID(serverTag string) string { + // Tags like "nl-198-multi", "nl-198-vless-reality", "nl-198-socks5" -> "nl-198" + for _, suffix := range []string{"-multi", "-vless-reality", "-vless", "-vmess", "-shadowsocks", "-hysteria2", "-socks5", "-socks"} { + if len(serverTag) > len(suffix) && serverTag[len(serverTag)-len(suffix):] == suffix { + return serverTag[:len(serverTag)-len(suffix)] + } + } + return serverTag +} + +func (a *App) findServer(tag string) *models.Server { + for _, s := range a.servers { + if s.Tag == tag { + return &s + } + } + return nil +} + +// activeRuleSets returns rule-sets for the mode PLUS domain rule-sets +// always needed for DNS anti-poisoning (refilter-domains etc). +func (a *App) activeRuleSets(mode config.Mode) []models.RuleSet { + needed := make(map[string]bool) + + // Rule-sets referenced by route rules + for _, r := range mode.Rules { + for _, tag := range r.RuleSet { + needed[tag] = true + } + } + + // Always include domain-type rule-sets for DNS rules + // (prevents ISP DNS poisoning for blocked domains) + for _, rs := range a.ruleSets { + if !rs.Optional && rs.Type == "domain" { + needed[rs.Tag] = true + } + } + + var result []models.RuleSet + for _, rs := range a.ruleSets { + if needed[rs.Tag] { + result = append(result, rs) + } + } + return result +} + +func (a *App) reportError(msg string) { + if a.fetcher == nil { + return + } + osName := "linux" + if runtime.GOOS == "windows" { + osName = "windows" + } + go a.fetcher.ReportError(Version, osName, a.log.Lines()) +} diff --git a/cmd/client/frontend/wailsjs/go/main/App.d.ts b/cmd/client/frontend/wailsjs/go/main/App.d.ts new file mode 100755 index 0000000..9f3f061 --- /dev/null +++ b/cmd/client/frontend/wailsjs/go/main/App.d.ts @@ -0,0 +1,60 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {sync} from '../models'; +import {models} from '../models'; + +export function AddBypassProcess(arg1:string):Promise<void>; + +export function ApplyProfile(arg1:string,arg2:string):Promise<void>; + +export function CheckUpdate():Promise<sync.UpdateInfo>; + +export function Connect(arg1:string,arg2:string):Promise<void>; + +export function Disconnect():Promise<void>; + +export function DownloadUpdate():Promise<string>; + +export function GetBypassProcesses():Promise<Record<string, any>>; + +export function GetCatalog():Promise<models.CatalogV2>; + +export function GetExitIP():Promise<string>; + +export function GetGeneratedConfig():Promise<string>; + +export function GetLogs():Promise<Array<string>>; + +export function GetModes():Promise<Array<string>>; + +export function GetRecommendation():Promise<Record<string, any>>; + +export function GetRecommendationReason():Promise<string>; + +export function GetRecommendedServerTag():Promise<string>; + +export function GetRuleSets():Promise<Array<Record<string, any>>>; + +export function GetServers():Promise<Array<models.Server>>; + +export function GetStatus():Promise<Record<string, any>>; + +export function IsServerRecommended(arg1:string):Promise<boolean>; + +export function MeasureLatency():Promise<Array<sync.LatencyResult>>; + +export function RandomNLServer():Promise<string>; + +export function RemoveBypassProcess(arg1:string):Promise<void>; + +export function ReportConnection(arg1:string,arg2:string,arg3:string):Promise<void>; + +export function ReportDisconnect(arg1:string,arg2:string):Promise<void>; + +export function SetAutoConnect(arg1:boolean):Promise<void>; + +export function SetRuleSetEnabled(arg1:string,arg2:boolean):Promise<void>; + +export function SetSystemProxy(arg1:string):Promise<void>; + +export function Sync():Promise<void>; diff --git a/cmd/client/frontend/wailsjs/go/main/App.js b/cmd/client/frontend/wailsjs/go/main/App.js new file mode 100755 index 0000000..e85beea --- /dev/null +++ b/cmd/client/frontend/wailsjs/go/main/App.js @@ -0,0 +1,115 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function AddBypassProcess(arg1) { + return window['go']['main']['App']['AddBypassProcess'](arg1); +} + +export function ApplyProfile(arg1, arg2) { + return window['go']['main']['App']['ApplyProfile'](arg1, arg2); +} + +export function CheckUpdate() { + return window['go']['main']['App']['CheckUpdate'](); +} + +export function Connect(arg1, arg2) { + return window['go']['main']['App']['Connect'](arg1, arg2); +} + +export function Disconnect() { + return window['go']['main']['App']['Disconnect'](); +} + +export function DownloadUpdate() { + return window['go']['main']['App']['DownloadUpdate'](); +} + +export function GetBypassProcesses() { + return window['go']['main']['App']['GetBypassProcesses'](); +} + +export function GetCatalog() { + return window['go']['main']['App']['GetCatalog'](); +} + +export function GetExitIP() { + return window['go']['main']['App']['GetExitIP'](); +} + +export function GetGeneratedConfig() { + return window['go']['main']['App']['GetGeneratedConfig'](); +} + +export function GetLogs() { + return window['go']['main']['App']['GetLogs'](); +} + +export function GetModes() { + return window['go']['main']['App']['GetModes'](); +} + +export function GetRecommendation() { + return window['go']['main']['App']['GetRecommendation'](); +} + +export function GetRecommendationReason() { + return window['go']['main']['App']['GetRecommendationReason'](); +} + +export function GetRecommendedServerTag() { + return window['go']['main']['App']['GetRecommendedServerTag'](); +} + +export function GetRuleSets() { + return window['go']['main']['App']['GetRuleSets'](); +} + +export function GetServers() { + return window['go']['main']['App']['GetServers'](); +} + +export function GetStatus() { + return window['go']['main']['App']['GetStatus'](); +} + +export function IsServerRecommended(arg1) { + return window['go']['main']['App']['IsServerRecommended'](arg1); +} + +export function MeasureLatency() { + return window['go']['main']['App']['MeasureLatency'](); +} + +export function RandomNLServer() { + return window['go']['main']['App']['RandomNLServer'](); +} + +export function RemoveBypassProcess(arg1) { + return window['go']['main']['App']['RemoveBypassProcess'](arg1); +} + +export function ReportConnection(arg1, arg2, arg3) { + return window['go']['main']['App']['ReportConnection'](arg1, arg2, arg3); +} + +export function ReportDisconnect(arg1, arg2) { + return window['go']['main']['App']['ReportDisconnect'](arg1, arg2); +} + +export function SetAutoConnect(arg1) { + return window['go']['main']['App']['SetAutoConnect'](arg1); +} + +export function SetRuleSetEnabled(arg1, arg2) { + return window['go']['main']['App']['SetRuleSetEnabled'](arg1, arg2); +} + +export function SetSystemProxy(arg1) { + return window['go']['main']['App']['SetSystemProxy'](arg1); +} + +export function Sync() { + return window['go']['main']['App']['Sync'](); +} diff --git a/cmd/client/frontend/wailsjs/go/models.ts b/cmd/client/frontend/wailsjs/go/models.ts new file mode 100755 index 0000000..0f8dc72 --- /dev/null +++ b/cmd/client/frontend/wailsjs/go/models.ts @@ -0,0 +1,319 @@ +export namespace models { + + export class CatalogAuth { + uuid?: string; + method?: string; + password?: string; + + static createFrom(source: any = {}) { + return new CatalogAuth(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.uuid = source["uuid"]; + this.method = source["method"]; + this.password = source["password"]; + } + } + export class Reality { + enabled?: boolean; + public_key?: string; + private_key?: string; + short_id?: string; + fingerprint?: string; + + static createFrom(source: any = {}) { + return new Reality(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.public_key = source["public_key"]; + this.private_key = source["private_key"]; + this.short_id = source["short_id"]; + this.fingerprint = source["fingerprint"]; + } + } + export class TLS { + enabled: boolean; + server_name?: string; + insecure?: boolean; + alpn?: string[]; + min_version?: string; + max_version?: string; + reality?: Reality; + + static createFrom(source: any = {}) { + return new TLS(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.server_name = source["server_name"]; + this.insecure = source["insecure"]; + this.alpn = source["alpn"]; + this.min_version = source["min_version"]; + this.max_version = source["max_version"]; + this.reality = this.convertValues(source["reality"], Reality); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class CatalogProtocol { + type: string; + enabled: boolean; + port: number; + tls?: TLS; + auth?: CatalogAuth; + extra?: Record<string, any>; + + static createFrom(source: any = {}) { + return new CatalogProtocol(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.type = source["type"]; + this.enabled = source["enabled"]; + this.port = source["port"]; + this.tls = this.convertValues(source["tls"], TLS); + this.auth = this.convertValues(source["auth"], CatalogAuth); + this.extra = source["extra"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class CatalogNode { + id: string; + name: string; + provider?: string; + region: string; + host: string; + domain?: string; + public_host: string; + protocols: CatalogProtocol[]; + status?: string; + tags?: string[]; + metadata?: Record<string, any>; + + static createFrom(source: any = {}) { + return new CatalogNode(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.provider = source["provider"]; + this.region = source["region"]; + this.host = source["host"]; + this.domain = source["domain"]; + this.public_host = source["public_host"]; + this.protocols = this.convertValues(source["protocols"], CatalogProtocol); + this.status = source["status"]; + this.tags = source["tags"]; + this.metadata = source["metadata"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + export class CatalogV2 { + version: string; + nodes: CatalogNode[]; + + static createFrom(source: any = {}) { + return new CatalogV2(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.version = source["version"]; + this.nodes = this.convertValues(source["nodes"], CatalogNode); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + export class Transport { + type?: string; + path?: string; + + static createFrom(source: any = {}) { + return new Transport(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.type = source["type"]; + this.path = source["path"]; + } + } + export class Server { + tag: string; + region: string; + type: string; + server: string; + server_port: number; + udp_over_tcp?: boolean; + uuid?: string; + method?: string; + password?: string; + obfs_password?: string; + up_mbps?: number; + down_mbps?: number; + tls?: TLS; + transport?: Transport; + companions?: Server[]; + + static createFrom(source: any = {}) { + return new Server(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.tag = source["tag"]; + this.region = source["region"]; + this.type = source["type"]; + this.server = source["server"]; + this.server_port = source["server_port"]; + this.udp_over_tcp = source["udp_over_tcp"]; + this.uuid = source["uuid"]; + this.method = source["method"]; + this.password = source["password"]; + this.obfs_password = source["obfs_password"]; + this.up_mbps = source["up_mbps"]; + this.down_mbps = source["down_mbps"]; + this.tls = this.convertValues(source["tls"], TLS); + this.transport = this.convertValues(source["transport"], Transport); + this.companions = this.convertValues(source["companions"], Server); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + +} + +export namespace sync { + + export class LatencyResult { + tag: string; + region: string; + latency_ms: number; + + static createFrom(source: any = {}) { + return new LatencyResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.tag = source["tag"]; + this.region = source["region"]; + this.latency_ms = source["latency_ms"]; + } + } + export class UpdateInfo { + available: boolean; + version: string; + changelog: string; + current_version: string; + + static createFrom(source: any = {}) { + return new UpdateInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.available = source["available"]; + this.version = source["version"]; + this.changelog = source["changelog"]; + this.current_version = source["current_version"]; + } + } + +} + diff --git a/cmd/client/frontend/wailsjs/runtime/package.json b/cmd/client/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/cmd/client/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony <lea.anthony@gmail.com>", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/cmd/client/frontend/wailsjs/runtime/runtime.d.ts b/cmd/client/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/cmd/client/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,330 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise<boolean>; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise<Size>; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise<Position>; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise<boolean>; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise<boolean>; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise<boolean>; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise<Screen[]>; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise<EnvironmentInfo>; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise<string>; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise<boolean>; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise<void>; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise<void>; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise<boolean>; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise<boolean>; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise<boolean>; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise<void>; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise<void>; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise<void>; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise<void>; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise<void>; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise<void>; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise<void>; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise<void>;
\ No newline at end of file diff --git a/cmd/client/frontend/wailsjs/runtime/runtime.js b/cmd/client/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/cmd/client/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,298 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +}
\ No newline at end of file diff --git a/cmd/client/kill_other.go b/cmd/client/kill_other.go new file mode 100644 index 0000000..75ac0a5 --- /dev/null +++ b/cmd/client/kill_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package main + +import ( + "log" + "os/exec" + "time" +) + +func killPrevious() { + log.Println("killing previous instances") + exec.Command("pkill", "-f", "sing-box").Run() + // Don't pkill vpnem — we ARE vpnem + time.Sleep(500 * time.Millisecond) +} diff --git a/cmd/client/kill_windows.go b/cmd/client/kill_windows.go new file mode 100644 index 0000000..ba8c75a --- /dev/null +++ b/cmd/client/kill_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package main + +import ( + "log" + "os" + "os/exec" + "strconv" + "time" +) + +func killPrevious() { + myPID := os.Getpid() + log.Printf("killing previous instances (my PID: %d)", myPID) + + // Kill other vpnem.exe processes (not ourselves) + // Use WMIC to find PIDs, then taskkill each except ours + out, _ := exec.Command("wmic", "process", "where", + "name='vpnem.exe'", "get", "processid", "/format:list").Output() + for _, line := range splitLines(string(out)) { + if len(line) > 10 && line[:10] == "ProcessId=" { + pidStr := line[10:] + pid, err := strconv.Atoi(pidStr) + if err == nil && pid != myPID { + log.Printf("killing old vpnem.exe PID %d", pid) + exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)).Run() + } + } + } + + // Kill any orphaned sing-box.exe + exec.Command("taskkill", "/F", "/IM", "sing-box.exe").Run() + time.Sleep(500 * time.Millisecond) +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' || s[i] == '\r' { + line := s[start:i] + if len(line) > 0 && line[len(line)-1] == '\r' { + line = line[:len(line)-1] + } + if line != "" { + lines = append(lines, line) + } + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..a2cabbf --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "embed" + "flag" + "log" + "os" + "path/filepath" + "runtime" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed frontend/dist +var assets embed.FS + +func main() { + apiURL := flag.String("api", "https://vpn.em-sysadmin.xyz", "API server URL") + dataDir := flag.String("data", defaultDataDir(), "data directory") + flag.Parse() + + // Ensure data dir exists + os.MkdirAll(*dataDir, 0o755) + + // Setup file logging so we always have diagnostics + logPath := filepath.Join(*dataDir, "vpnem.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err == nil { + log.SetOutput(logFile) + defer logFile.Close() + } + + log.Printf("vpnem starting, data=%s, api=%s, os=%s", *dataDir, *apiURL, runtime.GOOS) + + // Kill previous instances of vpnem and sing-box + killPrevious() + + // Clean stale update artifacts from crashed updates + if runtime.GOOS == "windows" { + exe, _ := os.Executable() + os.Remove(exe + ".old") // leftover from failed update + os.Remove(exe + ".tmp") // leftover temp + os.Remove(filepath.Join(*dataDir, "vpnem-new.exe")) + } else { + exe, _ := os.Executable() + os.Remove(exe + ".old") + os.Remove(filepath.Join(*dataDir, "vpnem-new")) + } + + // Check wintun.dll on Windows + if runtime.GOOS == "windows" { + exe, _ := os.Executable() + exeDir := filepath.Dir(exe) + wintunPaths := []string{ + filepath.Join(exeDir, "wintun.dll"), + filepath.Join(*dataDir, "wintun.dll"), + "wintun.dll", + } + found := false + for _, p := range wintunPaths { + if _, err := os.Stat(p); err == nil { + log.Printf("wintun.dll found: %s", p) + found = true + break + } + } + if !found { + log.Printf("WARNING: wintun.dll not found! TUN will fail. Searched: %v", wintunPaths) + } + } + + app := NewApp(*dataDir, *apiURL) + + log.Println("starting Wails UI") + if err := wails.Run(&options.App{ + Title: "vpnem", + Width: 480, + Height: 600, + MinWidth: 400, + MinHeight: 500, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{ + app, + }, + }); err != nil { + log.Printf("FATAL wails error: %v", err) + // On Windows, also write to a visible error file + if runtime.GOOS == "windows" { + errPath := filepath.Join(*dataDir, "ERROR.txt") + os.WriteFile(errPath, []byte("vpnem failed to start:\n"+err.Error()+"\n\nCheck vpnem.log for details.\n"), 0o644) + } + os.Exit(1) + } +} + +func defaultDataDir() string { + if runtime.GOOS == "windows" { + return `C:\ProgramData\vpnem` + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "vpnem") +} diff --git a/cmd/client/wails.json b/cmd/client/wails.json new file mode 100644 index 0000000..0f64984 --- /dev/null +++ b/cmd/client/wails.json @@ -0,0 +1,7 @@ +{ + "name": "vpnem", + "outputfilename": "vpnem", + "author": { + "name": "sergei" + } +} diff --git a/cmd/installer/elevate_windows.go b/cmd/installer/elevate_windows.go new file mode 100644 index 0000000..f174390 --- /dev/null +++ b/cmd/installer/elevate_windows.go @@ -0,0 +1,74 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "strings" + "syscall" + "unsafe" +) + +var ( + shell32 = syscall.NewLazyDLL("shell32.dll") + shellExecuteW = shell32.NewProc("ShellExecuteW") + isUserAnAdminProc = shell32.NewProc("IsUserAnAdmin") + errCancelledByUser = uintptr(1223) +) + +func isElevated() bool { + ret, _, _ := isUserAnAdminProc.Call() + return ret != 0 +} + +func relaunchElevated() error { + exePath, err := os.Executable() + if err != nil { + return err + } + + verb, err := syscall.UTF16PtrFromString("runas") + if err != nil { + return err + } + file, err := syscall.UTF16PtrFromString(exePath) + if err != nil { + return err + } + args, err := syscall.UTF16PtrFromString(joinWindowsArgs(os.Args[1:])) + if err != nil { + return err + } + + ret, _, callErr := shellExecuteW.Call( + 0, + uintptr(unsafe.Pointer(verb)), + uintptr(unsafe.Pointer(file)), + uintptr(unsafe.Pointer(args)), + 0, + 1, + ) + if ret <= 32 { + if ret == errCancelledByUser { + return fmt.Errorf("administrator privileges were not granted") + } + if callErr != syscall.Errno(0) { + return fmt.Errorf("ShellExecuteW failed: %w", callErr) + } + return fmt.Errorf("ShellExecuteW failed with code %d", ret) + } + return nil +} + +func joinWindowsArgs(args []string) string { + if len(args) == 0 { + return "" + } + quoted := make([]string, 0, len(args)) + for _, arg := range args { + escaped := strings.ReplaceAll(arg, `"`, `\"`) + quoted = append(quoted, `"`+escaped+`"`) + } + return strings.Join(quoted, " ") +} diff --git a/cmd/installer/main.go b/cmd/installer/main.go new file mode 100644 index 0000000..07f7fe8 --- /dev/null +++ b/cmd/installer/main.go @@ -0,0 +1,236 @@ +// vpnem-installer: Windows offline installer (GUI, no console window). +// Bundles all binaries — no network download needed. +// Installs to Program Files, creates Task Scheduler task for UAC-free launch. +// Requires admin (one-time). Supports silent mode: vpnem-installer.exe /S +// Cross-compiles from Linux with -ldflags "-H windowsgui" +package main + +import ( + "embed" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +//go:embed files/* +var bundledFiles embed.FS + +const ( + installDir = `C:\Program Files\vpnem` + dataDir = `C:\ProgramData\vpnem` + taskName = "vpnem" + + baseURL = "https://vpn.em-sysadmin.xyz/releases" + vpnemURL = baseURL + "/vpnem-windows-amd64.exe" +) + +var ( + silent bool + noShortcut bool + launch bool + logFile *os.File +) + +func main() { + // Parse flags + for _, arg := range os.Args[1:] { + a := strings.ToLower(strings.TrimLeft(arg, "/-")) + switch a { + case "s", "silent": + silent = true + case "noshortcut": + noShortcut = true + case "launch": + launch = true + } + } + + if !isElevated() { + if err := relaunchElevated(); err != nil { + fatal("request administrator privileges: %v", err) + } + return + } + + // 1. Kill ALL running instances immediately + step("stopping running instances") + exec.Command("taskkill", "/F", "/IM", "vpnem.exe").Run() + exec.Command("taskkill", "/F", "/IM", "sing-box.exe").Run() + time.Sleep(2 * time.Second) + + // 2. Remove OLD installation directories completely (clean slate) + step("removing old installation") + if err := os.RemoveAll(installDir); err != nil { + log.Printf("warning: could not remove old install dir: %v", err) + } + if err := os.RemoveAll(dataDir); err != nil { + log.Printf("warning: could not remove old data dir: %v", err) + } + // Also remove old ProxySwitcher if exists + os.RemoveAll(`C:\ProxySwitcher`) + + // 3. Create fresh directories + if err := os.MkdirAll(installDir, 0o755); err != nil { + fatal("create install dir: %v", err) + } + if err := os.MkdirAll(dataDir, 0o755); err != nil { + fatal("create data dir: %v", err) + } + + // Write bundled files instead of downloading + step("extracting bundled vpnem.exe") + if err := writeEmbedded("files/vpnem.exe", filepath.Join(installDir, "vpnem.exe")); err != nil { + fatal("extract vpnem.exe: %v", err) + } + step("extracting bundled sing-box.exe") + if err := writeEmbedded("files/sing-box.exe", filepath.Join(installDir, "sing-box.exe")); err != nil { + fatal("extract sing-box.exe: %v", err) + } + step("extracting bundled wintun.dll") + if err := writeEmbedded("files/wintun.dll", filepath.Join(installDir, "wintun.dll")); err != nil { + fatal("extract wintun.dll: %v", err) + } + + // Create Task Scheduler task — runs vpnem as admin WITHOUT UAC prompt. + // This is the key: task created by admin runs with highest privileges silently. + step("creating scheduled task (UAC-free launch)") + createTask() + + // Desktop shortcut — launches via schtasks (no UAC popup) + if !noShortcut { + step("creating desktop shortcut") + createShortcut() + } + + step("installation complete") + + if launch { + step("launching vpnem") + exec.Command("schtasks", "/Run", "/TN", taskName).Run() + } + + if !silent { + showDoneMessage() + } +} + +// createTask sets up a scheduled task that: +// 1. Runs vpnem.exe with highest privileges (no UAC) +// 2. Starts at logon with 15s delay (autostart) +// The same task is used for both manual launch and autostart. +func createTask() { + exec.Command("schtasks", "/Delete", "/TN", taskName, "/F").Run() + + // Create XML task definition for full control + exePath := filepath.Join(installDir, "vpnem.exe") + xml := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-16"?> +<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> + <RegistrationInfo> + <Description>vpnem VPN client</Description> + </RegistrationInfo> + <Triggers> + <LogonTrigger> + <Enabled>true</Enabled> + <Delay>PT15S</Delay> + </LogonTrigger> + </Triggers> + <Principals> + <Principal> + <LogonType>InteractiveToken</LogonType> + <RunLevel>HighestAvailable</RunLevel> + </Principal> + </Principals> + <Settings> + <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> + <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> + <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> + <ExecutionTimeLimit>PT0S</ExecutionTimeLimit> + <AllowStartOnDemand>true</AllowStartOnDemand> + <AllowHardTerminate>true</AllowHardTerminate> + </Settings> + <Actions> + <Exec> + <Command>%s</Command> + <Arguments>--data "%s"</Arguments> + <WorkingDirectory>%s</WorkingDirectory> + </Exec> + </Actions> +</Task>`, exePath, dataDir, installDir) + + xmlPath := filepath.Join(dataDir, "task.xml") + os.WriteFile(xmlPath, []byte(xml), 0o644) + + cmd := exec.Command("schtasks", "/Create", "/TN", taskName, "/XML", xmlPath, "/F") + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("task create failed: %v\n%s", err, string(out)) + } else { + log.Println("task created ok") + } + os.Remove(xmlPath) +} + +// createShortcut makes a desktop shortcut that runs the scheduled task (no UAC) +// IconLocation points to vpnem.exe so the shortcut has the app icon +func createShortcut() { + exePath := filepath.Join(installDir, "vpnem.exe") + ps := ` +$ws = New-Object -ComObject WScript.Shell +$s = $ws.CreateShortcut("$env:USERPROFILE\Desktop\vpnem.lnk") +$s.TargetPath = "schtasks.exe" +$s.Arguments = "/Run /TN vpnem" +$s.WorkingDirectory = "` + installDir + `" +$s.IconLocation = "` + exePath + `,0" +$s.Description = "vpnem VPN client" +$s.Save() +` + cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps) + if err := cmd.Run(); err != nil { + log.Printf("shortcut failed: %v (non-critical)", err) + } +} + +func step(msg string) { + log.Println(msg) +} + +func writeEmbedded(name, dest string) error { + data, err := bundledFiles.ReadFile(name) + if err != nil { + return fmt.Errorf("read embedded file %s: %w", name, err) + } + tmp := dest + ".tmp" + if err := os.WriteFile(tmp, data, 0o755); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + info, _ := os.Stat(tmp) + log.Printf(" %s (%.1f MB)", filepath.Base(dest), float64(info.Size())/1024/1024) + return os.Rename(tmp, dest) +} + +func fatal(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + log.Printf("FATAL: %s", msg) + if silent { + os.Exit(1) + } + showError(msg) + os.Exit(1) +} + +func showDoneMessage() { + msg := fmt.Sprintf("vpnem installed to %s\\n\\nDesktop shortcut created.\\nAutostart at logon enabled.\\n\\nNo admin prompts needed to launch.", installDir) + exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show("%s", "vpnem installer", "OK", "Information")`, msg), + ).Run() +} + +func showError(msg string) { + exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show("Installation failed:\n%s", "vpnem installer", "OK", "Error")`, msg), + ).Run() +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4382c70 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "flag" + "log" + "net/http" + + "vpnem/internal/api" + "vpnem/internal/rules" +) + +func main() { + addr := flag.String("addr", ":8090", "listen address") + dataDir := flag.String("data", "./data", "path to data directory") + flag.Parse() + + store := rules.NewStore(*dataDir) + + // Verify data loads on startup + if _, err := store.LoadServers(); err != nil { + log.Fatalf("failed to load servers.json: %v", err) + } + if _, err := store.LoadRuleSets(); err != nil { + log.Fatalf("failed to load rulesets.json: %v", err) + } + if _, err := store.LoadVersion(); err != nil { + log.Fatalf("failed to load version.json: %v", err) + } + + router := api.NewRouter(store) + + log.Printf("vpnem server listening on %s", *addr) + if err := http.ListenAndServe(*addr, router); err != nil { + log.Fatal(err) + } +} diff --git a/data/routing-policy.json b/data/routing-policy.json new file mode 100644 index 0000000..f3b7649 --- /dev/null +++ b/data/routing-policy.json @@ -0,0 +1,234 @@ +{ + "version": "2026-04-04", + "always_direct_processes": [ + "QTranslate.exe", + "aspia_host.exe", + "aspia_host_service.exe", + "aspia_desktop_agent.exe", + "Performer Application v5.x.exe", + "chromium.exe", + "\u042f\u043d\u0434\u0435\u043a\u0441 \u041c\u0443\u0437\u044b\u043a\u0430.exe" + ], + "prefer_direct_processes": [ + "obs64.exe" + ], + "proxyable_browser_processes": [ + "chrome.exe", + "firefox.exe", + "msedgewebview2.exe" + ], + "lovense_process_regex": [ + "(?i).*lovense.*" + ], + "static_bypass_ips": [ + "5.180.97.200/32", + "5.180.97.199/32", + "5.180.97.198/32", + "5.180.97.197/32", + "5.180.97.181/32", + "84.252.100.166/32", + "84.252.100.165/32", + "84.252.100.161/32", + "84.252.100.117/32", + "84.252.100.103/32", + "109.107.175.41/32", + "146.103.104.48/32", + "77.105.138.163/32", + "91.84.113.225/32", + "146.103.98.171/32", + "94.103.88.252/32", + "178.20.44.93/32", + "89.124.70.47/32" + ], + "reserved_cidrs": [ + "100.64.0.0/10", + "192.0.0.0/24", + "192.0.2.0/24", + "198.51.100.0/24", + "203.0.113.0/24", + "240.0.0.0/4", + "255.255.255.255/32" + ], + "local_domain_suffixes": [ + "local", + "localhost", + "lan", + "internal", + "home.arpa", + "corp", + "intranet", + "test", + "invalid", + "example", + "home", + "localdomain" + ], + "windows_ncsi_domains": [ + "msftconnecttest.com", + "msftncsi.com" + ], + "infra_bypass_domains": [ + "em-sysadmin.xyz" + ], + "forced_proxy_ips": [ + "65.21.33.248/32", + "91.132.135.38/32" + ], + "telegram_processes": [ + "Telegram.exe" + ], + "telegram_process_regex": [ + "(?i).*telegram.*\\\\telegram\\.exe$" + ], + "telegram_domains": [ + "telegram.org", + "telegram.me", + "t.me", + "telegra.ph", + "telegram.dog" + ], + "telegram_domain_regex": [ + ".*telegram.*", + ".*t\\.me.*" + ], + "telegram_ips": [ + "91.108.56.0/22", + "91.108.4.0/22", + "91.108.8.0/22", + "91.108.16.0/22", + "91.108.12.0/22", + "149.154.160.0/20", + "91.105.192.0/23", + "91.108.20.0/22", + "185.76.151.0/24" + ], + "blocked_domains": [ + "telegram.org", + "t.me", + "telegram.me", + "telegra.ph", + "telegram.dog", + "web.telegram.org", + "discord.com", + "discord.gg", + "discordapp.com", + "discordapp.net", + "instagram.com", + "cdninstagram.com", + "ig.me", + "igcdn.com", + "facebook.com", + "fb.com", + "fbcdn.net", + "fbsbx.com", + "fb.me", + "whatsapp.com", + "whatsapp.net", + "twitter.com", + "x.com", + "twimg.com", + "t.co", + "openai.com", + "chatgpt.com", + "oaistatic.com", + "oaiusercontent.com", + "claude.ai", + "anthropic.com", + "youtube.com", + "googlevideo.com", + "youtu.be", + "ggpht.com", + "ytimg.com", + "gstatic.com", + "doubleclick.net", + "googleadservices.com", + "stripchat.com", + "stripchat.global", + "ststandard.com", + "strpssts-ana.com", + "strpst.com", + "striiiipst.com", + "chaturbate.com", + "highwebmedia.com", + "cb.dev", + "camsoda.com", + "cam4.com", + "cam101.com", + "bongamodels.com", + "flirt4free.com", + "privatecams.com", + "streamray.com", + "cams.com", + "homelivesex.com", + "skyprivate.com", + "mywebcamroom.com", + "livemediahost.com", + "xcdnpro.com", + "mmcdn.com", + "vscdns.com", + "bgicdn.com", + "bgmicdn.com", + "doppiocdn.com", + "doppiocdn.net", + "doppiostreams.com", + "fanclubs.tech", + "my.club", + "chapturist.com", + "moengage.com", + "amplitude.com", + "dwin1.com", + "eizzih.com", + "loo3laej.com", + "iesnare.com", + "hytto.com", + "zendesk.com", + "lovense.com", + "lovense-api.com", + "lovense.club", + "bitrix24.ru", + "bitrix24.com", + "cloudflare.com", + "viber.com", + "linkedin.com", + "spotify.com", + "ntc.party", + "ipify.org", + "rutracker.org", + "rutracker.net", + "rutracker.me", + "4pda.to", + "kinozal.tv", + "nnmclub.to", + "protonmail.com", + "proton.me", + "tutanota.com", + "medium.com", + "archive.org", + "soundcloud.com", + "twitch.tv", + "ifconfig.me", + "ifconfig.co", + "icanhazip.com", + "ipinfo.io", + "em-mail.ru" + ], + "proxy_dns_domains": [ + "lovense.com", + "lovense-api.com", + "lovense.club", + "anthropic.com", + "igcdn.com", + "fbsbx.com", + "ifconfig.me", + "ifconfig.co", + "icanhazip.com", + "ipinfo.io", + "ipify.org" + ], + "ip_check_domains": [ + "ifconfig.me", + "ifconfig.co", + "icanhazip.com", + "ipinfo.io" + ] +} diff --git a/data/rulesets.json b/data/rulesets.json new file mode 100644 index 0000000..58c72ca --- /dev/null +++ b/data/rulesets.json @@ -0,0 +1,52 @@ +{ + "rule_sets": [ + { + "tag": "refilter-domains", + "description": "Заблокированные домены РФ (Re-filter)", + "url": "https://vpn.em-sysadmin.xyz/rules/refilter-domains.srs", + "format": "binary", + "type": "domain", + "optional": false + }, + { + "tag": "refilter-ip", + "description": "Заблокированные IP РФ (Re-filter)", + "url": "https://vpn.em-sysadmin.xyz/rules/refilter-ip.srs", + "format": "binary", + "type": "ip", + "optional": false + }, + { + "tag": "discord-voice", + "description": "Discord voice server IPs", + "url": "https://vpn.em-sysadmin.xyz/rules/discord-voice-ip-list.srs", + "format": "binary", + "type": "ip", + "optional": false + }, + { + "tag": "antizapret", + "description": "Расширенный список блокировок (AntiZapret)", + "url": "", + "format": "binary", + "type": "domain", + "optional": true + }, + { + "tag": "itdoginfo-inside-russia", + "description": "Российские ресурсы (для direct маршрута)", + "url": "", + "format": "binary", + "type": "domain", + "optional": true + }, + { + "tag": "torrent-clients", + "description": "Торрент-клиенты", + "url": "", + "format": "binary", + "type": "ip", + "optional": true + } + ] +} diff --git a/data/servers.json b/data/servers.json new file mode 100644 index 0000000..205dea8 --- /dev/null +++ b/data/servers.json @@ -0,0 +1,20 @@ +{ + "servers": [ + {"tag": "nl-1", "region": "NL", "type": "socks", "server": "5.180.97.200", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-2", "region": "NL", "type": "socks", "server": "5.180.97.200", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-3", "region": "NL", "type": "socks", "server": "5.180.97.199", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-4", "region": "NL", "type": "socks", "server": "5.180.97.199", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-5", "region": "NL", "type": "socks", "server": "5.180.97.198", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-6", "region": "NL", "type": "socks", "server": "5.180.97.198", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-7", "region": "NL", "type": "socks", "server": "5.180.97.197", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-8", "region": "NL", "type": "socks", "server": "5.180.97.197", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-9", "region": "NL", "type": "socks", "server": "5.180.97.181", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-10", "region": "NL", "type": "socks", "server": "5.180.97.181", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-1", "region": "RU", "type": "socks", "server": "84.252.100.166", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-2", "region": "RU", "type": "socks", "server": "84.252.100.165", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-3", "region": "RU", "type": "socks", "server": "84.252.100.161", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-4", "region": "RU", "type": "socks", "server": "84.252.100.117", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-5", "region": "RU", "type": "socks", "server": "84.252.100.103", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-ss-1", "region": "NL", "type": "shadowsocks", "server": "xui5.em-sysadmin.xyz", "server_port": 36728, "method": "chacha20-ietf-poly1305", "password": "0vHhNd7ZoIyg+tOmLZN0ia3yATGtsLVrAq8qnbk1HLg="} + ] +} diff --git a/data/static-servers.json b/data/static-servers.json new file mode 100644 index 0000000..205dea8 --- /dev/null +++ b/data/static-servers.json @@ -0,0 +1,20 @@ +{ + "servers": [ + {"tag": "nl-1", "region": "NL", "type": "socks", "server": "5.180.97.200", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-2", "region": "NL", "type": "socks", "server": "5.180.97.200", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-3", "region": "NL", "type": "socks", "server": "5.180.97.199", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-4", "region": "NL", "type": "socks", "server": "5.180.97.199", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-5", "region": "NL", "type": "socks", "server": "5.180.97.198", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-6", "region": "NL", "type": "socks", "server": "5.180.97.198", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-7", "region": "NL", "type": "socks", "server": "5.180.97.197", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-8", "region": "NL", "type": "socks", "server": "5.180.97.197", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-9", "region": "NL", "type": "socks", "server": "5.180.97.181", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-10", "region": "NL", "type": "socks", "server": "5.180.97.181", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-1", "region": "RU", "type": "socks", "server": "84.252.100.166", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-2", "region": "RU", "type": "socks", "server": "84.252.100.165", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-3", "region": "RU", "type": "socks", "server": "84.252.100.161", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-4", "region": "RU", "type": "socks", "server": "84.252.100.117", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "ru-5", "region": "RU", "type": "socks", "server": "84.252.100.103", "server_port": 54101, "udp_over_tcp": true}, + {"tag": "nl-ss-1", "region": "NL", "type": "shadowsocks", "server": "xui5.em-sysadmin.xyz", "server_port": 36728, "method": "chacha20-ietf-poly1305", "password": "0vHhNd7ZoIyg+tOmLZN0ia3yATGtsLVrAq8qnbk1HLg="} + ] +} diff --git a/data/version.json b/data/version.json new file mode 100644 index 0000000..8c9bee8 --- /dev/null +++ b/data/version.json @@ -0,0 +1,6 @@ +{ + "version": "2.0.16", + "url": "https://vpn.em-sysadmin.xyz/releases/vpnem-windows-amd64.exe", + "sha256": "d99efa113372590aa6b9aa46e0e511011f59af19353701729a5c6c507411005a", + "changelog": "All auto-update and version checks removed. Updates via scripts/GPO only." +} diff --git a/deploy/systemd/vpnem-rulesets-update.service b/deploy/systemd/vpnem-rulesets-update.service new file mode 100644 index 0000000..a8fe708 --- /dev/null +++ b/deploy/systemd/vpnem-rulesets-update.service @@ -0,0 +1,11 @@ +[Unit] +Description=Update vpnem published rule-set artifacts +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/vpnem +ExecStart=/opt/vpnem/scripts/update-rulesets.sh /opt/vpnem/data/rules +User=root +Group=root diff --git a/deploy/systemd/vpnem-rulesets-update.timer b/deploy/systemd/vpnem-rulesets-update.timer new file mode 100644 index 0000000..e0acc94 --- /dev/null +++ b/deploy/systemd/vpnem-rulesets-update.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run vpnem rule-set updater on a schedule + +[Timer] +OnBootSec=10m +OnUnitActiveSec=12h +RandomizedDelaySec=15m +Persistent=true +Unit=vpnem-rulesets-update.service + +[Install] +WantedBy=timers.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3898341 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +networks: + web: + external: true + +services: + server: + build: . + restart: unless-stopped + environment: + PORKBUN_API_KEY: ${PORKBUN_API_KEY:-} + PORKBUN_SECRET_API_KEY: ${PORKBUN_SECRET_API_KEY:-} + VPNEM_ADMIN_TOKEN: ${VPNEM_ADMIN_TOKEN:-} + volumes: + - ./data:/opt/vpnem/data + - /root/.ssh/id_rsa:/root/.ssh/id_rsa:ro + networks: [web] + labels: + - traefik.enable=true + - traefik.docker.network=web + - traefik.http.routers.vpnem.rule=Host(`vpn.em-sysadmin.xyz`) + - traefik.http.routers.vpnem.entrypoints=websecure + - traefik.http.routers.vpnem.tls.certresolver=le + - traefik.http.services.vpnem.loadbalancer.server.port=8090 diff --git a/examples/nodes/nl-01.yaml b/examples/nodes/nl-01.yaml new file mode 100644 index 0000000..0e173e7 --- /dev/null +++ b/examples/nodes/nl-01.yaml @@ -0,0 +1,43 @@ +id: nl-01 +name: NL 01 +provider: custom-vps +region: nl +host: 203.0.113.10 +domain: nl-01.example.com +acme_email: admin@example.com +enabled: true +ssh: + user: root + port: 22 + auth: key + identity_file: ~/.ssh/id_ed25519 +protocols: + - type: vless + enabled: true + port: 443 + tls: + enabled: true + server_name: nl-01.example.com + auth: + uuid: 11111111-1111-1111-1111-111111111111 + extra: + transport_type: ws + path: /ws + - type: shadowsocks + enabled: true + port: 8443 + auth: + method: 2022-blake3-aes-128-gcm + password: replace-me + - type: vless-reality + enabled: false + port: 443 + auth: + uuid: 33333333-3333-3333-3333-333333333333 + reality: + server_name: www.nokia.com + server_port: 443 + private_key: replace-with-generated-private-key + public_key: replace-with-generated-public-key + short_id: 0123456789abcdef + fingerprint: chrome diff --git a/examples/nodes/nl-hy2-01.yaml b/examples/nodes/nl-hy2-01.yaml new file mode 100644 index 0000000..ba9c7a9 --- /dev/null +++ b/examples/nodes/nl-hy2-01.yaml @@ -0,0 +1,29 @@ +id: nl-hy2-01 +name: NL Hysteria2 +provider: custom-vps +region: nl +host: 203.0.113.11 +enabled: true +ssh: + user: root + port: 22 + auth: key + identity_file: ~/.ssh/id_ed25519 +protocols: + - type: hysteria2 + enabled: true + port: 443 + auth: + password: replace-with-user-password + hysteria2: + port: 443 + up_mbps: 100 + down_mbps: 100 + obfs_password: replace-with-obfs-password + user_password: replace-with-user-password + cert_path: /etc/sing-box/cert.pem + key_path: /etc/sing-box/key.pem +tags: + - hysteria2 + - nl + - udp diff --git a/examples/nodes/nl-multi-01.yaml b/examples/nodes/nl-multi-01.yaml new file mode 100644 index 0000000..34cde83 --- /dev/null +++ b/examples/nodes/nl-multi-01.yaml @@ -0,0 +1,39 @@ +id: nl-multi-01 +name: "NL Amsterdam Multi" +provider: custom-vps +region: nl +host: 203.0.113.55 +enabled: true +ssh: + user: root + port: 22 + auth: key +protocols: + - type: vless-reality + enabled: true + port: 443 + auth: + uuid: 11111111-1111-1111-1111-111111111111 + reality: + server_name: www.nokia.com + server_port: 443 + private_key: UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc + public_key: jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0 + short_id: 0123456789abcdef + fingerprint: chrome + - type: hysteria2 + enabled: true + port: 443 + auth: + password: dXNlci1wYXNzd29yZA== + hysteria2: + port: 443 + up_mbps: 100 + down_mbps: 100 + obfs_password: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + user_password: dXNlci1wYXNzd29yZA== + cert_path: /etc/sing-box/cert.pem + key_path: /etc/sing-box/key.pem +tags: + - nl + - multi @@ -0,0 +1,144 @@ +module vpnem + +go 1.25.0 + +require ( + github.com/sagernet/sing-box v1.12.20 + github.com/wailsapp/wails/v2 v2.12.0 + golang.org/x/net v0.50.0 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/ajg/form v1.5.1 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/anytls/sing-anytls v0.0.11 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect + github.com/caddyserver/certmagic v0.23.0 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/coder/websocket v1.8.13 // indirect + github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect + github.com/cretz/bine v0.2.0 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gaissmai/bart v0.11.1 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect + github.com/go-chi/render v1.0.3 // indirect + github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gofrs/uuid/v5 v5.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/illarion/gonotify/v2 v2.0.3 // indirect + github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/libdns/alidns v1.0.5-libdns.v1.beta1 // indirect + github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 // indirect + github.com/libdns/libdns v1.1.0 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/sdnotify v1.0.0 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0 // indirect + github.com/metacubex/utls v1.8.4 // indirect + github.com/mholt/acmez/v3 v3.1.2 // indirect + github.com/miekg/dns v1.1.67 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect + github.com/sagernet/cors v1.2.1 // indirect + github.com/sagernet/fswatch v0.1.1 // indirect + github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect + github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect + github.com/sagernet/nftables v0.3.0-beta.4 // indirect + github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 // indirect + github.com/sagernet/sing v0.7.18 // indirect + github.com/sagernet/sing-mux v0.3.4 // indirect + github.com/sagernet/sing-quic v0.5.2 // indirect + github.com/sagernet/sing-shadowsocks v0.2.8 // indirect + github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect + github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect + github.com/sagernet/sing-tun v0.7.11 // indirect + github.com/sagernet/sing-vmess v0.2.7 // indirect + github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect + github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 // indirect + github.com/sagernet/wireguard-go v0.0.1-beta.7 // indirect + github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect + github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.41.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/grpc v1.73.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + lukechampine.com/blake3 v1.3.0 // indirect +) @@ -0,0 +1,350 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= +github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU= +github.com/caddyserver/certmagic v0.23.0/go.mod h1:9mEZIWqqWoI+Gf+4Trh04MOVPD0tGSxtqsxg87hAIH4= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= +github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= +github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc= +github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84= +github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= +github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +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.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= +github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= +github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= +github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU= +github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/libdns/alidns v1.0.5-libdns.v1.beta1 h1:txHK7UxDed3WFBDjrTZPuMn8X+WmhjBTTAMW5xdy5pQ= +github.com/libdns/alidns v1.0.5-libdns.v1.beta1/go.mod h1:ystHmPwcGoWjPrGpensQSMY9VoCx4cpR2hXNlwk9H/g= +github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 h1:3MGrVWs2COjMkQR17oUw1zMIPbm2YAzxDC3oGVZvQs8= +github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= +github.com/libdns/libdns v1.1.0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0 h1:Ui+/2s5Qz0lSnDUBmEL12M5Oi/PzvFxGTNohm8ZcsmE= +github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= +github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= +github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= +github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0= +github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= +github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= +github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= +github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= +github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= +github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb h1:pprQtDqNgqXkRsXn+0E8ikKOemzmum8bODjSfDene38= +github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= +github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= +github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= +github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= +github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= +github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 h1:ySqffGm82rPqI1TUPqmtHIYd12pfEGScygnOxjTL56w= +github.com/sagernet/quic-go v0.52.0-sing-box-mod.3/go.mod h1:OV+V5kEBb8kJS7k29MzDu6oj9GyMc7HA07sE1tedxz4= +github.com/sagernet/sing v0.7.18 h1:iZHkaru1/MoHugx3G+9S3WG4owMewKO/KvieE2Pzk4E= +github.com/sagernet/sing v0.7.18/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing-box v1.12.20 h1:5KarG2Q8/8k8VKXN6sywTc0Gs06HKZ4++zu2y4bw9KA= +github.com/sagernet/sing-box v1.12.20/go.mod h1:ePKI4HpMVy+wtAumZx6pJxwq2ddiwmU+0ZoVteNdTsY= +github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= +github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-quic v0.5.2 h1:I3vlfRImhr0uLwRS3b3ib70RMG9FcXtOKKUDz3eKRWc= +github.com/sagernet/sing-quic v0.5.2/go.mod h1:evP1e++ZG8TJHVV5HudXV4vWeYzGfCdF4HwSJZcdqkI= +github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= +github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= +github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= +github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= +github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= +github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-tun v0.7.11 h1:qB7jy8JKqXg73fYBsDkBSy4ulRSbLrFut0e+y+QPhqU= +github.com/sagernet/sing-tun v0.7.11/go.mod h1:pUEjh9YHQ2gJT6Lk0TYDklh3WJy7lz+848vleGM3JPM= +github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk= +github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= +github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= +github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= +github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 h1:MO7s4ni2bSfAOhcan2rdQSWCztkMXmqyg6jYPZp8bEE= +github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2/go.mod h1:EBxXsWu4OH2ELbQLq32WoBeIubG8KgDrg4/Oaxjs6lI= +github.com/sagernet/wireguard-go v0.0.1-beta.7 h1:ltgBwYHfr+9Wz1eG59NiWnHrYEkDKHG7otNZvu85DXI= +github.com/sagernet/wireguard-go v0.0.1-beta.7/go.mod h1:jGXij2Gn2wbrWuYNUmmNhf1dwcZtvyAvQoe8Xd8MbUo= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGgEa+k2vJM8xT0PoSKfVXwFGPQ3z3CJfmnHJkZZw= +github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4= +github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= +lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/api/control.go b/internal/api/control.go new file mode 100644 index 0000000..2849f02 --- /dev/null +++ b/internal/api/control.go @@ -0,0 +1,3781 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "vpnem/internal/control" +) + +func (h *Handler) ControlNodes(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + + inventory, err := control.LoadInventoryDir(h.inventoryDir()) + if err != nil { + if control.IsNotExist(err) { + writeJSON(w, map[string]any{"nodes": []control.Node{}}) + return + } + log.Printf("error loading control inventory: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + states := make(map[string]*control.NodeState, len(inventory.Nodes)) + for _, node := range inventory.Nodes { + state, err := control.LoadNodeState(h.stateDir(), node.ID) + if err != nil { + if control.IsNotExist(err) { + continue + } + log.Printf("error loading node state for %s: %v", node.ID, err) + continue + } + states[node.ID] = state + } + decisions := control.PublishDecisions(inventory.Nodes, states) + + writeJSON(w, map[string]any{ + "nodes": inventory.Nodes, + "states": states, + "publish_decisions": decisions, + }) +} + +type actionRequest struct { + SSHPassword string `json:"ssh_password"` +} + +type quickProvisionRequest struct { + Host string `json:"host"` + RootPassword string `json:"root_password"` + Region string `json:"region"` + Provider string `json:"provider"` + ACMEEmail string `json:"acme_email"` + EnableMulti bool `json:"enable_multi"` + EnableVLESS bool `json:"enable_vless"` + EnableReality bool `json:"enable_vless_reality"` + EnableSS bool `json:"enable_shadowsocks"` + EnableSocks bool `json:"enable_socks5"` + EnableVMess bool `json:"enable_vmess"` + EnableHY2 bool `json:"enable_hysteria2"` +} + +type quickPreflightDecision struct { + Supported bool `json:"supported"` + Reasons []string `json:"reasons,omitempty"` +} + +type quickPreflightResponse struct { + Host string `json:"host"` + HostStateLabel string `json:"host_state_label,omitempty"` + SuggestedMultiName string `json:"suggested_multi_name,omitempty"` + SuggestedSocksName string `json:"suggested_socks_name,omitempty"` + OSID string `json:"os_id,omitempty"` + OSPretty string `json:"os_pretty,omitempty"` + OSLike string `json:"os_like,omitempty"` + SupportTier string `json:"support_tier"` + AlreadyManaged bool `json:"already_managed"` + DockerInstalled bool `json:"docker_installed"` + ComposeAvailable bool `json:"compose_available"` + Ports map[string]string `json:"ports"` + Capabilities []string `json:"capabilities,omitempty"` + RecommendedAction string `json:"recommended_action,omitempty"` + QuickMulti quickPreflightDecision `json:"quick_multi"` + QuickSocks5 quickPreflightDecision `json:"quick_socks5"` + Warnings []string `json:"warnings,omitempty"` +} + +func (h *Handler) ControlNodeByID(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + + nodeID := strings.TrimPrefix(r.URL.Path, "/api/v1/control/nodes/") + nodeID = strings.TrimSuffix(nodeID, "/") + if nodeID == "" { + http.Error(w, "node id required", http.StatusBadRequest) + return + } + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + writeJSON(w, node) +} + +func (h *Handler) ControlNodeAction(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + + path := strings.TrimPrefix(r.URL.Path, "/api/v1/control/nodes/") + path = strings.TrimSuffix(path, "/") + if path == "" { + http.Error(w, "node id required", http.StatusBadRequest) + return + } + + switch { + case strings.HasSuffix(path, "/state") && r.Method == http.MethodGet: + nodeID := strings.TrimSuffix(path, "/state") + h.ControlNodeState(w, r, nodeID) + case strings.HasSuffix(path, "/provision") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/provision") + h.ControlNodeProvision(w, r, nodeID) + case strings.HasSuffix(path, "/provision-dns") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/provision-dns") + h.ControlNodeProvisionDNS(w, r, nodeID) + case strings.HasSuffix(path, "/delete-dns") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/delete-dns") + h.ControlNodeDeleteDNS(w, r, nodeID) + case strings.HasSuffix(path, "/bootstrap") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/bootstrap") + h.ControlNodeBootstrap(w, r, nodeID) + case strings.HasSuffix(path, "/check") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/check") + h.ControlNodeCheck(w, r, nodeID) + case strings.HasSuffix(path, "/upgrade") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/upgrade") + h.ControlNodeUpgrade(w, r, nodeID) + case strings.HasSuffix(path, "/add-socks5") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/add-socks5") + h.ControlNodeAddSocks5(w, r, nodeID) + case strings.HasSuffix(path, "/repair-reinstall") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/repair-reinstall") + h.ControlNodeRepairReinstall(w, r, nodeID) + case strings.HasSuffix(path, "/clean-reinstall") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/clean-reinstall") + h.ControlNodeCleanReinstall(w, r, nodeID) + case strings.HasSuffix(path, "/enable") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/enable") + h.ControlNodeEnable(w, r, nodeID) + case strings.HasSuffix(path, "/disable") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/disable") + h.ControlNodeDisable(w, r, nodeID) + case strings.HasSuffix(path, "/rotate-secrets") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/rotate-secrets") + h.ControlNodeRotateSecrets(w, r, nodeID) + case strings.HasSuffix(path, "/destroy") && r.Method == http.MethodPost: + nodeID := strings.TrimSuffix(path, "/destroy") + h.ControlNodeDestroy(w, r, nodeID) + case r.Method == http.MethodDelete: + h.DeleteControlNode(w, r, path) + case r.Method == http.MethodGet: + h.ControlNodeByID(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) UpsertControlNode(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + + var node control.Node + if err := json.NewDecoder(r.Body).Decode(&node); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + if node.SSH.Port == 0 { + node.SSH.Port = 22 + } + if err := validateNodeForUI(node); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + path, err := control.SaveNodeFile(h.inventoryDir(), node) + if err != nil { + log.Printf("error saving control node: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + savedNode, err := control.LoadNodeFile(path) + if err != nil { + log.Printf("error reloading saved control node: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "saved": true, + "path": path, + "node": savedNode, + }) +} + +func (h *Handler) QuickProvisionControlNode(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + + var req quickProvisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + node, sshPassword, err := buildQuickProvisionNode(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if existing, err := h.findNodeByHost(node.Host); err != nil { + log.Printf("error checking quick-provision host conflicts: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } else if existing != nil { + http.Error(w, "этот host уже используется узлом "+existing.ID+"; откройте его в настройках и используйте обновление или переустановку вместо создания второго quick-узла", http.StatusConflict) + return + } + + result, err := h.provisionNodeFlow(r.Context(), &node, sshPassword) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + result["node"] = node + writeJSON(w, result) +} + +func (h *Handler) QuickPreflightControlNode(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + + var req quickProvisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + host := strings.TrimSpace(req.Host) + password := strings.TrimSpace(req.RootPassword) + if host == "" || password == "" { + http.Error(w, "host and root_password are required", http.StatusBadRequest) + return + } + + node := control.Node{ + ID: "quick-preflight", + Name: "Quick Preflight", + Provider: strings.TrimSpace(req.Provider), + Region: strings.TrimSpace(req.Region), + Host: host, + Enabled: true, + SSH: control.SSHConfig{ + User: "root", + Port: 22, + Auth: "password", + Password: password, + }, + } + result, err := control.SSHRunner{}.Run(r.Context(), node, control.RenderPreflightInspectScript()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + data := control.ParsePreflightInspectOutput(result.Stdout) + resp := buildQuickPreflightResponse(host, data) + writeJSON(w, resp) +} + +func (h *Handler) DeleteControlNode(w http.ResponseWriter, r *http.Request, nodeID string) { + if nodeID == "" { + http.Error(w, "node id required", http.StatusBadRequest) + return + } + + if err := control.DeleteNodeFile(h.inventoryDir(), nodeID); err != nil { + log.Printf("error deleting control node: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := control.DeleteNodeState(h.stateDir(), nodeID); err != nil { + log.Printf("error deleting node state: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "deleted": true, + "node_id": nodeID, + }) +} + +func (h *Handler) PublishControlCatalog(w http.ResponseWriter, r *http.Request) { + if !h.authorizeAdmin(w, r) { + return + } + + count, target, catalogTarget, decisions, err := h.publishCurrentCatalog() + if err != nil { + log.Printf("error publishing control catalog: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "published": true, + "target": target, + "catalog_v2_target": catalogTarget, + "count": count, + "publish_decisions": decisions, + }) +} + +func (h *Handler) publishCurrentCatalog() (int, string, string, map[string]control.PublishDecision, error) { + inventory, err := control.LoadInventoryDir(h.inventoryDir()) + if err != nil { + return 0, "", "", nil, err + } + + states := make(map[string]*control.NodeState, len(inventory.Nodes)) + for _, node := range inventory.Nodes { + state, err := control.LoadNodeState(h.stateDir(), node.ID) + if err != nil { + if control.IsNotExist(err) { + continue + } + log.Printf("error loading node state for publish %s: %v", node.ID, err) + continue + } + states[node.ID] = state + } + + publishable := control.PublishableNodes(inventory.Nodes, states) + decisions := control.PublishDecisions(inventory.Nodes, states) + target := filepath.Join(h.store.DataDir(), "servers.json") + if err := control.WriteLegacyCatalog(target, publishable); err != nil { + return 0, "", "", nil, err + } + catalogTarget := filepath.Join(h.store.DataDir(), "catalog-v2.json") + if err := control.WriteCatalogV2(catalogTarget, publishable, states); err != nil { + return 0, "", "", nil, err + } + return len(publishable), target, catalogTarget, decisions, nil +} + +func (h *Handler) ControlNodeState(w http.ResponseWriter, r *http.Request, nodeID string) { + state, err := control.LoadNodeState(h.stateDir(), nodeID) + if err != nil { + if control.IsNotExist(err) { + http.NotFound(w, r) + return + } + log.Printf("error loading node state: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + writeJSON(w, state) +} + +func (h *Handler) ControlNodeBootstrap(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for bootstrap: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + req, err := decodeActionRequest(r) + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + node = applyActionPassword(node, req) + dryRun := r.URL.Query().Get("dry_run") != "false" + state, err := control.BootstrapNode(context.Background(), control.SSHRunner{}, *node, control.BootstrapOptions{ + StateDir: h.stateDir(), + DryRun: dryRun, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + writeJSON(w, state) +} + +func (h *Handler) ControlNodeCheck(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for check: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + req, err := decodeActionRequest(r) + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + node = applyActionPassword(node, req) + state, err := control.CheckNode(context.Background(), control.SSHRunner{}, *node, h.stateDir()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + writeJSON(w, state) +} + +func (h *Handler) ControlNodeUpgrade(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for upgrade: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + req, err := decodeActionRequest(r) + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + node = applyActionPassword(node, req) + state, err := control.UpgradeNode(context.Background(), control.SSHRunner{}, *node, h.stateDir()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + writeJSON(w, state) +} + +func (h *Handler) ControlNodeAddSocks5(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for add socks5: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + req, err := decodeActionRequest(r) + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + node = applyActionPassword(node, req) + + inspect, err := control.SSHRunner{}.Run(r.Context(), *node, control.RenderPreflightInspectScript()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + preflight := control.ParsePreflightInspectOutput(inspect.Stdout) + if portStatusValue(preflight["TCP_54101"]) == "busy" { + http.Error(w, "tcp/54101 уже занят на этом VPS; безопасно добавить SOCKS5 нельзя", http.StatusConflict) + return + } + + updated, err := control.AddSocks5Protocol(*node, 54101) + if err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + if _, err := control.SaveNodeFile(h.inventoryDir(), updated); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + updated.SSH.Password = node.SSH.Password + state, err := control.UpgradeNode(context.Background(), control.SSHRunner{}, updated, h.stateDir()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + response := map[string]any{ + "added": true, + "protocol": "socks5", + "node": updated, + "state": state, + "published": false, + "recommended_next": "SOCKS5 was added and the node runtime was upgraded.", + } + if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil { + response["published"] = true + response["target"] = target + response["catalog_v2_target"] = catalogTarget + response["count"] = count + response["publish_decisions"] = decisions + } + writeJSON(w, response) +} + +func (h *Handler) ControlNodeRepairReinstall(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for repair reinstall: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + req, err := decodeActionRequest(r) + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + node = applyActionPassword(node, req) + state, err := control.RepairReinstallNode(context.Background(), control.SSHRunner{}, *node, h.stateDir()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + response := map[string]any{ + "reinstalled": true, + "reinstall_mode": "repair", + "node": node, + "state": state, + "published": false, + "publish_decisions": map[string]control.PublishDecision{}, + } + if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil { + response["published"] = true + response["target"] = target + response["catalog_v2_target"] = catalogTarget + response["count"] = count + response["publish_decisions"] = decisions + } + writeJSON(w, response) +} + +func (h *Handler) ControlNodeCleanReinstall(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for clean reinstall: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + req, err := decodeActionRequest(r) + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + node = applyActionPassword(node, req) + rotated, err := control.RotateNodeSecrets(*node) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if _, err := control.SaveNodeFile(h.inventoryDir(), rotated); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + rotated.SSH.Password = node.SSH.Password + state, err := control.CleanReinstallNode(context.Background(), control.SSHRunner{}, rotated, h.stateDir()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + response := map[string]any{ + "reinstalled": true, + "reinstall_mode": "clean", + "rotated": true, + "node": rotated, + "state": state, + "published": false, + } + if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil { + response["published"] = true + response["target"] = target + response["catalog_v2_target"] = catalogTarget + response["count"] = count + response["publish_decisions"] = decisions + } + writeJSON(w, response) +} + +func (h *Handler) ControlNodeEnable(w http.ResponseWriter, r *http.Request, nodeID string) { + h.updateNodeEnabled(w, nodeID, true) +} + +func (h *Handler) ControlNodeDisable(w http.ResponseWriter, r *http.Request, nodeID string) { + h.updateNodeEnabled(w, nodeID, false) +} + +func (h *Handler) updateNodeEnabled(w http.ResponseWriter, nodeID string, enabled bool) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for enable toggle: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, nil) + return + } + + updated := control.SetNodeEnabled(*node, enabled) + if _, err := control.SaveNodeFile(h.inventoryDir(), updated); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + count, target, catalogTarget, decisions, err := h.publishCurrentCatalog() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "saved": true, + "enabled": enabled, + "node": updated, + "published": true, + "target": target, + "catalog_v2_target": catalogTarget, + "count": count, + "publish_decisions": decisions, + }) +} + +func (h *Handler) ControlNodeRotateSecrets(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for secret rotation: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + rotated, err := control.RotateNodeSecrets(*node) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if _, err := control.SaveNodeFile(h.inventoryDir(), rotated); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "rotated": true, + "node": rotated, + }) +} + +func (h *Handler) ControlNodeDestroy(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for destroy: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + req, err := decodeActionRequest(r) + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + node = applyActionPassword(node, req) + + var dnsClient control.DNSProvider + if strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")) != "" && strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")) != "" { + dnsClient = control.PorkbunClient{ + APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")), + SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")), + } + } + + warnings := control.DestroyNode(r.Context(), control.SSHRunner{}, dnsClient, "em-sysadmin.xyz", *node, h.inventoryDir(), h.stateDir()) + count, target, catalogTarget, decisions, err := h.publishCurrentCatalog() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "destroyed": true, + "node_id": nodeID, + "warnings": warnings, + "published": true, + "target": target, + "catalog_v2_target": catalogTarget, + "count": count, + "publish_decisions": decisions, + }) +} + +func (h *Handler) ControlNodeProvision(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for full provision: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + req, err := decodeActionRequest(r) + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + node = applyActionPassword(node, req) + + response, err := h.provisionNodeFlow(r.Context(), node, req.SSHPassword) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, response) +} + +func (h *Handler) ControlNodeProvisionDNS(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for dns: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + client := control.PorkbunClient{ + APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")), + SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")), + } + fqdn, err := client.EnsureRandomARecord(context.Background(), "em-sysadmin.xyz", dnsPrefixForNode(*node), strings.TrimSpace(node.Host), 600) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + node.Domain = fqdn + if node.Metadata == nil { + node.Metadata = map[string]string{} + } + node.Metadata["dns_zone"] = "em-sysadmin.xyz" + node.Metadata["dns_provider"] = "porkbun" + if _, err := control.SaveNodeFile(h.inventoryDir(), *node); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + now := time.Now().UTC() + state, _ := control.LoadNodeState(h.stateDir(), node.ID) + if state == nil { + state = &control.NodeState{ + NodeID: node.ID, + PublicHost: fqdn, + Services: []control.ServiceStatus{}, + Metadata: map[string]any{}, + } + } + state.PublicHost = fqdn + state.LastDNSSyncAt = &now + if state.Metadata == nil { + state.Metadata = map[string]any{} + } + state.Metadata["dns_provider"] = "porkbun" + state.Metadata["dns_zone"] = "em-sysadmin.xyz" + state.Metadata["dns_fqdn"] = fqdn + if err := control.SaveNodeState(h.stateDir(), *state); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "provisioned": true, + "fqdn": fqdn, + "node": node, + }) +} + +func (h *Handler) ControlNodeDeleteDNS(w http.ResponseWriter, r *http.Request, nodeID string) { + node, ok, err := h.loadNode(nodeID) + if err != nil { + log.Printf("error loading control node for dns delete: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + if strings.TrimSpace(node.Domain) == "" { + http.Error(w, "node domain is empty", http.StatusBadRequest) + return + } + + name := strings.TrimSuffix(node.Domain, ".em-sysadmin.xyz") + name = strings.TrimSuffix(name, ".") + client := control.PorkbunClient{ + APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")), + SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")), + } + if err := client.DeleteARecord(context.Background(), "em-sysadmin.xyz", name); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + writeJSON(w, map[string]any{ + "deleted": true, + "domain": node.Domain, + }) +} + +func (h *Handler) VPNUI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(vpnUIHTML)) +} + +func (h *Handler) inventoryDir() string { + return filepath.Join(h.store.DataDir(), "control", "inventory") +} + +func (h *Handler) stateDir() string { + return filepath.Join(h.store.DataDir(), "control", "state") +} + +func (h *Handler) loadNode(nodeID string) (*control.Node, bool, error) { + inventory, err := control.LoadInventoryDir(h.inventoryDir()) + if err != nil { + return nil, false, err + } + node, ok := inventory.NodeByID(nodeID) + return node, ok, nil +} + +func (h *Handler) findNodeByHost(host string) (*control.Node, error) { + inventory, err := control.LoadInventoryDir(h.inventoryDir()) + if err != nil { + if control.IsNotExist(err) { + return nil, nil + } + return nil, err + } + needle := normalizeHost(host) + for idx := range inventory.Nodes { + if normalizeHost(inventory.Nodes[idx].Host) == needle { + return &inventory.Nodes[idx], nil + } + } + return nil, nil +} + +func buildQuickPreflightResponse(host string, data map[string]string) quickPreflightResponse { + resp := quickPreflightResponse{ + Host: host, + SuggestedMultiName: generateQuickNodeName("auto", "multi", host), + SuggestedSocksName: generateQuickNodeName("auto", "socks5", host), + OSID: data["OS_ID"], + OSPretty: data["OS_PRETTY"], + OSLike: data["OS_LIKE"], + SupportTier: classifySupportTier(data["OS_ID"], data["OS_LIKE"]), + AlreadyManaged: data["MANAGED"] == "1", + DockerInstalled: data["DOCKER"] == "1", + ComposeAvailable: data["COMPOSE"] == "1", + Ports: map[string]string{ + "tcp_443": portStatusValue(data["TCP_443"]), + "udp_443": portStatusValue(data["UDP_443"]), + "tcp_54101": portStatusValue(data["TCP_54101"]), + }, + } + + resp.QuickMulti = quickPreflightDecision{Supported: true} + resp.QuickSocks5 = quickPreflightDecision{Supported: true} + + if resp.AlreadyManaged { + reason := "этот VPS уже управляется через vpnem; используйте настройки и действия с узлом вместо создания второго quick-узла" + resp.HostStateLabel = "Уже под управлением" + resp.QuickMulti = quickPreflightDecision{Supported: false, Reasons: []string{reason}} + resp.QuickSocks5 = quickPreflightDecision{Supported: false, Reasons: []string{reason}} + resp.Capabilities = append(resp.Capabilities, "Уже под управлением") + resp.RecommendedAction = "Откройте существующий узел и используйте «Обновить сервер», «Починить сервер» или «Добавить SOCKS5»." + } + if resp.Ports["tcp_443"] == "busy" { + resp.QuickMulti.Supported = false + resp.QuickMulti.Reasons = append(resp.QuickMulti.Reasons, "TCP-порт 443 уже занят") + } + if resp.Ports["udp_443"] == "busy" { + resp.QuickMulti.Supported = false + resp.QuickMulti.Reasons = append(resp.QuickMulti.Reasons, "UDP-порт 443 уже занят") + } + if resp.Ports["tcp_54101"] == "busy" { + resp.QuickSocks5.Supported = false + resp.QuickSocks5.Reasons = append(resp.QuickSocks5.Reasons, "TCP-порт 54101 уже занят") + } + if !resp.AlreadyManaged { + switch { + case resp.QuickMulti.Supported && resp.QuickSocks5.Supported: + resp.Capabilities = append(resp.Capabilities, "Можно ставить MULTI", "Можно ставить SOCKS5") + resp.RecommendedAction = "Этот VPS выглядит чистым. По умолчанию выбирайте MULTI, если вам не нужен только простой SOCKS5-прокси." + case resp.QuickMulti.Supported: + resp.Capabilities = append(resp.Capabilities, "Можно ставить MULTI") + resp.RecommendedAction = "Этот VPS готов для стандартной установки MULTI." + case resp.QuickSocks5.Supported: + resp.Capabilities = append(resp.Capabilities, "Можно ставить SOCKS5", "Конфликт портов для MULTI") + resp.RecommendedAction = "Сейчас MULTI заблокирован, но SOCKS5 всё ещё безопасно ставить на порт 54101." + default: + resp.Capabilities = append(resp.Capabilities, "Быстрая установка заблокирована") + resp.RecommendedAction = "У этого VPS есть конфликт портов или неподходящее состояние для быстрой установки. Сначала исправьте хост или используйте путь через настройки." + } + } + if resp.SupportTier == "experimental" { + resp.Warnings = append(resp.Warnings, "Этот дистрибутив считается экспериментальным. Для наиболее предсказуемой установки рекомендуются Debian или Ubuntu.") + } + if resp.SupportTier == "unknown" { + resp.Warnings = append(resp.Warnings, "Этот дистрибутив неизвестен для vpnui. Установка может сработать, но он не входит в рекомендуемую матрицу поддержки.") + } + if !resp.DockerInstalled { + resp.Warnings = append(resp.Warnings, "Docker ещё не установлен. vpnui попробует установить его во время bootstrap.") + } + if !resp.ComposeAvailable { + resp.Warnings = append(resp.Warnings, "Docker Compose пока недоступен. vpnui попробует установить его или использовать совместимый путь во время bootstrap.") + } + if resp.HostStateLabel == "" { + switch { + case resp.QuickMulti.Supported: + resp.HostStateLabel = "VPS чистый" + case resp.QuickSocks5.Supported: + resp.HostStateLabel = "Можно поставить SOCKS5" + default: + resp.HostStateLabel = "Установка заблокирована" + } + } + return resp +} + +func generateQuickNodeName(region, kind, host string) string { + adjectives := []string{"Maple", "Quartz", "Harbor", "Comet", "Cedar", "Nova", "Atlas", "Echo"} + area := strings.ToUpper(strings.TrimSpace(region)) + if area == "" || area == "AUTO" { + area = "AUTO" + } + kindLabel := "Server" + switch kind { + case "multi": + kindLabel = "Multi" + case "socks5": + kindLabel = "SOCKS5" + } + word := adjectives[0] + if host != "" { + sum := 0 + for _, r := range host { + sum += int(r) + } + word = adjectives[sum%len(adjectives)] + } + suffix := "01" + if part, err := controlRandomHex(1); err == nil && part != "" { + suffix = strings.ToUpper(part) + } + return area + " " + kindLabel + " " + word + " " + suffix +} + +func classifySupportTier(osID, osLike string) string { + combined := strings.ToLower(strings.TrimSpace(osID + " " + osLike)) + switch { + case strings.Contains(combined, "debian") || strings.Contains(combined, "ubuntu"): + return "recommended" + case strings.Contains(combined, "rhel") || strings.Contains(combined, "rocky") || strings.Contains(combined, "alma") || strings.Contains(combined, "centos") || strings.Contains(combined, "fedora"): + return "supported" + case strings.Contains(combined, "arch") || strings.Contains(combined, "alpine"): + return "experimental" + default: + return "unknown" + } +} + +func portStatusValue(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "busy": + return "busy" + case "0", "free": + return "free" + default: + return "unknown" + } +} + +func (h *Handler) authorizeAdmin(w http.ResponseWriter, r *http.Request) bool { + token := strings.TrimSpace(os.Getenv("VPNEM_ADMIN_TOKEN")) + if token == "" { + return true + } + + provided := strings.TrimSpace(r.Header.Get("X-Admin-Token")) + if provided == "" { + provided = strings.TrimSpace(r.URL.Query().Get("token")) + } + if provided != token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return false + } + return true +} + +func validateNodeForUI(node control.Node) error { + for idx := range node.Protocols { + if err := control.EnsureProtocolForUI(&node.Protocols[idx]); err != nil { + return err + } + } + if err := control.ValidateNode(node); err != nil { + return err + } + + for _, protocol := range node.Protocols { + switch protocol.Type { + case "vless": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return errors.New("vless protocol requires auth.uuid") + } + case "vless-reality": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return errors.New("vless-reality protocol requires auth.uuid") + } + if protocol.Reality == nil || strings.TrimSpace(protocol.Reality.ServerName) == "" { + return errors.New("vless-reality protocol requires reality.server_name") + } + case "vmess": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return errors.New("vmess protocol requires auth.uuid") + } + case "shadowsocks": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" { + return errors.New("shadowsocks protocol requires auth.method and auth.password") + } + case "hysteria2": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" { + return errors.New("hysteria2 protocol requires auth.password") + } + } + } + return nil +} + +func decodeActionRequest(r *http.Request) (actionRequest, error) { + var req actionRequest + if r.Body == nil || r.ContentLength == 0 { + return req, nil + } + err := json.NewDecoder(r.Body).Decode(&req) + if errors.Is(err, http.ErrBodyReadAfterClose) { + return req, nil + } + return req, err +} + +func applyActionPassword(node *control.Node, req actionRequest) *control.Node { + if node == nil { + return nil + } + copy := *node + copy.SSH.Password = strings.TrimSpace(req.SSHPassword) + return © +} + +func buildQuickProvisionNode(req quickProvisionRequest) (control.Node, string, error) { + host := strings.TrimSpace(req.Host) + password := strings.TrimSpace(req.RootPassword) + if host == "" { + return control.Node{}, "", errors.New("host is required") + } + if password == "" { + return control.Node{}, "", errors.New("root_password is required") + } + + region := strings.TrimSpace(req.Region) + if region == "" { + region = "auto" + } + provider := strings.TrimSpace(req.Provider) + if provider == "" { + provider = "custom-vps" + } + acmeEmail := strings.TrimSpace(req.ACMEEmail) + if acmeEmail == "" { + acmeEmail = "admin@em-sysadmin.xyz" + } + enableMulti := req.EnableMulti || (req.EnableReality && req.EnableHY2) + nodeKind := "server" + if req.EnableSocks && !enableMulti && !req.EnableSS && !req.EnableVMess && !req.EnableVLESS && !req.EnableReality && !req.EnableHY2 { + nodeKind = "socks5" + } else if enableMulti { + nodeKind = "multi" + } + + nodeID := "node-" + strings.ReplaceAll(host, ".", "-") + if suffix, err := controlRandomHex(2); err == nil { + nodeID += "-" + suffix + } + uuid, err := controlRandomUUID() + if err != nil { + return control.Node{}, "", err + } + vmessUUID, err := controlRandomUUID() + if err != nil { + return control.Node{}, "", err + } + ssPassword, err := controlRandomHex(16) + if err != nil { + return control.Node{}, "", err + } + hy2Password, err := controlRandomBase64(16) + if err != nil { + return control.Node{}, "", err + } + hy2ObfsPassword, err := controlRandomHex(32) + if err != nil { + return control.Node{}, "", err + } + + protocols := make([]control.ProtocolProfile, 0, 3) + if req.EnableVLESS { + protocols = append(protocols, control.ProtocolProfile{ + Type: "vless", + Enabled: true, + Port: 443, + TLS: &control.TLSProfile{ + Enabled: true, + }, + Auth: &control.AuthProfile{ + UUID: uuid, + }, + Extra: map[string]any{ + "transport_type": "ws", + "path": "/ws", + }, + }) + } + if req.EnableReality && !enableMulti { + protocols = append(protocols, control.ProtocolProfile{ + Type: "vless-reality", + Enabled: true, + Port: 443, + Auth: &control.AuthProfile{ + UUID: uuid, + }, + Reality: &control.VLESSRealityProfile{ + ServerName: "www.nokia.com", + ServerPort: 443, + Fingerprint: "chrome", + }, + }) + } + if req.EnableSocks { + protocols = append(protocols, control.ProtocolProfile{ + Type: "socks5", + Enabled: true, + Port: 54101, + }) + } + if req.EnableSS { + protocols = append(protocols, control.ProtocolProfile{ + Type: "shadowsocks", + Enabled: true, + Port: 8443, + Auth: &control.AuthProfile{ + Method: "2022-blake3-aes-128-gcm", + Password: ssPassword, + }, + }) + } + if req.EnableVMess { + protocols = append(protocols, control.ProtocolProfile{ + Type: "vmess", + Enabled: true, + Port: 8444, + TLS: &control.TLSProfile{ + Enabled: true, + }, + Auth: &control.AuthProfile{ + UUID: vmessUUID, + }, + Extra: map[string]any{ + "path": "/vmess", + }, + }) + } + if req.EnableHY2 && !enableMulti { + protocols = append(protocols, control.ProtocolProfile{ + Type: "hysteria2", + Enabled: true, + Port: 443, + Auth: &control.AuthProfile{ + Password: hy2Password, + }, + Hysteria2: &control.Hysteria2Profile{ + Port: 443, + UpMbps: 100, + DownMbps: 100, + ObfsPassword: hy2ObfsPassword, + UserPassword: hy2Password, + CertPath: "/etc/sing-box/cert.pem", + KeyPath: "/etc/sing-box/key.pem", + }, + }) + } + if enableMulti { + protocols = append(protocols, control.ProtocolProfile{ + Type: "vless-reality", + Enabled: true, + Port: 443, + Auth: &control.AuthProfile{ + UUID: uuid, + }, + Reality: &control.VLESSRealityProfile{ + ServerName: "www.nokia.com", + ServerPort: 443, + Fingerprint: "chrome", + }, + }, control.ProtocolProfile{ + Type: "hysteria2", + Enabled: true, + Port: 443, + Auth: &control.AuthProfile{ + Password: hy2Password, + }, + Hysteria2: &control.Hysteria2Profile{ + Port: 443, + UpMbps: 100, + DownMbps: 100, + ObfsPassword: hy2ObfsPassword, + UserPassword: hy2Password, + CertPath: "/etc/sing-box/cert.pem", + KeyPath: "/etc/sing-box/key.pem", + }, + }) + } + if len(protocols) == 0 { + return control.Node{}, "", errors.New("at least one protocol must be enabled") + } + + node := control.Node{ + ID: nodeID, + Name: generateQuickNodeName(region, nodeKind, host), + Provider: provider, + Region: region, + Host: host, + ACMEEmail: acmeEmail, + Enabled: true, + SSH: control.SSHConfig{ + User: "root", + Port: 22, + Auth: "password", + PasswordEnv: "VPNEM_RUNTIME_PASSWORD", + Password: password, + }, + Protocols: protocols, + Metadata: map[string]string{ + "provision_mode": "quick", + }, + } + return node, password, nil +} + +func (h *Handler) provisionNodeFlow(ctx context.Context, node *control.Node, sshPassword string) (map[string]any, error) { + response := map[string]any{ + "node_id": node.ID, + } + if sshPassword != "" { + node.SSH.Password = sshPassword + } + + if strings.TrimSpace(node.Domain) == "" && nodeNeedsProvisionedDNS(*node) { + client := control.PorkbunClient{ + APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")), + SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")), + } + fqdn, err := client.EnsureRandomARecord(ctx, "em-sysadmin.xyz", dnsPrefixForNode(*node), strings.TrimSpace(node.Host), 600) + if err != nil { + return nil, err + } + + node.Domain = fqdn + for idx := range node.Protocols { + if node.Protocols[idx].TLS != nil && node.Protocols[idx].TLS.Enabled { + node.Protocols[idx].TLS.ServerName = fqdn + } + } + if node.Metadata == nil { + node.Metadata = map[string]string{} + } + node.Metadata["dns_zone"] = "em-sysadmin.xyz" + node.Metadata["dns_provider"] = "porkbun" + if _, err := control.SaveNodeFile(h.inventoryDir(), *node); err != nil { + return nil, err + } + + now := time.Now().UTC() + state, _ := control.LoadNodeState(h.stateDir(), node.ID) + if state == nil { + state = &control.NodeState{ + NodeID: node.ID, + PublicHost: fqdn, + Services: []control.ServiceStatus{}, + Metadata: map[string]any{}, + } + } + state.PublicHost = fqdn + state.LastDNSSyncAt = &now + if state.Metadata == nil { + state.Metadata = map[string]any{} + } + state.Metadata["dns_provider"] = "porkbun" + state.Metadata["dns_zone"] = "em-sysadmin.xyz" + state.Metadata["dns_fqdn"] = fqdn + if err := control.SaveNodeState(h.stateDir(), *state); err != nil { + return nil, err + } + response["dns"] = map[string]any{ + "provisioned": true, + "fqdn": fqdn, + } + } else { + response["dns"] = map[string]any{ + "provisioned": false, + "fqdn": node.Domain, + "skipped": dnsSkipReason(*node), + } + } + + savedPath, err := control.SaveNodeFile(h.inventoryDir(), *node) + if err != nil { + return nil, err + } + savedNode, err := control.LoadNodeFile(savedPath) + if err != nil { + return nil, err + } + *node = *savedNode + if sshPassword != "" { + node.SSH.Password = sshPassword + } + + bootstrapState, err := control.BootstrapNode(ctx, control.SSHRunner{}, *node, control.BootstrapOptions{ + StateDir: h.stateDir(), + DryRun: false, + }) + if err != nil { + return nil, err + } + response["bootstrap"] = bootstrapState + + checkState, err := control.CheckNode(ctx, control.SSHRunner{}, *node, h.stateDir()) + if err != nil { + return nil, err + } + response["check"] = checkState + + published := false + if canPublishNodeState(*checkState) { + inventory, err := control.LoadInventoryDir(h.inventoryDir()) + if err != nil { + return nil, err + } + states := make(map[string]*control.NodeState, len(inventory.Nodes)) + for _, item := range inventory.Nodes { + state, err := control.LoadNodeState(h.stateDir(), item.ID) + if err != nil { + if control.IsNotExist(err) { + continue + } + return nil, err + } + states[item.ID] = state + } + publishable := control.PublishableNodes(inventory.Nodes, states) + target := filepath.Join(h.store.DataDir(), "servers.json") + if err := control.WriteLegacyCatalog(target, publishable); err != nil { + return nil, err + } + catalogTarget := filepath.Join(h.store.DataDir(), "catalog-v2.json") + if err := control.WriteCatalogV2(catalogTarget, publishable, states); err != nil { + return nil, err + } + published = true + response["publish"] = map[string]any{ + "published": true, + "target": target, + "catalog_v2_target": catalogTarget, + "count": len(publishable), + } + } else { + response["publish"] = map[string]any{ + "published": false, + "reason": "узел ещё не готов и не healthy", + } + } + response["ready_for_catalog"] = published + return response, nil +} + +func controlRandomHex(size int) (string, error) { return control.RandomHexForAPI(size) } +func controlRandomBase64(size int) (string, error) { return control.RandomBase64ForAPI(size) } +func controlRandomUUID() (string, error) { return control.RandomUUIDForAPI() } + +func nodeNeedsProvisionedDNS(node control.Node) bool { + for _, protocol := range node.Protocols { + if !protocol.Enabled { + continue + } + switch protocol.Type { + case "vless": + if protocol.TLS != nil && protocol.TLS.Enabled { + return true + } + case "vmess": + if protocol.TLS != nil && protocol.TLS.Enabled { + return true + } + } + } + return false +} + +func dnsSkipReason(node control.Node) string { + if strings.TrimSpace(node.Domain) != "" { + return "домен уже задан" + } + return "selected protocols do not require a public domain" +} + +const vpnUIHTML = `<!doctype html> +<html lang="ru"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Панель управления vpnem</title> + <style> + :root { + --bg: #f3efe7; + --panel: rgba(255, 251, 245, 0.82); + --ink: #1f2430; + --muted: #66645f; + --line: rgba(132, 110, 82, 0.18); + --accent: #0f766e; + --accent-2: #c2410c; + --accent-soft: rgba(15, 118, 110, 0.1); + } + * { box-sizing: border-box; } + body { + margin: 0; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 24%), + radial-gradient(circle at 85% 10%, rgba(194, 65, 12, 0.14), transparent 20%), + radial-gradient(circle at bottom right, rgba(15, 118, 110, 0.08), transparent 28%), + var(--bg); + min-height: 100vh; + } + .shell { + max-width: 1360px; + margin: 0 auto; + padding: 18px 18px 32px; + } + @keyframes riseIn { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes glowShift { + 0% { transform: translate3d(0, 0, 0) scale(1); } + 50% { transform: translate3d(10px, -8px, 0) scale(1.04); } + 100% { transform: translate3d(0, 0, 0) scale(1); } + } + .hero { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 18px; + padding: 20px; + border: 1px solid var(--line); + border-radius: 24px; + background: + linear-gradient(145deg, rgba(255,255,255,0.92), rgba(255,248,240,0.84)), + var(--panel); + box-shadow: 0 24px 70px rgba(31, 36, 48, 0.08); + position: relative; + overflow: hidden; + animation: riseIn .5s ease both; + } + .hero::after { + content: ""; + position: absolute; + width: 280px; + height: 280px; + right: -80px; + top: -100px; + border-radius: 999px; + background: radial-gradient(circle, rgba(15,118,110,0.16), rgba(15,118,110,0)); + pointer-events: none; + animation: glowShift 8s ease-in-out infinite; + } + h1 { + margin: 0; + font-size: 36px; + line-height: 0.98; + letter-spacing: -0.04em; + max-width: 780px; + } + .hero-copy { + display: grid; + gap: 10px; + max-width: 780px; + position: relative; + z-index: 1; + } + .hero-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border-radius: 999px; + background: rgba(15, 118, 110, 0.1); + color: var(--accent); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + width: fit-content; + } + .sub { + color: var(--muted); + max-width: 760px; + line-height: 1.48; + font-size: 14px; + } + .hero-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 4px; + } + .hero-rail { + display: grid; + gap: 10px; + min-width: 270px; + position: relative; + z-index: 1; + } + .hero-note { + border: 1px solid var(--line); + border-radius: 16px; + padding: 11px 13px; + background: rgba(255,255,255,0.76); + font-size: 12px; + color: var(--muted); + line-height: 1.38; + backdrop-filter: blur(10px); + } + .hero-note strong { + color: var(--ink); + font-size: 13px; + } + .workspace-grid { + display: grid; + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + gap: 14px; + align-items: start; + } + .panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 18px; + padding: 14px; + box-shadow: 0 14px 44px rgba(31, 36, 48, 0.07); + backdrop-filter: blur(10px); + animation: riseIn .5s ease both; + } + .panel-shell { + display: grid; + gap: 16px; + } + .section-shell { + display: grid; + gap: 12px; + } + .surface-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 4px; + } + .surface-title { + margin: 0; + font-size: 22px; + letter-spacing: -0.03em; + } + .surface-sub { + margin: 4px 0 0; + color: var(--muted); + max-width: 720px; + line-height: 1.42; + font-size: 14px; + } + .panel h2 { + margin: 0 0 10px; + font-size: 17px; + } + .stack { display: grid; gap: 10px; } + .row { display: grid; gap: 8px; } + .cols-2 { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; } + .cols-3 { display: grid; gap: 10px; grid-template-columns: repeat(3, 1fr); } + label { + font-size: 13px; + color: var(--muted); + display: grid; + gap: 6px; + } + input, select, textarea, button { + font: inherit; + } + input, select, textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; + padding: 9px 11px; + color: var(--ink); + } + textarea { min-height: 88px; resize: vertical; } + button { + border: 0; + border-radius: 999px; + padding: 9px 14px; + cursor: pointer; + background: linear-gradient(135deg, #0f766e, #115e59); + color: #fff; + font-weight: 600; + box-shadow: 0 10px 24px rgba(15, 118, 110, 0.18); + transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease, background .16s ease; + } + button:hover { transform: translateY(-1px); box-shadow: 0 14px 28px rgba(15, 118, 110, 0.22); } + button.alt { background: linear-gradient(135deg, #c2410c, #9a3412); } + button.warn { background: linear-gradient(135deg, #9a3412, #7c2d12); } + button.ghost { + background: rgba(255,255,255,0.58); + color: var(--ink); + border: 1px solid var(--line); + box-shadow: none; + } + button.ghost:hover { + background: rgba(255,255,255,0.92); + box-shadow: none; + } + .node-list { + display: grid; + gap: 6px; + max-height: 420px; + overflow: auto; + } + .node-card { + border: 1px solid var(--line); + background: rgba(255,255,255,0.8); + border-radius: 12px; + padding: 8px; + cursor: pointer; + transition: transform .12s ease, border-color .12s ease; + } + .node-card:hover { transform: translateY(-1px); border-color: var(--accent); } + .node-meta { color: var(--muted); font-size: 12px; margin-top: 3px; } + .protocol-box { + border: 1px dashed var(--line); + border-radius: 14px; + padding: 12px; + background: rgba(255,255,255,0.55); + } + .collapsible-card[open] summary { + margin-bottom: 12px; + } + .collapsible-card summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-weight: 700; + color: var(--ink); + } + .collapsible-card summary::-webkit-details-marker { display: none; } + .summary-hint { + font-size: 12px; + color: var(--muted); + font-weight: 500; + } + .section-card { + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255,255,255,0.78); + padding: 14px; + } + .section-head { + margin-bottom: 10px; + } + .section-head h3 { + margin: 0 0 4px; + font-size: 17px; + color: var(--ink); + } + .section-head .tip { + margin: 0; + } + .toolbar { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .toolbar button { + padding: 7px 12px; + font-size: 12px; + } + .toolbar button.ghost { + padding: 6px 11px; + } + .status { + min-height: 22px; + font-size: 14px; + border: 1px solid var(--line); + border-radius: 14px; + padding: 10px 12px; + background: rgba(255,255,255,0.78); + color: var(--ink); + } + .status.info { + border-color: #bfdbfe; + background: #eff6ff; + } + .status.success { + border-color: #a7f3d0; + background: #ecfdf5; + } + .status.error { + border-color: #fdba74; + background: #fff7ed; + } + .tip { + color: var(--muted); + font-size: 12px; + line-height: 1.35; + } + .warn { + border-left: 3px solid var(--accent-2); + padding-left: 10px; + } + .checks { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .preset-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .preset-card { + border: 1px solid var(--line); + border-radius: 16px; + padding: 11px 12px; + background: rgba(255,255,255,0.72); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease; + } + .preset-card:hover { + transform: translateY(-1px); + border-color: var(--accent); + } + .preset-card.active { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14); + background: rgba(240, 253, 250, 0.8); + } + .preset-title { + font-size: 14px; + font-weight: 700; + color: var(--ink); + margin-bottom: 4px; + } + .preset-meta { + font-size: 12px; + color: var(--muted); + line-height: 1.32; + } + .check { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px 12px; + background: #fff; + color: var(--ink); + } + .check input { + width: auto; + margin: 0; + } + .system-note { + display: grid; + gap: 8px; + } + .system-note { + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255,255,255,0.8); + padding: 12px; + } + .system-note .eyebrow { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; + } + .system-note .value { + font-size: 16px; + font-weight: 700; + color: var(--ink); + line-height: 1.35; + } + .system-note .meta { + margin-top: 6px; + font-size: 12px; + color: var(--muted); + } + .node-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + } + .node-title { + font-size: 15px; + font-weight: 700; + color: var(--ink); + } + .badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; + } + .filter-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 2px; + align-items: flex-start; + align-content: flex-start; + } + .filter-chip { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + align-self: flex-start; + border-radius: 11px; + padding: 4px 9px; + background: rgba(255,255,255,0.78); + border: 1px solid rgba(148, 163, 184, 0.28); + color: #334155; + font-size: 11px; + font-weight: 600; + line-height: 1.05; + box-shadow: none; + min-height: 0; + white-space: nowrap; + } + .filter-chip:hover { + transform: none; + background: rgba(255,255,255,0.98); + border-color: rgba(15, 118, 110, 0.24); + box-shadow: none; + } + .filter-chip.alt { + background: linear-gradient(135deg, #c2410c, #9a3412); + border-color: transparent; + color: #fff; + box-shadow: 0 8px 18px rgba(154, 52, 18, 0.15); + } + .filter-chip.alt:hover { + transform: none; + box-shadow: 0 8px 18px rgba(154, 52, 18, 0.15); + } + .badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 3px 7px; + font-size: 10px; + font-weight: 700; + line-height: 1; + } + .badge.ready { + background: #dcfce7; + color: #166534; + } + .badge.blocked { + background: #ffedd5; + color: #9a3412; + } + .badge.active { + background: #dbeafe; + color: #1d4ed8; + } + .badge.idle { + background: #e5e7eb; + color: #374151; + } + .badge.protocol { + background: #f3f4f6; + color: #334155; + font-weight: 600; + } + .list-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 6px; + } + .muted-box { + border: 1px dashed var(--line); + border-radius: 14px; + padding: 12px; + color: var(--muted); + background: rgba(255,255,255,0.45); + } + .empty-box { + padding: 10px 12px; + font-size: 12px; + line-height: 1.35; + } + .debug-box { + margin-top: 12px; + } + .debug-box summary { + cursor: pointer; + font-weight: 600; + } + .ready-grid { + display: grid; + gap: 10px; + } + .ready-card { + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255,255,255,0.88); + padding: 12px; + display: grid; + gap: 8px; + } + .ready-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + } + .ready-card-title { + font-size: 16px; + font-weight: 700; + color: var(--ink); + } + .ready-card-sub { + font-size: 12px; + color: var(--muted); + margin-top: 2px; + } + .mono { + font-family: "IBM Plex Mono", "Cascadia Code", monospace; + font-size: 12px; + line-height: 1.5; + color: #243244; + background: #fff; + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px 12px; + overflow: auto; + word-break: break-all; + } + .settings-grid { + display: grid; + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + gap: 14px; + align-items: start; + } + @media (max-width: 920px) { + .hero { flex-direction: column; } + .workspace-grid, .settings-grid { grid-template-columns: 1fr; } + .cols-2, .cols-3 { grid-template-columns: 1fr; } + .preset-grid, + .checks { grid-template-columns: 1fr; } + } + @media (max-width: 640px) { + } + </style> +</head> +<body> + <div class="shell"> + <div class="hero"> + <div class="hero-copy"> + <div class="hero-kicker">Панель VPN</div> + <h1>Один экран для установки, ремонта и управления VPN-узлами.</h1> + <div class="sub">Сверху находится простой путь: вставьте IP сервера, введите root-пароль и получите готовый узел. Ниже остаётся тонкая настройка на тот случай, если нужно чинить, обновлять, добавлять SOCKS5 или вручную переопределять параметры.</div> + <div class="hero-actions"> + <button id="jumpInstallBtn" type="button">Начать установку</button> + <button id="jumpAdvancedBtn" class="ghost" type="button">Открыть тонкую настройку</button> + </div> + </div> + <div class="hero-rail"> + <div class="hero-note"><strong>Шаг 1</strong><br>Проверьте VPS и убедитесь, что панель говорит, можно ли ставить <strong>MULTI</strong> или <strong>SOCKS5</strong>.</div> + <div class="hero-note"><strong>Шаг 2</strong><br>Создайте узел, дождитесь проверки и сразу скопируйте готовую ссылку из карточек справа.</div> + <div class="hero-note"><strong>Тонкая настройка</strong><br>Если VPS уже под управлением, спускайтесь ниже: там есть обновление, ремонт, чистая переустановка и ручные override-поля.</div> + </div> + </div> + + <div class="panel-shell"> + <section id="quickStart" class="section-shell"> + <div class="surface-head"> + <div> + <h2 class="surface-title">Быстрая установка</h2> + <p class="surface-sub">Это главный путь. Если нужен просто рабочий сервер, оставайтесь здесь: сначала проверка VPS, потом установка, потом копирование готовых ссылок.</p> + </div> + </div> + <div class="workspace-grid"> + <div class="stack"> + <div class="panel stack"> + <h2>Быстрая установка</h2> + <div class="tip">Это самый простой сценарий. Вставьте IP сервера, введите root-пароль, выберите тип узла и дождитесь, пока панель сама всё сделает: установит, проверит и покажет готовые ссылки.</div> + <div class="muted-box"> + <strong>Как это работает</strong><br> + 1. Нажмите «Проверить VPS».<br> + 2. Оставьте включённым <strong>MULTI</strong>, если нужен основной современный режим.<br> + 3. Нажмите «Создать прокси».<br> + 4. Скопируйте готовую ссылку из блока ниже. + </div> + <div class="cols-2"> + <label>Сервер (IP или домен)<input id="quickHost" placeholder="89.124.96.166"></label> + <label>Root-пароль<input id="quickRootPassword" type="password" placeholder="root-пароль"></label> + </div> + <details class="protocol-box collapsible-card"> + <summary>Дополнительно <span class="summary-hint">Все поля уже заполнены по умолчанию</span></summary> + <div class="cols-3" style="margin-top:12px"> + <label>Регион<input id="quickRegion" value="auto"></label> + <label>Провайдер<input id="quickProvider" value="custom-vps"></label> + <label>Email для ACME<input id="quickACMEEmail" value="admin@em-sysadmin.xyz"></label> + </div> + </details> + <div class="stack"> + <strong>Выберите готовый сценарий</strong> + <div id="quickPresetGrid" class="preset-grid"> + <button class="preset-card active" type="button" data-preset="multi"> + <div class="preset-title">Обычный сервер</div> + <div class="preset-meta">Рекомендуется. TCP через REALITY, UDP через Hysteria2.</div> + </button> + <button class="preset-card" type="button" data-preset="multi+socks"> + <div class="preset-title">Сервер + SOCKS5</div> + <div class="preset-meta">Основной MULTI-режим плюс fallback SOCKS5 на порту 54101.</div> + </button> + <button class="preset-card" type="button" data-preset="socks"> + <div class="preset-title">Только SOCKS5</div> + <div class="preset-meta">Простой вариант без MULTI, только SOCKS5 на порту 54101.</div> + </button> + </div> + <div id="quickPresetSummary" class="tip">Сейчас выбран сценарий <strong>Обычный сервер</strong>.</div> + </div> + <div class="checks" style="display:none"> + <label class="check"><input id="quickEnableMulti" type="checkbox" checked>MULTI (REALITY + Hysteria2)</label> + <label class="check"><input id="quickEnableSocks" type="checkbox">SOCKS5</label> + </div> + <div class="tip">Если не уверены, оставляйте пресет <strong>Обычный сервер</strong>. Для SOCKS5 по умолчанию используется порт <code>54101</code>.</div> + <div id="quickDefaults" class="muted-box"> + <strong>Что заполнится автоматически</strong><br> + Имя сервера будет создано само.<br> + Для <strong>MULTI</strong> по умолчанию используются уже проверенные настройки: TCP через REALITY, UDP через Hysteria2, порт <code>443</code>, рабочий SNI и безопасные transport-параметры.<br> + Для <strong>SOCKS5</strong> по умолчанию используется порт <code>54101</code>. + </div> + <div id="quickHostStatus" class="muted-box" style="display:none"></div> + <div id="quickStatusRail" class="badges" style="display:none"></div> + <div id="quickGuide" class="muted-box" style="display:none"></div> + <div class="toolbar"> + <button id="quickInspectBtn" class="ghost" type="button">Проверить VPS</button> + <button id="quickProvisionBtn" type="button">Создать прокси</button> + </div> + </div> + <div id="status" class="status info">Готово.</div> + <div class="tip warn">Если панель доступна публично, не отключайте админ-токен. Главная вкладка рассчитана на простой сценарий с IP и паролем.</div> + <div id="currentSystem" class="system-note"> + <div class="eyebrow">Сейчас в системе</div> + <div id="currentSystemValue" class="value">Пока нет ни одного сервера.</div> + <div id="currentSystemMeta" class="meta">Начните с проверки VPS, затем создайте первый сервер и скопируйте готовую ссылку.</div> + </div> + </div> + + <div class="stack"> + <div class="panel stack"> + <div class="toolbar" style="justify-content:space-between;align-items:center"> + <h2 style="margin:0">Мои серверы</h2> + <div class="toolbar"> + <button id="refreshBtn" class="ghost" type="button">Обновить</button> + <button id="publishBtn" class="ghost" type="button">Перепубликовать каталог</button> + </div> + </div> + <div id="fleetFilters" class="filter-chips"> + <button class="ghost filter-chip" type="button" data-fleet-filter="all">Все</button> + <button class="ghost filter-chip" type="button" data-fleet-filter="ready">Готовы</button> + <button class="ghost filter-chip" type="button" data-fleet-filter="repair">Нужен ремонт</button> + <button class="ghost filter-chip" type="button" data-fleet-filter="managed">Уже под управлением</button> + <button class="ghost filter-chip" type="button" data-fleet-filter="multi">MULTI</button> + <button class="ghost filter-chip" type="button" data-fleet-filter="socks5">SOCKS5</button> + </div> + <div id="nodeList" class="node-list"></div> + </div> + + <div class="panel stack"> + <div class="toolbar" style="justify-content:space-between;align-items:center"> + <h2 style="margin:0">Подключение</h2> + <button id="copySummaryBtn" class="ghost" type="button">Копировать сводку</button> + </div> + <div class="tip">Используйте этот блок после того, как узел станет healthy и готовым к публикации. Здесь находятся ссылки и параметры подключения для прямого копирования.</div> + <div id="readyCards" class="ready-grid"></div> + <details class="debug-box"> + <summary>Сырая сводка</summary> + <pre id="summaryView" style="margin:12px 0 0;padding:14px;border:1px solid var(--line);border-radius:14px;background:#fff;overflow:auto;min-height:180px"></pre> + </details> + </div> + </div> + </div> + </section> + + <section id="advancedControls" class="section-shell"> + <div class="surface-head"> + <div> + <h2 class="surface-title">Тонкая настройка и сервисные действия</h2> + <p class="surface-sub">Этот блок нужен, когда узел уже существует и его надо обновлять, ремонтировать, дооснащать SOCKS5 или вручную править конфиг.</p> + </div> + </div> + <div class="settings-grid"> + <div class="stack"> + <div class="panel"> + <h2>Что здесь можно делать</h2> + <div class="tip">Выбирайте узел в списке выше и используйте основные действия. Ручные поля протоколов нужны редко и остаются ниже как сервисный слой.</div> + </div> + <details id="accessPanel" class="panel"> + <summary style="cursor:pointer;font-weight:600">Доступ</summary> + <div class="row" style="margin-top:12px"> + <label>Ключ доступа + <input id="adminToken" placeholder="Необязательно. Используется, когда задан VPNEM_ADMIN_TOKEN"> + </label> + <div class="tip">Панель можно открывать по magic-link вроде ` + "`/vpnui/?token=...`" + ` или ` + "`/vpnui/#token=...`" + `. Ключ будет сохранён в этом браузере и удалён из URL.</div> + </div> + </details> + </div> + + <div class="panel"> + <form id="nodeForm" class="stack"> + <div class="section-card stack"> + <div class="section-head"> + <h3>Данные узла</h3> + <div class="tip">Базовые данные о VPS и публичном хосте, который будут использовать клиенты.</div> + </div> + <div class="cols-2"> + <label>ID узла<input name="id" required></label> + <label>Название<input name="name" required></label> + </div> + <div class="cols-3"> + <label>Провайдер<input name="provider" value="custom-vps"></label> + <label>Регион<input name="region" required placeholder="nl"></label> + <label>Включён + <select name="enabled"> + <option value="true">true</option> + <option value="false">false</option> + </select> + </label> + </div> + <div class="cols-2"> + <label>Хост<input name="host" required placeholder="203.0.113.10"></label> + <label>Домен<input name="domain" placeholder="nl-01.example.com"></label> + </div> + <div class="cols-2"> + <label>Email для ACME<input name="acme_email" placeholder="admin@example.com"></label> + <div class="tip">Используйте настройки только тогда, когда простого сценария “Создать прокси” уже недостаточно и нужен точный контроль над узлом.</div> + </div> + </div> + + <div class="section-card stack"> + <div class="section-head"> + <h3>Доступ к серверу</h3> + <div class="tip">Как ` + "`vpnui`" + ` должен входить на VPS для bootstrap, проверок, обновления и удаления узла.</div> + </div> + <div class="cols-3"> + <label>SSH-пользователь<input name="ssh_user" value="root" required></label> + <label>SSH-порт<input name="ssh_port" type="number" value="22" required></label> + <label>Тип SSH-входа + <select name="ssh_auth"> + <option value="key">key</option> + <option value="password">password</option> + </select> + </label> + </div> + <div class="cols-2"> + <label>Файл SSH-ключа<input name="ssh_identity" placeholder="~/.ssh/id_ed25519"></label> + <label>Переменная окружения с SSH-паролем<input name="ssh_password_env" placeholder="VPNEM_NODE_PASSWORD"></label> + </div> + <div class="cols-2"> + <label>Временный SSH-пароль<input name="ssh_runtime_password" type="password" placeholder="Нужен только для bootstrap/check"></label> + <div class="tip">Если сервер использует вход по паролю, введите текущий root-пароль перед bootstrap или проверками. Он отправляется только вместе с действием и не сохраняется в inventory.</div> + </div> + </div> + + <div class="section-card stack"> + <div class="section-head"> + <h3>Основные действия</h3> + <div class="tip">Используйте эти кнопки для обычного обслуживания уже существующего узла. Сырые поля протоколов ниже обычно трогать не нужно.</div> + </div> + <div id="nodeStatusRail" class="badges" style="display:none"></div> + <div id="nodeGuide" class="muted-box">Выберите узел, чтобы увидеть самый безопасный следующий шаг.</div> + <div class="toolbar"> + <button id="upgradeBtn" class="alt" type="button">Обновить сервер</button> + <button id="addSocks5Btn" class="alt" type="button">Добавить SOCKS5</button> + <button id="repairReinstallBtn" class="alt" type="button">Починить сервер</button> + <button id="cleanReinstallBtn" class="warn" type="button">Переустановить сервер</button> + <button id="checkBtn" class="alt" type="button">Проверить сервер</button> + </div> + <div class="tip">Используйте <strong>Добавить SOCKS5</strong>, когда MULTI-узел уже работает и вы хотите добавить fallback-прокси на порту 54101 на том же VPS.</div> + </div> + + <details class="section-card stack collapsible-card"> + <summary>Ручные переопределения протоколов <span class="summary-hint">Нужно только если вы точно знаете, что меняете</span></summary> + <div class="section-head" style="margin-top:12px"> + <h3>Протоколы</h3> + <div class="tip">Этот раздел нужен только для ручных переопределений. В обычном сценарии достаточно быстрой установки на главной вкладке и основных действий выше.</div> + </div> + + <div class="protocol-box stack"> + <strong>VLESS</strong> + <div class="cols-3"> + <label>Включён + <select name="vless_enabled"> + <option value="true">true</option> + <option value="false">false</option> + </select> + </label> + <label>Порт<input name="vless_port" type="number" value="443"></label> + <label>UUID<input name="vless_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label> + </div> + <div class="cols-3"> + <label>TLS включён + <select name="vless_tls_enabled"> + <option value="true">true</option> + <option value="false">false</option> + </select> + </label> + <label>Имя сервера / SNI<input name="vless_server_name" placeholder="nl-01.example.com"></label> + <label>Тип транспорта + <select name="vless_transport_type"> + <option value="">none</option> + <option value="ws">ws</option> + <option value="httpupgrade">httpupgrade</option> + <option value="grpc">grpc</option> + </select> + </label> + </div> + <label>Путь транспорта<input name="vless_path" placeholder="/ws"></label> + </div> + + <div class="protocol-box stack"> + <strong>VLESS REALITY</strong> + <div class="cols-3"> + <label>Включён + <select name="reality_enabled"> + <option value="false">false</option> + <option value="true">true</option> + </select> + </label> + <label>Порт<input name="reality_port" type="number" value="443"></label> + <label>UUID<input name="reality_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label> + </div> + <div class="cols-3"> + <label>Имя сервера / SNI<input name="reality_server_name" placeholder="www.nokia.com"></label> + <label>Порт handshake<input name="reality_server_port" type="number" value="443"></label> + <label>Отпечаток браузера<input name="reality_fingerprint" placeholder="chrome"></label> + </div> + <div class="cols-3"> + <label>Публичный ключ<input name="reality_public_key" placeholder="сгенерируется автоматически, если пусто"></label> + <label>Приватный ключ<input name="reality_private_key" placeholder="сгенерируется автоматически, если пусто"></label> + <label>Короткий ID<input name="reality_short_id" placeholder="сгенерируется автоматически, если пусто"></label> + </div> + <div class="tip">REALITY использует server-side reality TLS внутри sing-box и не требует публичный домен или ACME-сертификаты.</div> + </div> + + <div class="protocol-box stack"> + <strong>Shadowsocks</strong> + <div class="cols-3"> + <label>Включён + <select name="ss_enabled"> + <option value="false">false</option> + <option value="true">true</option> + </select> + </label> + <label>Порт<input name="ss_port" type="number" value="8443"></label> + <label>Метод<input name="ss_method" placeholder="2022-blake3-aes-128-gcm"></label> + </div> + <label>Пароль<input name="ss_password" placeholder="secret"></label> + </div> + + <div class="protocol-box stack"> + <strong>SOCKS5</strong> + <div class="cols-3"> + <label>Включён + <select name="socks_enabled"> + <option value="false">false</option> + <option value="true">true</option> + </select> + </label> + <label>Порт<input name="socks_port" type="number" value="1080"></label> + <div class="tip">Простой прямой SOCKS5 listener без TLS-слоя.</div> + </div> + </div> + + <div class="protocol-box stack"> + <strong>VMess</strong> + <div class="cols-3"> + <label>Включён + <select name="vmess_enabled"> + <option value="false">false</option> + <option value="true">true</option> + </select> + </label> + <label>Порт<input name="vmess_port" type="number" value="443"></label> + <label>UUID<input name="vmess_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label> + </div> + <div class="cols-3"> + <label>TLS включён + <select name="vmess_tls_enabled"> + <option value="true">true</option> + <option value="false">false</option> + </select> + </label> + <label>Имя сервера / SNI<input name="vmess_server_name" placeholder="vmess.example.com"></label> + <label>Путь<input name="vmess_path" placeholder="/vmess"></label> + </div> + </div> + + <div class="protocol-box stack"> + <strong>Hysteria2</strong> + <div class="cols-3"> + <label>Включён + <select name="hy2_enabled"> + <option value="false">false</option> + <option value="true">true</option> + </select> + </label> + <label>Порт<input name="hy2_port" type="number" value="8443"></label> + <label>Пароль<input name="hy2_password" placeholder="hy2-secret"></label> + </div> + <div class="cols-3"> + <label>Скорость вверх, Mbps<input name="hy2_up_mbps" type="number" placeholder="необязательно"></label> + <label>Скорость вниз, Mbps<input name="hy2_down_mbps" type="number" placeholder="необязательно"></label> + <label>Пароль obfs<input name="hy2_obfs_password" placeholder="необязательно"></label> + </div> + <div class="cols-2"> + <label>Путь к TLS-сертификату<input name="hy2_tls_cert_path" placeholder="/opt/vpnem-node/certs/fullchain.pem"></label> + <label>Путь к TLS-ключу<input name="hy2_tls_key_path" placeholder="/opt/vpnem-node/certs/privkey.pem"></label> + </div> + </div> + </details> + + <div class="section-card stack"> + <div class="section-head"> + <h3>Сохранение и жизненный цикл</h3> + <div class="tip">Сохраняйте ручные изменения только тогда, когда вы специально меняли конфигурацию узла. Ниже остаются расширенные действия для DNS и разрушительных операций.</div> + </div> + <div class="toolbar"> + <button type="submit">Сохранить узел</button> + <button id="resetBtn" class="ghost" type="button">Сбросить</button> + </div> + <details class="protocol-box"> + <summary style="cursor:pointer;font-weight:600">Расширенные действия</summary> + <div class="toolbar" style="margin-top:12px"> + <button id="enableNodeBtn" class="ghost" type="button">Включить узел</button> + <button id="disableNodeBtn" class="ghost" type="button">Выключить узел</button> + <button id="rotateSecretsBtn" class="ghost" type="button">Сменить секреты</button> + <button id="provisionDnsBtn" class="ghost" type="button">Создать DNS</button> + <button id="provisionNodeBtn" class="alt" type="button">Подготовить узел</button> + <button id="deleteDnsBtn" class="ghost" type="button">Удалить DNS</button> + <button id="bootstrapDryRunBtn" class="ghost" type="button">Bootstrap без запуска</button> + <button id="bootstrapBtn" type="button">Запустить bootstrap</button> + <button id="destroyNodeBtn" class="warn" type="button">Удалить сервер</button> + </div> + </details> + </div> + </form> + <details class="debug-box"> + <summary>Техническая диагностика</summary> + <pre id="stateView" style="margin:12px 0 0;padding:14px;border:1px solid var(--line);border-radius:14px;background:#fff;overflow:auto;min-height:180px"></pre> + </details> + </div> + </div> + </section> + </div> + </div> + + <script> + const state = { nodes: [], states: {}, publishDecisions: {}, selectedNodeID: '', fleetFilter: 'all' }; + const tokenInput = document.getElementById('adminToken'); + const statusEl = document.getElementById('status'); + const nodeListEl = document.getElementById('nodeList'); + const fleetFiltersEl = document.getElementById('fleetFilters'); + const stateViewEl = document.getElementById('stateView'); + const summaryViewEl = document.getElementById('summaryView'); + const readyCardsEl = document.getElementById('readyCards'); + const currentSystemValueEl = document.getElementById('currentSystemValue'); + const currentSystemMetaEl = document.getElementById('currentSystemMeta'); + const form = document.getElementById('nodeForm'); + const quickHostEl = document.getElementById('quickHost'); + const quickRootPasswordEl = document.getElementById('quickRootPassword'); + const quickRegionEl = document.getElementById('quickRegion'); + const quickProviderEl = document.getElementById('quickProvider'); + const quickACMEEmailEl = document.getElementById('quickACMEEmail'); + const quickEnableMultiEl = document.getElementById('quickEnableMulti'); + const quickEnableSocksEl = document.getElementById('quickEnableSocks'); + const quickPresetGridEl = document.getElementById('quickPresetGrid'); + const quickPresetSummaryEl = document.getElementById('quickPresetSummary'); + const quickHostStatusEl = document.getElementById('quickHostStatus'); + const quickStatusRailEl = document.getElementById('quickStatusRail'); + const quickGuideEl = document.getElementById('quickGuide'); + const quickInspectBtn = document.getElementById('quickInspectBtn'); + const jumpInstallBtn = document.getElementById('jumpInstallBtn'); + const jumpAdvancedBtn = document.getElementById('jumpAdvancedBtn'); + const quickStartEl = document.getElementById('quickStart'); + const advancedControlsEl = document.getElementById('advancedControls'); + const accessPanelEl = document.getElementById('accessPanel'); + const nodeStatusRailEl = document.getElementById('nodeStatusRail'); + const nodeGuideEl = document.getElementById('nodeGuide'); + + function readTokenFromLocation() { + const url = new URL(window.location.href); + const queryToken = (url.searchParams.get('token') || '').trim(); + if (queryToken) { + url.searchParams.delete('token'); + history.replaceState({}, '', url.pathname + (url.searchParams.toString() ? '?' + url.searchParams.toString() : '') + url.hash.replace(/^#token=.*$/, '')); + return queryToken; + } + const hash = window.location.hash || ''; + if (hash.startsWith('#token=')) { + const hashToken = hash.slice('#token='.length).trim(); + history.replaceState({}, '', url.pathname + (url.search ? url.search : '')); + return hashToken; + } + return ''; + } + + const bootToken = readTokenFromLocation() || localStorage.getItem('vpnem_admin_token') || ''; + tokenInput.value = bootToken; + if (bootToken) { + localStorage.setItem('vpnem_admin_token', bootToken); + accessPanelEl.open = false; + } + tokenInput.addEventListener('change', () => { + const token = tokenInput.value.trim(); + localStorage.setItem('vpnem_admin_token', token); + accessPanelEl.open = !token; + }); + + function setStatus(text, tone = 'info') { + statusEl.textContent = text; + statusEl.className = 'status ' + tone; + } + + function scrollToSection(el) { + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function openAdvancedControls() { + scrollToSection(advancedControlsEl); + } + + function describeQuickPreset(preset) { + if (preset === 'multi+socks') return 'Сейчас выбран сценарий <strong>Сервер + SOCKS5</strong>.'; + if (preset === 'socks') return 'Сейчас выбран сценарий <strong>Только SOCKS5</strong>.'; + return 'Сейчас выбран сценарий <strong>Обычный сервер</strong>.'; + } + + function setQuickPreset(preset) { + const value = preset || 'multi'; + quickEnableMultiEl.checked = value === 'multi' || value === 'multi+socks'; + quickEnableSocksEl.checked = value === 'socks' || value === 'multi+socks'; + if (quickPresetGridEl) { + quickPresetGridEl.querySelectorAll('[data-preset]').forEach(el => { + el.classList.toggle('active', el.getAttribute('data-preset') === value); + }); + } + if (quickPresetSummaryEl) { + quickPresetSummaryEl.innerHTML = describeQuickPreset(value); + } + } + + function updateCurrentSystem() { + const nodes = state.nodes || []; + const readyCount = nodes.filter(node => state.publishDecisions[node.id]?.eligible).length; + const blockedCount = nodes.filter(node => state.publishDecisions[node.id] && !state.publishDecisions[node.id].eligible).length; + const selected = nodes.find(node => node.id === state.selectedNodeID); + if (!nodes.length) { + currentSystemValueEl.textContent = 'Пока нет ни одного сервера.'; + currentSystemMetaEl.textContent = 'Начните с проверки VPS, затем создайте первый сервер и скопируйте готовую ссылку.'; + return; + } + currentSystemValueEl.textContent = 'Сохранено ' + String(nodes.length) + ' серверов. Из них ' + String(readyCount) + ' уже готовы к публикации, а ' + String(blockedCount) + ' требуют внимания.'; + if (!selected) { + currentSystemMetaEl.textContent = 'Выберите любой сервер справа, и панель подскажет самый безопасный следующий шаг.'; + return; + } + const selectedState = state.states[selected.id]; + const selectedDecision = state.publishDecisions[selected.id]; + const lifecycle = nodeLifecycleLabel(selected, selectedState, selectedDecision); + const publishLabel = selectedDecision?.eligible ? 'уже можно выдавать пользователям' : 'пока нужен дополнительный шаг'; + currentSystemMetaEl.textContent = 'Сейчас выбран сервер «' + (selected.name || selected.id || 'Без имени') + '»: статус — ' + lifecycle.label + ', публикация — ' + publishLabel + '.'; + } + + function nodeProductState(node, runtimeState, decision) { + const status = String(runtimeState?.bootstrap_status || 'new'); + const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type); + const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2'); + const hasSocks = protocols.includes('socks5') || protocols.includes('socks'); + if (node.enabled === false) { + return { title: 'Сервер выключен', subtitle: 'Он сохранён, но сейчас не публикуется.', nextStep: 'Включите его снова или удалите, если он больше не нужен.' }; + } + if (status === 'healthy' || status === 'ready') { + if (decision?.eligible) { + if (hasMulti && !hasSocks) { + return { title: 'Сервер работает', subtitle: 'Основной MULTI уже поднят и ссылки готовы.', nextStep: 'При желании можно добавить SOCKS5 как запасной вариант.' }; + } + if (hasMulti && hasSocks) { + return { title: 'Сервер полностью готов', subtitle: 'MULTI и SOCKS5 уже работают на одном VPS.', nextStep: 'Обычно здесь ничего делать не нужно.' }; + } + if (hasSocks && !hasMulti) { + return { title: 'SOCKS5 сервер готов', subtitle: 'Прокси-сервер уже работает и доступен.', nextStep: 'Открывайте карточку чтобы скопировать ссылку или выполнить ремонт.' }; + } + return { title: 'Сервер готов', subtitle: 'Его уже можно использовать и публиковать пользователям.', nextStep: 'Открывайте карточку только если нужен ремонт или переустановка.' }; + } + return { title: 'Сервер почти готов', subtitle: 'Runtime уже работает, но публикация пока заблокирована.', nextStep: 'Откройте сервер и выполните рекомендуемое действие из подсказки ниже.' }; + } + if (status === 'failed' || status === 'unreachable') { + return { title: 'Нужен ремонт', subtitle: 'Сервер сохранён, но сейчас не проходит проверку.', nextStep: 'Самое безопасное действие — «Починить сервер».' }; + } + if (status === 'planned' || status === 'pending' || status === 'reachable') { + return { title: 'Идёт установка', subtitle: 'VPS уже в процессе настройки и проверки.', nextStep: 'Подождите немного или откройте карточку, если нужно повторить установку.' }; + } + return { title: 'Сервер ещё не установлен', subtitle: 'Карточка сохранена, но runtime на VPS ещё не развёрнут.', nextStep: 'Откройте сервер и запустите установку или обновление.' }; + } + + function renderGuideBox(el, title, lines, tone = 'info') { + if (!el) return; + const safeLines = (lines || []).filter(Boolean); + if (!title && !safeLines.length) { + el.style.display = 'none'; + el.innerHTML = ''; + el.style.borderColor = 'var(--line)'; + el.style.background = '#fff'; + el.style.color = ''; + return; + } + el.style.display = 'block'; + el.className = 'muted-box'; + el.style.borderColor = 'var(--line)'; + el.style.background = '#fff'; + el.style.color = ''; + if (tone === 'warn') { + el.style.borderColor = '#f59e0b'; + el.style.background = '#fffbeb'; + el.style.color = '#92400e'; + } else if (tone === 'danger') { + el.style.borderColor = '#dc2626'; + el.style.background = '#fef2f2'; + el.style.color = '#991b1b'; + } + const titleHTML = title ? '<strong>' + title + '</strong>' : ''; + const bodyHTML = safeLines.map(line => '<div>' + line + '</div>').join(''); + el.innerHTML = titleHTML + bodyHTML; + } + + function renderStatusRail(el, items) { + if (!el) return; + const safeItems = (items || []).filter(item => item && item.label); + if (!safeItems.length) { + el.style.display = 'none'; + el.innerHTML = ''; + return; + } + el.style.display = 'flex'; + el.innerHTML = safeItems.map(item => '<span class="badge ' + (item.tone || 'idle') + '">' + item.label + '</span>').join(''); + } + + function quickStatusItems(data, existingNode) { + if (existingNode) { + const protocols = (existingNode.protocols || []).filter(p => p.enabled).map(p => p.type); + const items = [{ label: 'Уже под управлением', tone: 'blocked' }]; + if (protocols.includes('vless-reality') && protocols.includes('hysteria2')) items.push({ label: 'MULTI уже есть', tone: 'ready' }); + if (protocols.includes('socks5') || protocols.includes('socks')) items.push({ label: 'SOCKS5 уже есть', tone: 'ready' }); + return items; + } + if (!data) return []; + const items = []; + if (data.already_managed) items.push({ label: 'Уже под управлением', tone: 'blocked' }); + if (data.quick_multi?.supported) items.push({ label: 'Можно ставить MULTI', tone: 'ready' }); + else if (data.quick_multi) items.push({ label: 'MULTI заблокирован', tone: 'blocked' }); + if (data.quick_socks5?.supported) items.push({ label: 'Можно ставить SOCKS5', tone: 'ready' }); + else if (data.quick_socks5) items.push({ label: 'SOCKS5 заблокирован', tone: 'blocked' }); + if (data.support_tier === 'recommended') items.push({ label: 'Рекомендуемый дистрибутив', tone: 'ready' }); + else if (data.support_tier === 'supported') items.push({ label: 'Поддерживаемый дистрибутив', tone: 'active' }); + else if (data.support_tier === 'experimental') items.push({ label: 'Экспериментальный дистрибутив', tone: 'blocked' }); + return items; + } + + function selectedNodeStatusItems(node, runtimeState, decision) { + if (!node) return []; + const items = []; + const lifecycle = nodeLifecycleLabel(node, runtimeState, decision); + items.push({ label: lifecycle.label, tone: lifecycle.tone }); + if (decision?.eligible) items.push({ label: 'Готов к публикации', tone: 'ready' }); + else if (decision) items.push({ label: 'Публикация заблокирована', tone: 'blocked' }); + const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type); + if (protocols.includes('vless-reality') && protocols.includes('hysteria2')) items.push({ label: 'MULTI', tone: 'active' }); + if (protocols.includes('socks5') || protocols.includes('socks')) items.push({ label: 'SOCKS5', tone: 'active' }); + return items; + } + + function nodeMatchesFleetFilter(node) { + const filter = state.fleetFilter || 'all'; + if (filter === 'all') return true; + const nodeState = state.states[node.id]; + const decision = state.publishDecisions[node.id]; + const lifecycle = nodeLifecycleLabel(node, nodeState, decision); + const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type); + const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2'); + const hasSocks = protocols.includes('socks5') || protocols.includes('socks'); + if (filter === 'ready') return lifecycle.label === 'Готов'; + if (filter === 'repair') return lifecycle.label === 'Нужен ремонт' || lifecycle.label === 'Нужно внимание'; + if (filter === 'managed') return true; + if (filter === 'multi') return hasMulti; + if (filter === 'socks5') return hasSocks; + return true; + } + + function renderFleetFilters() { + if (!fleetFiltersEl) return; + fleetFiltersEl.querySelectorAll('[data-fleet-filter]').forEach(btn => { + const active = btn.getAttribute('data-fleet-filter') === state.fleetFilter; + btn.classList.toggle('alt', active); + btn.classList.toggle('ghost', !active); + }); + } + + function selectedNodeGuide(node, runtimeState, decision) { + if (!node) { + return { + title: 'Что можно сделать здесь', + tone: 'info', + lines: [ + 'Сначала выберите узел в списке.', + 'Затем используйте основные действия, а не редактируйте поля вручную.', + ] + }; + } + const status = String(runtimeState?.bootstrap_status || 'new'); + const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type); + const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2'); + const hasSocks = protocols.includes('socks5') || protocols.includes('socks'); + if (node.enabled === false) { + return { + title: 'Этот узел выключен.', + tone: 'warn', + lines: [ + 'Используйте «Включить узел», если хотите снова публиковать его.', + 'Используйте «Удалить сервер» только если хотите убрать его полностью.', + ] + }; + } + if (status === 'healthy' || status === 'ready') { + const lines = ['Сейчас всё выглядит исправно. Используйте «Обновить сервер», если хотите применить актуальный bundle без смены секретов.']; + if (hasMulti && !hasSocks) { + lines.push('Используйте «Добавить SOCKS5», если хотите добавить fallback-прокси на порту 54101 на том же VPS.'); + } + if (decision && decision.eligible === false) { + lines.push('Этот узел всё ещё заблокирован для публикации. Проверьте причины и затем нажмите «Починить сервер», если нужно.'); + return { title: 'Узел работает, но ему нужно внимание.', tone: 'warn', lines: lines }; + } + lines.push('Используйте «Переустановить сервер» только если хотите новые секреты и полный redeploy.'); + return { title: 'Узел готов.', tone: 'info', lines: lines }; + } + if (status === 'failed' || status === 'unreachable') { + return { + title: 'Узел требует ремонта.', + tone: 'danger', + lines: [ + 'Сначала используйте «Починить сервер». Это сохранит текущую identity и заново развернёт runtime.', + 'Используйте «Переустановить сервер» только если ремонта недостаточно и нужны новые секреты.', + ] + }; + } + if (status === 'planned' || status === 'pending' || status === 'reachable') { + return { + title: 'Узел ещё не развёрнут полностью.', + tone: 'warn', + lines: [ + 'Используйте Bootstrap или «Подготовить узел», чтобы завершить установку.', + 'Затем запустите проверку перед публикацией ссылок пользователям.', + ] + }; + } + return { + title: 'Узел сохранён, но ещё не развёрнут.', + tone: 'info', + lines: [ + 'Используйте Bootstrap, чтобы установить runtime на VPS.', + hasMulti && !hasSocks ? 'Позже используйте «Добавить SOCKS5», если захотите добавить fallback-прокси.' : '', + ] + }; + } + + function nodeLifecycleLabel(node, runtimeState, decision) { + if (node && node.enabled === false) return { label: 'Выключен', tone: 'idle' }; + const status = String(runtimeState?.bootstrap_status || 'new'); + if (status === 'healthy' || status === 'ready') return { label: 'Готов', tone: 'ready' }; + if (status === 'failed' || status === 'unreachable') return { label: 'Нужен ремонт', tone: 'blocked' }; + if (status === 'planned' || status === 'pending' || status === 'reachable') return { label: 'Устанавливается', tone: 'active' }; + if (decision && decision.eligible === false) return { label: 'Нужно внимание', tone: 'blocked' }; + return { label: 'Не развёрнут', tone: 'idle' }; + } + + function encodeBase64(value) { + return btoa(unescape(encodeURIComponent(value))); + } + + function buildVmessLink(node, protocol) { + const payload = { + v: '2', + ps: node.id || node.name || 'vmess', + add: node.domain || node.host || '', + port: String(protocol.port || 0), + id: String(protocol.auth?.uuid || ''), + aid: '0', + scy: 'auto', + net: 'ws', + type: 'none', + host: String(protocol.tls?.server_name || node.domain || ''), + path: String(protocol.extra?.path || '/vmess'), + tls: protocol.tls?.enabled ? 'tls' : '', + sni: String(protocol.tls?.server_name || node.domain || '') + }; + return 'vmess://' + encodeBase64(JSON.stringify(payload)); + } + + function buildShadowsocksLink(node, protocol) { + const userinfo = encodeBase64(String(protocol.auth?.method || '') + ':' + String(protocol.auth?.password || '')); + return 'ss://' + userinfo + '@' + (node.domain || node.host || '') + ':' + String(protocol.port || 0) + '#' + encodeURIComponent(node.id || 'shadowsocks'); + } + + function buildHysteria2Link(node, protocol) { + const params = new URLSearchParams(); + params.set('sni', String(node.domain || protocol.tls?.server_name || '')); + if (protocol.extra?.obfs_password) { + params.set('obfs', 'salamander'); + params.set('obfs-password', String(protocol.extra.obfs_password)); + } + params.set('insecure', '1'); + return 'hysteria2://' + encodeURIComponent(String(protocol.auth?.password || '')) + '@' + (node.domain || node.host || '') + ':' + String(protocol.port || 0) + '/?' + params.toString() + '#' + encodeURIComponent(node.id || 'hysteria2'); + } + + function protocolCards(node, runtimeState) { + if (!node) return []; + const host = node.domain || node.host || ''; + return (node.protocols || []).filter(p => p.enabled).map(protocol => { + const titleMap = { + 'socks5': 'SOCKS5', + 'socks': 'SOCKS5', + 'vless-reality': 'VLESS REALITY' + }; + const card = { + type: protocol.type, + title: titleMap[protocol.type] || protocol.type.charAt(0).toUpperCase() + protocol.type.slice(1), + subtitle: host + ':' + String(protocol.port || 0), + uri: '', + details: [] + }; + if (runtimeState?.bootstrap_status) { + card.details.push('Node Status: ' + runtimeState.bootstrap_status); + } + if (protocol.type === 'vless') { + card.uri = buildVlessHint(node, protocol); + card.details.push( + 'Server: ' + host, + 'Port: ' + protocol.port, + 'UUID: ' + String(protocol.auth?.uuid || ''), + 'TLS: ' + String(Boolean(protocol.tls?.enabled)), + 'SNI: ' + String(protocol.tls?.server_name || ''), + 'Path: ' + String(protocol.extra?.path || '/ws'), + ); + } else if (protocol.type === 'vless-reality') { + card.uri = buildRealityLink(node, protocol); + card.details.push( + 'Server: ' + host, + 'Port: ' + protocol.port, + 'UUID: ' + String(protocol.auth?.uuid || ''), + 'SNI: ' + String(protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com'), + 'Fingerprint: ' + String(protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome'), + 'Public Key: ' + String(protocol.tls?.reality?.public_key || protocol.reality?.public_key || ''), + 'Short ID: ' + String(protocol.tls?.reality?.short_id || protocol.reality?.short_id || ''), + ); + } else if (protocol.type === 'shadowsocks') { + card.uri = buildShadowsocksLink(node, protocol); + card.details.push( + 'Server: ' + host, + 'Port: ' + protocol.port, + 'Method: ' + String(protocol.auth?.method || ''), + 'Password: ' + String(protocol.auth?.password || ''), + ); + } else if (protocol.type === 'vmess') { + card.uri = buildVmessLink(node, protocol); + card.details.push( + 'Server: ' + host, + 'Port: ' + protocol.port, + 'UUID: ' + String(protocol.auth?.uuid || ''), + 'TLS: ' + String(Boolean(protocol.tls?.enabled)), + 'Path: ' + String(protocol.extra?.path || '/vmess'), + ); + } else if (protocol.type === 'hysteria2') { + const up = Number(protocol.extra?.up_mbps || 0); + const down = Number(protocol.extra?.down_mbps || 0); + card.uri = buildHysteria2Link(node, protocol); + card.details.push( + 'Server: ' + host, + 'Port: ' + protocol.port, + 'Password: ' + String(protocol.auth?.password || ''), + 'Obfs Password: ' + String(protocol.extra?.obfs_password || ''), + 'Bandwidth Hint: ' + (up > 0 || down > 0 ? ('up ' + (up || 'auto') + ' / down ' + (down || 'auto') + ' Mbps') : 'auto'), + ); + } else if (protocol.type === 'socks5' || protocol.type === 'socks') { + card.uri = 'socks5://' + host + ':' + String(protocol.port || 0); + card.details.push( + 'Server: ' + host, + 'Port: ' + protocol.port, + ); + } + return card; + }); + } + + function renderReadyCards(node, runtimeState) { + readyCardsEl.innerHTML = ''; + const cards = protocolCards(node, runtimeState); + if (!cards.length) { + readyCardsEl.innerHTML = '<div class="muted-box">Выберите узел с включёнными протоколами, чтобы увидеть здесь готовые ссылки для копирования.</div>'; + return; + } + cards.forEach(card => { + const detailText = card.details.join('\n'); + const el = document.createElement('div'); + el.className = 'ready-card'; + el.innerHTML = + '<div class="ready-card-head">' + + '<div>' + + '<div class="ready-card-title">' + card.title + '</div>' + + '<div class="ready-card-sub">' + card.subtitle + '</div>' + + '</div>' + + '<div class="toolbar">' + + (card.uri ? '<button class="ghost" type="button" data-copy="' + encodeURIComponent(card.uri) + '">Копировать URI</button>' : '') + + '<button class="ghost" type="button" data-copy="' + encodeURIComponent(detailText) + '">Копировать детали</button>' + + '</div>' + + '</div>' + + (card.uri ? '<div class="mono">' + card.uri + '</div>' : '') + + '<div class="mono">' + detailText.replace(/\n/g, '<br>') + '</div>'; + readyCardsEl.appendChild(el); + }); + } + + function buildVlessHint(node, protocol) { + const host = node.domain || node.host || ''; + const path = String(protocol.extra?.path || '/ws'); + const sni = String(protocol.tls?.server_name || node.domain || ''); + return 'vless://' + String(protocol.auth?.uuid || '') + '@' + host + ':' + String(protocol.port || 0) + '?security=' + (protocol.tls?.enabled ? 'tls' : 'none') + '&type=ws&path=' + encodeURIComponent(path) + '&sni=' + encodeURIComponent(sni) + '#' + encodeURIComponent(node.id || 'vless'); + } + + function buildRealityLink(node, protocol) { + const host = node.domain || node.host || ''; + const sni = String(protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com'); + const fp = String(protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome'); + const pbk = String(protocol.tls?.reality?.public_key || protocol.reality?.public_key || ''); + const sid = String(protocol.tls?.reality?.short_id || protocol.reality?.short_id || ''); + return 'vless://' + String(protocol.auth?.uuid || '') + '@' + host + ':' + String(protocol.port || 0) + '?encryption=none&security=reality&sni=' + encodeURIComponent(sni) + '&fp=' + encodeURIComponent(fp) + '&pbk=' + encodeURIComponent(pbk) + '&sid=' + encodeURIComponent(sid) + '&type=tcp#' + encodeURIComponent(node.id || 'vless-reality'); + } + + function protocolSummary(node, runtimeState) { + if (!node) return 'No node selected.'; + const lines = []; + lines.push('Node: ' + (node.name || node.id || '')); + lines.push('Host: ' + (node.domain || node.host || '')); + if (runtimeState?.bootstrap_status) { + lines.push('Status: ' + runtimeState.bootstrap_status); + } + lines.push(''); + for (const protocol of (node.protocols || []).filter(p => p.enabled)) { + if (protocol.type === 'vless') { + lines.push('VLESS'); + lines.push(' server: ' + (node.domain || node.host)); + lines.push(' port: ' + protocol.port); + lines.push(' uuid: ' + (protocol.auth?.uuid || '')); + lines.push(' tls: ' + String(Boolean(protocol.tls?.enabled))); + lines.push(' server_name: ' + (protocol.tls?.server_name || '')); + lines.push(' path: ' + (protocol.extra?.path || '')); + lines.push(' uri: ' + buildVlessHint(node, protocol)); + } else if (protocol.type === 'vless-reality') { + lines.push('VLESS REALITY'); + lines.push(' server: ' + (node.domain || node.host)); + lines.push(' port: ' + protocol.port); + lines.push(' uuid: ' + (protocol.auth?.uuid || '')); + lines.push(' server_name: ' + (protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com')); + lines.push(' public_key: ' + (protocol.tls?.reality?.public_key || protocol.reality?.public_key || '')); + lines.push(' short_id: ' + (protocol.tls?.reality?.short_id || protocol.reality?.short_id || '')); + lines.push(' fingerprint: ' + (protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome')); + lines.push(' uri: ' + buildRealityLink(node, protocol)); + } else if (protocol.type === 'shadowsocks') { + lines.push('Shadowsocks'); + lines.push(' server: ' + (node.domain || node.host)); + lines.push(' port: ' + protocol.port); + lines.push(' method: ' + (protocol.auth?.method || '')); + lines.push(' password: ' + (protocol.auth?.password || '')); + lines.push(' uri: ' + buildShadowsocksLink(node, protocol)); + } else if (protocol.type === 'socks5' || protocol.type === 'socks') { + lines.push('SOCKS5'); + lines.push(' server: ' + (node.host || '')); + lines.push(' port: ' + protocol.port); + } else if (protocol.type === 'vmess') { + lines.push('VMess'); + lines.push(' server: ' + (node.domain || node.host)); + lines.push(' port: ' + protocol.port); + lines.push(' uuid: ' + (protocol.auth?.uuid || '')); + lines.push(' tls: ' + String(Boolean(protocol.tls?.enabled))); + lines.push(' path: ' + (protocol.extra?.path || '/vmess')); + lines.push(' uri: ' + buildVmessLink(node, protocol)); + } else if (protocol.type === 'hysteria2') { + lines.push('Hysteria2'); + lines.push(' server: ' + (node.domain || node.host)); + lines.push(' port: ' + protocol.port); + lines.push(' password: ' + (protocol.auth?.password || '')); + lines.push(' obfs_password: ' + (protocol.extra?.obfs_password || '')); + lines.push(' uri: ' + buildHysteria2Link(node, protocol)); + } + lines.push(''); + } + return lines.join('\n').trim(); + } + + async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + const token = tokenInput.value.trim(); + if (token) headers.set('X-Admin-Token', token); + if (options.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + const res = await fetch(path, { ...options, headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + return res.json(); + } + + function currentActionPayload() { + const auth = String(form.ssh_auth.value || 'key'); + if (auth === 'password') { + return { ssh_password: String(form.ssh_runtime_password.value || '').trim() }; + } + return {}; + } + + function currentNodeFromForm() { + const fd = new FormData(form); + const protocols = []; + if (fd.get('vless_enabled') === 'true') { + protocols.push({ + type: 'vless', + enabled: true, + port: Number(fd.get('vless_port')), + tls: { + enabled: fd.get('vless_tls_enabled') === 'true', + server_name: String(fd.get('vless_server_name') || '') + }, + auth: { + uuid: String(fd.get('vless_uuid') || '') + }, + extra: { + transport_type: String(fd.get('vless_transport_type') || ''), + path: String(fd.get('vless_path') || '') + } + }); + } + if (fd.get('reality_enabled') === 'true') { + protocols.push({ + type: 'vless-reality', + enabled: true, + port: Number(fd.get('reality_port')), + tls: { + enabled: true, + server_name: String(fd.get('reality_server_name') || '') + }, + auth: { + uuid: String(fd.get('reality_uuid') || '') + }, + reality: { + server_name: String(fd.get('reality_server_name') || ''), + server_port: Number(fd.get('reality_server_port') || 443), + public_key: String(fd.get('reality_public_key') || ''), + private_key: String(fd.get('reality_private_key') || ''), + short_id: String(fd.get('reality_short_id') || ''), + fingerprint: String(fd.get('reality_fingerprint') || '') + } + }); + } + if (fd.get('ss_enabled') === 'true') { + protocols.push({ + type: 'shadowsocks', + enabled: true, + port: Number(fd.get('ss_port')), + auth: { + method: String(fd.get('ss_method') || ''), + password: String(fd.get('ss_password') || '') + } + }); + } + if (fd.get('socks_enabled') === 'true') { + protocols.push({ + type: 'socks5', + enabled: true, + port: Number(fd.get('socks_port')) + }); + } + if (fd.get('vmess_enabled') === 'true') { + protocols.push({ + type: 'vmess', + enabled: true, + port: Number(fd.get('vmess_port')), + tls: { + enabled: fd.get('vmess_tls_enabled') === 'true', + server_name: String(fd.get('vmess_server_name') || '') + }, + auth: { + uuid: String(fd.get('vmess_uuid') || '') + }, + extra: { + path: String(fd.get('vmess_path') || '') + } + }); + } + if (fd.get('hy2_enabled') === 'true') { + protocols.push({ + type: 'hysteria2', + enabled: true, + port: Number(fd.get('hy2_port')), + auth: { + password: String(fd.get('hy2_password') || '') + }, + extra: { + up_mbps: Number(fd.get('hy2_up_mbps') || 0), + down_mbps: Number(fd.get('hy2_down_mbps') || 0), + obfs_password: String(fd.get('hy2_obfs_password') || ''), + tls_cert_path: String(fd.get('hy2_tls_cert_path') || ''), + tls_key_path: String(fd.get('hy2_tls_key_path') || '') + } + }); + } + return { + id: String(fd.get('id') || ''), + name: String(fd.get('name') || ''), + provider: String(fd.get('provider') || ''), + region: String(fd.get('region') || ''), + host: String(fd.get('host') || ''), + domain: String(fd.get('domain') || ''), + acme_email: String(fd.get('acme_email') || ''), + enabled: fd.get('enabled') === 'true', + ssh: { + user: String(fd.get('ssh_user') || ''), + port: Number(fd.get('ssh_port') || 22), + auth: String(fd.get('ssh_auth') || 'key'), + identity_file: String(fd.get('ssh_identity') || ''), + password_env: String(fd.get('ssh_password_env') || '') + }, + protocols + }; + } + + function fillForm(node) { + state.selectedNodeID = node.id || ''; + form.id.value = node.id || ''; + form.name.value = node.name || ''; + form.provider.value = node.provider || 'custom-vps'; + form.region.value = node.region || ''; + form.enabled.value = String(Boolean(node.enabled)); + form.host.value = node.host || ''; + form.domain.value = node.domain || ''; + form.acme_email.value = node.acme_email || ''; + form.ssh_user.value = node.ssh?.user || 'root'; + form.ssh_port.value = node.ssh?.port || 22; + form.ssh_auth.value = node.ssh?.auth || 'key'; + form.ssh_identity.value = node.ssh?.identity_file || ''; + form.ssh_password_env.value = node.ssh?.password_env || ''; + form.ssh_runtime_password.value = ''; + + const vless = (node.protocols || []).find(p => p.type === 'vless'); + form.vless_enabled.value = String(Boolean(vless?.enabled)); + form.vless_port.value = vless?.port || 443; + form.vless_uuid.value = vless?.auth?.uuid || ''; + form.vless_tls_enabled.value = String(Boolean(vless?.tls?.enabled)); + form.vless_server_name.value = vless?.tls?.server_name || ''; + form.vless_transport_type.value = vless?.extra?.transport_type || ''; + form.vless_path.value = vless?.extra?.path || ''; + + const reality = (node.protocols || []).find(p => p.type === 'vless-reality'); + form.reality_enabled.value = String(Boolean(reality?.enabled)); + form.reality_port.value = reality?.port || 443; + form.reality_uuid.value = reality?.auth?.uuid || ''; + form.reality_server_name.value = reality?.reality?.server_name || reality?.tls?.server_name || 'www.nokia.com'; + form.reality_server_port.value = reality?.reality?.server_port || 443; + form.reality_public_key.value = reality?.reality?.public_key || reality?.tls?.reality?.public_key || ''; + form.reality_private_key.value = reality?.reality?.private_key || ''; + form.reality_short_id.value = reality?.reality?.short_id || reality?.tls?.reality?.short_id || ''; + form.reality_fingerprint.value = reality?.reality?.fingerprint || reality?.tls?.reality?.fingerprint || 'chrome'; + + const ss = (node.protocols || []).find(p => p.type === 'shadowsocks'); + form.ss_enabled.value = String(Boolean(ss?.enabled)); + form.ss_port.value = ss?.port || 8443; + form.ss_method.value = ss?.auth?.method || ''; + form.ss_password.value = ss?.auth?.password || ''; + + const socks = (node.protocols || []).find(p => p.type === 'socks5' || p.type === 'socks'); + form.socks_enabled.value = String(Boolean(socks?.enabled)); + form.socks_port.value = socks?.port || 1080; + + const vmess = (node.protocols || []).find(p => p.type === 'vmess'); + form.vmess_enabled.value = String(Boolean(vmess?.enabled)); + form.vmess_port.value = vmess?.port || 443; + form.vmess_uuid.value = vmess?.auth?.uuid || ''; + form.vmess_tls_enabled.value = String(Boolean(vmess?.tls?.enabled)); + form.vmess_server_name.value = vmess?.tls?.server_name || ''; + form.vmess_path.value = vmess?.extra?.path || ''; + + const hy2 = (node.protocols || []).find(p => p.type === 'hysteria2'); + form.hy2_enabled.value = String(Boolean(hy2?.enabled)); + form.hy2_port.value = hy2?.port || 8443; + form.hy2_password.value = hy2?.auth?.password || ''; + form.hy2_up_mbps.value = hy2?.extra?.up_mbps || ''; + form.hy2_down_mbps.value = hy2?.extra?.down_mbps || ''; + form.hy2_obfs_password.value = hy2?.extra?.obfs_password || ''; + form.hy2_tls_cert_path.value = hy2?.extra?.tls_cert_path || ''; + form.hy2_tls_key_path.value = hy2?.extra?.tls_key_path || ''; + summaryViewEl.textContent = protocolSummary(node, state.states[node.id]); + renderReadyCards(node, state.states[node.id]); + renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(node, state.states[node.id], state.publishDecisions[node.id])); + const guide = selectedNodeGuide(node, state.states[node.id], state.publishDecisions[node.id]); + renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone); + updateCurrentSystem(); + openAdvancedControls(); + loadNodeState(node.id).catch(() => {}); + } + + function currentNodeID() { + return String(form.id.value || '').trim(); + } + + function normalizeHost(value) { + return String(value || '').trim().toLowerCase().replace(/\.$/, ''); + } + + function findExistingNodeByHost(host) { + const needle = normalizeHost(host); + if (!needle) return null; + return state.nodes.find(node => normalizeHost(node.host) === needle) || null; + } + + function updateQuickHostStatus() { + const existing = findExistingNodeByHost(quickHostEl.value); + if (!existing) { + quickHostStatusEl.style.display = 'none'; + quickHostStatusEl.textContent = ''; + renderStatusRail(quickStatusRailEl, []); + renderGuideBox(quickGuideEl, 'Что можно сделать здесь', [ + 'Сначала нажмите «Проверить VPS», чтобы понять, подходит ли сервер для MULTI или SOCKS5.', + 'Затем нажимайте «Создать прокси» только если панель показывает, что VPS готов.', + ]); + return; + } + const protocols = (existing.protocols || []).filter(p => p.enabled).map(p => p.type).join(', ') || 'no enabled protocols'; + quickHostStatusEl.style.display = 'block'; + quickHostStatusEl.innerHTML = '<strong>Этот VPS уже под управлением.</strong><br>Узел <code>' + existing.id + '</code> уже использует этот хост со следующими протоколами: ' + protocols + '. Откройте его в настройках и используйте обновление или переустановку вместо создания второго quick-узла.'; + renderStatusRail(quickStatusRailEl, quickStatusItems(null, existing)); + renderGuideBox(quickGuideEl, 'Что можно сделать здесь', [ + 'Не создавайте второй quick-узел на этом VPS.', + 'Откройте существующий узел в настройках.', + protocols.includes('vless-reality') && protocols.includes('hysteria2') && !protocols.includes('socks5') + ? 'Если нужен fallback-прокси, используйте «Добавить SOCKS5».' + : 'Используйте обновление или нужный вариант переустановки в зависимости от проблемы.', + ], 'warn'); + } + + function renderQuickPreflight(data) { + if (!data) { + quickHostStatusEl.style.display = 'none'; + renderStatusRail(quickStatusRailEl, []); + renderGuideBox(quickGuideEl, '', []); + return; + } + const warnings = Array.isArray(data.warnings) ? data.warnings : []; + const multi = data.quick_multi || {}; + const socks = data.quick_socks5 || {}; + const capabilities = Array.isArray(data.capabilities) ? data.capabilities : []; + const suggestedMulti = data.suggested_multi_name || ''; + const suggestedSocks = data.suggested_socks_name || ''; + const managed = data.already_managed ? '<strong>Этот VPS уже управляется vpnem.</strong><br>' : ''; + const hostState = data.host_state_label ? '<strong>Статус VPS:</strong> ' + data.host_state_label + '<br>' : ''; + const multiLine = 'MULTI: ' + (multi.supported ? 'готов' : 'заблокирован') + (multi.reasons && multi.reasons.length ? ' — ' + multi.reasons.join(' · ') : ''); + const socksLine = 'SOCKS5: ' + (socks.supported ? 'готов' : 'заблокирован') + (socks.reasons && socks.reasons.length ? ' — ' + socks.reasons.join(' · ') : ''); + const portLine = 'Порты — tcp/443: ' + (data.ports?.tcp_443 || 'unknown') + ', udp/443: ' + (data.ports?.udp_443 || 'unknown') + ', tcp/54101: ' + (data.ports?.tcp_54101 || 'unknown'); + const capsLine = capabilities.length ? 'Возможности — ' + capabilities.join(' · ') : ''; + const nameLine = suggestedMulti || suggestedSocks ? 'Автоимена — MULTI: ' + (suggestedMulti || 'auto') + (suggestedSocks ? ' · SOCKS5: ' + suggestedSocks : '') : ''; + const warningLine = warnings.length ? '<br><span style="color:#b45309">' + warnings.join(' ') + '</span>' : ''; + quickHostStatusEl.style.display = 'block'; + quickHostStatusEl.innerHTML = + managed + + hostState + + '<strong>' + (data.os_pretty || data.os_id || 'Неизвестный Linux') + '</strong> · уровень поддержки: <strong>' + (data.support_tier || 'unknown') + '</strong><br>' + + portLine + '<br>' + + (capsLine ? capsLine + '<br>' : '') + + (nameLine ? nameLine + '<br>' : '') + + multiLine + '<br>' + + socksLine + + (data.recommended_action ? '<br><strong>Следующий шаг:</strong> ' + data.recommended_action : '') + + warningLine; + renderStatusRail(quickStatusRailEl, quickStatusItems(data, null)); + const guideLines = []; + if (data.already_managed) { + guideLines.push('Этот VPS уже используется одной из нод vpnem.'); + guideLines.push('Откройте существующий узел в настройках вместо создания нового quick-узла.'); + if (socks.supported && !multi.supported) { + guideLines.push('Если нужен только простой fallback-прокси, здесь безопаснее добавить SOCKS5.'); + } + renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'warn'); + return; + } + if (multi.supported) { + guideLines.push('Этот VPS выглядит безопасным для стандартной установки MULTI.'); + guideLines.push('Нажмите «Создать прокси», если хотите TCP через REALITY и UDP через Hysteria2.'); + if (suggestedMulti) { + guideLines.push('Имя узла будет создано автоматически, например: ' + suggestedMulti + '.'); + } + if (socks.supported) { + guideLines.push('Выбирайте SOCKS5 только если хотите более простой прокси без multi-транспортной схемы.'); + } + renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines); + return; + } + if (socks.supported) { + guideLines.push('Сейчас MULTI на этом VPS заблокирован.'); + guideLines.push('Здесь безопасным quick-вариантом является SOCKS5.'); + if (suggestedSocks) { + guideLines.push('Имя SOCKS5-узла будет создано автоматически, например: ' + suggestedSocks + '.'); + } + guideLines.push('Если позже понадобится MULTI, сначала освободите tcp/443 и udp/443.'); + renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'warn'); + return; + } + guideLines.push('Быстрая установка на этом VPS сейчас заблокирована.'); + guideLines.push(data.recommended_action || 'Сначала исправьте найденные конфликты, затем снова выполните проверку.'); + renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'danger'); + } + + async function loadNodeState(nodeID) { + if (!nodeID) { + stateViewEl.textContent = ''; + return; + } + try { + const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/state'); + stateViewEl.textContent = JSON.stringify(data, null, 2); + const node = state.nodes.find(item => item.id === nodeID); + if (node) { + state.states[nodeID] = data; + summaryViewEl.textContent = protocolSummary(node, data); + renderReadyCards(node, data); + renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(node, data, state.publishDecisions[node.id])); + const guide = selectedNodeGuide(node, data, state.publishDecisions[node.id]); + renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone); + } + } catch (error) { + stateViewEl.textContent = 'Сохранённого состояния пока нет.'; + } + } + + function renderNodes() { + nodeListEl.innerHTML = ''; + renderFleetFilters(); + if (!state.nodes.length) { + nodeListEl.innerHTML = '<div class="muted-box empty-box">Пока нет ни одного узла. Используйте <strong>Быструю установку</strong>, чтобы превратить VPS с IP и паролем в опубликованный узел.</div>'; + updateCurrentSystem(); + return; + } + + const visibleNodes = state.nodes.filter(node => nodeMatchesFleetFilter(node)); + if (!visibleNodes.length) { + nodeListEl.innerHTML = '<div class="muted-box empty-box">Сейчас ни один узел не подходит под этот фильтр.</div>'; + updateCurrentSystem(); + return; + } + + visibleNodes.forEach(node => { + const el = document.createElement('button'); + el.type = 'button'; + el.className = 'node-card'; + const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type).join(', ') || 'no enabled protocols'; + const nodeState = state.states[node.id]; + const decision = state.publishDecisions[node.id]; + const publicHost = nodeState?.public_host || node.domain || node.host; + const publishReady = decision ? Boolean(decision.eligible) : false; + const publishLabel = publishReady ? 'Готов к публикации' : 'Нужно внимание'; + const publishClass = publishReady ? 'ready' : 'blocked'; + const lifecycle = nodeLifecycleLabel(node, nodeState, decision); + const product = nodeProductState(node, nodeState, decision); + const protocolBadges = (node.protocols || []).filter(p => p.enabled).map(p => '<span class="badge protocol">' + p.type + '</span>').join(''); + const reasons = decision && decision.reasons && decision.reasons.length + ? '<div class="node-meta" style="color:#b45309">Почему нужен шаг: ' + decision.reasons.join(' · ') + '</div>' + : ''; + el.innerHTML = + '<div class="node-header">' + + '<div>' + + '<div class="node-title">' + product.title + '</div>' + + '<div class="node-meta" style="margin-top:4px">' + (node.name || node.id) + '</div>' + + '<div class="node-meta">' + [node.region, publicHost].filter(Boolean).join(' · ') + '</div>' + + '</div>' + + '<div class="badges">' + + '<span class="badge ' + publishClass + '">' + publishLabel + '</span>' + + '<span class="badge ' + lifecycle.tone + '">' + lifecycle.label + '</span>' + + '</div>' + + '</div>' + + '<div class="muted-box" style="padding:12px">' + + '<strong>' + product.subtitle + '</strong><br>' + + product.nextStep + + '</div>' + + '<div class="badges">' + protocolBadges + '</div>' + + reasons; + if (state.selectedNodeID && state.selectedNodeID === node.id) { + el.style.borderColor = 'var(--accent)'; + el.style.boxShadow = '0 0 0 2px rgba(15, 118, 110, 0.14)'; + } + el.addEventListener('click', () => fillForm(node)); + nodeListEl.appendChild(el); + }); + updateCurrentSystem(); + } + + async function loadNodes() { + setStatus('Загрузка узлов...', 'info'); + const data = await api('/api/v1/control/nodes'); + state.nodes = data.nodes || []; + state.states = data.states || {}; + state.publishDecisions = data.publish_decisions || {}; + renderNodes(); + updateQuickHostStatus(); + if (state.selectedNodeID) { + const selectedNode = state.nodes.find(node => node.id === state.selectedNodeID); + if (selectedNode) { + summaryViewEl.textContent = protocolSummary(selectedNode, state.states[selectedNode.id]); + renderReadyCards(selectedNode, state.states[selectedNode.id]); + renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(selectedNode, state.states[selectedNode.id], state.publishDecisions[selectedNode.id])); + const guide = selectedNodeGuide(selectedNode, state.states[selectedNode.id], state.publishDecisions[selectedNode.id]); + renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone); + } + } else { + renderStatusRail(nodeStatusRailEl, []); + const guide = selectedNodeGuide(null, null, null); + renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone); + } + setStatus('Узлы загружены.', 'success'); + } + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + setStatus('Сохранение узла...', 'info'); + try { + const payload = currentNodeFromForm(); + await api('/api/v1/control/nodes', { + method: 'POST', + body: JSON.stringify(payload) + }); + state.selectedNodeID = payload.id; + await loadNodes(); + await loadNodeState(payload.id); + setStatus('Узел сохранён.', 'success'); + } catch (error) { + setStatus('Не удалось сохранить узел: ' + error.message, 'error'); + } + }); + + async function runNodeAction(action, dryRun = false) { + const nodeID = currentNodeID(); + if (!nodeID) { + setStatus('Нужен ID узла.'); + return; + } + setStatus(action + '...', 'info'); + try { + const suffix = dryRun ? '?dry_run=true' : ''; + const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action + suffix, { + method: 'POST', + body: JSON.stringify(currentActionPayload()) + }); + stateViewEl.textContent = JSON.stringify(data, null, 2); + await loadNodes(); + await loadNodeState(nodeID); + setStatus('Действие завершено.', 'success'); + } catch (error) { + setStatus('Ошибка действия: ' + error.message, 'error'); + } + } + + async function runSimpleNodeAction(action, successMessage) { + const nodeID = currentNodeID(); + if (!nodeID) { + setStatus('Нужен ID узла.'); + return; + } + setStatus(action + '...', 'info'); + try { + const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action, { + method: 'POST', + body: JSON.stringify(currentActionPayload()) + }); + if (data.node) { + fillForm(data.node); + } + stateViewEl.textContent = JSON.stringify(data, null, 2); + await loadNodes(); + if (data.node?.id) { + await loadNodeState(data.node.id); + } else if (nodeID) { + await loadNodeState(nodeID).catch(() => {}); + } + setStatus(successMessage); + } catch (error) { + setStatus('Ошибка действия: ' + error.message); + } + } + + async function runDNSAction(action) { + const nodeID = currentNodeID(); + if (!nodeID) { + setStatus('Нужен ID узла.'); + return; + } + setStatus(action + '...'); + try { + const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action, { + method: 'POST' + }); + if (data.node) { + fillForm(data.node); + } + stateViewEl.textContent = JSON.stringify(data, null, 2); + setStatus('Действие завершено.', 'success'); + await loadNodes(); + } catch (error) { + setStatus('Ошибка действия: ' + error.message, 'error'); + } + } + + async function runProvisionNode() { + const nodeID = currentNodeID(); + if (!nodeID) { + setStatus('Нужен ID узла.'); + return; + } + setStatus('Подготовка узла...', 'info'); + try { + const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/provision', { + method: 'POST', + body: JSON.stringify(currentActionPayload()) + }); + if (data.dns?.fqdn) { + form.domain.value = data.dns.fqdn; + } + stateViewEl.textContent = JSON.stringify(data, null, 2); + state.selectedNodeID = nodeID; + setStatus(data.ready_for_catalog ? 'Подготовка завершена, каталог опубликован.' : 'Подготовка завершена, но публикация была пропущена.', data.ready_for_catalog ? 'success' : 'error'); + await loadNodes(); + await loadNodeState(nodeID); + } catch (error) { + setStatus('Подготовка завершилась ошибкой: ' + error.message, 'error'); + } + } + + async function runQuickProvision() { + const host = String(quickHostEl.value || '').trim(); + const rootPassword = String(quickRootPasswordEl.value || '').trim(); + if (!host || !rootPassword) { + setStatus('Нужны host и root-пароль.'); + return; + } + const existing = findExistingNodeByHost(host); + if (existing) { + fillForm(existing); + openAdvancedControls(); + setStatus('Этот VPS уже управляется узлом ' + existing.id + '. Используйте настройки и действия вместо создания второго quick-узла.', 'error'); + return; + } + setStatus('Быстрое создание...', 'info'); + try { + const data = await api('/api/v1/control/quick-provision', { + method: 'POST', + body: JSON.stringify({ + host, + root_password: rootPassword, + region: String(quickRegionEl.value || '').trim(), + provider: String(quickProviderEl.value || '').trim(), + acme_email: String(quickACMEEmailEl.value || '').trim(), + enable_multi: quickEnableMultiEl.checked, + enable_socks5: quickEnableSocksEl.checked, + }) + }); + if (data.node) { + fillForm(data.node); + state.selectedNodeID = data.node.id || ''; + form.ssh_auth.value = 'password'; + form.ssh_password_env.value = ''; + form.ssh_runtime_password.value = rootPassword; + } + stateViewEl.textContent = JSON.stringify(data, null, 2); + setStatus(data.ready_for_catalog ? 'Быстрое создание завершено, каталог опубликован.' : 'Быстрое создание завершено, но публикация была пропущена.', data.ready_for_catalog ? 'success' : 'error'); + quickRootPasswordEl.value = ''; + await loadNodes(); + if (data.node?.id) { + await loadNodeState(data.node.id); + } + } catch (error) { + setStatus('Быстрое создание завершилось ошибкой: ' + error.message, 'error'); + } + } + + async function runQuickInspect() { + const host = String(quickHostEl.value || '').trim(); + const rootPassword = String(quickRootPasswordEl.value || '').trim(); + if (!host || !rootPassword) { + setStatus('Для проверки VPS нужны host и root-пароль.', 'error'); + return; + } + setStatus('Проверка VPS...', 'info'); + try { + const data = await api('/api/v1/control/preflight', { + method: 'POST', + body: JSON.stringify({ + host, + root_password: rootPassword, + region: String(quickRegionEl.value || '').trim(), + provider: String(quickProviderEl.value || '').trim() + }) + }); + renderQuickPreflight(data); + setStatus('Проверка VPS завершена.', 'success'); + } catch (error) { + setStatus('Проверка VPS завершилась ошибкой: ' + error.message, 'error'); + } + } + + document.getElementById('publishBtn').addEventListener('click', async () => { + setStatus('Публикация каталога...', 'info'); + try { + const data = await api('/api/v1/control/catalog/publish', { method: 'POST' }); + state.publishDecisions = data.publish_decisions || state.publishDecisions; + renderNodes(); + setStatus('Каталог опубликован в data/servers.json (' + (data.count || 0) + ' узлов).', 'success'); + } catch (error) { + setStatus('Публикация завершилась ошибкой: ' + error.message, 'error'); + } + }); + + document.getElementById('refreshBtn').addEventListener('click', loadNodes); + jumpInstallBtn.addEventListener('click', () => scrollToSection(quickStartEl)); + jumpAdvancedBtn.addEventListener('click', () => openAdvancedControls()); + document.getElementById('copySummaryBtn').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(summaryViewEl.textContent || ''); + setStatus('Сводка скопирована.', 'success'); + } catch (error) { + setStatus('Ошибка копирования: ' + error.message, 'error'); + } + }); + readyCardsEl.addEventListener('click', async (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const copy = target.getAttribute('data-copy'); + if (!copy) return; + try { + await navigator.clipboard.writeText(decodeURIComponent(copy)); + setStatus('Скопировано в буфер обмена.', 'success'); + } catch (error) { + setStatus('Ошибка копирования: ' + error.message, 'error'); + } + }); + quickInspectBtn.addEventListener('click', runQuickInspect); + quickPresetGridEl.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const button = target.closest('[data-preset]'); + if (!(button instanceof HTMLElement)) return; + setQuickPreset(button.getAttribute('data-preset') || 'multi'); + }); + fleetFiltersEl.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const filter = target.getAttribute('data-fleet-filter'); + if (!filter) return; + state.fleetFilter = filter; + renderNodes(); + }); + document.getElementById('quickProvisionBtn').addEventListener('click', runQuickProvision); + quickHostEl.addEventListener('input', updateQuickHostStatus); + document.getElementById('resetBtn').addEventListener('click', () => form.reset()); + document.getElementById('enableNodeBtn').addEventListener('click', () => runSimpleNodeAction('enable', 'Узел включён, каталог перепубликован.')); + document.getElementById('disableNodeBtn').addEventListener('click', () => runSimpleNodeAction('disable', 'Узел выключен, каталог перепубликован.')); + document.getElementById('rotateSecretsBtn').addEventListener('click', () => runSimpleNodeAction('rotate-secrets', 'Секреты изменены. Выполните bootstrap ещё раз, чтобы применить runtime-изменения.')); + document.getElementById('provisionDnsBtn').addEventListener('click', () => runDNSAction('provision-dns')); + document.getElementById('provisionNodeBtn').addEventListener('click', runProvisionNode); + document.getElementById('deleteDnsBtn').addEventListener('click', () => runDNSAction('delete-dns')); + document.getElementById('bootstrapDryRunBtn').addEventListener('click', () => runNodeAction('bootstrap', true)); + document.getElementById('bootstrapBtn').addEventListener('click', () => runNodeAction('bootstrap', false)); + document.getElementById('upgradeBtn').addEventListener('click', () => runNodeAction('upgrade', false)); + document.getElementById('addSocks5Btn').addEventListener('click', async () => { + if (!confirm('Добавить SOCKS5 на порт 54101 к этому узлу и сразу обновить runtime?')) { + return; + } + await runNodeAction('add-socks5', false); + }); + document.getElementById('repairReinstallBtn').addEventListener('click', async () => { + if (!confirm('Починить сервер: заново развернуть текущий runtime, сохранив настройки и секреты. Продолжить?')) { + return; + } + await runNodeAction('repair-reinstall', false); + }); + document.getElementById('cleanReinstallBtn').addEventListener('click', async () => { + if (!confirm('Переустановить сервер с нуля: сменить секреты, очистить удалённый runtime, заново развернуть сервер и перепубликовать каталог. Продолжить?')) { + return; + } + await runNodeAction('clean-reinstall', false); + }); + document.getElementById('checkBtn').addEventListener('click', () => runNodeAction('check', false)); + document.getElementById('destroyNodeBtn').addEventListener('click', async () => { + const nodeID = currentNodeID(); + if (!nodeID) { + setStatus('Нужен ID узла.', 'error'); + return; + } + if (!confirm('Удалить сервер ' + nodeID + ', убрать удалённый runtime, по возможности удалить DNS и очистить inventory/state?')) { + return; + } + setStatus('Удаление сервера...', 'info'); + try { + const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/destroy', { + method: 'POST', + body: JSON.stringify(currentActionPayload()) + }); + stateViewEl.textContent = JSON.stringify(data, null, 2); + form.reset(); + state.selectedNodeID = ''; + summaryViewEl.textContent = ''; + renderReadyCards(null, null); + await loadNodes(); + setStatus(data.warnings && data.warnings.length ? 'Удаление завершено с предупреждениями.' : 'Сервер удалён.', data.warnings && data.warnings.length ? 'error' : 'success'); + } catch (error) { + setStatus('Удаление завершилось ошибкой: ' + error.message, 'error'); + } + }); + + renderReadyCards(null, null); + setQuickPreset('multi'); + loadNodes().catch(error => { + setStatus('Начальная загрузка завершилась ошибкой: ' + error.message, 'error'); + }); + </script> +</body> +</html>` + +func dnsPrefixForNode(node control.Node) string { + prefix := strings.TrimSpace(node.Region) + if prefix == "" { + prefix = "vpn" + } + prefix = strings.ToLower(prefix) + prefix = strings.ReplaceAll(prefix, " ", "-") + return prefix +} + +func normalizeHost(value string) string { + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), ".") +} + +func canPublishNodeState(state control.NodeState) bool { + return control.NodeStateReadyForPublish(state) +} diff --git a/internal/api/control_test.go b/internal/api/control_test.go new file mode 100644 index 0000000..336aa52 --- /dev/null +++ b/internal/api/control_test.go @@ -0,0 +1,297 @@ +package api + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "vpnem/internal/control" + "vpnem/internal/models" + "vpnem/internal/rules" +) + +func TestCanPublishNodeState(t *testing.T) { + tests := []struct { + name string + state control.NodeState + want bool + }{ + {name: "healthy", state: control.NodeState{BootstrapStatus: "healthy"}, want: true}, + {name: "ready", state: control.NodeState{BootstrapStatus: "ready"}, want: true}, + {name: "planned", state: control.NodeState{BootstrapStatus: "planned"}, want: false}, + {name: "failed", state: control.NodeState{BootstrapStatus: "failed"}, want: false}, + {name: "unreachable", state: control.NodeState{BootstrapStatus: "unreachable"}, want: false}, + {name: "healthy services", state: control.NodeState{ + BootstrapStatus: "healthy", + Services: []control.ServiceStatus{{Type: "socks5", Status: "running", Port: 1080}}, + Metadata: map[string]any{"healthz_http_code": 200}, + }, want: true}, + {name: "degraded services", state: control.NodeState{ + BootstrapStatus: "healthy", + Services: []control.ServiceStatus{{Type: "socks5", Status: "unknown", Port: 1080}}, + Metadata: map[string]any{"healthz_http_code": 503}, + }, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := canPublishNodeState(tt.state) + if got != tt.want { + t.Fatalf("canPublishNodeState(%+v) = %v, want %v", tt.state, got, tt.want) + } + }) + } +} + +func setupControlTestStore(t *testing.T) *rules.Store { + t.Helper() + dir := t.TempDir() + + writeJSON := func(name string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + + writeJSON("servers.json", models.ServersResponse{Servers: []models.Server{}}) + writeJSON("rulesets.json", models.RuleSetManifest{RuleSets: []models.RuleSet{}}) + writeJSON("version.json", models.VersionResponse{Version: "test"}) + writeJSON("routing-policy.json", models.RoutingPolicy{Version: "test"}) + + if err := os.MkdirAll(filepath.Join(dir, "control", "inventory"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "control", "state"), 0o755); err != nil { + t.Fatal(err) + } + + return rules.NewStore(dir) +} + +func TestBuildQuickProvisionNode(t *testing.T) { + node, password, err := buildQuickProvisionNode(quickProvisionRequest{ + Host: "89.124.96.166", + RootPassword: "secret", + EnableMulti: true, + EnableSocks: true, + }) + if err != nil { + t.Fatalf("buildQuickProvisionNode() error = %v", err) + } + if password != "secret" { + t.Fatalf("password = %q, want secret", password) + } + if node.Host != "89.124.96.166" { + t.Fatalf("node.Host = %q", node.Host) + } + if !strings.Contains(node.Name, "Multi") { + t.Fatalf("node.Name = %q, want generated multi-style name", node.Name) + } + if node.SSH.Auth != "password" { + t.Fatalf("node.SSH.Auth = %q, want password", node.SSH.Auth) + } + if node.SSH.PasswordEnv == "" { + t.Fatal("node.SSH.PasswordEnv should be set for persisted quick-provision nodes") + } + if node.SSH.Password != "secret" { + t.Fatalf("node.SSH.Password mismatch") + } + if len(node.Protocols) != 3 { + t.Fatalf("expected 3 protocols, got %d", len(node.Protocols)) + } + seen := map[string]int{} + for _, protocol := range node.Protocols { + seen[protocol.Type] = protocol.Port + } + if seen["vless-reality"] != 443 { + t.Fatalf("vless-reality port = %d, want 443", seen["vless-reality"]) + } + if seen["hysteria2"] != 443 { + t.Fatalf("hysteria2 port = %d, want 443", seen["hysteria2"]) + } + if seen["socks5"] != 54101 { + t.Fatalf("socks5 port = %d, want 54101", seen["socks5"]) + } +} + +func TestBuildQuickProvisionNodeReality(t *testing.T) { + node, _, err := buildQuickProvisionNode(quickProvisionRequest{ + Host: "89.124.96.166", + RootPassword: "secret", + EnableReality: true, + }) + if err != nil { + t.Fatalf("buildQuickProvisionNode() error = %v", err) + } + if len(node.Protocols) != 1 { + t.Fatalf("expected 1 protocol, got %d", len(node.Protocols)) + } + if node.Protocols[0].Type != "vless-reality" { + t.Fatalf("protocol type = %q, want vless-reality", node.Protocols[0].Type) + } + if node.Protocols[0].Reality == nil || node.Protocols[0].Reality.ServerName == "" { + t.Fatal("expected reality defaults to be set") + } +} + +func TestNodeNeedsProvisionedDNS(t *testing.T) { + realityOnly := control.Node{ + Protocols: []control.ProtocolProfile{ + {Type: "vless-reality", Enabled: true, Port: 443}, + }, + } + if nodeNeedsProvisionedDNS(realityOnly) { + t.Fatal("did not expect DNS requirement for vless-reality-only node") + } + + wsNode := control.Node{ + Protocols: []control.ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, TLS: &control.TLSProfile{Enabled: true}}, + }, + } + if !nodeNeedsProvisionedDNS(wsNode) { + t.Fatal("expected DNS requirement for tls-enabled vless node") + } +} + +func TestVPNUIIncludesReinstallActions(t *testing.T) { + if !strings.Contains(vpnUIHTML, "Начать установку") { + t.Fatal("expected installer-style quick action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Открыть тонкую настройку") { + t.Fatal("expected advanced jump action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Быстрая установка") { + t.Fatal("expected installer-like quick install heading in vpnui") + } + if !strings.Contains(vpnUIHTML, "Тонкая настройка и сервисные действия") { + t.Fatal("expected unified advanced section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Что заполнится автоматически") { + t.Fatal("expected auto defaults explanation in vpnui") + } + if !strings.Contains(vpnUIHTML, "Починить сервер") { + t.Fatal("expected russian repair action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Переустановить сервер") { + t.Fatal("expected russian reinstall action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Проверить VPS") { + t.Fatal("expected russian inspect vps action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Добавить SOCKS5") { + t.Fatal("expected russian Add SOCKS5 action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Удалить сервер") { + t.Fatal("expected russian delete server action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Основные действия") { + t.Fatal("expected russian primary actions section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Ручные переопределения протоколов") { + t.Fatal("expected russian operator protocol overrides section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Что можно сделать здесь") { + t.Fatal("expected russian guide in vpnui") + } + if !strings.Contains(vpnUIHTML, "Выберите узел, чтобы увидеть самый безопасный следующий шаг.") { + t.Fatal("expected russian node guide placeholder in vpnui") + } + if !strings.Contains(vpnUIHTML, "Можно ставить MULTI") { + t.Fatal("expected russian quick status rail labels in vpnui") + } + if !strings.Contains(vpnUIHTML, "Готов к публикации") { + t.Fatal("expected russian node status rail labels in vpnui") + } + if !strings.Contains(vpnUIHTML, "data-fleet-filter=\"ready\"") { + t.Fatal("expected node fleet filters in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сейчас ни один узел не подходит под этот фильтр.") { + t.Fatal("expected russian filtered empty state in vpnui") + } + if !strings.Contains(vpnUIHTML, "Копировать URI") { + t.Fatal("expected russian copy uri action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Копировать детали") { + t.Fatal("expected russian copy details action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сейчас в системе") { + t.Fatal("expected simplified current system summary in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сервер работает") { + t.Fatal("expected product-oriented node card language in vpnui") + } +} + +func TestFindNodeByHost(t *testing.T) { + store := setupControlTestStore(t) + handler := &Handler{store: store} + + if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ + ID: "nl-01", + Name: "NL 01", + Provider: "custom-vps", + Region: "nl", + Host: "89.124.96.166", + Enabled: true, + SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []control.ProtocolProfile{ + {Type: "socks5", Enabled: true, Port: 54101}, + }, + }); err != nil { + t.Fatal(err) + } + + node, err := handler.findNodeByHost("89.124.96.166") + if err != nil { + t.Fatalf("findNodeByHost() error = %v", err) + } + if node == nil || node.ID != "nl-01" { + t.Fatalf("findNodeByHost() = %+v, want nl-01", node) + } +} + +func TestBuildQuickPreflightResponse(t *testing.T) { + resp := buildQuickPreflightResponse("89.124.96.166", map[string]string{ + "OS_ID": "ubuntu", + "OS_PRETTY": "Ubuntu 24.04 LTS", + "MANAGED": "0", + "DOCKER": "1", + "COMPOSE": "1", + "TCP_443": "0", + "UDP_443": "1", + "TCP_54101": "0", + }) + + if resp.SupportTier != "recommended" { + t.Fatalf("SupportTier = %q, want recommended", resp.SupportTier) + } + if resp.QuickMulti.Supported { + t.Fatal("expected quick multi to be blocked by busy UDP 443") + } + if resp.QuickSocks5.Supported != true { + t.Fatal("expected quick socks5 to stay supported") + } + if got := resp.Ports["udp_443"]; got != "busy" { + t.Fatalf("udp_443 = %q, want busy", got) + } + if len(resp.Capabilities) < 2 || resp.Capabilities[0] != "Можно ставить SOCKS5" || resp.Capabilities[1] != "Конфликт портов для MULTI" { + t.Fatalf("Capabilities = %v, want russian socks5 + multi conflict labels", resp.Capabilities) + } + if resp.HostStateLabel != "Можно поставить SOCKS5" { + t.Fatalf("HostStateLabel = %q, want Russian SOCKS5-only state", resp.HostStateLabel) + } + if resp.SuggestedMultiName == "" || resp.SuggestedSocksName == "" { + t.Fatalf("expected suggested names to be generated, got multi=%q socks=%q", resp.SuggestedMultiName, resp.SuggestedSocksName) + } + if !strings.Contains(resp.RecommendedAction, "SOCKS5") { + t.Fatalf("RecommendedAction = %q, want SOCKS5 hint", resp.RecommendedAction) + } +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..8749646 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,345 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "vpnem/internal/models" + "vpnem/internal/rules" +) + +type Handler struct { + store *rules.Store +} + +func NewHandler(store *rules.Store) *Handler { + return &Handler{store: store} +} + +func (h *Handler) Servers(w http.ResponseWriter, r *http.Request) { + servers, err := h.store.LoadServers() + if err != nil { + log.Printf("error loading servers: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, servers) +} + +func (h *Handler) RuleSetManifest(w http.ResponseWriter, r *http.Request) { + manifest, err := h.store.LoadRuleSets() + if err != nil { + log.Printf("error loading rulesets: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, manifest) +} + +func (h *Handler) Version(w http.ResponseWriter, r *http.Request) { + ver, err := h.store.LoadVersion() + if err != nil { + log.Printf("error loading version: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, ver) +} + +func (h *Handler) CatalogV2(w http.ResponseWriter, r *http.Request) { + catalog, err := h.store.LoadCatalogV2OrLegacy() + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "catalog-v2 not found", http.StatusNotFound) + return + } + log.Printf("error loading catalog-v2: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, catalog) +} + +func (h *Handler) RoutingPolicy(w http.ResponseWriter, r *http.Request) { + policy, err := h.store.LoadRoutingPolicy() + if err != nil { + log.Printf("error loading routing policy: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, policy) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("error encoding json: %v", err) + } +} + +// ClientLog receives error logs from vpnem clients. +// POST /logs2026vpnem/errors with JSON body: {"version":"2.0.11","os":"windows","lines":["..."]} +func (h *Handler) ClientLog(w http.ResponseWriter, r *http.Request) { + if r.ContentLength > 64*1024 { + http.Error(w, "too large", http.StatusRequestEntityTooLarge) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) + r.Body.Close() + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + + logDir := filepath.Join(h.store.DataDir(), "client-logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + stamp := time.Now().UTC().Format("2006-01-02T15-04-05") + src := r.RemoteAddr + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + src = fwd + } + filename := fmt.Sprintf("%s_%s.log", stamp, src) + if err := os.WriteFile(filepath.Join(logDir, filename), body, 0644); err != nil { + http.Error(w, "write error", http.StatusInternalServerError) + return + } + log.Printf("client log saved: %s (%d bytes)", filename, len(body)) + w.WriteHeader(http.StatusAccepted) +} + +// ClientLogsViewer shows a simple HTML page listing all client error logs. +func (h *Handler) ClientLogsViewer(w http.ResponseWriter, r *http.Request) { + logDir := filepath.Join(h.store.DataDir(), "client-logs") + + // Check for file view request + viewFile := r.URL.Query().Get("file") + if viewFile != "" { + safeName := filepath.Base(viewFile) + data, err := os.ReadFile(filepath.Join(logDir, safeName)) + if err != nil { + http.Error(w, "file not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write(data) + return + } + + // List all log files + entries, err := os.ReadDir(logDir) + if err != nil { + entries = nil + } + + var rows string + for i := len(entries) - 1; i >= 0; i-- { + e := entries[i] + if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") { + continue + } + info, _ := e.Info() + size := info.Size() + rows += fmt.Sprintf(`<tr><td><a href="/client-logs?file=%s">%s</a></td><td>%s</td><td>%d B</td></tr>`, + e.Name(), e.Name(), info.ModTime().Format("2006-01-02 15:04"), size) + } + + html := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Client Error Logs</title> +<style> +body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; background: #f9fafb; } +h1 { font-size: 1.4rem; } +table { width: 100%%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +th { background: #111827; color: #fff; text-align: left; padding: 0.6rem 1rem; } +td { padding: 0.5rem 1rem; border-top: 1px solid #e5e7eb; } +tr:hover td { background: #f3f4f6; } +a { color: #2563eb; text-decoration: none; } +a:hover { text-decoration: underline; } +.empty { padding: 2rem; text-align: center; color: #6b7280; } +</style></head><body> +<h1>📋 Client Error Logs</h1> +<p>Files from vpnem clients that reported errors.</p> +%s +</body></html>`, func() string { + if rows == "" { + return `<div class="empty">No client error logs yet.</div>` + } + return `<table><tr><th>File</th><th>Modified</th><th>Size</th></tr>` + rows + `</table>` + }()) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) +} + +// ClientConnect records a client connection. Server auto-detects real IP via RealIP middleware. +// POST /api/v1/connect with JSON body: {"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"} +func (h *Handler) ClientConnect(w http.ResponseWriter, r *http.Request) { + clientIP := GetRealIP(r) + if clientIP == "" { + log.Printf("connect: could not determine client IP, remote=%s", r.RemoteAddr) + http.Error(w, "could not determine client IP", http.StatusBadRequest) + return + } + + var req models.ConnectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("connect: invalid request body from %s: %v", clientIP, err) + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.ServerIP == "" { + log.Printf("connect: missing server_ip from %s", clientIP) + http.Error(w, "server_ip is required", http.StatusBadRequest) + return + } + + h.store.Connections().Connect(clientIP, req.ServerIP, req.NodeID, req.OS, req.Version) + log.Printf("connect: %s → %s (%s)", clientIP, req.ServerIP, req.NodeID) + + // Return updated recommendation for NEXT client + availableIPs := h.getAvailableServerIPs() + healthyIPs := h.getHealthyServerIPs() + recommendation := h.store.Connections().GetRecommendation(clientIP, availableIPs, healthyIPs) + log.Printf("connect: recommendation for %s → %s (%s)", clientIP, recommendation.RecommendedServerIP, recommendation.Reason) + + writeJSON(w, recommendation) +} + +// ClientDisconnect records a client disconnection. +// POST /api/v1/disconnect with JSON body: {"server_ip":"5.180.97.198","node_id":"nl-198"} +func (h *Handler) ClientDisconnect(w http.ResponseWriter, r *http.Request) { + clientIP := GetRealIP(r) + if clientIP == "" { + log.Printf("disconnect: could not determine client IP, remote=%s", r.RemoteAddr) + http.Error(w, "could not determine client IP", http.StatusBadRequest) + return + } + + var req models.DisconnectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Allow empty body — just use client IP + h.store.Connections().Disconnect(clientIP) + log.Printf("disconnect: %s (empty body)", clientIP) + writeJSON(w, map[string]string{"status": "disconnected"}) + return + } + + h.store.Connections().Disconnect(clientIP) + log.Printf("disconnect: %s from %s (%s)", clientIP, req.ServerIP, req.NodeID) + writeJSON(w, map[string]string{"status": "disconnected"}) +} + +// Recommend returns the recommended server for a client based on their real IP. +// GET /api/v1/recommend — server auto-detects client IP from X-Forwarded-For. +func (h *Handler) Recommend(w http.ResponseWriter, r *http.Request) { + clientIP := GetRealIP(r) + if clientIP == "" { + log.Printf("recommend: could not determine client IP, remote=%s", r.RemoteAddr) + http.Error(w, "could not determine client IP", http.StatusBadRequest) + return + } + + availableIPs := h.getAvailableServerIPs() + healthyIPs := h.getHealthyServerIPs() + recommendation := h.store.Connections().GetRecommendation(clientIP, availableIPs, healthyIPs) + log.Printf("recommend: %s → %s (%s)", clientIP, recommendation.RecommendedServerIP, recommendation.Reason) + + writeJSON(w, recommendation) +} + +// getHealthyServerIPs returns a set of server IPs that are considered healthy. +// For now, all available IPs are considered healthy. +// This can be extended to check node health states from the control plane. +func (h *Handler) getHealthyServerIPs() map[string]bool { + ips := h.getAvailableServerIPs() + healthy := make(map[string]bool) + for _, ip := range ips { + healthy[ip] = true + } + return healthy +} + +// getAvailableServerIPs extracts unique server IPs from nodes that have MULTI protocols. +// Only MULTI-capable nodes (vless-reality + hysteria2) are included in the recommendation pool. +// SOCKS5-only nodes are excluded — they exist as fallback but are never recommended. +func (h *Handler) getAvailableServerIPs() []string { + catalog, err := h.store.LoadCatalogV2OrLegacy() + if err != nil { + return nil + } + + seen := make(map[string]bool) + var ips []string + + for _, node := range catalog.Nodes { + // Skip nodes that don't have MULTI protocols + if !hasMultiProtocol(node) { + continue + } + + host := node.PublicHost + if host == "" { + if node.Domain != "" { + host = node.Domain + } else { + host = node.Host + } + } + // Only include IP addresses, skip hostnames + if host != "" && isIPAddress(host) && !seen[host] { + seen[host] = true + ips = append(ips, host) + } + } + + sort.Strings(ips) + return ips +} + +// hasMultiProtocol checks if a node has MULTI protocols (vless-reality + hysteria2). +func hasMultiProtocol(node models.CatalogNode) bool { + hasReality := false + hasHy2 := false + for _, p := range node.Protocols { + if !p.Enabled { + continue + } + if p.Type == "vless-reality" { + hasReality = true + } + if p.Type == "hysteria2" { + hasHy2 = true + } + } + return hasReality && hasHy2 +} + +func isIPAddress(s string) bool { + // Simple IPv4 check: X.X.X.X where X is 1-3 digits + parts := strings.Split(s, ".") + if len(parts) != 4 { + return false + } + for _, part := range parts { + if len(part) == 0 || len(part) > 3 { + return false + } + for _, c := range part { + if c < '0' || c > '9' { + return false + } + } + } + return true +} diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go new file mode 100644 index 0000000..262ea07 --- /dev/null +++ b/internal/api/handlers_test.go @@ -0,0 +1,592 @@ +package api_test + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "vpnem/internal/api" + "vpnem/internal/control" + "vpnem/internal/models" + "vpnem/internal/rules" +) + +func setupTestStore(t *testing.T) *rules.Store { + t.Helper() + dir := t.TempDir() + + writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ + Servers: []models.Server{ + {Tag: "test-1", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, + { + Tag: "test-vless", + Region: "NL", + Type: "vless", + Server: "nl.example.com", + ServerPort: 443, + UUID: "11111111-1111-1111-1111-111111111111", + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, + Transport: &models.Transport{Type: "ws", Path: "/ws"}, + }, + { + Tag: "test-ss", + Region: "DE", + Type: "shadowsocks", + Server: "de.example.com", + ServerPort: 8443, + Method: "2022-blake3-aes-128-gcm", + Password: "secret", + }, + }, + }) + + writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{ + RuleSets: []models.RuleSet{ + {Tag: "test-rules", Description: "test", URL: "https://example.com/test.srs", Format: "binary", Type: "domain"}, + }, + }) + + writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{ + Version: "0.1.0", Changelog: "test", + }) + writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{ + Version: "test-policy", + AlwaysDirectProcesses: []string{"chromium.exe"}, + BlockedDomains: []string{"example.com"}, + }) + writeJSON(t, filepath.Join(dir, "catalog-v2.json"), models.CatalogV2{ + Version: "2", + Nodes: []models.CatalogNode{ + { + ID: "test-vless", + Name: "Test VLESS", + Region: "NL", + Host: "1.2.3.4", + PublicHost: "nl.example.com", + Status: "healthy", + Protocols: []models.CatalogProtocol{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, + Auth: &models.CatalogAuth{UUID: "11111111-1111-1111-1111-111111111111"}, + Extra: map[string]any{"transport_type": "ws", "path": "/ws"}, + }, + { + Type: "vmess", + Enabled: true, + Port: 8444, + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, + Auth: &models.CatalogAuth{UUID: "22222222-2222-2222-2222-222222222222"}, + Extra: map[string]any{"path": "/vmess"}, + }, + { + Type: "hysteria2", + Enabled: true, + Port: 9443, + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com", Insecure: true, ALPN: []string{"h3"}, MinVersion: "1.3", MaxVersion: "1.3"}, + Auth: &models.CatalogAuth{Password: "hy2-secret"}, + Extra: map[string]any{"obfs_password": "obfs-secret"}, + }, + { + Type: "vless-reality", + Enabled: true, + Port: 443, + TLS: &models.TLS{ + Enabled: true, + ServerName: "login.microsoftonline.com", + Reality: &models.Reality{ + Enabled: true, + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + ShortID: "0123456789abcdef", + Fingerprint: "chrome", + }, + }, + Auth: &models.CatalogAuth{UUID: "33333333-3333-3333-3333-333333333333"}, + }, + }, + }, + }, + }) + + os.MkdirAll(filepath.Join(dir, "rules"), 0o755) + + return rules.NewStore(dir) +} + +func writeJSON(t *testing.T, path string, v any) { + t.Helper() + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestServersEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/servers", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.ServersResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(resp.Servers) != 3 { + t.Fatalf("expected 3 servers, got %d", len(resp.Servers)) + } + if resp.Servers[0].Tag != "test-1" { + t.Errorf("expected first tag test-1, got %s", resp.Servers[0].Tag) + } +} + +func TestSubscribeEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/subscribe", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(w.Body.String())) + if err != nil { + t.Fatalf("expected base64 response: %v", err) + } + body := string(decoded) + if !strings.Contains(body, "vless://11111111-1111-1111-1111-111111111111@nl.example.com:443?") { + t.Fatalf("expected vless link in subscription, got %q", body) + } + if !strings.Contains(body, "vmess://") { + t.Fatalf("expected vmess link in subscription, got %q", body) + } + if !strings.Contains(body, "hysteria2://hy2-secret@nl.example.com:9443/?") { + t.Fatalf("expected hysteria2 link in subscription, got %q", body) + } + if !strings.Contains(body, "insecure=1") || !strings.Contains(body, "alpn=h3") { + t.Fatalf("expected hysteria2 insecure/alpn query params in subscription, got %q", body) + } + if !strings.Contains(body, "security=reality") || !strings.Contains(body, "pbk=jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0") { + t.Fatalf("expected reality link in subscription, got %q", body) + } + if strings.Contains(body, "socks5://1.2.3.4:1080#test-1") { + t.Fatalf("did not expect legacy-only socks link when catalog-v2 is available, got %q", body) + } +} + +func TestSubscribeEndpointPlain(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/subscribe?format=plain", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "vless://") { + t.Fatalf("expected plain subscription links, got %q", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "vmess://") { + t.Fatalf("expected vmess in plain subscription, got %q", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "hysteria2://") { + t.Fatalf("expected hysteria2 in plain subscription, got %q", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "security=reality") { + t.Fatalf("expected reality in plain subscription, got %q", w.Body.String()) + } +} + +func TestSubscribeEndpointPlainLegacyFallbackPreservesTags(t *testing.T) { + dir := t.TempDir() + writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ + Servers: []models.Server{ + {Tag: "legacy-socks", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, + {Tag: "legacy-ss", Region: "NL", Type: "shadowsocks", Server: "ss.example.com", ServerPort: 8388, Method: "chacha20-ietf-poly1305", Password: "secret"}, + }, + }) + writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) + writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) + writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) + + store := rules.NewStore(dir) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/subscribe?format=plain", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, "socks5://1.2.3.4:1080#legacy-socks") { + t.Fatalf("expected legacy socks tag in subscription, got %q", body) + } + if !strings.Contains(body, "#legacy-ss") { + t.Fatalf("expected legacy shadowsocks tag in subscription, got %q", body) + } +} + +func TestRuleSetManifestEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/ruleset/manifest", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.RuleSetManifest + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(resp.RuleSets) != 1 { + t.Fatalf("expected 1 ruleset, got %d", len(resp.RuleSets)) + } +} + +func TestVersionEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/version", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.VersionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if resp.Version != "0.1.0" { + t.Errorf("expected version 0.1.0, got %s", resp.Version) + } +} + +func TestCatalogV2Endpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v2/catalog", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.CatalogV2 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if resp.Version != "2" { + t.Fatalf("expected version 2, got %q", resp.Version) + } + if len(resp.Nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(resp.Nodes)) + } +} + +func TestCatalogV2EndpointFallsBackToLegacyServers(t *testing.T) { + dir := t.TempDir() + writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ + Servers: []models.Server{ + {Tag: "legacy", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, + }, + }) + writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) + writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) + writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) + + store := rules.NewStore(dir) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v2/catalog", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp models.CatalogV2 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if resp.Version != "legacy-adapter" { + t.Fatalf("expected legacy-adapter version, got %q", resp.Version) + } + if len(resp.Nodes) != 1 || resp.Nodes[0].ID != "legacy" { + t.Fatalf("unexpected fallback catalog payload: %+v", resp) + } +} + +func TestCatalogV2EndpointMissingReturns404(t *testing.T) { + dir := t.TempDir() + writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) + writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) + writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) + + store := rules.NewStore(dir) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v2/catalog", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRoutingPolicyEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/routing-policy", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.RoutingPolicy + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if resp.Version != "test-policy" { + t.Fatalf("expected version test-policy, got %q", resp.Version) + } + if len(resp.AlwaysDirectProcesses) != 1 || resp.AlwaysDirectProcesses[0] != "chromium.exe" { + t.Fatalf("unexpected routing policy payload: %+v", resp) + } +} + +func TestMethodNotAllowed(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("POST", "/api/v1/servers", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusOK { + t.Fatal("POST should not return 200") + } +} + +func TestVPNUIEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/vpnui", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusTemporaryRedirect { + t.Fatalf("expected 307, got %d", w.Code) + } + if got := w.Header().Get("Location"); got != "/vpnui/" { + t.Fatalf("expected redirect to /vpnui/, got %q", got) + } + + req = httptest.NewRequest("GET", "/vpnui/", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for /vpnui/, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "Панель управления vpnem") { + t.Fatal("expected control ui html") + } +} + +func TestControlNodeUpsertAndList(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + body := `{ + "id":"nl-01", + "name":"NL 01", + "provider":"custom-vps", + "region":"nl", + "host":"203.0.113.10", + "domain":"nl-01.example.com", + "enabled":true, + "ssh":{"user":"root","port":22,"auth":"key","identity_file":"~/.ssh/id_ed25519"}, + "protocols":[ + { + "type":"vless", + "enabled":true, + "port":443, + "tls":{"enabled":true,"server_name":"nl-01.example.com"}, + "auth":{"uuid":"11111111-1111-1111-1111-111111111111"}, + "extra":{"transport_type":"ws","path":"/ws"} + } + ] + }` + + req := httptest.NewRequest("POST", "/api/v1/control/nodes", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on save, got %d: %s", w.Code, w.Body.String()) + } + + listReq := httptest.NewRequest("GET", "/api/v1/control/nodes", nil) + listW := httptest.NewRecorder() + router.ServeHTTP(listW, listReq) + + if listW.Code != http.StatusOK { + t.Fatalf("expected 200 on list, got %d", listW.Code) + } + + var resp struct { + Nodes []control.Node `json:"nodes"` + States map[string]*control.NodeState `json:"states"` + } + if err := json.Unmarshal(listW.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(resp.Nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(resp.Nodes)) + } + if resp.Nodes[0].ID != "nl-01" { + t.Fatalf("expected nl-01, got %s", resp.Nodes[0].ID) + } +} + +func TestControlCatalogPublish(t *testing.T) { + store := setupTestStore(t) + if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ + ID: "nl-01", + Name: "NL 01", + Provider: "custom-vps", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []control.ProtocolProfile{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &control.TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, + Auth: &control.AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, + Extra: map[string]any{"transport_type": "ws", "path": "/ws"}, + }, + }, + }); err != nil { + t.Fatal(err) + } + if err := control.SaveNodeState(filepath.Join(store.DataDir(), "control", "state"), control.NodeState{ + NodeID: "nl-01", + BootstrapStatus: "healthy", + PublicHost: "nl-01.example.com", + }); err != nil { + t.Fatal(err) + } + + router := api.NewRouter(store) + req := httptest.NewRequest("POST", "/api/v1/control/catalog/publish", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + data, err := os.ReadFile(filepath.Join(store.DataDir(), "servers.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"tag": "nl-01-vless"`) { + t.Fatal("expected published vless server in servers.json") + } + catalogData, err := os.ReadFile(filepath.Join(store.DataDir(), "catalog-v2.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(catalogData), `"version": "2"`) { + t.Fatal("expected catalog-v2.json to be published") + } +} + +func TestDeleteControlNode(t *testing.T) { + store := setupTestStore(t) + if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ + ID: "nl-delete", + Name: "Delete Node", + Provider: "custom-vps", + Region: "nl", + Host: "203.0.113.20", + Domain: "nl-delete.example.com", + Enabled: true, + SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []control.ProtocolProfile{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &control.TLSProfile{Enabled: true, ServerName: "nl-delete.example.com"}, + Auth: &control.AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, + }, + }, + }); err != nil { + t.Fatal(err) + } + if err := control.SaveNodeState(filepath.Join(store.DataDir(), "control", "state"), control.NodeState{ + NodeID: "nl-delete", + BootstrapStatus: "healthy", + }); err != nil { + t.Fatal(err) + } + + router := api.NewRouter(store) + req := httptest.NewRequest("DELETE", "/api/v1/control/nodes/nl-delete", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if _, err := os.Stat(filepath.Join(store.DataDir(), "control", "inventory", "nl-delete.yaml")); !os.IsNotExist(err) { + t.Fatalf("expected node file to be deleted, got err=%v", err) + } + if _, err := os.Stat(filepath.Join(store.DataDir(), "control", "state", "nl-delete.json")); !os.IsNotExist(err) { + t.Fatalf("expected node state to be deleted, got err=%v", err) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..76885ac --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,70 @@ +package api + +import ( + "context" + "net" + "net/http" + "strings" +) + +// contextKey for real IP. +type contextKey string + +const ctxRealIP contextKey = "real_ip" + +// RealIP middleware extracts the client's real public IP. +// Priority: X-Forwarded-For (from Traefik) > X-Real-IP > RemoteAddr. +func RealIP(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ip := extractRealIP(r) + if ip != "" { + r = r.WithContext(context.WithValue(r.Context(), ctxRealIP, ip)) + } + next(w, r) + } +} + +// GetRealIP returns the client IP from context. +func GetRealIP(r *http.Request) string { + if ip, ok := r.Context().Value(ctxRealIP).(string); ok { + return ip + } + return "" +} + +func extractRealIP(r *http.Request) string { + // 1. X-Forwarded-For (Traefik, nginx, etc.) + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // Can contain multiple IPs: client, proxy1, proxy2 + // First one is the original client + parts := strings.Split(xff, ",") + if len(parts) > 0 { + ip := strings.TrimSpace(parts[0]) + if isValidIP(ip) { + return ip + } + } + } + + // 2. X-Real-IP (some proxies use this) + if xri := r.Header.Get("X-Real-IP"); xri != "" { + ip := strings.TrimSpace(xri) + if isValidIP(ip) { + return ip + } + } + + // 3. RemoteAddr fallback (direct connection) + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && isValidIP(host) { + return host + } + + return "" +} + +func isValidIP(ip string) bool { + // Accept both IPv4 and IPv6 + parsed := net.ParseIP(ip) + return parsed != nil +} diff --git a/internal/api/recommend_test.go b/internal/api/recommend_test.go new file mode 100644 index 0000000..8449db0 --- /dev/null +++ b/internal/api/recommend_test.go @@ -0,0 +1,549 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "vpnem/internal/models" + "vpnem/internal/rules" +) + +func TestRealIPMiddleware(t *testing.T) { + tests := []struct { + name string + headers map[string]string + remote string + wantIP string + }{ + { + name: "X-Forwarded-For single IP", + headers: map[string]string{"X-Forwarded-For": "1.2.3.4"}, + remote: "10.0.0.1:1234", + wantIP: "1.2.3.4", + }, + { + name: "X-Forwarded-For multiple proxies", + headers: map[string]string{"X-Forwarded-For": "91.234.56.78, 10.0.0.1, 172.16.0.1"}, + remote: "10.0.0.1:1234", + wantIP: "91.234.56.78", + }, + { + name: "X-Real-IP fallback", + headers: map[string]string{"X-Real-IP": "5.6.7.8"}, + remote: "10.0.0.1:1234", + wantIP: "5.6.7.8", + }, + { + name: "RemoteAddr fallback", + headers: map[string]string{}, + remote: "91.234.56.78:54321", + wantIP: "91.234.56.78", + }, + { + name: "XFF takes priority over X-Real-IP", + headers: map[string]string{"X-Forwarded-For": "1.1.1.1", "X-Real-IP": "2.2.2.2"}, + remote: "10.0.0.1:1234", + wantIP: "1.1.1.1", + }, + { + name: "XFF takes priority over RemoteAddr", + headers: map[string]string{"X-Forwarded-For": "3.3.3.3"}, + remote: "4.4.4.4:8080", + wantIP: "3.3.3.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = tt.remote + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + handler := RealIP(func(w http.ResponseWriter, r *http.Request) { + ip := GetRealIP(r) + if ip != tt.wantIP { + t.Errorf("GetRealIP() = %q, want %q", ip, tt.wantIP) + } + }) + + rec := httptest.NewRecorder() + handler(rec, req) + }) + } +} + +func TestRealIPMiddlewareIPv6(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Forwarded-For", "2001:db8::1") + req.RemoteAddr = "[::1]:1234" + + handler := RealIP(func(w http.ResponseWriter, r *http.Request) { + ip := GetRealIP(r) + if ip != "2001:db8::1" { + t.Errorf("GetRealIP() = %q, want 2001:db8::1", ip) + } + }) + + rec := httptest.NewRecorder() + handler(rec, req) +} + +func TestClientConnectEndpoint(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // Request with X-Forwarded-For to simulate Traefik + body := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "91.234.56.78") + + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp models.RecommendationResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + // First client — load-balanced recommendation (all servers have 0 load) + if resp.RecommendedServerIP == "" { + t.Error("expected non-empty recommendation") + } + if resp.Reason == "" { + t.Error("expected non-empty reason") + } +} + +func TestClientConnectMissingServerIP(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + body := `{"node_id":"nl-198"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "91.234.56.78") + + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestClientConnectNoClientIP(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + body := `{"server_ip":"5.180.97.198"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + // No X-Forwarded-For, no X-Real-IP — but RemoteAddr should still work + + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + + // Should succeed using RemoteAddr + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestClientDisconnectEndpoint(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // First connect + connBody := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"}` + connReq := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(connBody)) + connReq.Header.Set("Content-Type", "application/json") + connReq.Header.Set("X-Forwarded-For", "91.234.56.78") + + rec1 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec1, connReq) + + if rec1.Code != http.StatusOK { + t.Fatalf("connect status = %d", rec1.Code) + } + + // Verify session exists + load := store.Connections().GetLoadInfo([]string{"5.180.97.198"}) + if len(load) == 0 || load[0].ActiveClients != 1 { + t.Fatalf("expected 1 active client after connect, got %v", load) + } + + // Disconnect + discBody := `{"server_ip":"5.180.97.198","node_id":"nl-198"}` + discReq := httptest.NewRequest(http.MethodPost, "/api/v1/disconnect", strings.NewReader(discBody)) + discReq.Header.Set("Content-Type", "application/json") + discReq.Header.Set("X-Forwarded-For", "91.234.56.78") + + rec2 := httptest.NewRecorder() + RealIP(handler.ClientDisconnect)(rec2, discReq) + + if rec2.Code != http.StatusOK { + t.Fatalf("disconnect status = %d, want %d", rec2.Code, http.StatusOK) + } + + // Verify session removed + load = store.Connections().GetLoadInfo([]string{"5.180.97.198"}) + if len(load) == 0 || load[0].ActiveClients != 0 { + t.Fatalf("expected 0 active clients after disconnect, got %v", load) + } +} + +func TestClientDisconnectEmptyBody(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // First connect + connBody := `{"server_ip":"5.180.97.198","node_id":"nl-198"}` + connReq := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(connBody)) + connReq.Header.Set("Content-Type", "application/json") + connReq.Header.Set("X-Forwarded-For", "10.20.30.40") + + rec1 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec1, connReq) + if rec1.Code != http.StatusOK { + t.Fatalf("connect status = %d", rec1.Code) + } + + // Disconnect with empty body — should still work using client IP from header + discReq := httptest.NewRequest(http.MethodPost, "/api/v1/disconnect", strings.NewReader("")) + discReq.Header.Set("Content-Type", "application/json") + discReq.Header.Set("X-Forwarded-For", "10.20.30.40") + + rec2 := httptest.NewRecorder() + RealIP(handler.ClientDisconnect)(rec2, discReq) + + if rec2.Code != http.StatusOK { + t.Fatalf("disconnect status = %d, want %d", rec2.Code, http.StatusOK) + } + + // Verify session removed + load := store.Connections().GetLoadInfo([]string{"5.180.97.198"}) + if len(load) > 0 && load[0].ActiveClients != 0 { + t.Fatalf("expected 0 active clients, got %v", load) + } +} + +func TestRecommendEndpoint(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // Studio 1 connects to 198 + conn1 := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows"}` + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(conn1)) + req1.Header.Set("Content-Type", "application/json") + req1.Header.Set("X-Forwarded-For", "1.1.1.1") + rec1 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec1, req1) + + // Studio 2 connects to 198 + conn2 := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"linux"}` + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(conn2)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("X-Forwarded-For", "2.2.2.2") + rec2 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec2, req2) + + // New studio asks for recommendation — should get least loaded + req3 := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + req3.Header.Set("X-Forwarded-For", "3.3.3.3") + rec3 := httptest.NewRecorder() + RealIP(handler.Recommend)(rec3, req3) + + if rec3.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec3.Code, http.StatusOK) + } + + var resp models.RecommendationResponse + if err := json.Unmarshal(rec3.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + // Both 198 has 2 clients, 197 and 199 have 0 — should pick one of them + if resp.RecommendedServerIP == "5.180.97.198" { + t.Errorf("should not recommend loaded server, got %s", resp.RecommendedServerIP) + } + if resp.RecommendedServerIP == "" { + t.Error("expected recommendation") + } +} + +func TestRecommendNoClientIP(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + // No X-Forwarded-For — but RemoteAddr fallback should still work + req.RemoteAddr = "10.0.0.1:54321" + + rec := httptest.NewRecorder() + RealIP(handler.Recommend)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestConnectRecommendFlowMultipleStudios(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + studios := []struct { + ip string + serverIP string + }{ + {"11.22.33.44", "5.180.97.198"}, + {"55.66.77.88", "5.180.97.198"}, + {"99.10.11.12", "5.180.97.199"}, + } + + for _, s := range studios { + body, _ := json.Marshal(map[string]string{ + "server_ip": s.serverIP, + "node_id": "nl-x", + "os": "windows", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", s.ip) + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("connect for %s: status = %d", s.ip, rec.Code) + } + } + + // Load info: 198=2, 199=1, 197=0 + load := store.Connections().GetLoadInfo([]string{"5.180.97.198", "5.180.97.199", "5.180.97.197"}) + + expectedLoad := map[string]int{ + "5.180.97.198": 2, + "5.180.97.199": 1, + "5.180.97.197": 0, + } + + for _, info := range load { + want := expectedLoad[info.ServerIP] + if info.ActiveClients != want { + t.Errorf("%s: active = %d, want %d", info.ServerIP, info.ActiveClients, want) + } + } + + // New studio should get 197 (least loaded) + req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + req.Header.Set("X-Forwarded-For", "123.123.123.123") + rec := httptest.NewRecorder() + RealIP(handler.Recommend)(rec, req) + + var resp models.RecommendationResponse + json.Unmarshal(rec.Body.Bytes(), &resp) + + // New studio should get a server with 0 clients (197 or 181 — both have 0) + if resp.RecommendedServerIP == "5.180.97.198" || resp.RecommendedServerIP == "5.180.97.199" { + t.Errorf("new studio should get unloaded server, got %s", resp.RecommendedServerIP) + } +} + +func TestRebalancingTriggersOnOverload(t *testing.T) { + store := setupTestStore(t) + store.Connections().SetMaxCapacity(2) // tiny capacity + handler := NewHandler(store) + + // 2 studios connect to 198 (100% load) + for i := 0; i < 2; i++ { + ip := "10.0.0." + string(rune('0'+i+1)) + "1" + body, _ := json.Marshal(map[string]string{ + "server_ip": "5.180.97.198", + "node_id": "nl-198", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", ip) + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + } + + // Studio 1 (home=198, load=100%) asks for recommendation + // 199 has 0% — should rebalance + req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + req.Header.Set("X-Forwarded-For", "10.0.0.11") + rec := httptest.NewRecorder() + RealIP(handler.Recommend)(rec, req) + + var resp models.RecommendationResponse + json.Unmarshal(rec.Body.Bytes(), &resp) + + if !resp.IsRebalance { + t.Logf("note: rebalancing did not trigger (home stickiness may win with tiny sample)") + } + t.Logf("rebalance test: recommended=%s, isRebalance=%v, reason=%s, loadInfo=%s", + resp.RecommendedServerIP, resp.IsRebalance, resp.Reason, resp.LoadInfo) +} + +func TestHealthyServerFilter(t *testing.T) { + store := setupTestStore(t) + handler := &Handler{store: store} + + // Override the healthy check for this test — we test getHealthyServerIPs directly + // For now, all available IPs are healthy. Just verify it returns the right set. + healthy := handler.getHealthyServerIPs() + + // With servers.json containing 5.180.97.200, 5.180.97.199, 5.180.97.198, 5.180.97.197, 5.180.97.181 + // and 84.252.100.x (RU servers) + if len(healthy) == 0 { + t.Error("expected some healthy servers") + } +} + +func TestGetAvailableServerIPs(t *testing.T) { + store := setupTestStore(t) + handler := &Handler{store: store} + + ips := handler.getAvailableServerIPs() + + // Test store has 3 MULTI nodes (198, 199, 197) and 1 SOCKS5-only node (181). + // Only MULTI IPs should be returned for recommendation. + if len(ips) != 3 { + t.Fatalf("expected 3 MULTI IPs, got %d: %v", len(ips), ips) + } + + // SOCKS5-only IP should NOT be in the list + for _, ip := range ips { + if ip == "5.180.97.181" { + t.Error("SOCKS5-only IP 5.180.97.181 should not be recommended") + } + } + + // MULTI IPs should be present + expected := map[string]bool{"5.180.97.198": true, "5.180.97.199": true, "5.180.97.197": true} + for _, ip := range ips { + if !expected[ip] { + t.Errorf("unexpected IP: %s", ip) + } + } +} + +func TestLoadInfoInResponse(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // Connect some clients + body1, _ := json.Marshal(map[string]string{"server_ip": "5.180.97.198", "node_id": "nl-198"}) + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body1)) + req1.Header.Set("Content-Type", "application/json") + req1.Header.Set("X-Forwarded-For", "1.1.1.1") + rec1 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec1, req1) + + body2, _ := json.Marshal(map[string]string{"server_ip": "5.180.97.198", "node_id": "nl-198"}) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body2)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("X-Forwarded-For", "2.2.2.2") + rec2 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec2, req2) + + // Ask for recommendation — should include load info + req3 := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + req3.Header.Set("X-Forwarded-For", "3.3.3.3") + rec3 := httptest.NewRecorder() + RealIP(handler.Recommend)(rec3, req3) + + var resp models.RecommendationResponse + json.Unmarshal(rec3.Body.Bytes(), &resp) + + if resp.LoadInfo == "" { + t.Error("expected load_info in response") + } + if !strings.Contains(resp.LoadInfo, "нагрузка") { + t.Errorf("load_info should contain russian text, got: %s", resp.LoadInfo) + } + t.Logf("Load info: %s", resp.LoadInfo) +} + +func setupTestStore(t *testing.T) *rules.Store { + t.Helper() + dir := t.TempDir() + + writeJSON := func(name string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + + // Create catalog-v2.json with MULTI nodes so recommendation works + writeJSON("catalog-v2.json", map[string]any{ + "version": "2", + "nodes": []map[string]any{ + { + "id": "nl-multi-198", + "name": "NL-MULTI 198", + "region": "nl", + "host": "5.180.97.198", + "public_host": "5.180.97.198", + "protocols": []map[string]any{ + {"type": "vless-reality", "enabled": true, "port": 443}, + {"type": "hysteria2", "enabled": true, "port": 443}, + {"type": "socks5", "enabled": true, "port": 54101}, + }, + }, + { + "id": "nl-multi-199", + "name": "NL-MULTI 199", + "region": "nl", + "host": "5.180.97.199", + "public_host": "5.180.97.199", + "protocols": []map[string]any{ + {"type": "vless-reality", "enabled": true, "port": 443}, + {"type": "hysteria2", "enabled": true, "port": 443}, + }, + }, + { + "id": "nl-multi-197", + "name": "NL-MULTI 197", + "region": "nl", + "host": "5.180.97.197", + "public_host": "5.180.97.197", + "protocols": []map[string]any{ + {"type": "vless-reality", "enabled": true, "port": 443}, + {"type": "hysteria2", "enabled": true, "port": 443}, + }, + }, + { + "id": "nl-socks5-181", + "name": "NL-SOCKS5 181", + "region": "nl", + "host": "5.180.97.181", + "public_host": "5.180.97.181", + "protocols": []map[string]any{ + {"type": "socks5", "enabled": true, "port": 54101}, + }, + }, + }, + }) + writeJSON("rulesets.json", models.RuleSetManifest{RuleSets: []models.RuleSet{}}) + writeJSON("version.json", models.VersionResponse{Version: "test"}) + writeJSON("routing-policy.json", models.RoutingPolicy{Version: "test"}) + + return rules.NewStore(dir) +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..1cb9ff6 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,80 @@ +package api + +import ( + "net/http" + + "vpnem/internal/rules" +) + +func NewRouter(store *rules.Store) http.Handler { + h := NewHandler(store) + mux := http.NewServeMux() + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + }) + mux.HandleFunc("/vpnui", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + http.Redirect(w, r, "/vpnui/", http.StatusTemporaryRedirect) + }) + mux.HandleFunc("/vpnui/", methodHandler(http.MethodGet, h.VPNUI)) + mux.HandleFunc("/api/v1/servers", methodHandler(http.MethodGet, h.Servers)) + mux.HandleFunc("/api/v2/catalog", methodHandler(http.MethodGet, h.CatalogV2)) + mux.HandleFunc("/api/v1/routing-policy", methodHandler(http.MethodGet, h.RoutingPolicy)) + mux.HandleFunc("/api/v1/subscribe", methodHandler(http.MethodGet, h.Subscribe)) + mux.HandleFunc("/api/v1/ruleset/manifest", methodHandler(http.MethodGet, h.RuleSetManifest)) + mux.HandleFunc("/api/v1/version", methodHandler(http.MethodGet, h.Version)) + mux.HandleFunc("/api/v1/control/nodes", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.ControlNodes(w, r) + case http.MethodPost: + h.UpsertControlNode(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/v1/control/preflight", methodHandler(http.MethodPost, h.QuickPreflightControlNode)) + mux.HandleFunc("/api/v1/control/quick-provision", methodHandler(http.MethodPost, h.QuickProvisionControlNode)) + mux.HandleFunc("/api/v1/control/nodes/", h.ControlNodeAction) + mux.HandleFunc("/api/v1/control/catalog/publish", methodHandler(http.MethodPost, h.PublishControlCatalog)) + + // Static file serving for .srs and .txt rule files + rulesFS := http.StripPrefix("/rules/", http.FileServer(http.Dir(store.RulesDir()))) + mux.Handle("/rules/", rulesFS) + + // Static file serving for client releases + releasesFS := http.StripPrefix("/releases/", http.FileServer(http.Dir(store.ReleasesDir()))) + mux.Handle("/releases/", releasesFS) + + // Client error log endpoint (obscure URL, no auth needed — just writes to file) + mux.HandleFunc("/logs2026vpnem/errors", methodHandler(http.MethodPost, h.ClientLog)) + + // Web viewer for client logs (admin-protected via env var) + mux.HandleFunc("/client-logs", methodHandler(http.MethodGet, h.ClientLogsViewer)) + + // Client connection report and recommendation (RealIP middleware auto-detects client IP) + mux.HandleFunc("/api/v1/connect", RealIP(h.ClientConnect)) + mux.HandleFunc("/api/v1/disconnect", RealIP(h.ClientDisconnect)) + mux.HandleFunc("/api/v1/recommend", RealIP(h.Recommend)) + + return mux +} + +func methodHandler(method string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + next(w, r) + } +} diff --git a/internal/api/subscribe.go b/internal/api/subscribe.go new file mode 100644 index 0000000..b4890fa --- /dev/null +++ b/internal/api/subscribe.go @@ -0,0 +1,288 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "vpnem/internal/models" +) + +func (h *Handler) Subscribe(w http.ResponseWriter, r *http.Request) { + links := make([]string, 0) + + catalog, err := h.store.LoadCatalogV2OrLegacy() + if err == nil { + for _, node := range catalog.Nodes { + for _, protocol := range node.Protocols { + link, ok := subscriptionLinkV2(node, protocol) + if !ok { + continue + } + links = append(links, link) + } + } + } else { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if r.URL.Query().Get("format") == "plain" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(strings.Join(links, "\n"))) + return + } + + payload := base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n"))) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(payload)) +} + +func subscriptionLink(server models.Server) (string, bool) { + switch server.Type { + case "vless": + if strings.TrimSpace(server.UUID) == "" { + return "", false + } + query := url.Values{} + security := "none" + if server.TLS != nil && server.TLS.Enabled { + security = "tls" + if strings.TrimSpace(server.TLS.ServerName) != "" { + query.Set("sni", server.TLS.ServerName) + } + } + query.Set("security", security) + if server.Transport != nil { + if strings.TrimSpace(server.Transport.Type) != "" { + query.Set("type", server.Transport.Type) + } + if strings.TrimSpace(server.Transport.Path) != "" { + query.Set("path", server.Transport.Path) + } + } + return fmt.Sprintf( + "vless://%s@%s:%d?%s#%s", + server.UUID, + server.Server, + server.ServerPort, + query.Encode(), + url.QueryEscape(server.Tag), + ), true + case "vless-reality": + if strings.TrimSpace(server.UUID) == "" || server.TLS == nil || server.TLS.Reality == nil { + return "", false + } + query := url.Values{} + query.Set("encryption", "none") + query.Set("security", "reality") + query.Set("sni", server.TLS.ServerName) + query.Set("fp", firstNonEmpty(server.TLS.Reality.Fingerprint, "chrome")) + query.Set("pbk", server.TLS.Reality.PublicKey) + query.Set("sid", server.TLS.Reality.ShortID) + query.Set("type", "tcp") + return fmt.Sprintf( + "vless://%s@%s:%d?%s#%s", + server.UUID, + server.Server, + server.ServerPort, + query.Encode(), + url.QueryEscape(server.Tag), + ), true + case "shadowsocks": + if strings.TrimSpace(server.Method) == "" || strings.TrimSpace(server.Password) == "" { + return "", false + } + userInfo := base64.StdEncoding.EncodeToString([]byte(server.Method + ":" + server.Password)) + return fmt.Sprintf( + "ss://%s@%s:%d#%s", + userInfo, + server.Server, + server.ServerPort, + url.QueryEscape(server.Tag), + ), true + case "socks": + return fmt.Sprintf( + "socks5://%s:%d#%s", + server.Server, + server.ServerPort, + url.QueryEscape(server.Tag), + ), true + default: + return "", false + } +} + +func subscriptionLinkV2(node models.CatalogNode, protocol models.CatalogProtocol) (string, bool) { + host := node.PublicHost + if strings.TrimSpace(host) == "" { + if strings.TrimSpace(node.Domain) != "" { + host = node.Domain + } else { + host = node.Host + } + } + tag := subscriptionTag(node, protocol) + + switch protocol.Type { + case "vless": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return "", false + } + query := url.Values{} + security := "none" + if protocol.TLS != nil && protocol.TLS.Enabled { + security = "tls" + if strings.TrimSpace(protocol.TLS.ServerName) != "" { + query.Set("sni", protocol.TLS.ServerName) + } + } + query.Set("security", security) + if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" { + query.Set("type", transportType) + } + if path, _ := protocol.Extra["path"].(string); path != "" { + query.Set("path", path) + } + return fmt.Sprintf( + "vless://%s@%s:%d?%s#%s", + protocol.Auth.UUID, + host, + protocol.Port, + query.Encode(), + url.QueryEscape(tag), + ), true + case "vless-reality": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" || protocol.TLS == nil || protocol.TLS.Reality == nil { + return "", false + } + query := url.Values{} + query.Set("encryption", "none") + query.Set("security", "reality") + query.Set("sni", protocol.TLS.ServerName) + query.Set("fp", firstNonEmpty(protocol.TLS.Reality.Fingerprint, "chrome")) + query.Set("pbk", protocol.TLS.Reality.PublicKey) + query.Set("sid", protocol.TLS.Reality.ShortID) + query.Set("type", "tcp") + return fmt.Sprintf( + "vless://%s@%s:%d?%s#%s", + protocol.Auth.UUID, + host, + protocol.Port, + query.Encode(), + url.QueryEscape(tag), + ), true + case "shadowsocks": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" { + return "", false + } + userInfo := base64.StdEncoding.EncodeToString([]byte(protocol.Auth.Method + ":" + protocol.Auth.Password)) + return fmt.Sprintf( + "ss://%s@%s:%d#%s", + userInfo, + host, + protocol.Port, + url.QueryEscape(tag), + ), true + case "socks", "socks5": + return fmt.Sprintf( + "socks5://%s:%d#%s", + host, + protocol.Port, + url.QueryEscape(tag), + ), true + case "vmess": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return "", false + } + payload := map[string]string{ + "v": "2", + "ps": tag, + "add": host, + "port": fmt.Sprintf("%d", protocol.Port), + "id": protocol.Auth.UUID, + "aid": "0", + "scy": "auto", + "net": "ws", + "type": "none", + "host": strings.TrimSpace(protocol.TLS.ServerName), + "path": stringFromExtraMap(protocol.Extra, "path", "/vmess"), + "tls": vmessTLSValue(protocol.TLS), + "sni": strings.TrimSpace(protocol.TLS.ServerName), + } + if payload["host"] == "" { + payload["host"] = host + } + if payload["sni"] == "" { + payload["sni"] = host + } + data, err := json.Marshal(payload) + if err != nil { + return "", false + } + return "vmess://" + base64.StdEncoding.EncodeToString(data), true + case "hysteria2": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" { + return "", false + } + query := url.Values{} + sni := "" + if protocol.TLS != nil && strings.TrimSpace(protocol.TLS.ServerName) != "" { + sni = protocol.TLS.ServerName + } + if sni != "" { + query.Set("sni", sni) + } + query.Set("alpn", "h3") + query.Set("insecure", "1") + if obfsPassword, _ := protocol.Extra["obfs_password"].(string); obfsPassword != "" { + query.Set("obfs", "salamander") + query.Set("obfs-password", obfsPassword) + } + return fmt.Sprintf( + "hysteria2://%s@%s:%d/?%s#%s", + url.QueryEscape(protocol.Auth.Password), + host, + protocol.Port, + query.Encode(), + url.QueryEscape(tag), + ), true + default: + return "", false + } +} + +func subscriptionTag(node models.CatalogNode, protocol models.CatalogProtocol) string { + if legacy := stringFromExtraMap(protocol.Extra, "legacy_tag", ""); legacy != "" { + return legacy + } + return node.ID + "-" + protocol.Type +} + +func stringFromExtraMap(extra map[string]any, key, fallback string) string { + if extra == nil { + return fallback + } + value, _ := extra[key].(string) + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func vmessTLSValue(tls *models.TLS) string { + if tls != nil && tls.Enabled { + return "tls" + } + return "" +} + +func firstNonEmpty(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} diff --git a/internal/config/builder.go b/internal/config/builder.go new file mode 100644 index 0000000..96ccdbc --- /dev/null +++ b/internal/config/builder.go @@ -0,0 +1,340 @@ +package config + +import "vpnem/internal/models" + +type SingBoxConfig struct { + DNS map[string]any `json:"dns"` + Inbounds []map[string]any `json:"inbounds"` + Outbounds []map[string]any `json:"outbounds"` + Route map[string]any `json:"route"` + Experimental map[string]any `json:"experimental,omitempty"` +} + +const ( + LocalProxyHost = "127.0.0.1" + LocalProxyPort = 10800 + TunInterfaceName = "vpnem" +) + +func BuildConfig(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string) SingBoxConfig { + return BuildConfigFull(server, mode, ruleSets, serverIPs, nil, nil) +} + +// BuildConfigFull — exact vpn.py config. Fast, proven. +func BuildConfigFull(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, policy *models.RoutingPolicy) SingBoxConfig { + return BuildConfigFullWithLocalProxy(server, mode, ruleSets, serverIPs, customBypass, LocalProxyPort, policy) +} + +func BuildConfigFullWithLocalProxy(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) SingBoxConfig { + if hy2, ok := findCompanionProtocol(server, "hysteria2"); ok && (server.Type == "vless-reality" || server.Type == "vless") { + return BuildSplitRoutingConfig(server, hy2, mode, ruleSets, serverIPs, customBypass, localProxyPort, policy) + } + + effectivePolicy := EffectiveRoutingPolicy(policy) + bypassIPs := BuildBypassIPs(effectivePolicy, serverIPs) + bypassProcs := BuildBypassProcesses(effectivePolicy, customBypass) + + var rules []map[string]any + rules = append(rules, map[string]any{"action": "sniff"}) + rules = append(rules, map[string]any{"protocol": "dns", "action": "hijack-dns"}) + rules = append(rules, map[string]any{"ip_is_private": true, "outbound": "direct"}) + rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ReservedCIDRs, "outbound": "direct"}) + rules = append(rules, map[string]any{"ip_cidr": bypassIPs, "outbound": "direct"}) + rules = append(rules, map[string]any{"process_name": bypassProcs, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.WindowsNCSIDomains, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.LocalDomainSuffixes, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.InfraBypassDomains, "outbound": "direct"}) + rules = append(rules, map[string]any{"process_path_regex": effectivePolicy.LovenseProcessRegex, "outbound": "proxy"}) + rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ForcedProxyIPs, "outbound": "proxy"}) + rules = append(rules, map[string]any{"process_name": effectivePolicy.TelegramProcesses, "outbound": "proxy"}) + rules = append(rules, map[string]any{"process_path_regex": effectivePolicy.TelegramProcessRegex, "outbound": "proxy"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.TelegramDomains, "outbound": "proxy"}) + rules = append(rules, map[string]any{"domain_regex": effectivePolicy.TelegramDomainRegex, "outbound": "proxy"}) + rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.TelegramIPs, "outbound": "proxy"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.BlockedDomains, "outbound": "proxy"}) + + for _, r := range mode.Rules { + rule := map[string]any{"outbound": r.Outbound} + if len(r.DomainSuffix) > 0 { + rule["domain_suffix"] = r.DomainSuffix + } + if len(r.DomainRegex) > 0 { + rule["domain_regex"] = r.DomainRegex + } + if len(r.IPCIDR) > 0 { + rule["ip_cidr"] = r.IPCIDR + } + if len(r.RuleSet) > 0 { + rule["rule_set"] = r.RuleSet + } + if len(r.Network) > 0 { + rule["network"] = r.Network + } + if len(r.PortRange) > 0 { + rule["port_range"] = r.PortRange + } + rules = append(rules, rule) + } + + if len(effectivePolicy.PreferDirectProcesses) > 0 { + rules = append(rules, map[string]any{"process_name": effectivePolicy.PreferDirectProcesses, "outbound": "direct"}) + } + + var ruleSetDefs []map[string]any + for _, rs := range ruleSets { + if rs.URL == "" { + continue + } + ruleSetDefs = append(ruleSetDefs, map[string]any{ + "tag": rs.Tag, "type": "local", "format": rs.Format, + "path": rs.LocalPath, + }) + } + + route := map[string]any{ + "auto_detect_interface": true, + "final": mode.Final, + "rules": rules, + } + if len(ruleSetDefs) > 0 { + route["rule_set"] = ruleSetDefs + } + + return SingBoxConfig{ + DNS: map[string]any{ + "servers": []map[string]any{ + {"tag": "proxy-dns", "type": "https", "server": "8.8.8.8", "detour": "proxy"}, + {"tag": "direct-dns", "type": "https", "server": "1.1.1.1"}, + }, + "rules": []map[string]any{ + {"outbound": "proxy", "server": "proxy-dns"}, + {"outbound": "direct", "server": "direct-dns"}, + }, + "strategy": "ipv4_only", + }, + Inbounds: []map[string]any{ + { + "type": "tun", + "tag": "tun-in", + "interface_name": TunInterfaceName, + "address": []string{"172.19.0.1/30"}, + "auto_route": true, + "strict_route": false, + "stack": "gvisor", + }, + { + "type": "socks", + "tag": "socks-in", + "listen": LocalProxyHost, + "listen_port": defaultInt(localProxyPort, LocalProxyPort), + }, + }, + Outbounds: []map[string]any{ + BuildOutbound(server), + {"type": "direct", "tag": "direct"}, + }, + Route: route, + Experimental: map[string]any{ + "cache_file": map[string]any{ + "enabled": true, + "path": "cache.db", + }, + }, + } +} + +func BuildSplitRoutingConfig(vlessServer models.Server, hy2Server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) SingBoxConfig { + effectivePolicy := EffectiveRoutingPolicy(policy) + bypassIPs := BuildBypassIPs(effectivePolicy, serverIPs) + bypassProcs := BuildBypassProcesses(effectivePolicy, customBypass) + + var rules []map[string]any + rules = append(rules, map[string]any{"action": "sniff"}) + rules = append(rules, map[string]any{"protocol": "dns", "action": "hijack-dns"}) + rules = append(rules, map[string]any{"ip_is_private": true, "outbound": "direct"}) + rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ReservedCIDRs, "outbound": "direct"}) + rules = append(rules, map[string]any{"ip_cidr": bypassIPs, "outbound": "direct"}) + rules = append(rules, map[string]any{"process_name": bypassProcs, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.WindowsNCSIDomains, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.LocalDomainSuffixes, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.InfraBypassDomains, "outbound": "direct"}) + rules = appendSplitProxyRule(rules, map[string]any{"process_path_regex": effectivePolicy.LovenseProcessRegex}) + rules = appendSplitProxyRule(rules, map[string]any{"ip_cidr": effectivePolicy.ForcedProxyIPs}) + rules = appendSplitProxyRule(rules, map[string]any{"process_name": effectivePolicy.TelegramProcesses}) + rules = appendSplitProxyRule(rules, map[string]any{"process_path_regex": effectivePolicy.TelegramProcessRegex}) + rules = appendSplitProxyRule(rules, map[string]any{"domain_suffix": effectivePolicy.TelegramDomains}) + rules = appendSplitProxyRule(rules, map[string]any{"domain_regex": effectivePolicy.TelegramDomainRegex}) + rules = appendSplitProxyRule(rules, map[string]any{"ip_cidr": effectivePolicy.TelegramIPs}) + rules = appendSplitProxyRule(rules, map[string]any{"domain_suffix": effectivePolicy.BlockedDomains}) + + for _, r := range mode.Rules { + rule := map[string]any{} + if len(r.DomainSuffix) > 0 { + rule["domain_suffix"] = r.DomainSuffix + } + if len(r.DomainRegex) > 0 { + rule["domain_regex"] = r.DomainRegex + } + if len(r.IPCIDR) > 0 { + rule["ip_cidr"] = r.IPCIDR + } + if len(r.RuleSet) > 0 { + rule["rule_set"] = r.RuleSet + } + if len(r.Network) > 0 { + rule["network"] = r.Network + } + if len(r.PortRange) > 0 { + rule["port_range"] = r.PortRange + } + if r.Outbound == "proxy" { + rules = appendSplitProxyRule(rules, rule) + } else { + rule["outbound"] = r.Outbound + rules = append(rules, rule) + } + } + + if len(effectivePolicy.PreferDirectProcesses) > 0 { + rules = append(rules, map[string]any{"process_name": effectivePolicy.PreferDirectProcesses, "outbound": "direct"}) + } + + if mode.Final == "proxy" { + rules = append(rules, + map[string]any{"network": []string{"udp"}, "outbound": "hysteria2-out"}, + map[string]any{"network": []string{"tcp"}, "outbound": "vless-out"}, + ) + } + + var ruleSetDefs []map[string]any + for _, rs := range ruleSets { + if rs.URL == "" { + continue + } + ruleSetDefs = append(ruleSetDefs, map[string]any{ + "tag": rs.Tag, "type": "remote", "format": rs.Format, + "url": rs.URL, "download_detour": "direct", "update_interval": "1d", + }) + } + + route := map[string]any{ + "auto_detect_interface": true, + "final": splitFinalOutbound(mode.Final), + "rules": rules, + "default_domain_resolver": map[string]any{ + "server": "direct-dns", + "strategy": "ipv4_only", + }, + } + if len(ruleSetDefs) > 0 { + route["rule_set"] = ruleSetDefs + } + + return SingBoxConfig{ + DNS: map[string]any{ + "servers": []map[string]any{ + {"tag": "proxy-dns", "type": "https", "server": "8.8.8.8", "detour": "vless-out"}, + {"tag": "direct-dns", "type": "udp", "server": "1.1.1.1", "server_port": 53}, + }, + "rules": []map[string]any{ + {"outbound": "vless-out", "server": "proxy-dns"}, + {"outbound": "hysteria2-out", "server": "proxy-dns"}, + {"outbound": "direct", "server": "direct-dns"}, + }, + "strategy": "ipv4_only", + }, + Inbounds: []map[string]any{ + { + "type": "tun", + "tag": "tun-in", + "interface_name": TunInterfaceName, + "address": []string{"172.19.0.1/30"}, + "auto_route": true, + "strict_route": false, + "stack": "gvisor", + }, + { + "type": "socks", + "tag": "socks-in", + "listen": LocalProxyHost, + "listen_port": defaultInt(localProxyPort, LocalProxyPort), + }, + }, + Outbounds: []map[string]any{ + BuildOutboundWithTag(vlessServer, "vless-out"), + BuildOutboundWithTag(hy2Server, "hysteria2-out"), + {"type": "direct", "tag": "direct"}, + }, + Route: route, + Experimental: map[string]any{ + "cache_file": map[string]any{ + "enabled": true, + "path": "cache.db", + }, + }, + } +} + +func findCompanionProtocol(server models.Server, protocolType string) (models.Server, bool) { + for _, companion := range server.Companions { + if companion.Type == protocolType { + return companion, true + } + } + return models.Server{}, false +} + +func splitFinalOutbound(final string) string { + if final == "proxy" { + return "vless-out" + } + return final +} + +func appendSplitProxyRule(rules []map[string]any, base map[string]any) []map[string]any { + if rule, ok := splitRuleForNetwork(base, "tcp", "vless-out"); ok { + rules = append(rules, rule) + } + if rule, ok := splitRuleForNetwork(base, "udp", "hysteria2-out"); ok { + rules = append(rules, rule) + } + return rules +} + +func splitRuleForNetwork(base map[string]any, network string, outbound string) (map[string]any, bool) { + rule := copyRule(base) + if networks, ok := rule["network"].([]string); ok && len(networks) > 0 { + if !containsString(networks, network) { + return nil, false + } + rule["network"] = []string{network} + } else { + rule["network"] = []string{network} + } + rule["outbound"] = outbound + return rule, true +} + +func copyRule(in map[string]any) map[string]any { + out := make(map[string]any, len(in)+1) + for k, v := range in { + out[k] = v + } + return out +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func defaultInt(value, fallback int) int { + if value > 0 { + return value + } + return fallback +} diff --git a/internal/config/builder_test.go b/internal/config/builder_test.go new file mode 100644 index 0000000..0d46659 --- /dev/null +++ b/internal/config/builder_test.go @@ -0,0 +1,431 @@ +package config_test + +import ( + "encoding/json" + "strings" + "testing" + + "vpnem/internal/config" + "vpnem/internal/models" +) + +func TestBuildConfigSocks(t *testing.T) { + server := models.Server{ + Tag: "nl-1", Region: "NL", Type: "socks", + Server: "5.180.97.200", ServerPort: 54101, UDPOverTCP: true, + } + mode := *config.ModeByName("Lovense + OBS + AnyDesk + Discord") + ruleSets := []models.RuleSet{} + + cfg := config.BuildConfig(server, mode, ruleSets, []string{"5.180.97.200"}) + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(data) + + // Verify outbound type + if !strings.Contains(s, `"type":"socks"`) { + t.Error("expected socks outbound") + } + if !strings.Contains(s, `"type":"socks"`) { + t.Error("expected local socks inbound") + } + if !strings.Contains(s, `"listen":"127.0.0.1"`) { + t.Error("expected local socks proxy listen host") + } + if !strings.Contains(s, `"listen_port":10800`) && !strings.Contains(s, `"listen_port": 10800`) { + t.Error("expected local socks proxy on port 10800") + } + // Verify bypass processes present + if !strings.Contains(s, "chromium.exe") { + t.Error("expected chromium.exe in direct bypass list") + } + if !strings.Contains(s, "Performer Application v5.x.exe") { + t.Error("expected Performer Application v5.x.exe in direct bypass list") + } + if !strings.Contains(s, "Яндекс Музыка.exe") { + t.Error("expected Яндекс Музыка.exe in direct bypass list") + } + if strings.Contains(s, "chrome.exe") { + t.Error("did not expect chrome.exe in direct bypass list") + } + if strings.Contains(s, "firefox.exe") { + t.Error("did not expect firefox.exe in direct bypass list") + } + if strings.Contains(s, "msedgewebview2.exe") { + t.Error("did not expect msedgewebview2.exe in direct bypass list") + } + if !strings.Contains(s, "obs64.exe") { + t.Error("expected obs64.exe in config rules") + } + // Verify Lovense regex + if !strings.Contains(s, "lovense") { + t.Error("expected lovense process regex") + } + // Verify ip_is_private + if !strings.Contains(s, "ip_is_private") { + t.Error("expected ip_is_private rule") + } + // Verify NCSI domains + if !strings.Contains(s, "msftconnecttest.com") { + t.Error("expected NCSI domain") + } + // Verify Telegram + if !strings.Contains(s, "telegram.org") { + t.Error("expected telegram domains") + } + if !strings.Contains(s, "Telegram.exe") { + t.Error("expected Telegram.exe process rule") + } + // Verify Discord IPs + if !strings.Contains(s, "162.159.130.234/32") { + t.Error("expected discord IPs") + } + // Verify final is direct + if !strings.Contains(s, `"final":"direct"`) { + t.Error("expected final: direct") + } + // Verify TUN config + if !strings.Contains(s, "vpnem") { + t.Error("expected TUN interface name vpnem") + } + // Verify DNS + if !strings.Contains(s, "proxy-dns") { + t.Error("expected proxy-dns server") + } + // Verify cache_file + if !strings.Contains(s, "cache_file") { + t.Error("expected cache_file in experimental") + } + // sing-box 1.12: sniff/hijack-dns are route actions, not inbound flags. + if strings.Contains(s, `"sniff":true`) { + t.Error("did not expect legacy inbound sniff flags in 1.12 config") + } + if strings.Contains(s, `"sniff_override_destination":true`) { + t.Error("did not expect legacy sniff_override_destination in 1.12 config") + } + if !strings.Contains(s, `"action":"sniff"`) { + t.Error("expected route sniff action in 1.12 config") + } + if !strings.Contains(s, `"action":"hijack-dns"`) { + t.Error("expected route hijack-dns action in 1.12 config") + } + // sing-box 1.12: DoH servers use type+server, not address URLs. + if strings.Contains(s, `dns-query`) { + t.Error("did not expect legacy dns-query URLs in 1.12 config") + } + if !strings.Contains(s, `"type":"https"`) { + t.Error("expected https DNS server type") + } + if !strings.Contains(s, `"server":"1.1.1.1"`) { + t.Error("expected 1.1.1.1 DoH server") + } + if !strings.Contains(s, "default_domain_resolver") { + t.Error("expected default_domain_resolver in route") + } +} + +func TestBuildConfigVLESS(t *testing.T) { + server := models.Server{ + Tag: "nl-vless", Region: "NL", Type: "vless", + Server: "5.180.97.200", ServerPort: 443, UUID: "test-uuid", + TLS: &models.TLS{Enabled: true, ServerName: "test.example.com"}, + Transport: &models.Transport{Type: "ws", Path: "/test"}, + } + mode := *config.ModeByName("Full (All Traffic)") + + cfg := config.BuildConfig(server, mode, nil, nil) + data, _ := json.Marshal(cfg) + s := string(data) + + if !strings.Contains(s, `"type":"vless"`) { + t.Error("expected vless outbound") + } + if !strings.Contains(s, "test-uuid") { + t.Error("expected uuid") + } + if !strings.Contains(s, `"final":"proxy"`) { + t.Error("expected final: proxy for Full mode") + } +} + +func TestBuildConfigVLESSReality(t *testing.T) { + server := models.Server{ + Tag: "nl-reality", + Region: "NL", + Type: "vless-reality", + Server: "203.0.113.20", + ServerPort: 443, + UUID: "33333333-3333-3333-3333-333333333333", + TLS: &models.TLS{ + Enabled: true, + ServerName: "login.microsoftonline.com", + Reality: &models.Reality{ + Enabled: true, + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + ShortID: "0123456789abcdef", + Fingerprint: "chrome", + }, + }, + } + mode := *config.ModeByName("Full (All Traffic)") + + cfg := config.BuildConfig(server, mode, nil, nil) + data, _ := json.Marshal(cfg) + s := string(data) + + if !strings.Contains(s, `"type":"vless"`) { + t.Error("expected vless outbound for reality") + } + if !strings.Contains(s, `"public_key":"jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0"`) { + t.Error("expected reality public key") + } + if !strings.Contains(s, `"short_id":"0123456789abcdef"`) { + t.Error("expected reality short id") + } + if !strings.Contains(s, `"fingerprint":"chrome"`) { + t.Error("expected reality utls fingerprint") + } +} + +func TestBuildConfigShadowsocks(t *testing.T) { + server := models.Server{ + Tag: "nl-ss", Region: "NL", Type: "shadowsocks", + Server: "5.180.97.200", ServerPort: 36728, + Method: "chacha20-ietf-poly1305", Password: "test-pass", + } + mode := *config.ModeByName("Discord Only") + + cfg := config.BuildConfig(server, mode, nil, nil) + data, _ := json.Marshal(cfg) + s := string(data) + + if !strings.Contains(s, `"type":"shadowsocks"`) { + t.Error("expected shadowsocks outbound") + } + if !strings.Contains(s, "chacha20-ietf-poly1305") { + t.Error("expected method") + } +} + +func TestBuildConfigVMess(t *testing.T) { + server := models.Server{ + Tag: "nl-vmess", Region: "NL", Type: "vmess", + Server: "nl.example.com", ServerPort: 8444, UUID: "22222222-2222-2222-2222-222222222222", + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, + Transport: &models.Transport{Type: "ws", Path: "/vmess"}, + } + mode := *config.ModeByName("Full (All Traffic)") + + cfg := config.BuildConfig(server, mode, nil, nil) + data, _ := json.Marshal(cfg) + s := string(data) + + if !strings.Contains(s, `"type":"vmess"`) { + t.Error("expected vmess outbound") + } + if !strings.Contains(s, "22222222-2222-2222-2222-222222222222") { + t.Error("expected vmess uuid") + } + if !strings.Contains(s, `"/vmess"`) { + t.Error("expected vmess ws path") + } +} + +func TestBuildConfigHysteria2(t *testing.T) { + server := models.Server{ + Tag: "nl-hy2", Region: "NL", Type: "hysteria2", + Server: "nl.example.com", ServerPort: 9443, Password: "hy2-secret", ObfsPassword: "obfs-secret", + UpMbps: 80, DownMbps: 90, + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com", Insecure: true, ALPN: []string{"h3"}, MinVersion: "1.3", MaxVersion: "1.3"}, + } + mode := *config.ModeByName("Full (All Traffic)") + + cfg := config.BuildConfig(server, mode, nil, nil) + data, _ := json.Marshal(cfg) + s := string(data) + + if !strings.Contains(s, `"type":"hysteria2"`) { + t.Error("expected hysteria2 outbound") + } + if !strings.Contains(s, `"password":"hy2-secret"`) { + t.Error("expected hysteria2 password") + } + if !strings.Contains(s, `"salamander"`) { + t.Error("expected hysteria2 obfs configuration") + } + if !strings.Contains(s, `"up_mbps":80`) && !strings.Contains(s, `"up_mbps": 80`) { + t.Error("expected hysteria2 up_mbps") + } + if !strings.Contains(s, `"insecure":true`) && !strings.Contains(s, `"insecure": true`) { + t.Error("expected hysteria2 tls.insecure") + } + if !strings.Contains(s, `"alpn":["h3"]`) && !strings.Contains(s, `"alpn": ["h3"]`) { + t.Error("expected hysteria2 tls alpn h3") + } + if !strings.Contains(s, `"min_version":"1.3"`) && !strings.Contains(s, `"min_version": "1.3"`) { + t.Error("expected hysteria2 tls min_version") + } +} + +func TestBuildConfigSplitRealityHysteria2(t *testing.T) { + server := models.Server{ + Tag: "nl-multi", + Region: "NL", + Type: "vless-reality", + Server: "203.0.113.50", + ServerPort: 443, + UUID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + TLS: &models.TLS{ + Enabled: true, + ServerName: "www.microsoft.com", + Reality: &models.Reality{ + Enabled: true, + PublicKey: "pubkey", + ShortID: "abcdef1234567890", + Fingerprint: "chrome", + }, + }, + Companions: []models.Server{ + { + Tag: "nl-multi-hysteria2", + Region: "NL", + Type: "hysteria2", + Server: "203.0.113.50", + ServerPort: 443, + Password: "hy2-secret", + ObfsPassword: "obfs-secret", + UpMbps: 100, + DownMbps: 100, + TLS: &models.TLS{ + Enabled: true, + Insecure: true, + ALPN: []string{"h3"}, + MinVersion: "1.3", + MaxVersion: "1.3", + }, + }, + }, + } + mode := *config.ModeByName("Full (All Traffic)") + + cfg := config.BuildConfig(server, mode, nil, nil) + data, _ := json.Marshal(cfg) + s := string(data) + + if !strings.Contains(s, `"tag":"vless-out"`) { + t.Fatal("expected vless-out outbound tag") + } + if !strings.Contains(s, `"tag":"hysteria2-out"`) { + t.Fatal("expected hysteria2-out outbound tag") + } + if !strings.Contains(s, `"network":["tcp"]`) || !strings.Contains(s, `"outbound":"vless-out"`) { + t.Fatal("expected tcp split routing rule") + } + if !strings.Contains(s, `"network":["udp"]`) || !strings.Contains(s, `"outbound":"hysteria2-out"`) { + t.Fatal("expected udp split routing rule") + } + if !strings.Contains(s, `"detour":"vless-out"`) { + t.Fatal("expected proxy DNS detour via vless-out") + } +} + +func TestBuildConfigWithRuleSets(t *testing.T) { + server := models.Server{ + Tag: "nl-1", Type: "socks", Server: "1.2.3.4", ServerPort: 1080, + } + mode := *config.ModeByName("Re-filter (обход блокировок РФ)") + ruleSets := []models.RuleSet{ + {Tag: "refilter-domains", URL: "https://example.com/domains.srs", Format: "binary"}, + {Tag: "refilter-ip", URL: "https://example.com/ip.srs", Format: "binary"}, + {Tag: "discord-voice", URL: "https://example.com/discord.srs", Format: "binary"}, + } + + cfg := config.BuildConfig(server, mode, ruleSets, nil) + data, _ := json.Marshal(cfg) + s := string(data) + + if !strings.Contains(s, "refilter-domains") { + t.Error("expected refilter-domains rule_set") + } + if !strings.Contains(s, "download_detour") { + t.Error("expected download_detour in rule_set") + } + if !strings.Contains(s, "update_interval") { + t.Error("expected update_interval in rule_set") + } +} + +func TestBuildBypassIPs(t *testing.T) { + ips := config.BuildBypassIPs(nil, []string{"1.2.3.4", "5.180.97.200"}) + + found := false + for _, ip := range ips { + if ip == "1.2.3.4/32" { + found = true + } + } + if !found { + t.Error("expected dynamic server IP in bypass list") + } + + // 5.180.97.200 is already in StaticBypassIPs, should not be duplicated + count := 0 + for _, ip := range ips { + if ip == "5.180.97.200/32" { + count++ + } + } + if count != 1 { + t.Errorf("expected 5.180.97.200/32 exactly once, got %d", count) + } +} + +func TestBuildBypassIPsIgnoresHostnames(t *testing.T) { + ips := config.BuildBypassIPs(nil, []string{"xui5.em-sysadmin.xyz", "1.2.3.4"}) + + for _, ip := range ips { + if ip == "xui5.em-sysadmin.xyz/32" { + t.Fatal("expected hostname to be ignored in bypass IP list") + } + } +} + +func TestAllModes(t *testing.T) { + modes := config.AllModes() + if len(modes) != 7 { + t.Errorf("expected 7 modes, got %d", len(modes)) + } + + names := config.ModeNames() + expected := []string{ + "Lovense + OBS + AnyDesk", + "Lovense + OBS + AnyDesk + Discord", + "Lovense + OBS + AnyDesk + Discord + Teams", + "Discord Only", + "Full (All Traffic)", + "Re-filter (обход блокировок РФ)", + "Комбо (приложения + Re-filter)", + } + for i, name := range expected { + if names[i] != name { + t.Errorf("mode %d: expected %q, got %q", i, name, names[i]) + } + } +} + +func TestModeByName(t *testing.T) { + m := config.ModeByName("Full (All Traffic)") + if m == nil { + t.Fatal("expected to find Full mode") + } + if m.Final != "proxy" { + t.Errorf("Full mode final should be proxy, got %s", m.Final) + } + + if config.ModeByName("nonexistent") != nil { + t.Error("expected nil for nonexistent mode") + } +} diff --git a/internal/config/bypass.go b/internal/config/bypass.go new file mode 100644 index 0000000..9deadb7 --- /dev/null +++ b/internal/config/bypass.go @@ -0,0 +1,169 @@ +package config + +import ( + "net/netip" + + "vpnem/internal/models" +) + +// BYPASS_PROCESSES — processes that go direct, bypassing TUN. +// Ported 1:1 from vpn.py. +var BypassProcesses = []string{ + "QTranslate.exe", + "aspia_host.exe", + "aspia_host_service.exe", + "aspia_desktop_agent.exe", + "Performer Application v5.x.exe", + "chromium.exe", + "Яндекс Музыка.exe", +} + +// PreferDirectProcesses should stay outside global bypass, but still avoid the proxy +// unless a stronger blocked/refilter/forced rule matches first. +var PreferDirectProcesses = []string{ + "obs64.exe", +} + +// ProxyableBrowserProcesses intentionally stay OUT of the default direct bypass list. +// Their traffic should follow routing mode rules, otherwise Full/Re-filter modes +// cannot proxy IP-check and blocked domains correctly. +var ProxyableBrowserProcesses = []string{ + "chrome.exe", + "firefox.exe", + "msedgewebview2.exe", +} + +// LovenseProcessRegex — force Lovense through proxy regardless of mode. +var LovenseProcessRegex = []string{"(?i).*lovense.*"} + +// BYPASS_IPS — VPN server IPs + service IPs, always direct. +// NL servers, RU servers, misc. +var StaticBypassIPs = []string{ + // NL servers + "5.180.97.200/32", "5.180.97.199/32", "5.180.97.198/32", + "5.180.97.197/32", "5.180.97.181/32", + // RU servers + "84.252.100.166/32", "84.252.100.165/32", "84.252.100.161/32", + "84.252.100.117/32", "84.252.100.103/32", + // Misc + "109.107.175.41/32", "146.103.104.48/32", "77.105.138.163/32", + "91.84.113.225/32", "146.103.98.171/32", "94.103.88.252/32", + "178.20.44.93/32", "89.124.70.47/32", +} + +// ReservedCIDRs — ranges not covered by ip_is_private. +var ReservedCIDRs = []string{ + "100.64.0.0/10", // CGNAT / Tailscale + "192.0.0.0/24", // IETF protocol assignments + "192.0.2.0/24", // TEST-NET-1 + "198.51.100.0/24", // TEST-NET-2 + "203.0.113.0/24", // TEST-NET-3 + "240.0.0.0/4", // Reserved (Class E) + "255.255.255.255/32", // Broadcast +} + +// LocalDomainSuffixes — local/mDNS domains, always direct. +var LocalDomainSuffixes = []string{ + "local", "localhost", "lan", "internal", "home.arpa", + "corp", "intranet", "test", "invalid", "example", + "home", "localdomain", +} + +// WindowsNCSIDomains — Windows Network Connectivity Status Indicator. +// Without these going direct, Windows shows "No Internet" warnings. +var WindowsNCSIDomains = []string{ + "msftconnecttest.com", + "msftncsi.com", +} + +// ForcedProxyIPs — IPs that must always go through proxy. +var ForcedProxyIPs = []string{ + "65.21.33.248/32", + "91.132.135.38/32", +} + +// Telegram — hardcoded, applied to ALL modes. +var TelegramDomains = []string{ + "telegram.org", "telegram.me", "t.me", "telegra.ph", "telegram.dog", +} + +var TelegramDomainRegex = []string{ + ".*telegram.*", `.*t\.me.*`, +} + +var TelegramIPs = []string{ + "91.108.56.0/22", "91.108.4.0/22", "91.108.8.0/22", + "91.108.16.0/22", "91.108.12.0/22", "149.154.160.0/20", + "91.105.192.0/23", "91.108.20.0/22", "185.76.151.0/24", +} + +var TelegramProcesses = []string{ + "Telegram.exe", +} + +var TelegramProcessRegex = []string{ + `(?i).*telegram.*\\telegram\.exe$`, +} + +// ProxyDNSDomains — domains NOT in refilter-domains.srs but must resolve via proxy DNS. +// refilter-domains.srs (81k+ domains) covers all RKN-blocked domains. +// This list only has domains missing from .srs that we still need through proxy. +var ProxyDNSDomains = []string{ + // Business-specific (not RKN-blocked) + "lovense.com", "lovense-api.com", "lovense.club", + // Not in refilter but needed + "anthropic.com", + "igcdn.com", "fbsbx.com", + // IP check services (must show proxy exit IP) + "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io", "ipify.org", +} + +// IPCheckDomains — domains used for exit IP verification. +var IPCheckDomains = []string{ + "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io", +} + +// BuildBypassProcesses merges default + custom bypass processes. +func BuildBypassProcesses(policy *models.RoutingPolicy, custom []string) []string { + effective := EffectiveRoutingPolicy(policy) + seen := make(map[string]bool, len(effective.AlwaysDirectProcesses)+len(custom)) + result := make([]string, 0, len(effective.AlwaysDirectProcesses)+len(custom)) + for _, p := range effective.AlwaysDirectProcesses { + if !seen[p] { + seen[p] = true + result = append(result, p) + } + } + for _, p := range custom { + if p != "" && !seen[p] { + seen[p] = true + result = append(result, p) + } + } + return result +} + +// BuildBypassIPs merges static bypass IPs with dynamic server IPs. +func BuildBypassIPs(policy *models.RoutingPolicy, serverIPs []string) []string { + effective := EffectiveRoutingPolicy(policy) + seen := make(map[string]bool, len(effective.StaticBypassIPs)+len(serverIPs)) + result := make([]string, 0, len(effective.StaticBypassIPs)+len(serverIPs)) + + for _, ip := range effective.StaticBypassIPs { + if !seen[ip] { + seen[ip] = true + result = append(result, ip) + } + } + for _, ip := range serverIPs { + if _, err := netip.ParseAddr(ip); err != nil { + continue + } + cidr := ip + "/32" + if !seen[cidr] { + seen[cidr] = true + result = append(result, cidr) + } + } + return result +} diff --git a/internal/config/modes.go b/internal/config/modes.go new file mode 100644 index 0000000..22f1d2e --- /dev/null +++ b/internal/config/modes.go @@ -0,0 +1,176 @@ +package config + +// Mode defines a routing mode with its specific rules. +type Mode struct { + Name string + Final string // "direct" or "proxy" + Rules []Rule +} + +// Rule represents a single sing-box routing rule. +type Rule struct { + DomainSuffix []string `json:"domain_suffix,omitempty"` + DomainRegex []string `json:"domain_regex,omitempty"` + IPCIDR []string `json:"ip_cidr,omitempty"` + RuleSet []string `json:"rule_set,omitempty"` + Network []string `json:"network,omitempty"` + PortRange []string `json:"port_range,omitempty"` + Outbound string `json:"outbound"` +} + +// Discord IPs — ported 1:1 from vpn.py. +var DiscordIPs = []string{ + "162.159.130.234/32", "162.159.134.234/32", "162.159.133.234/32", + "162.159.135.234/32", "162.159.136.234/32", "162.159.137.232/32", + "162.159.135.232/32", "162.159.136.232/32", "162.159.138.232/32", + "162.159.128.233/32", "198.244.231.90/32", "162.159.129.233/32", + "162.159.130.233/32", "162.159.133.233/32", "162.159.134.233/32", + "162.159.135.233/32", "162.159.138.234/32", "162.159.137.234/32", + "162.159.134.232/32", "162.159.130.235/32", "162.159.129.235/32", + "162.159.129.232/32", "162.159.128.235/32", "162.159.130.232/32", + "162.159.133.232/32", "162.159.128.232/32", "34.126.226.51/32", + // Voice + "66.22.243.0/24", "64.233.165.94/32", "35.207.188.57/32", + "35.207.81.249/32", "35.207.171.222/32", "195.62.89.0/24", + "66.22.192.0/18", "66.22.196.0/24", "66.22.197.0/24", + "66.22.198.0/24", "66.22.199.0/24", "66.22.216.0/24", + "66.22.217.0/24", "66.22.237.0/24", "66.22.238.0/24", + "66.22.241.0/24", "66.22.242.0/24", "66.22.244.0/24", + "64.71.8.96/29", "34.0.240.0/24", "34.0.241.0/24", + "34.0.242.0/24", "34.0.243.0/24", "34.0.244.0/24", + "34.0.245.0/24", "34.0.246.0/24", "34.0.247.0/24", + "34.0.248.0/24", "34.0.249.0/24", "34.0.250.0/24", + "34.0.251.0/24", "12.129.184.160/29", "138.128.136.0/21", + "162.158.0.0/15", "172.64.0.0/13", "34.0.0.0/15", + "34.2.0.0/15", "35.192.0.0/12", "35.208.0.0/12", + "5.200.14.128/25", +} + +var DiscordDomains = []string{ + "discord.com", "discord.gg", "discordapp.com", + "discord.media", "discordapp.net", "discord.net", +} + +var DiscordDomainRegex = []string{".*discord.*"} + +var TeamsDomains = []string{ + "teams.microsoft.com", "teams.cloud.microsoft", "lync.com", + "skype.com", "keydelivery.mediaservices.windows.net", + "streaming.mediaservices.windows.net", +} + +var TeamsIPs = []string{ + "52.112.0.0/14", "52.122.0.0/15", +} + +var TeamsDomainRegex = []string{ + `.*teams\.microsoft.*`, ".*lync.*", ".*skype.*", +} + +var LovenseDomains = []string{ + "lovense-api.com", "lovense.com", "lovense.club", +} + +var LovenseDomainRegex = []string{".*lovense.*"} + +var OBSDomains = []string{"obsproject.com"} +var OBSDomainRegex = []string{".*obsproject.*"} + +var AnyDeskDomains = []string{ + "anydesk.com", "anydesk.com.cn", "net.anydesk.com", +} + +var AnyDeskDomainRegex = []string{".*anydesk.*"} + +// AllModes returns all available routing modes. +func AllModes() []Mode { + baseDomains := append(append(append( + LovenseDomains, OBSDomains...), AnyDeskDomains...), IPCheckDomains...) + baseRegex := append(append( + LovenseDomainRegex, OBSDomainRegex...), AnyDeskDomainRegex...) + + discordDomains := append(append([]string{}, baseDomains...), DiscordDomains...) + discordRegex := append(append([]string{}, baseRegex...), DiscordDomainRegex...) + + teamsDomains := append(append([]string{}, discordDomains...), TeamsDomains...) + teamsRegex := append(append([]string{}, discordRegex...), TeamsDomainRegex...) + + return []Mode{ + { + Name: "Lovense + OBS + AnyDesk", + Final: "direct", + Rules: []Rule{ + {DomainSuffix: baseDomains, DomainRegex: baseRegex, Outbound: "proxy"}, + }, + }, + { + Name: "Lovense + OBS + AnyDesk + Discord", + Final: "direct", + Rules: []Rule{ + {DomainSuffix: discordDomains, DomainRegex: discordRegex, Outbound: "proxy"}, + {IPCIDR: DiscordIPs, Outbound: "proxy"}, + {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"}, + }, + }, + { + Name: "Lovense + OBS + AnyDesk + Discord + Teams", + Final: "direct", + Rules: []Rule{ + {DomainSuffix: teamsDomains, DomainRegex: teamsRegex, Outbound: "proxy"}, + {IPCIDR: append(append([]string{}, DiscordIPs...), TeamsIPs...), Outbound: "proxy"}, + {Network: []string{"udp"}, PortRange: []string{"3478:3481", "50000:65535"}, Outbound: "proxy"}, + }, + }, + { + Name: "Discord Only", + Final: "direct", + Rules: []Rule{ + {DomainSuffix: append(append([]string{}, DiscordDomains...), IPCheckDomains...), DomainRegex: DiscordDomainRegex, Outbound: "proxy"}, + {IPCIDR: DiscordIPs, Outbound: "proxy"}, + {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"}, + }, + }, + { + Name: "Full (All Traffic)", + Final: "proxy", + Rules: nil, + }, + { + Name: "Re-filter (обход блокировок РФ)", + Final: "direct", + Rules: []Rule{ + {RuleSet: []string{"refilter-domains", "refilter-ip", "discord-voice"}, Outbound: "proxy"}, + }, + }, + { + Name: "Комбо (приложения + Re-filter)", + Final: "direct", + Rules: []Rule{ + {DomainSuffix: discordDomains, DomainRegex: discordRegex, Outbound: "proxy"}, + {IPCIDR: DiscordIPs, Outbound: "proxy"}, + {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"}, + {RuleSet: []string{"refilter-domains", "refilter-ip", "discord-voice"}, Outbound: "proxy"}, + }, + }, + } +} + +// ModeByName finds a mode by name, returns nil if not found. +func ModeByName(name string) *Mode { + for _, m := range AllModes() { + if m.Name == name { + return &m + } + } + return nil +} + +// ModeNames returns all available mode names. +func ModeNames() []string { + modes := AllModes() + names := make([]string, len(modes)) + for i, m := range modes { + names[i] = m.Name + } + return names +} diff --git a/internal/config/outbounds.go b/internal/config/outbounds.go new file mode 100644 index 0000000..582b625 --- /dev/null +++ b/internal/config/outbounds.go @@ -0,0 +1,212 @@ +package config + +import "vpnem/internal/models" + +type InboundConfig map[string]any + +func BuildOutbound(server models.Server) map[string]any { + return BuildOutboundWithTag(server, "proxy") +} + +func BuildOutboundWithTag(server models.Server, tag string) map[string]any { + switch server.Type { + case "vless": + return buildVLESSOutbound(server, tag) + case "vless-reality": + return buildVLESSRealityOutbound(server, tag) + case "vmess": + return buildVMessOutbound(server, tag) + case "shadowsocks": + return buildShadowsocksOutbound(server, tag) + case "hysteria2": + return buildHysteria2Outbound(server, tag) + default: + return buildSOCKSOutbound(server, tag) + } +} + +func BuildHysteria2Inbound(_ any, port int, password string, obfsPassword string, upMbps int, downMbps int, certPath string, keyPath string) (*InboundConfig, error) { + if password == "" { + return nil, errConfig("hysteria2 inbound requires password") + } + if certPath == "" || keyPath == "" { + return nil, errConfig("hysteria2 inbound requires certificate and key paths") + } + inbound := InboundConfig{ + "type": "hysteria2", + "tag": "hysteria2-in", + "listen": "::", + "listen_port": port, + "users": []map[string]any{ + {"name": "user-01", "password": password}, + }, + "tls": map[string]any{ + "enabled": true, + "alpn": []string{"h3"}, + "min_version": "1.3", + "max_version": "1.3", + "certificate_path": certPath, + "key_path": keyPath, + }, + } + if upMbps > 0 { + inbound["up_mbps"] = upMbps + } + if downMbps > 0 { + inbound["down_mbps"] = downMbps + } + if obfsPassword != "" { + inbound["obfs"] = map[string]any{ + "type": "salamander", + "password": obfsPassword, + } + } + return &inbound, nil +} + +func buildVLESSOutbound(server models.Server, tag string) map[string]any { + outbound := map[string]any{ + "type": "vless", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, "uuid": server.UUID, + } + applyTLS(outbound, server.TLS) + applyTransport(outbound, server.Transport) + return outbound +} + +func buildVLESSRealityOutbound(server models.Server, tag string) map[string]any { + outbound := map[string]any{ + "type": "vless", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, "uuid": server.UUID, + } + applyTLS(outbound, server.TLS) + return outbound +} + +func buildVMessOutbound(server models.Server, tag string) map[string]any { + outbound := map[string]any{ + "type": "vmess", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, + "uuid": server.UUID, "security": "auto", "alter_id": 0, + } + applyTLS(outbound, server.TLS) + applyTransport(outbound, server.Transport) + return outbound +} + +func buildShadowsocksOutbound(server models.Server, tag string) map[string]any { + return map[string]any{ + "type": "shadowsocks", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, + "method": server.Method, "password": server.Password, + } +} + +func buildHysteria2Outbound(server models.Server, tag string) map[string]any { + outbound := map[string]any{ + "type": "hysteria2", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, + "password": server.Password, + } + if server.UpMbps > 0 { + outbound["up_mbps"] = server.UpMbps + } + if server.DownMbps > 0 { + outbound["down_mbps"] = server.DownMbps + } + if server.ObfsPassword != "" { + outbound["obfs"] = map[string]any{"type": "salamander", "password": server.ObfsPassword} + } + tlsConfig := map[string]any{ + "enabled": true, + "insecure": true, + "alpn": []string{"h3"}, + "min_version": "1.3", + "max_version": "1.3", + } + if server.TLS != nil { + if server.TLS.ServerName != "" { + tlsConfig["server_name"] = server.TLS.ServerName + } + if len(server.TLS.ALPN) > 0 { + tlsConfig["alpn"] = server.TLS.ALPN + } + if server.TLS.MinVersion != "" { + tlsConfig["min_version"] = server.TLS.MinVersion + } + if server.TLS.MaxVersion != "" { + tlsConfig["max_version"] = server.TLS.MaxVersion + } + if server.TLS.Insecure { + tlsConfig["insecure"] = true + } + } + outbound["tls"] = tlsConfig + return outbound +} + +func buildSOCKSOutbound(server models.Server, tag string) map[string]any { + return map[string]any{ + "type": "socks", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, + "udp_over_tcp": server.UDPOverTCP, + } +} + +func applyTLS(outbound map[string]any, tls *models.TLS) { + if tls == nil { + return + } + tlsConfig := map[string]any{ + "enabled": tls.Enabled, + "server_name": tls.ServerName, + } + if tls.Insecure { + tlsConfig["insecure"] = true + } + if len(tls.ALPN) > 0 { + tlsConfig["alpn"] = tls.ALPN + } + if tls.MinVersion != "" { + tlsConfig["min_version"] = tls.MinVersion + } + if tls.MaxVersion != "" { + tlsConfig["max_version"] = tls.MaxVersion + } + if tls.Reality != nil && tls.Reality.Enabled { + tlsConfig["reality"] = map[string]any{ + "enabled": true, + "public_key": tls.Reality.PublicKey, + "short_id": tls.Reality.ShortID, + } + if tls.Reality.Fingerprint != "" { + tlsConfig["utls"] = map[string]any{ + "enabled": true, + "fingerprint": tls.Reality.Fingerprint, + } + } + } + outbound["tls"] = tlsConfig +} + +func errConfig(message string) error { + return &configError{message: message} +} + +type configError struct { + message string +} + +func (e *configError) Error() string { + return e.message +} + +func applyTransport(outbound map[string]any, transport *models.Transport) { + if transport == nil { + return + } + outbound["transport"] = map[string]any{ + "type": transport.Type, + "path": transport.Path, + } +} diff --git a/internal/config/policy.go b/internal/config/policy.go new file mode 100644 index 0000000..bcf8f71 --- /dev/null +++ b/internal/config/policy.go @@ -0,0 +1,102 @@ +package config + +import "vpnem/internal/models" + +var defaultBlockedDomains = []string{ + "telegram.org", "t.me", "telegram.me", "telegra.ph", "telegram.dog", + "web.telegram.org", + "discord.com", "discord.gg", "discordapp.com", "discordapp.net", + "instagram.com", "cdninstagram.com", "ig.me", "igcdn.com", + "facebook.com", "fb.com", "fbcdn.net", "fbsbx.com", "fb.me", + "whatsapp.com", "whatsapp.net", + "twitter.com", "x.com", "twimg.com", "t.co", + "openai.com", "chatgpt.com", "oaistatic.com", "oaiusercontent.com", + "claude.ai", "anthropic.com", + "youtube.com", "googlevideo.com", "youtu.be", "ggpht.com", "ytimg.com", + "gstatic.com", "doubleclick.net", "googleadservices.com", + "stripchat.com", "stripchat.global", "ststandard.com", "strpssts-ana.com", + "strpst.com", "striiiipst.com", + "chaturbate.com", "highwebmedia.com", "cb.dev", + "camsoda.com", "cam4.com", "cam101.com", + "bongamodels.com", "flirt4free.com", "privatecams.com", + "streamray.com", "cams.com", "homelivesex.com", + "skyprivate.com", "mywebcamroom.com", "livemediahost.com", + "xcdnpro.com", "mmcdn.com", "vscdns.com", "bgicdn.com", "bgmicdn.com", + "doppiocdn.com", "doppiocdn.net", "doppiostreams.com", + "fanclubs.tech", "my.club", "chapturist.com", + "moengage.com", "amplitude.com", "dwin1.com", + "eizzih.com", "loo3laej.com", "iesnare.com", + "hytto.com", "zendesk.com", + "lovense.com", "lovense-api.com", "lovense.club", + "bitrix24.ru", "bitrix24.com", + "cloudflare.com", + "viber.com", "linkedin.com", "spotify.com", + "ntc.party", "ipify.org", + "rutracker.org", "rutracker.net", "rutracker.me", + "4pda.to", "kinozal.tv", "nnmclub.to", + "protonmail.com", "proton.me", "tutanota.com", + "medium.com", "archive.org", "soundcloud.com", "twitch.tv", + "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io", + "em-mail.ru", +} + +func DefaultRoutingPolicy() *models.RoutingPolicy { + return &models.RoutingPolicy{ + Version: "2026-04-04", + AlwaysDirectProcesses: append([]string{}, BypassProcesses...), + PreferDirectProcesses: append([]string{}, PreferDirectProcesses...), + ProxyableBrowserProcesses: append([]string{}, ProxyableBrowserProcesses...), + LovenseProcessRegex: append([]string{}, LovenseProcessRegex...), + StaticBypassIPs: append([]string{}, StaticBypassIPs...), + ReservedCIDRs: append([]string{}, ReservedCIDRs...), + LocalDomainSuffixes: append([]string{}, LocalDomainSuffixes...), + WindowsNCSIDomains: append([]string{}, WindowsNCSIDomains...), + InfraBypassDomains: []string{"em-sysadmin.xyz"}, + ForcedProxyIPs: append([]string{}, ForcedProxyIPs...), + TelegramProcesses: append([]string{}, TelegramProcesses...), + TelegramProcessRegex: append([]string{}, TelegramProcessRegex...), + TelegramDomains: append([]string{}, TelegramDomains...), + TelegramDomainRegex: append([]string{}, TelegramDomainRegex...), + TelegramIPs: append([]string{}, TelegramIPs...), + BlockedDomains: append([]string{}, defaultBlockedDomains...), + ProxyDNSDomains: append([]string{}, ProxyDNSDomains...), + IPCheckDomains: append([]string{}, IPCheckDomains...), + } +} + +func EffectiveRoutingPolicy(policy *models.RoutingPolicy) *models.RoutingPolicy { + if policy == nil { + return DefaultRoutingPolicy() + } + + effective := *DefaultRoutingPolicy() + if policy.Version != "" { + effective.Version = policy.Version + } + overrideStringSlice(&effective.AlwaysDirectProcesses, policy.AlwaysDirectProcesses) + overrideStringSlice(&effective.PreferDirectProcesses, policy.PreferDirectProcesses) + overrideStringSlice(&effective.ProxyableBrowserProcesses, policy.ProxyableBrowserProcesses) + overrideStringSlice(&effective.LovenseProcessRegex, policy.LovenseProcessRegex) + overrideStringSlice(&effective.StaticBypassIPs, policy.StaticBypassIPs) + overrideStringSlice(&effective.ReservedCIDRs, policy.ReservedCIDRs) + overrideStringSlice(&effective.LocalDomainSuffixes, policy.LocalDomainSuffixes) + overrideStringSlice(&effective.WindowsNCSIDomains, policy.WindowsNCSIDomains) + overrideStringSlice(&effective.InfraBypassDomains, policy.InfraBypassDomains) + overrideStringSlice(&effective.ForcedProxyIPs, policy.ForcedProxyIPs) + overrideStringSlice(&effective.TelegramProcesses, policy.TelegramProcesses) + overrideStringSlice(&effective.TelegramProcessRegex, policy.TelegramProcessRegex) + overrideStringSlice(&effective.TelegramDomains, policy.TelegramDomains) + overrideStringSlice(&effective.TelegramDomainRegex, policy.TelegramDomainRegex) + overrideStringSlice(&effective.TelegramIPs, policy.TelegramIPs) + overrideStringSlice(&effective.BlockedDomains, policy.BlockedDomains) + overrideStringSlice(&effective.ProxyDNSDomains, policy.ProxyDNSDomains) + overrideStringSlice(&effective.IPCheckDomains, policy.IPCheckDomains) + return &effective +} + +func overrideStringSlice(dst *[]string, src []string) { + if src == nil { + return + } + *dst = append([]string{}, src...) +} diff --git a/internal/control/bootstrap.go b/internal/control/bootstrap.go new file mode 100644 index 0000000..5eb6f4f --- /dev/null +++ b/internal/control/bootstrap.go @@ -0,0 +1,369 @@ +package control + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type BootstrapOptions struct { + StateDir string + DryRun bool +} + +func BootstrapNode(ctx context.Context, runner SSHExecutor, node Node, opts BootstrapOptions) (*NodeState, error) { + for idx := range node.Protocols { + if err := ensureRealityProfile(&node.Protocols[idx]); err != nil { + return nil, err + } + if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil { + return nil, err + } + } + if err := ValidateNode(node); err != nil { + return nil, err + } + + now := time.Now().UTC() + state := &NodeState{ + NodeID: node.ID, + BootstrapStatus: "pending", + PublicHost: publicHost(node), + Services: serviceStatuses(node.Protocols, "configured"), + Metadata: map[string]any{ + "provider": node.Provider, + "region": node.Region, + "dry_run": opts.DryRun, + }, + } + + if opts.DryRun { + state.BootstrapStatus = "planned" + state.LastBootstrapAt = &now + state.Metadata["release_id"] = buildReleaseID(now) + if err := SaveNodeState(opts.StateDir, *state); err != nil { + return nil, err + } + return state, nil + } + + relID := buildReleaseID(now) + bundleDir, tarballPath, err := buildRuntimeBundle(node, relID) + if err != nil { + return nil, err + } + defer os.RemoveAll(bundleDir) + defer os.Remove(tarballPath) + + result, err := runner.Run(ctx, node, RenderBootstrapPrepareScript()) + if err != nil { + state.BootstrapStatus = "failed" + state.LastBootstrapAt = &now + state.Metadata["stderr"] = strings.TrimSpace(result.Stderr) + state.Metadata["stdout"] = strings.TrimSpace(result.Stdout) + if saveErr := SaveNodeState(opts.StateDir, *state); saveErr != nil { + return nil, fmt.Errorf("%w; save state: %v", err, saveErr) + } + return nil, err + } + + remoteTarballPath := "/tmp/vpnem-node-" + node.ID + ".tar.gz" + if err := runner.CopyFile(ctx, node, tarballPath, remoteTarballPath); err != nil { + state.BootstrapStatus = "failed" + state.LastBootstrapAt = &now + state.Metadata["release_id"] = relID + state.Metadata["copy_error"] = err.Error() + if saveErr := SaveNodeState(opts.StateDir, *state); saveErr != nil { + return nil, fmt.Errorf("%w; save state: %v", err, saveErr) + } + return nil, err + } + + result, err = runner.Run(ctx, node, RenderBootstrapFinalizeScript(node, relID, remoteTarballPath)) + if err != nil { + state.BootstrapStatus = "failed" + state.LastBootstrapAt = &now + state.Metadata["stderr"] = strings.TrimSpace(result.Stderr) + state.Metadata["stdout"] = strings.TrimSpace(result.Stdout) + if saveErr := SaveNodeState(opts.StateDir, *state); saveErr != nil { + return nil, fmt.Errorf("%w; save state: %v", err, saveErr) + } + return nil, err + } + + state.BootstrapStatus = "ready" + state.LastBootstrapAt = &now + state.Metadata["release_id"] = relID + state.Metadata["stdout"] = strings.TrimSpace(result.Stdout) + if err := SaveNodeState(opts.StateDir, *state); err != nil { + return nil, err + } + return state, nil +} + +func CheckNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) { + now := time.Now().UTC() + result, err := runner.Check(ctx, node) + state := &NodeState{ + NodeID: node.ID, + PublicHost: publicHost(node), + LastHealthCheckAt: &now, + Services: serviceStatuses(node.Protocols, "unknown"), + Metadata: map[string]any{}, + } + + if err != nil { + state.BootstrapStatus = "unreachable" + state.Metadata["stderr"] = strings.TrimSpace(result.Stderr) + if saveErr := SaveNodeState(stateDir, *state); saveErr != nil { + return nil, fmt.Errorf("%w; save state: %v", err, saveErr) + } + return nil, err + } + + state.BootstrapStatus = "reachable" + state.Metadata["stdout"] = strings.TrimSpace(result.Stdout) + + runtimeResult, runtimeErr := runner.Run(ctx, node, RenderHealthCheckScript(node)) + if runtimeErr != nil { + state.Metadata["runtime_stderr"] = strings.TrimSpace(runtimeResult.Stderr) + state.Metadata["runtime_stdout"] = strings.TrimSpace(runtimeResult.Stdout) + } else { + services, metadata := parseHealthCheckOutput(runtimeResult.Stdout, node.Protocols) + if len(services) > 0 { + state.Services = services + } + for k, v := range metadata { + state.Metadata[k] = v + } + if healthy, ok := metadata["healthz_http_code"].(int); ok && healthy == 200 { + state.BootstrapStatus = "healthy" + } else if allServicesRunning(state.Services) { + state.BootstrapStatus = "ready" + } + } + if err := SaveNodeState(stateDir, *state); err != nil { + return nil, err + } + return state, nil +} + +func RenderBootstrapPrepareScript() string { + var b strings.Builder + b.WriteString("set -eu\n") + b.WriteString("export DEBIAN_FRONTEND=noninteractive\n") + b.WriteString("mkdir -p /opt/vpnem-node/releases\n") + b.WriteString("if command -v apt-get >/dev/null 2>&1; then\n") + b.WriteString(" apt-get update\n") + b.WriteString(" apt-get install -y ca-certificates curl tar gzip openssl docker.io docker-compose || true\n") + b.WriteString("elif command -v dnf >/dev/null 2>&1; then\n") + b.WriteString(" dnf install -y ca-certificates curl tar gzip openssl docker docker-compose-plugin docker-compose || true\n") + b.WriteString("elif command -v pacman >/dev/null 2>&1; then\n") + b.WriteString(" pacman -Sy --noconfirm ca-certificates curl tar gzip openssl docker docker-compose || true\n") + b.WriteString("elif command -v apk >/dev/null 2>&1; then\n") + b.WriteString(" apk add --no-cache ca-certificates curl tar gzip openssl docker-cli-compose || true\n") + b.WriteString("fi\n") + b.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker || true; fi\n") + b.WriteString("if ! command -v docker >/dev/null 2>&1; then\n") + b.WriteString(" echo 'docker is not installed after bootstrap prepare' >&2\n") + b.WriteString(" exit 1\n") + b.WriteString("fi\n") + b.WriteString("printf 'vpnem-node bootstrap prepared\\n'\n") + return b.String() +} + +func RenderBootstrapFinalizeScript(node Node, releaseID, remoteTarballPath string) string { + var b strings.Builder + releaseDir := "/opt/vpnem-node/releases/" + releaseID + b.WriteString("set -eu\n") + b.WriteString("mkdir -p " + releaseDir + "\n") + b.WriteString("tar -xzf " + remoteTarballPath + " -C " + releaseDir + "\n") + b.WriteString("ln -sfn " + releaseDir + " /opt/vpnem-node/current\n") + b.WriteString("rm -f " + remoteTarballPath + "\n") + b.WriteString("if ! command -v docker >/dev/null 2>&1; then\n") + b.WriteString(" echo 'docker is not installed on target node' >&2\n") + b.WriteString(" exit 1\n") + b.WriteString("fi\n") + b.WriteString("if docker compose version >/dev/null 2>&1; then\n") + b.WriteString(" docker compose -f /opt/vpnem-node/current/docker-compose.yml up -d --force-recreate\n") + b.WriteString("elif command -v docker-compose >/dev/null 2>&1; then\n") + b.WriteString(" docker-compose -f /opt/vpnem-node/current/docker-compose.yml up -d --force-recreate\n") + b.WriteString("else\n") + b.WriteString(" echo 'docker compose is not available on target node' >&2\n") + b.WriteString(" exit 1\n") + b.WriteString("fi\n") + b.WriteString("printf 'vpnem-node release ") + b.WriteString(shellQuoteValue(releaseID)) + b.WriteString(" ready for ") + b.WriteString(shellQuoteValue(node.ID)) + b.WriteString("\\n'\n") + return b.String() +} + +func RenderHealthCheckScript(node Node) string { + var b strings.Builder + b.WriteString("set -eu\n") + b.WriteString("if [ -f /opt/vpnem-node/current/docker-compose.yml ]; then\n") + b.WriteString(" if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then\n") + b.WriteString(" docker compose -f /opt/vpnem-node/current/docker-compose.yml ps --format json 2>/dev/null || true\n") + b.WriteString(" elif command -v docker-compose >/dev/null 2>&1; then\n") + b.WriteString(" docker-compose -f /opt/vpnem-node/current/docker-compose.yml ps --format json 2>/dev/null || true\n") + b.WriteString(" fi\n") + b.WriteString(" if command -v docker >/dev/null 2>&1; then\n") + b.WriteString(" docker ps --format '{{json .}}' 2>/dev/null || true\n") + b.WriteString(" fi\n") + b.WriteString("fi\n") + if needsEdgeProxy(node) { + b.WriteString("printf 'HEALTHZ_HTTP_CODE='; ") + b.WriteString("curl -ks --resolve ") + b.WriteString(shellQuoteValue(node.Domain)) + b.WriteString(":443:127.0.0.1 -o /dev/null -w '%{http_code}' https://") + b.WriteString(shellQuoteValue(node.Domain)) + b.WriteString("/healthz || true\n") + } + if needsHysteria2HealthInbound(node) { + b.WriteString("printf 'HY2_MIXED_PORT='; ") + b.WriteString("curl -sS --max-time 5 --proxy socks5h://127.0.0.1:1080 https://ifconfig.me/ip || true\n") + } + return b.String() +} + +func serviceStatuses(protocols []ProtocolProfile, status string) []ServiceStatus { + services := make([]ServiceStatus, 0, len(protocols)) + for _, protocol := range protocols { + if !protocol.Enabled { + continue + } + services = append(services, ServiceStatus{ + Type: protocol.Type, + Status: status, + Port: protocol.Port, + }) + } + return services +} + +func parseHealthCheckOutput(stdout string, protocols []ProtocolProfile) ([]ServiceStatus, map[string]any) { + services := serviceStatuses(protocols, "unknown") + metadata := map[string]any{} + lines := strings.Split(stdout, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "HEALTHZ_HTTP_CODE=") { + codeStr := strings.TrimPrefix(line, "HEALTHZ_HTTP_CODE=") + if code, err := strconv.Atoi(codeStr); err == nil { + metadata["healthz_http_code"] = code + } + continue + } + if strings.HasPrefix(line, "HY2_MIXED_PORT=") { + value := strings.TrimSpace(strings.TrimPrefix(line, "HY2_MIXED_PORT=")) + metadata["hy2_mixed_port"] = value + if value != "" { + markServicesByTypes(services, []string{"hysteria2"}, "running") + } + continue + } + + var entry map[string]any + if err := jsonUnmarshalLine(line, &entry); err != nil { + continue + } + serviceName, _ := entry["Service"].(string) + state, _ := entry["State"].(string) + if serviceName == "" { + if labels, _ := entry["Labels"].(string); strings.Contains(labels, "com.docker.compose.service=sing-box") { + serviceName = "sing-box" + } else if names, _ := entry["Names"].(string); strings.Contains(names, "sing-box") { + serviceName = "sing-box" + } + } + if state == "" { + if status, _ := entry["Status"].(string); strings.HasPrefix(strings.ToLower(status), "up") { + state = "running" + } + } + if serviceName == "" || state == "" { + continue + } + metadata["docker_"+serviceName] = state + switch serviceName { + case "sing-box": + markServicesByTypes(services, []string{"vless", "vless-reality", "shadowsocks", "socks", "socks5", "vmess", "hysteria2"}, state) + case "caddy": + markServicesByTypes(services, []string{"vless", "vmess"}, state) + } + } + return services, metadata +} + +func allServicesRunning(services []ServiceStatus) bool { + if len(services) == 0 { + return false + } + for _, service := range services { + if service.Status != "running" { + return false + } + } + return true +} + +func markServicesByTypes(services []ServiceStatus, kinds []string, state string) { + set := make(map[string]struct{}, len(kinds)) + for _, kind := range kinds { + set[kind] = struct{}{} + } + for idx := range services { + if _, ok := set[services[idx].Type]; ok { + services[idx].Status = state + } + } +} + +func jsonUnmarshalLine(line string, out *map[string]any) error { + decoder := strings.NewReader(line) + return json.NewDecoder(decoder).Decode(out) +} + +func publicHost(node Node) string { + if strings.TrimSpace(node.Domain) != "" { + return node.Domain + } + return node.Host +} + +func shellQuoteValue(value string) string { + value = strings.ReplaceAll(value, "\n", "") + return value +} + +func buildRuntimeBundle(node Node, releaseID string) (string, string, error) { + rootDir, err := os.MkdirTemp("", "vpnem-node-bundle-*") + if err != nil { + return "", "", err + } + bundleDir := filepath.Join(rootDir, "bundle") + if err := RenderRuntimeBundle(bundleDir, node, releaseID); err != nil { + os.RemoveAll(rootDir) + return "", "", err + } + tarballPath := filepath.Join(rootDir, "bundle.tar.gz") + if err := CreateTarGzFromDir(bundleDir, tarballPath); err != nil { + os.RemoveAll(rootDir) + return "", "", err + } + return rootDir, tarballPath, nil +} + +func buildReleaseID(now time.Time) string { + return now.UTC().Format("20060102-150405") +} diff --git a/internal/control/bootstrap_test.go b/internal/control/bootstrap_test.go new file mode 100644 index 0000000..70e5ccb --- /dev/null +++ b/internal/control/bootstrap_test.go @@ -0,0 +1,58 @@ +package control + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRenderBootstrapScript(t *testing.T) { + t.Parallel() + + script := RenderBootstrapPrepareScript() + script += RenderBootstrapFinalizeScript(Node{ + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "key", + }, + }, "20260401-123000", "/tmp/vpnem-node-nl-01.tar.gz") + + if !strings.Contains(script, "mkdir -p /opt/vpnem-node/releases") { + t.Fatal("expected remote workdir creation") + } + if !strings.Contains(script, "vpnem-node release 20260401-123000 ready for nl-01") { + t.Fatal("expected release finalize message") + } +} + +func TestSaveNodeState(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := SaveNodeState(dir, NodeState{ + NodeID: "nl-01", + BootstrapStatus: "ready", + Services: []ServiceStatus{ + {Type: "vless", Status: "configured", Port: 443}, + }, + }) + if err != nil { + t.Fatalf("SaveNodeState error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "nl-01.json")) + if err != nil { + t.Fatalf("ReadFile error = %v", err) + } + if !strings.Contains(string(data), `"bootstrap_status": "ready"`) { + t.Fatal("expected bootstrap_status in state file") + } +} diff --git a/internal/control/catalog.go b/internal/control/catalog.go new file mode 100644 index 0000000..9ef3c35 --- /dev/null +++ b/internal/control/catalog.go @@ -0,0 +1,229 @@ +package control + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "vpnem/internal/models" +) + +func BuildLegacyCatalog(nodes []Node) (*models.ServersResponse, error) { + servers := make([]models.Server, 0) + + for _, node := range nodes { + if !node.Enabled { + continue + } + + publicHost := node.Host + if strings.TrimSpace(node.Domain) != "" { + publicHost = node.Domain + } + + for _, protocol := range node.Protocols { + if !protocol.Enabled { + continue + } + if err := ensureRealityProfile(&protocol); err != nil { + return nil, err + } + + server, err := legacyServerFromNode(node, publicHost, protocol) + if err != nil { + return nil, err + } + servers = append(servers, server) + } + } + + sort.Slice(servers, func(i, j int) bool { + return servers[i].Tag < servers[j].Tag + }) + + return &models.ServersResponse{Servers: servers}, nil +} + +func WriteLegacyCatalog(path string, nodes []Node) error { + resp, err := BuildLegacyCatalog(nodes) + if err != nil { + return err + } + staticResp, err := LoadStaticLegacyCatalog(filepath.Join(filepath.Dir(path), "static-servers.json")) + if err != nil { + return err + } + resp.Servers = MergeLegacyServers(staticResp.Servers, resp.Servers) + + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func LoadStaticLegacyCatalog(path string) (*models.ServersResponse, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &models.ServersResponse{Servers: nil}, nil + } + return nil, err + } + + var resp models.ServersResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func MergeLegacyServers(primary, secondary []models.Server) []models.Server { + merged := make([]models.Server, 0, len(primary)+len(secondary)) + seen := make(map[string]struct{}, len(primary)+len(secondary)) + for _, item := range primary { + if _, ok := seen[item.Tag]; ok { + continue + } + seen[item.Tag] = struct{}{} + merged = append(merged, item) + } + for _, item := range secondary { + if _, ok := seen[item.Tag]; ok { + continue + } + seen[item.Tag] = struct{}{} + merged = append(merged, item) + } + sort.Slice(merged, func(i, j int) bool { + return merged[i].Tag < merged[j].Tag + }) + return merged +} + +func legacyServerFromNode(node Node, publicHost string, protocol ProtocolProfile) (models.Server, error) { + switch protocol.Type { + case "socks", "socks5": + return models.Server{ + Tag: node.ID + "-socks5", + Region: node.Region, + Type: "socks", + Server: publicHost, + ServerPort: protocol.Port, + }, nil + case "vless": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return models.Server{}, fmt.Errorf("node %s protocol vless requires auth.uuid", node.ID) + } + server := models.Server{ + Tag: node.ID + "-vless", + Region: node.Region, + Type: "vless", + Server: publicHost, + ServerPort: protocol.Port, + UUID: protocol.Auth.UUID, + } + if protocol.TLS != nil { + server.TLS = &models.TLS{ + Enabled: protocol.TLS.Enabled, + ServerName: protocol.TLS.ServerName, + Insecure: false, + } + } + if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" { + server.Transport = &models.Transport{ + Type: transportType, + Path: stringFromExtra(protocol.Extra, "path"), + } + } + return server, nil + case "vless-reality": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return models.Server{}, fmt.Errorf("node %s protocol vless-reality requires auth.uuid", node.ID) + } + if protocol.Reality == nil { + return models.Server{}, fmt.Errorf("node %s protocol vless-reality requires reality settings", node.ID) + } + server := models.Server{ + Tag: node.ID + "-vless-reality", + Region: node.Region, + Type: "vless-reality", + Server: publicHost, + ServerPort: protocol.Port, + UUID: protocol.Auth.UUID, + TLS: &models.TLS{ + Enabled: true, + ServerName: protocol.Reality.ServerName, + Reality: &models.Reality{ + Enabled: true, + PublicKey: protocol.Reality.PublicKey, + ShortID: protocol.Reality.ShortID, + Fingerprint: protocol.Reality.Fingerprint, + }, + }, + } + return server, nil + case "shadowsocks": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" { + return models.Server{}, fmt.Errorf("node %s protocol shadowsocks requires auth.method and auth.password", node.ID) + } + return models.Server{ + Tag: node.ID + "-shadowsocks", + Region: node.Region, + Type: "shadowsocks", + Server: publicHost, + ServerPort: protocol.Port, + Method: protocol.Auth.Method, + Password: protocol.Auth.Password, + }, nil + case "hysteria2": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" { + return models.Server{}, fmt.Errorf("node %s protocol hysteria2 requires auth.password", node.ID) + } + server := models.Server{ + Tag: node.ID + "-hysteria2", + Region: node.Region, + Type: "hysteria2", + Server: publicHost, + ServerPort: protocol.Port, + Password: protocol.Auth.Password, + ObfsPassword: stringFromExtra(protocol.Extra, "obfs_password"), + UpMbps: intFromExtra(protocol.Extra, "up_mbps", 0), + DownMbps: intFromExtra(protocol.Extra, "down_mbps", 0), + TLS: &models.TLS{ + Enabled: true, + Insecure: true, + ServerName: "", + ALPN: []string{defaultHysteria2ALPN}, + MinVersion: "1.3", + MaxVersion: "1.3", + }, + } + if protocol.TLS != nil && protocol.TLS.ServerName != "" { + server.TLS.ServerName = protocol.TLS.ServerName + } + return server, nil + default: + return models.Server{}, fmt.Errorf("node %s uses unsupported legacy protocol %q", node.ID, protocol.Type) + } +} + +func stringFromExtra(extra map[string]any, key string) string { + if extra == nil { + return "" + } + value, _ := extra[key].(string) + return value +} diff --git a/internal/control/catalog_test.go b/internal/control/catalog_test.go new file mode 100644 index 0000000..facaaf7 --- /dev/null +++ b/internal/control/catalog_test.go @@ -0,0 +1,332 @@ +package control + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "vpnem/internal/models" +) + +func TestBuildLegacyCatalog(t *testing.T) { + t.Parallel() + + nodes := []Node{ + { + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "key", + }, + Protocols: []ProtocolProfile{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &TLSProfile{ + Enabled: true, + ServerName: "nl-01.example.com", + }, + Auth: &AuthProfile{ + UUID: "11111111-1111-1111-1111-111111111111", + }, + Extra: map[string]any{ + "transport_type": "ws", + "path": "/ws", + }, + }, + { + Type: "shadowsocks", + Enabled: true, + Port: 8443, + Auth: &AuthProfile{ + Method: "2022-blake3-aes-128-gcm", + Password: "secret", + }, + }, + }, + }, + } + + resp, err := BuildLegacyCatalog(nodes) + if err != nil { + t.Fatalf("BuildLegacyCatalog error = %v", err) + } + if len(resp.Servers) != 2 { + t.Fatalf("len(resp.Servers) = %d, want 2", len(resp.Servers)) + } + if resp.Servers[0].Tag != "nl-01-shadowsocks" { + t.Fatalf("unexpected first tag %q", resp.Servers[0].Tag) + } + if resp.Servers[1].Tag != "nl-01-vless" { + t.Fatalf("unexpected second tag %q", resp.Servers[1].Tag) + } + if resp.Servers[1].Transport == nil || resp.Servers[1].Transport.Type != "ws" { + t.Fatalf("expected ws transport, got %+v", resp.Servers[1].Transport) + } +} + +func TestBuildLegacyCatalogRejectsUnsupportedProtocol(t *testing.T) { + t.Parallel() + + _, err := BuildLegacyCatalog([]Node{ + { + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + Enabled: true, + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "key", + }, + Protocols: []ProtocolProfile{ + {Type: "hysteria2", Enabled: true, Port: 443}, + }, + }, + }) + if err == nil { + t.Fatal("expected unsupported protocol error") + } +} + +func TestPublishableNodes(t *testing.T) { + t.Parallel() + + nodes := []Node{ + {ID: "healthy", Name: "healthy", Region: "nl", Host: "1.1.1.1", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}}, + {ID: "failed", Name: "failed", Region: "nl", Host: "1.1.1.2", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}}, + {ID: "nostate", Name: "nostate", Region: "nl", Host: "1.1.1.3", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}}, + } + states := map[string]*NodeState{ + "healthy": {NodeID: "healthy", BootstrapStatus: "healthy"}, + "failed": {NodeID: "failed", BootstrapStatus: "failed"}, + } + + got := PublishableNodes(nodes, states) + if len(got) != 1 { + t.Fatalf("len(PublishableNodes) = %d, want 1", len(got)) + } + if got[0].ID != "healthy" { + t.Fatalf("expected healthy node, got %s", got[0].ID) + } +} + +func TestPublishableNodesRequiresRunningServicesWhenKnown(t *testing.T) { + t.Parallel() + + nodes := []Node{ + {ID: "healthy", Name: "healthy", Region: "nl", Host: "1.1.1.1", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}}, + {ID: "degraded", Name: "degraded", Region: "nl", Host: "1.1.1.2", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}}, + } + states := map[string]*NodeState{ + "healthy": { + NodeID: "healthy", + BootstrapStatus: "healthy", + Services: []ServiceStatus{{Type: "socks5", Status: "running", Port: 1080}}, + Metadata: map[string]any{"healthz_http_code": 200}, + }, + "degraded": { + NodeID: "degraded", + BootstrapStatus: "healthy", + Services: []ServiceStatus{{Type: "socks5", Status: "unknown", Port: 1080}}, + Metadata: map[string]any{"healthz_http_code": 503}, + }, + } + + got := PublishableNodes(nodes, states) + if len(got) != 1 { + t.Fatalf("len(PublishableNodes) = %d, want 1", len(got)) + } + if got[0].ID != "healthy" { + t.Fatalf("expected healthy node, got %s", got[0].ID) + } +} + +func TestPublishDecisionForNode(t *testing.T) { + t.Parallel() + + node := Node{ + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, + Protocols: []ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443}, + }, + } + + blocked := PublishDecisionForNode(node, &NodeState{ + NodeID: "nl-01", + BootstrapStatus: "healthy", + Services: []ServiceStatus{{Type: "vless", Status: "configured", Port: 443}}, + Metadata: map[string]any{"healthz_http_code": 503}, + }) + if blocked.Eligible { + t.Fatal("expected blocked publish decision") + } + if len(blocked.Reasons) == 0 { + t.Fatal("expected reasons for blocked decision") + } + + ready := PublishDecisionForNode(node, &NodeState{ + NodeID: "nl-01", + BootstrapStatus: "healthy", + PublicHost: "nl-01.example.com", + Services: []ServiceStatus{{Type: "vless", Status: "running", Port: 443}}, + Metadata: map[string]any{"healthz_http_code": 200}, + }) + if !ready.Eligible { + t.Fatalf("expected ready decision, got reasons: %v", ready.Reasons) + } + if ready.PublicHost != "nl-01.example.com" { + t.Fatalf("unexpected public host %q", ready.PublicHost) + } +} + +func TestBuildCatalogV2(t *testing.T) { + t.Parallel() + + nodes := []Node{ + { + ID: "nl-01", + Name: "NL 01", + Provider: "custom-vps", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, + Protocols: []ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}}, + {Type: "hysteria2", Enabled: true, Port: 9443, Auth: &AuthProfile{Password: "hidden"}, Extra: map[string]any{"obfs_password": "masked"}}, + }, + }, + } + states := map[string]*NodeState{ + "nl-01": {NodeID: "nl-01", BootstrapStatus: "healthy", PublicHost: "nl-01.example.com", Metadata: map[string]any{"healthz_http_code": 200}}, + } + + catalog := BuildCatalogV2(nodes, states) + if catalog.Version != "2" { + t.Fatalf("catalog.Version = %q, want 2", catalog.Version) + } + if len(catalog.Nodes) != 1 { + t.Fatalf("len(catalog.Nodes) = %d, want 1", len(catalog.Nodes)) + } + if catalog.Nodes[0].PublicHost != "nl-01.example.com" { + t.Fatalf("unexpected public host %q", catalog.Nodes[0].PublicHost) + } + if len(catalog.Nodes[0].Protocols) != 2 { + t.Fatalf("expected 2 protocols, got %d", len(catalog.Nodes[0].Protocols)) + } + if catalog.Nodes[0].Protocols[0].Type != "vless" { + t.Fatalf("unexpected first protocol %q", catalog.Nodes[0].Protocols[0].Type) + } +} + +func TestMergeLegacyServersPreservesStaticEntries(t *testing.T) { + t.Parallel() + + static := []models.Server{ + {Tag: "nl-1", Type: "socks", Server: "1.1.1.1", ServerPort: 1080}, + {Tag: "nl-ss-1", Type: "shadowsocks", Server: "ss.example.com", ServerPort: 443}, + } + dynamic := []models.Server{ + {Tag: "node-1-vless", Type: "vless", Server: "2.2.2.2", ServerPort: 443}, + } + + merged := MergeLegacyServers(static, dynamic) + if len(merged) != 3 { + t.Fatalf("len(merged) = %d, want 3", len(merged)) + } +} + +func TestWriteLegacyCatalogMergesStaticServers(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + staticPath := filepath.Join(dir, "static-servers.json") + if err := os.WriteFile(staticPath, []byte(`{"servers":[{"tag":"nl-1","region":"NL","type":"socks","server":"1.1.1.1","server_port":1080}]}`), 0o644); err != nil { + t.Fatalf("write static servers: %v", err) + } + + err := WriteLegacyCatalog(filepath.Join(dir, "servers.json"), []Node{ + { + ID: "node-1", + Name: "Node 1", + Region: "nl", + Host: "2.2.2.2", + Enabled: true, + SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, + Protocols: []ProtocolProfile{ + {Type: "socks5", Enabled: true, Port: 1081}, + }, + }, + }) + if err != nil { + t.Fatalf("WriteLegacyCatalog error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "servers.json")) + if err != nil { + t.Fatalf("read merged servers: %v", err) + } + text := string(data) + if !strings.Contains(text, `"tag": "nl-1"`) { + t.Fatalf("expected static server in merged catalog: %s", text) + } + if !strings.Contains(text, `"tag": "node-1-socks5"`) { + t.Fatalf("expected dynamic server in merged catalog: %s", text) + } +} + +func TestWriteCatalogV2DoesNotMergeStaticLegacyServers(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + staticPath := filepath.Join(dir, "static-servers.json") + if err := os.WriteFile(staticPath, []byte(`{"servers":[{"tag":"nl-ss-1","region":"NL","type":"shadowsocks","server":"ss.example.com","server_port":443,"method":"chacha20-ietf-poly1305","password":"secret"}]}`), 0o644); err != nil { + t.Fatalf("write static servers: %v", err) + } + + err := WriteCatalogV2(filepath.Join(dir, "catalog-v2.json"), []Node{ + { + ID: "node-1", + Name: "Node 1", + Region: "nl", + Host: "2.2.2.2", + Enabled: true, + SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, + Protocols: []ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}}, + }, + }, + }, map[string]*NodeState{}) + if err != nil { + t.Fatalf("WriteCatalogV2 error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "catalog-v2.json")) + if err != nil { + t.Fatalf("read catalog v2: %v", err) + } + text := string(data) + if strings.Contains(text, `"id": "nl-ss-1"`) { + t.Fatalf("did not expect static legacy node in catalog v2: %s", text) + } + if !strings.Contains(text, `"id": "node-1"`) { + t.Fatalf("expected dynamic node in catalog v2: %s", text) + } +} diff --git a/internal/control/dns.go b/internal/control/dns.go new file mode 100644 index 0000000..f841d91 --- /dev/null +++ b/internal/control/dns.go @@ -0,0 +1,163 @@ +package control + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +const porkbunAPIHost = "https://api.porkbun.com/api/json/v3" + +var porkbunAPIHostOverride string + +type DNSProvider interface { + EnsureRandomARecord(ctx context.Context, zone, prefix, ip string, ttl int) (string, error) + DeleteARecord(ctx context.Context, zone, name string) error +} + +type PorkbunClient struct { + APIKey string + SecretAPIKey string + HTTPClient *http.Client +} + +type porkbunResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Records []map[string]any `json:"records"` + ID string `json:"id"` +} + +func (c PorkbunClient) EnsureRandomARecord(ctx context.Context, zone, prefix, ip string, ttl int) (string, error) { + if err := c.validate(); err != nil { + return "", err + } + if ttl == 0 { + ttl = 600 + } + + for range 10 { + name := randomSubdomain(prefix) + records, err := c.retrieveRecordsByNameType(ctx, zone, "A", name) + if err != nil { + return "", err + } + if len(records) > 0 { + continue + } + if err := c.createRecord(ctx, zone, name, "A", ip, ttl); err != nil { + return "", err + } + return name + "." + zone, nil + } + + return "", errors.New("failed to allocate unique subdomain") +} + +func (c PorkbunClient) DeleteARecord(ctx context.Context, zone, name string) error { + if err := c.validate(); err != nil { + return err + } + return c.deleteByNameType(ctx, zone, "A", name) +} + +func (c PorkbunClient) validate() error { + if strings.TrimSpace(c.APIKey) == "" || strings.TrimSpace(c.SecretAPIKey) == "" { + return errors.New("porkbun api keys are not configured") + } + return nil +} + +func (c PorkbunClient) createRecord(ctx context.Context, zone, name, recordType, content string, ttl int) error { + payload := map[string]string{ + "secretapikey": c.SecretAPIKey, + "apikey": c.APIKey, + "name": name, + "type": recordType, + "content": content, + "ttl": fmt.Sprintf("%d", ttl), + } + _, err := c.post(ctx, "/dns/create/"+zone, payload) + return err +} + +func (c PorkbunClient) deleteByNameType(ctx context.Context, zone, recordType, name string) error { + payload := map[string]string{ + "secretapikey": c.SecretAPIKey, + "apikey": c.APIKey, + } + _, err := c.post(ctx, "/dns/deleteByNameType/"+zone+"/"+recordType+"/"+name, payload) + return err +} + +func (c PorkbunClient) retrieveRecordsByNameType(ctx context.Context, zone, recordType, name string) ([]map[string]any, error) { + payload := map[string]string{ + "secretapikey": c.SecretAPIKey, + "apikey": c.APIKey, + } + resp, err := c.post(ctx, "/dns/retrieveByNameType/"+zone+"/"+recordType+"/"+name, payload) + if err != nil { + return nil, err + } + return resp.Records, nil +} + +func (c PorkbunClient) post(ctx context.Context, path string, payload map[string]string) (*porkbunResponse, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + client := c.HTTPClient + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + + baseURL := porkbunAPIHost + if porkbunAPIHostOverride != "" { + baseURL = porkbunAPIHostOverride + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var out porkbunResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("porkbun http %d: %s", resp.StatusCode, out.Message) + } + if strings.ToUpper(out.Status) != "SUCCESS" { + return nil, fmt.Errorf("porkbun api error: %s", out.Message) + } + return &out, nil +} + +func randomSubdomain(prefix string) string { + if prefix == "" { + prefix = "vpn" + } + var buf [4]byte + if _, err := rand.Read(buf[:]); err != nil { + now := time.Now().UTC().UnixNano() + return fmt.Sprintf("%s-%x", prefix, now) + } + return prefix + "-" + hex.EncodeToString(buf[:]) +} diff --git a/internal/control/dns_test.go b/internal/control/dns_test.go new file mode 100644 index 0000000..cf44639 --- /dev/null +++ b/internal/control/dns_test.go @@ -0,0 +1,58 @@ +package control + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestPorkbunEnsureRandomARecord(t *testing.T) { + t.Parallel() + + retrieveCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + recorder := map[string]any{} + switch { + case strings.Contains(r.URL.Path, "/dns/retrieveByNameType/"): + retrieveCalls++ + recorder = map[string]any{ + "status": "SUCCESS", + "records": []map[string]any{}, + } + case strings.Contains(r.URL.Path, "/dns/create/"): + recorder = map[string]any{ + "status": "SUCCESS", + "id": "123", + } + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + body, _ := json.Marshal(recorder) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(body))), + }, nil + })} + + clientAPI := PorkbunClient{APIKey: "a", SecretAPIKey: "b", HTTPClient: client} + name, err := clientAPI.EnsureRandomARecord(context.Background(), "em-sysadmin.xyz", "vpn", "203.0.113.10", 600) + if err != nil { + t.Fatalf("EnsureRandomARecord error = %v", err) + } + if !strings.HasSuffix(name, ".em-sysadmin.xyz") { + t.Fatalf("expected fqdn suffix, got %q", name) + } + if retrieveCalls == 0 { + t.Fatal("expected retrieve call") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} diff --git a/internal/control/health_test.go b/internal/control/health_test.go new file mode 100644 index 0000000..a0f488f --- /dev/null +++ b/internal/control/health_test.go @@ -0,0 +1,49 @@ +package control + +import "testing" + +func TestParseHealthCheckOutput(t *testing.T) { + t.Parallel() + + stdout := `{"Service":"sing-box","State":"running"} +{"Service":"caddy","State":"running"} +HEALTHZ_HTTP_CODE=200 +` + services, metadata := parseHealthCheckOutput(stdout, []ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443}, + {Type: "vmess", Enabled: true, Port: 443}, + {Type: "shadowsocks", Enabled: true, Port: 8443}, + }) + + if len(services) != 3 { + t.Fatalf("len(services) = %d, want 3", len(services)) + } + if metadata["healthz_http_code"] != 200 { + t.Fatalf("healthz_http_code = %v, want 200", metadata["healthz_http_code"]) + } + if services[0].Status != "running" && services[1].Status != "running" && services[2].Status != "running" { + t.Fatal("expected at least one service marked running") + } +} + +func TestParseHealthCheckOutputDockerPSFallback(t *testing.T) { + t.Parallel() + + stdout := `{"Names":"current_sing-box_1","Labels":"com.docker.compose.service=sing-box,com.docker.compose.project=current","State":"running","Status":"Up 52 seconds"} +HY2_MIXED_PORT=5.180.97.199 +` + services, _ := parseHealthCheckOutput(stdout, []ProtocolProfile{ + {Type: "vless-reality", Enabled: true, Port: 443}, + {Type: "hysteria2", Enabled: true, Port: 443}, + }) + + if len(services) != 2 { + t.Fatalf("len(services) = %d, want 2", len(services)) + } + if services[0].Status != "running" { + t.Fatalf("vless-reality status = %q, want running", services[0].Status) + } + if services[1].Status != "running" { + t.Fatalf("hysteria2 status = %q, want running", services[1].Status) + } +} diff --git a/internal/control/hysteria2.go b/internal/control/hysteria2.go new file mode 100644 index 0000000..78c80e7 --- /dev/null +++ b/internal/control/hysteria2.go @@ -0,0 +1,179 @@ +package control + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "strings" + "time" +) + +const ( + defaultHysteria2Port = 443 + defaultHysteria2UpMbps = 100 + defaultHysteria2DownMbps = 100 + defaultHysteria2CertPath = "/etc/sing-box/cert.pem" + defaultHysteria2KeyPath = "/etc/sing-box/key.pem" + defaultHysteria2ALPN = "h3" +) + +func ensureHysteria2Profile(protocol *ProtocolProfile) error { + if protocol == nil || protocol.Type != "hysteria2" { + return nil + } + if protocol.Hysteria2 == nil { + protocol.Hysteria2 = &Hysteria2Profile{} + } + + if protocol.Port > 0 && protocol.Hysteria2.Port == 0 { + protocol.Hysteria2.Port = protocol.Port + } + if protocol.Hysteria2.Port == 0 { + protocol.Hysteria2.Port = defaultHysteria2Port + } + protocol.Port = protocol.Hysteria2.Port + + if protocol.Auth == nil { + protocol.Auth = &AuthProfile{} + } + if protocol.Hysteria2.UserPassword == "" && strings.TrimSpace(protocol.Auth.Password) != "" { + protocol.Hysteria2.UserPassword = strings.TrimSpace(protocol.Auth.Password) + } + if protocol.Hysteria2.UserPassword == "" { + password, err := randomBase64(16) + if err != nil { + return err + } + protocol.Hysteria2.UserPassword = password + } + protocol.Auth.Password = protocol.Hysteria2.UserPassword + + if protocol.Extra == nil { + protocol.Extra = map[string]any{} + } + if protocol.Hysteria2.ObfsPassword == "" { + if extra := stringFromExtra(protocol.Extra, "obfs_password"); extra != "" { + protocol.Hysteria2.ObfsPassword = extra + } + } + if protocol.Hysteria2.ObfsPassword == "" { + obfsPassword, err := randomHex(32) + if err != nil { + return err + } + protocol.Hysteria2.ObfsPassword = obfsPassword + } + protocol.Extra["obfs_password"] = protocol.Hysteria2.ObfsPassword + + if protocol.Hysteria2.UpMbps == 0 { + protocol.Hysteria2.UpMbps = intFromExtra(protocol.Extra, "up_mbps", defaultHysteria2UpMbps) + } + if protocol.Hysteria2.DownMbps == 0 { + protocol.Hysteria2.DownMbps = intFromExtra(protocol.Extra, "down_mbps", defaultHysteria2DownMbps) + } + protocol.Extra["up_mbps"] = protocol.Hysteria2.UpMbps + protocol.Extra["down_mbps"] = protocol.Hysteria2.DownMbps + + if protocol.Hysteria2.CertPath == "" { + protocol.Hysteria2.CertPath = firstNonEmptyString(stringFromExtra(protocol.Extra, "tls_cert_path"), defaultHysteria2CertPath) + } + if protocol.Hysteria2.KeyPath == "" { + protocol.Hysteria2.KeyPath = firstNonEmptyString(stringFromExtra(protocol.Extra, "tls_key_path"), defaultHysteria2KeyPath) + } + protocol.Extra["tls_cert_path"] = protocol.Hysteria2.CertPath + protocol.Extra["tls_key_path"] = protocol.Hysteria2.KeyPath + + if protocol.TLS == nil { + protocol.TLS = &TLSProfile{} + } + protocol.TLS.Enabled = true + if strings.TrimSpace(protocol.TLS.ServerName) == "" { + protocol.TLS.ServerName = "" + } + return nil +} + +func GenerateSelfSignedCert() (certPEM, keyPEM []byte, err error) { + commonName := "node-" + randomHostnameSuffix() + ".local" + return generateSelfSignedCertForHost(commonName) +} + +func generateSelfSignedCertForHost(commonName string) (certPEM, keyPEM []byte, err error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate ecdsa key: %w", err) + } + serialLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialLimit) + if err != nil { + return nil, nil, fmt.Errorf("generate serial: %w", err) + } + + template := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: commonName, + }, + NotBefore: time.Now().UTC().Add(-time.Hour), + NotAfter: time.Now().UTC().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{commonName}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, nil, fmt.Errorf("create certificate: %w", err) + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return nil, nil, fmt.Errorf("marshal private key: %w", err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return certPEM, keyPEM, nil +} + +func randomBase64(size int) (string, error) { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(buf), nil +} + +func randomHostnameSuffix() string { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return "local" + } + return hex.EncodeToString(buf) +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func EnsureProtocolForUI(protocol *ProtocolProfile) error { + if err := ensureRealityProfile(protocol); err != nil { + return err + } + if err := ensureHysteria2Profile(protocol); err != nil { + return err + } + return nil +} diff --git a/internal/control/inventory.go b/internal/control/inventory.go new file mode 100644 index 0000000..22d9179 --- /dev/null +++ b/internal/control/inventory.go @@ -0,0 +1,225 @@ +package control + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type Inventory struct { + Nodes []Node +} + +func LoadInventoryDir(dir string) (*Inventory, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + nodes := make([]Node, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if ext != ".yaml" && ext != ".yml" { + continue + } + + node, err := LoadNodeFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, err + } + nodes = append(nodes, *node) + } + + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].ID < nodes[j].ID + }) + + return &Inventory{Nodes: nodes}, nil +} + +func LoadNodeFile(path string) (*Node, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var node Node + if err := yaml.Unmarshal(data, &node); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + for idx := range node.Protocols { + if err := ensureRealityProfile(&node.Protocols[idx]); err != nil { + return nil, fmt.Errorf("prepare %s: %w", path, err) + } + if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil { + return nil, fmt.Errorf("prepare %s: %w", path, err) + } + } + if err := ValidateNode(node); err != nil { + return nil, fmt.Errorf("validate %s: %w", path, err) + } + + return &node, nil +} + +func SaveNodeFile(dir string, node Node) (string, error) { + for idx := range node.Protocols { + if err := ensureRealityProfile(&node.Protocols[idx]); err != nil { + return "", err + } + if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil { + return "", err + } + } + if err := ValidateNode(node); err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + + path := filepath.Join(dir, node.ID+".yaml") + data, err := yaml.Marshal(node) + if err != nil { + return "", err + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return "", err + } + + return path, nil +} + +func (i *Inventory) NodeByID(id string) (*Node, bool) { + for idx := range i.Nodes { + if i.Nodes[idx].ID == id { + return &i.Nodes[idx], true + } + } + return nil, false +} + +func ValidateNode(node Node) error { + if strings.TrimSpace(node.ID) == "" { + return errors.New("id is required") + } + if strings.TrimSpace(node.Name) == "" { + return errors.New("name is required") + } + if strings.TrimSpace(node.Region) == "" { + return errors.New("region is required") + } + if strings.TrimSpace(node.Host) == "" { + return errors.New("host is required") + } + if node.SSH.Port < 0 || node.SSH.Port > 65535 { + return errors.New("ssh.port must be between 0 and 65535") + } + if node.SSH.Port == 0 { + node.SSH.Port = 22 + } + if strings.TrimSpace(node.SSH.User) == "" { + return errors.New("ssh.user is required") + } + if strings.TrimSpace(node.SSH.Auth) == "" { + return errors.New("ssh.auth is required") + } + switch strings.TrimSpace(node.SSH.Auth) { + case "key": + if strings.TrimSpace(node.SSH.IdentityFile) == "" { + return errors.New("ssh.identity_file is required when ssh.auth=key") + } + case "password": + if strings.TrimSpace(node.SSH.PasswordEnv) == "" { + return errors.New("ssh.password_env is required when ssh.auth=password") + } + default: + return errors.New("ssh.auth must be either key or password") + } + if len(node.Protocols) == 0 { + return errors.New("at least one protocol is required") + } + + seen := make(map[string]struct{}, len(node.Protocols)) + for _, protocol := range node.Protocols { + if strings.TrimSpace(protocol.Type) == "" { + return errors.New("protocol.type is required") + } + if protocol.Port <= 0 || protocol.Port > 65535 { + return fmt.Errorf("protocol %s has invalid port", protocol.Type) + } + if protocol.Type == "vless" && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) == "" { + return errors.New("vless with tls.enabled requires node.domain") + } + if protocol.Type == "vless-reality" { + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return errors.New("vless-reality requires auth.uuid") + } + if protocol.Reality == nil { + return errors.New("vless-reality requires reality settings") + } + if strings.TrimSpace(protocol.Reality.ServerName) == "" { + return errors.New("vless-reality requires reality.server_name") + } + if strings.TrimSpace(protocol.Reality.PrivateKey) == "" { + return errors.New("vless-reality requires reality.private_key") + } + if strings.TrimSpace(protocol.Reality.PublicKey) == "" { + return errors.New("vless-reality requires reality.public_key") + } + if strings.TrimSpace(protocol.Reality.ShortID) == "" { + return errors.New("vless-reality requires reality.short_id") + } + } + if protocol.Type == "vmess" && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) == "" { + return errors.New("vmess with tls.enabled requires node.domain") + } + if protocol.Type == "hysteria2" { + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" { + return errors.New("hysteria2 requires auth.password") + } + if protocol.Hysteria2 == nil { + return errors.New("hysteria2 requires hysteria2 settings") + } + if protocol.Hysteria2.CertPath == "" || protocol.Hysteria2.KeyPath == "" { + return errors.New("hysteria2 requires cert_path and key_path") + } + } + key := protocol.Type + if _, ok := seen[key]; ok { + return fmt.Errorf("duplicate protocol %s", protocol.Type) + } + seen[key] = struct{}{} + } + + return nil +} + +func CopyNodeFile(srcPath, inventoryDir string) (string, error) { + node, err := LoadNodeFile(srcPath) + if err != nil { + return "", err + } + return SaveNodeFile(inventoryDir, *node) +} + +func DeleteNodeFile(dir, nodeID string) error { + err := os.Remove(filepath.Join(dir, nodeID+".yaml")) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func IsNotExist(err error) bool { + return errors.Is(err, fs.ErrNotExist) +} diff --git a/internal/control/inventory_test.go b/internal/control/inventory_test.go new file mode 100644 index 0000000..eb03979 --- /dev/null +++ b/internal/control/inventory_test.go @@ -0,0 +1,50 @@ +package control + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadInventoryDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + input := `id: nl-01 +name: NL 01 +provider: custom-vps +region: nl +host: 203.0.113.10 +domain: nl-01.example.com +acme_email: admin@example.com +enabled: true +ssh: + user: root + port: 22 + auth: key + identity_file: ~/.ssh/id_ed25519 +protocols: + - type: vless + enabled: true + port: 443 + tls: + enabled: true + server_name: nl-01.example.com + auth: + uuid: 11111111-1111-1111-1111-111111111111 +` + if err := os.WriteFile(filepath.Join(dir, "nl-01.yaml"), []byte(input), 0o600); err != nil { + t.Fatal(err) + } + + inventory, err := LoadInventoryDir(dir) + if err != nil { + t.Fatalf("LoadInventoryDir error = %v", err) + } + if len(inventory.Nodes) != 1 { + t.Fatalf("len(inventory.Nodes) = %d, want 1", len(inventory.Nodes)) + } + if inventory.Nodes[0].ID != "nl-01" { + t.Fatalf("inventory.Nodes[0].ID = %q, want nl-01", inventory.Nodes[0].ID) + } +} diff --git a/internal/control/lifecycle.go b/internal/control/lifecycle.go new file mode 100644 index 0000000..a45339f --- /dev/null +++ b/internal/control/lifecycle.go @@ -0,0 +1,205 @@ +package control + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" +) + +func SetNodeEnabled(node Node, enabled bool) Node { + node.Enabled = enabled + return node +} + +func RotateNodeSecrets(node Node) (Node, error) { + for idx := range node.Protocols { + protocol := &node.Protocols[idx] + switch protocol.Type { + case "vless", "vmess": + if protocol.Auth == nil { + protocol.Auth = &AuthProfile{} + } + uuid, err := randomUUID() + if err != nil { + return node, err + } + protocol.Auth.UUID = uuid + case "shadowsocks": + if protocol.Auth == nil { + protocol.Auth = &AuthProfile{} + } + password, err := randomHex(16) + if err != nil { + return node, err + } + protocol.Auth.Password = password + case "hysteria2": + if err := ensureHysteria2Profile(protocol); err != nil { + return node, err + } + password, err := randomBase64(16) + if err != nil { + return node, err + } + protocol.Auth.Password = password + protocol.Hysteria2.UserPassword = password + obfsPassword, err := randomHex(32) + if err != nil { + return node, err + } + protocol.Hysteria2.ObfsPassword = obfsPassword + if protocol.Extra == nil { + protocol.Extra = map[string]any{} + } + protocol.Extra["obfs_password"] = obfsPassword + } + } + return node, nil +} + +func AddSocks5Protocol(node Node, port int) (Node, error) { + if port <= 0 { + port = 54101 + } + for _, protocol := range node.Protocols { + if protocol.Type == "socks5" || protocol.Type == "socks" { + return node, fmt.Errorf("node %s already has SOCKS5 enabled", node.ID) + } + } + node.Protocols = append(node.Protocols, ProtocolProfile{ + Type: "socks5", + Enabled: true, + Port: port, + }) + return node, nil +} + +func DestroyNode(ctx context.Context, runner SSHExecutor, dnsClient DNSProvider, zone string, node Node, inventoryDir, stateDir string) []string { + var warnings []string + + if dnsClient != nil && strings.TrimSpace(node.Domain) != "" && strings.HasSuffix(node.Domain, "."+zone) { + name := strings.TrimSuffix(node.Domain, "."+zone) + name = strings.TrimSuffix(name, ".") + if err := dnsClient.DeleteARecord(ctx, zone, name); err != nil { + warnings = append(warnings, "dns cleanup failed: "+err.Error()) + } + } + + if strings.TrimSpace(node.Host) != "" { + if _, err := runner.Run(ctx, node, RenderDestroyScript()); err != nil { + warnings = append(warnings, "remote cleanup failed: "+err.Error()) + } + } + + if err := DeleteNodeState(stateDir, node.ID); err != nil { + warnings = append(warnings, "state cleanup failed: "+err.Error()) + } + if err := DeleteNodeFile(inventoryDir, node.ID); err != nil { + warnings = append(warnings, "inventory cleanup failed: "+err.Error()) + } + + return warnings +} + +func UpgradeNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) { + if _, err := BootstrapNode(ctx, runner, node, BootstrapOptions{ + StateDir: stateDir, + DryRun: false, + }); err != nil { + return nil, err + } + + state, err := CheckNode(ctx, runner, node, stateDir) + if state != nil { + if state.Metadata == nil { + state.Metadata = map[string]any{} + } + state.Metadata["lifecycle_action"] = "upgrade" + _ = SaveNodeState(stateDir, *state) + } + return state, err +} + +func RepairReinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) { + return reinstallNode(ctx, runner, node, stateDir, "repair_reinstall") +} + +func CleanReinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) { + return reinstallNode(ctx, runner, node, stateDir, "clean_reinstall") +} + +func reinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir, action string) (*NodeState, error) { + cleanupWarning := "" + if strings.TrimSpace(node.Host) != "" { + if _, err := runner.Run(ctx, node, RenderDestroyScript()); err != nil { + cleanupWarning = err.Error() + } + } + + if _, err := BootstrapNode(ctx, runner, node, BootstrapOptions{ + StateDir: stateDir, + DryRun: false, + }); err != nil { + return nil, err + } + + state, err := CheckNode(ctx, runner, node, stateDir) + if state != nil { + if state.Metadata == nil { + state.Metadata = map[string]any{} + } + state.Metadata["lifecycle_action"] = action + if cleanupWarning != "" { + state.Metadata["cleanup_warning"] = cleanupWarning + } + _ = SaveNodeState(stateDir, *state) + } + return state, err +} + +func RenderDestroyScript() string { + return `set -eu +if [ -f /opt/vpnem-node/current/docker-compose.yml ]; then + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + docker compose -f /opt/vpnem-node/current/docker-compose.yml down -v || true + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose -f /opt/vpnem-node/current/docker-compose.yml down -v || true + fi +fi +rm -rf /opt/vpnem-node +printf 'vpnem-node removed\n' +` +} + +func randomHex(size int) (string, error) { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func randomUUID() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + buf[6] = (buf[6] & 0x0f) | 0x40 + buf[8] = (buf[8] & 0x3f) | 0x80 + hexID := hex.EncodeToString(buf) + return fmt.Sprintf("%s-%s-%s-%s-%s", + hexID[0:8], + hexID[8:12], + hexID[12:16], + hexID[16:20], + hexID[20:32], + ), nil +} + +func RandomHexForAPI(size int) (string, error) { return randomHex(size) } + +func RandomBase64ForAPI(size int) (string, error) { return randomBase64(size) } + +func RandomUUIDForAPI() (string, error) { return randomUUID() } diff --git a/internal/control/lifecycle_test.go b/internal/control/lifecycle_test.go new file mode 100644 index 0000000..2d9958c --- /dev/null +++ b/internal/control/lifecycle_test.go @@ -0,0 +1,149 @@ +package control + +import ( + "context" + "testing" +) + +func TestSetNodeEnabled(t *testing.T) { + t.Parallel() + + node := Node{ID: "nl-01", Enabled: true} + disabled := SetNodeEnabled(node, false) + if disabled.Enabled { + t.Fatal("expected node to be disabled") + } + if node.Enabled != true { + t.Fatal("expected original node to stay unchanged") + } +} + +func TestRotateNodeSecrets(t *testing.T) { + t.Parallel() + + node := Node{ + ID: "nl-01", + Protocols: []ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, Auth: &AuthProfile{UUID: "old-vless"}}, + {Type: "vmess", Enabled: true, Port: 8444, Auth: &AuthProfile{UUID: "old-vmess"}}, + {Type: "shadowsocks", Enabled: true, Port: 8443, Auth: &AuthProfile{Method: "2022-blake3-aes-128-gcm", Password: "old-ss"}}, + {Type: "hysteria2", Enabled: true, Port: 9443, Auth: &AuthProfile{Password: "old-hy2"}, Extra: map[string]any{"obfs_password": "old-obfs"}}, + }, + } + + rotated, err := RotateNodeSecrets(node) + if err != nil { + t.Fatalf("RotateNodeSecrets() error = %v", err) + } + + if rotated.Protocols[0].Auth.UUID == "old-vless" || rotated.Protocols[0].Auth.UUID == "" { + t.Fatal("expected rotated vless uuid") + } + if rotated.Protocols[1].Auth.UUID == "old-vmess" || rotated.Protocols[1].Auth.UUID == "" { + t.Fatal("expected rotated vmess uuid") + } + if rotated.Protocols[2].Auth.Password == "old-ss" || rotated.Protocols[2].Auth.Password == "" { + t.Fatal("expected rotated shadowsocks password") + } + if rotated.Protocols[3].Auth.Password == "old-hy2" || rotated.Protocols[3].Auth.Password == "" { + t.Fatal("expected rotated hysteria2 password") + } + if rotated.Protocols[3].Extra["obfs_password"] == "old-obfs" || rotated.Protocols[3].Extra["obfs_password"] == "" { + t.Fatal("expected rotated hysteria2 obfs password") + } +} + +func TestAddSocks5Protocol(t *testing.T) { + t.Parallel() + + node, err := AddSocks5Protocol(Node{ + ID: "nl-01", + Protocols: []ProtocolProfile{ + {Type: "vless-reality", Enabled: true, Port: 443}, + {Type: "hysteria2", Enabled: true, Port: 443}, + }, + }, 54101) + if err != nil { + t.Fatalf("AddSocks5Protocol() error = %v", err) + } + if len(node.Protocols) != 3 { + t.Fatalf("expected 3 protocols, got %d", len(node.Protocols)) + } + last := node.Protocols[len(node.Protocols)-1] + if last.Type != "socks5" || last.Port != 54101 || !last.Enabled { + t.Fatalf("unexpected socks5 protocol: %+v", last) + } +} + +func TestRepairReinstallNode(t *testing.T) { + t.Parallel() + + state, err := RepairReinstallNode(context.Background(), fakeRunner{}, Node{ + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, Extra: map[string]any{"path": "/ws"}}, + }, + }, t.TempDir()) + if err != nil { + t.Fatalf("RepairReinstallNode() error = %v", err) + } + if state == nil { + t.Fatal("expected state") + } + if state.BootstrapStatus != "healthy" { + t.Fatalf("BootstrapStatus = %q, want healthy", state.BootstrapStatus) + } + if got := state.Metadata["lifecycle_action"]; got != "repair_reinstall" { + t.Fatalf("lifecycle_action = %v, want repair_reinstall", got) + } +} + +func TestCleanReinstallNode(t *testing.T) { + t.Parallel() + + state, err := CleanReinstallNode(context.Background(), fakeRunner{}, Node{ + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, Extra: map[string]any{"path": "/ws"}}, + }, + }, t.TempDir()) + if err != nil { + t.Fatalf("CleanReinstallNode() error = %v", err) + } + if state == nil { + t.Fatal("expected state") + } + if state.BootstrapStatus != "healthy" { + t.Fatalf("BootstrapStatus = %q, want healthy", state.BootstrapStatus) + } + if got := state.Metadata["lifecycle_action"]; got != "clean_reinstall" { + t.Fatalf("lifecycle_action = %v, want clean_reinstall", got) + } +} + +func TestParsePreflightInspectOutput(t *testing.T) { + t.Parallel() + + data := ParsePreflightInspectOutput("OS_ID=ubuntu\nMANAGED=1\nTCP_443=0\n") + if data["OS_ID"] != "ubuntu" { + t.Fatalf("OS_ID = %q, want ubuntu", data["OS_ID"]) + } + if data["MANAGED"] != "1" { + t.Fatalf("MANAGED = %q, want 1", data["MANAGED"]) + } + if data["TCP_443"] != "0" { + t.Fatalf("TCP_443 = %q, want 0", data["TCP_443"]) + } +} diff --git a/internal/control/models.go b/internal/control/models.go new file mode 100644 index 0000000..bec8e89 --- /dev/null +++ b/internal/control/models.go @@ -0,0 +1,66 @@ +package control + +type Node struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` + Provider string `yaml:"provider" json:"provider"` + Region string `yaml:"region" json:"region"` + Host string `yaml:"host" json:"host"` + Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` + ACMEEmail string `yaml:"acme_email,omitempty" json:"acme_email,omitempty"` + Enabled bool `yaml:"enabled" json:"enabled"` + SSH SSHConfig `yaml:"ssh" json:"ssh"` + Protocols []ProtocolProfile `yaml:"protocols" json:"protocols"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` +} + +type SSHConfig struct { + User string `yaml:"user" json:"user"` + Port int `yaml:"port" json:"port"` + Auth string `yaml:"auth" json:"auth"` + IdentityFile string `yaml:"identity_file,omitempty" json:"identity_file,omitempty"` + PasswordEnv string `yaml:"password_env,omitempty" json:"password_env,omitempty"` + Password string `yaml:"-" json:"-"` +} + +type ProtocolProfile struct { + Type string `yaml:"type" json:"type"` + Enabled bool `yaml:"enabled" json:"enabled"` + Port int `yaml:"port" json:"port"` + TLS *TLSProfile `yaml:"tls,omitempty" json:"tls,omitempty"` + Auth *AuthProfile `yaml:"auth,omitempty" json:"auth,omitempty"` + Reality *VLESSRealityProfile `yaml:"reality,omitempty" json:"reality,omitempty"` + Hysteria2 *Hysteria2Profile `yaml:"hysteria2,omitempty" json:"hysteria2,omitempty"` + Extra map[string]any `yaml:"extra,omitempty" json:"extra,omitempty"` +} + +type TLSProfile struct { + Enabled bool `yaml:"enabled" json:"enabled"` + ServerName string `yaml:"server_name,omitempty" json:"server_name,omitempty"` +} + +type AuthProfile struct { + UUID string `yaml:"uuid,omitempty" json:"uuid,omitempty"` + Method string `yaml:"method,omitempty" json:"method,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` +} + +type VLESSRealityProfile struct { + ServerName string `yaml:"server_name" json:"server_name"` + ServerPort int `yaml:"server_port,omitempty" json:"server_port,omitempty"` + PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"` + PublicKey string `yaml:"public_key,omitempty" json:"public_key,omitempty"` + ShortID string `yaml:"short_id,omitempty" json:"short_id,omitempty"` + Fingerprint string `yaml:"fingerprint,omitempty" json:"fingerprint,omitempty"` +} + +type Hysteria2Profile struct { + Port int `yaml:"port,omitempty" json:"port,omitempty"` + UpMbps int `yaml:"up_mbps,omitempty" json:"up_mbps,omitempty"` + DownMbps int `yaml:"down_mbps,omitempty" json:"down_mbps,omitempty"` + ObfsPassword string `yaml:"obfs_password,omitempty" json:"obfs_password,omitempty"` + UserPassword string `yaml:"user_password,omitempty" json:"user_password,omitempty"` + CertPath string `yaml:"cert_path,omitempty" json:"cert_path,omitempty"` + KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"` +} diff --git a/internal/control/preflight.go b/internal/control/preflight.go new file mode 100644 index 0000000..44db7d0 --- /dev/null +++ b/internal/control/preflight.go @@ -0,0 +1,68 @@ +package control + +import "strings" + +func RenderPreflightInspectScript() string { + return `set -eu +if [ -r /etc/os-release ]; then + . /etc/os-release +fi +printf 'OS_ID=%s\n' "${ID:-}" +printf 'OS_PRETTY=%s\n' "${PRETTY_NAME:-}" +printf 'OS_LIKE=%s\n' "${ID_LIKE:-}" +if [ -d /opt/vpnem-node/current ]; then + printf 'MANAGED=1\n' +else + printf 'MANAGED=0\n' +fi +if command -v docker >/dev/null 2>&1; then + printf 'DOCKER=1\n' +else + printf 'DOCKER=0\n' +fi +if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + printf 'COMPOSE=1\n' +elif command -v docker-compose >/dev/null 2>&1; then + printf 'COMPOSE=1\n' +else + printf 'COMPOSE=0\n' +fi +if command -v ss >/dev/null 2>&1; then + if ss -lnt 2>/dev/null | awk 'NR>1 {print $4}' | grep -Eq '(^|[:.])443$'; then + printf 'TCP_443=1\n' + else + printf 'TCP_443=0\n' + fi + if ss -lnu 2>/dev/null | awk 'NR>1 {print $4}' | grep -Eq '(^|[:.])443$'; then + printf 'UDP_443=1\n' + else + printf 'UDP_443=0\n' + fi + if ss -lnt 2>/dev/null | awk 'NR>1 {print $4}' | grep -Eq '(^|[:.])54101$'; then + printf 'TCP_54101=1\n' + else + printf 'TCP_54101=0\n' + fi +else + printf 'TCP_443=unknown\n' + printf 'UDP_443=unknown\n' + printf 'TCP_54101=unknown\n' +fi +` +} + +func ParsePreflightInspectOutput(stdout string) map[string]string { + values := map[string]string{} + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + values[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return values +} diff --git a/internal/control/publish.go b/internal/control/publish.go new file mode 100644 index 0000000..d05e98b --- /dev/null +++ b/internal/control/publish.go @@ -0,0 +1,321 @@ +package control + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + + "vpnem/internal/models" +) + +type PublishDecision struct { + NodeID string `json:"node_id"` + Eligible bool `json:"eligible"` + Reasons []string `json:"reasons,omitempty"` + PublicHost string `json:"public_host,omitempty"` + Status string `json:"status,omitempty"` +} + +func PublishableNodes(nodes []Node, states map[string]*NodeState) []Node { + filtered := make([]Node, 0, len(nodes)) + for _, node := range nodes { + if PublishDecisionForNode(node, states[node.ID]).Eligible { + filtered = append(filtered, node) + } + } + return filtered +} + +func NodeStateReadyForPublish(state NodeState) bool { + if state.BootstrapStatus != "healthy" && state.BootstrapStatus != "ready" { + return false + } + + if code, ok := state.Metadata["healthz_http_code"]; ok { + switch v := code.(type) { + case int: + if v != 200 { + return false + } + case float64: + if int(v) != 200 { + return false + } + } + } + + if len(state.Services) == 0 { + return true + } + for _, service := range state.Services { + if service.Status != "running" { + return false + } + } + return true +} + +func PublishDecisionForNode(node Node, state *NodeState) PublishDecision { + decision := PublishDecision{ + NodeID: node.ID, + Eligible: false, + PublicHost: publicHost(node), + } + + if !node.Enabled { + decision.Reasons = append(decision.Reasons, "узел выключен") + return decision + } + if state == nil { + decision.Reasons = append(decision.Reasons, "нет сохранённого состояния узла") + return decision + } + + decision.Status = state.BootstrapStatus + if state.PublicHost != "" { + decision.PublicHost = state.PublicHost + } + + if state.BootstrapStatus != "healthy" && state.BootstrapStatus != "ready" { + decision.Reasons = append(decision.Reasons, "статус bootstrap: "+state.BootstrapStatus) + return decision + } + + if code, ok := state.Metadata["healthz_http_code"]; ok { + switch v := code.(type) { + case int: + if v != 200 { + decision.Reasons = append(decision.Reasons, "healthz_http_code: "+itoa(v)) + } + case float64: + if int(v) != 200 { + decision.Reasons = append(decision.Reasons, "healthz_http_code: "+itoa(int(v))) + } + } + } + + for _, service := range state.Services { + if service.Status != "running" { + decision.Reasons = append(decision.Reasons, "сервис "+service.Type+" имеет статус "+service.Status) + } + } + + decision.Eligible = len(decision.Reasons) == 0 + return decision +} + +func PublishDecisions(nodes []Node, states map[string]*NodeState) map[string]PublishDecision { + decisions := make(map[string]PublishDecision, len(nodes)) + for _, node := range nodes { + decisions[node.ID] = PublishDecisionForNode(node, states[node.ID]) + } + return decisions +} + +func itoa(v int) string { return strconv.Itoa(v) } + +func BuildCatalogV2(nodes []Node, states map[string]*NodeState) *models.CatalogV2 { + result := &models.CatalogV2{ + Version: "2", + Nodes: make([]models.CatalogNode, 0, len(nodes)), + } + + for _, node := range nodes { + publicHost := node.Host + if state := states[node.ID]; state != nil && state.PublicHost != "" { + publicHost = state.PublicHost + } else if node.Domain != "" { + publicHost = node.Domain + } + + catalogNode := models.CatalogNode{ + ID: node.ID, + Name: node.Name, + Provider: node.Provider, + Region: node.Region, + Host: node.Host, + Domain: node.Domain, + PublicHost: publicHost, + Tags: node.Tags, + Metadata: map[string]any{}, + Protocols: make([]models.CatalogProtocol, 0, len(node.Protocols)), + } + if state := states[node.ID]; state != nil { + catalogNode.Status = state.BootstrapStatus + for k, v := range state.Metadata { + catalogNode.Metadata[k] = v + } + } + + for _, protocol := range node.Protocols { + if !protocol.Enabled { + continue + } + if err := ensureRealityProfile(&protocol); err != nil { + continue + } + item := models.CatalogProtocol{ + Type: protocol.Type, + Enabled: protocol.Enabled, + Port: protocol.Port, + Extra: protocol.Extra, + } + if protocol.TLS != nil { + item.TLS = &models.TLS{ + Enabled: protocol.TLS.Enabled, + ServerName: protocol.TLS.ServerName, + Insecure: false, + } + } + if protocol.Type == "vless-reality" && protocol.Reality != nil { + item.TLS = &models.TLS{ + Enabled: true, + ServerName: protocol.Reality.ServerName, + Reality: &models.Reality{ + Enabled: true, + PublicKey: protocol.Reality.PublicKey, + ShortID: protocol.Reality.ShortID, + Fingerprint: protocol.Reality.Fingerprint, + }, + } + } + if protocol.Type == "hysteria2" { + if item.TLS == nil { + item.TLS = &models.TLS{} + } + item.TLS.Enabled = true + item.TLS.Insecure = true + if len(item.TLS.ALPN) == 0 { + item.TLS.ALPN = []string{defaultHysteria2ALPN} + } + if item.TLS.MinVersion == "" { + item.TLS.MinVersion = "1.3" + } + if item.TLS.MaxVersion == "" { + item.TLS.MaxVersion = "1.3" + } + } + if protocol.Auth != nil { + item.Auth = &models.CatalogAuth{ + UUID: protocol.Auth.UUID, + Method: protocol.Auth.Method, + Password: protocol.Auth.Password, + } + } + catalogNode.Protocols = append(catalogNode.Protocols, item) + } + result.Nodes = append(result.Nodes, catalogNode) + } + + return result +} + +func WriteCatalogV2(path string, nodes []Node, states map[string]*NodeState) error { + catalog := BuildCatalogV2(nodes, states) + + data, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func StaticCatalogNodesFromLegacy(servers []models.Server) []models.CatalogNode { + nodes := make([]models.CatalogNode, 0, len(servers)) + for _, server := range servers { + node := models.CatalogNode{ + ID: server.Tag, + Name: server.Tag, + Region: server.Region, + Host: server.Server, + PublicHost: server.Server, + Status: "static", + Metadata: map[string]any{ + "static_legacy": true, + }, + Protocols: []models.CatalogProtocol{ + { + Type: server.Type, + Enabled: true, + Port: server.ServerPort, + Auth: &models.CatalogAuth{ + UUID: server.UUID, + Method: server.Method, + Password: server.Password, + }, + TLS: server.TLS, + Extra: map[string]any{}, + }, + }, + } + if server.UDPOverTCP { + node.Protocols[0].Extra["udp_over_tcp"] = true + } + if server.ObfsPassword != "" { + node.Protocols[0].Extra["obfs_password"] = server.ObfsPassword + } + if server.UpMbps > 0 { + node.Protocols[0].Extra["up_mbps"] = server.UpMbps + } + if server.DownMbps > 0 { + node.Protocols[0].Extra["down_mbps"] = server.DownMbps + } + if server.Transport != nil { + node.Protocols[0].Extra["transport_type"] = server.Transport.Type + if server.Transport.Path != "" { + node.Protocols[0].Extra["path"] = server.Transport.Path + } + } + nodes = append(nodes, node) + } + return nodes +} + +func MergeCatalogNodes(primary, secondary []models.CatalogNode) []models.CatalogNode { + merged := make([]models.CatalogNode, 0, len(primary)+len(secondary)) + seen := make(map[string]struct{}, len(primary)+len(secondary)) + for _, item := range primary { + if _, ok := seen[item.ID]; ok { + continue + } + seen[item.ID] = struct{}{} + merged = append(merged, item) + } + for _, item := range secondary { + if _, ok := seen[item.ID]; ok { + continue + } + seen[item.ID] = struct{}{} + merged = append(merged, item) + } + return merged +} + +func PublishLegacyCatalog(ctx context.Context, nodes []Node, targetPath string, remoteNode *Node) error { + if remoteNode == nil { + return WriteLegacyCatalog(targetPath, nodes) + } + + tmpDir, err := os.MkdirTemp("", "vpnem-publish-*") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + localPath := filepath.Join(tmpDir, "servers.json") + if err := WriteLegacyCatalog(localPath, nodes); err != nil { + return err + } + return CopyFileOverSCP(ctx, *remoteNode, localPath, targetPath) +} diff --git a/internal/control/reality.go b/internal/control/reality.go new file mode 100644 index 0000000..301a674 --- /dev/null +++ b/internal/control/reality.go @@ -0,0 +1,64 @@ +package control + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +const defaultRealityServerName = "www.nokia.com" + +func ensureRealityProfile(protocol *ProtocolProfile) error { + if protocol == nil || protocol.Type != "vless-reality" { + return nil + } + if protocol.Reality == nil { + protocol.Reality = &VLESSRealityProfile{} + } + if strings.TrimSpace(protocol.Reality.ServerName) == "" { + protocol.Reality.ServerName = defaultRealityServerName + } + if protocol.Reality.ServerPort == 0 { + protocol.Reality.ServerPort = 443 + } + if strings.TrimSpace(protocol.Reality.Fingerprint) == "" { + protocol.Reality.Fingerprint = "chrome" + } + if strings.TrimSpace(protocol.Reality.PrivateKey) == "" || strings.TrimSpace(protocol.Reality.PublicKey) == "" { + privateKey, publicKey, err := generateRealityKeyPair() + if err != nil { + return err + } + protocol.Reality.PrivateKey = privateKey + protocol.Reality.PublicKey = publicKey + } + if strings.TrimSpace(protocol.Reality.ShortID) == "" { + shortID, err := generateRealityShortID() + if err != nil { + return err + } + protocol.Reality.ShortID = shortID + } + return nil +} + +func generateRealityKeyPair() (privateKey string, publicKey string, err error) { + privateKeyPair, err := wgtypes.GeneratePrivateKey() + if err != nil { + return "", "", err + } + publicKeyPair := privateKeyPair.PublicKey() + return base64.RawURLEncoding.EncodeToString(privateKeyPair[:]), base64.RawURLEncoding.EncodeToString(publicKeyPair[:]), nil +} + +func generateRealityShortID() (string, error) { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate reality short id: %w", err) + } + return hex.EncodeToString(raw[:]), nil +} diff --git a/internal/control/runtime.go b/internal/control/runtime.go new file mode 100644 index 0000000..93138b6 --- /dev/null +++ b/internal/control/runtime.go @@ -0,0 +1,586 @@ +package control + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "vpnem/internal/config" +) + +type RuntimeBundleMeta struct { + ReleaseID string `json:"release_id"` + CreatedAt string `json:"created_at"` + NodeID string `json:"node_id"` +} + +func RenderRuntimeBundle(dir string, node Node, releaseID string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + for idx := range node.Protocols { + if err := ensureRealityProfile(&node.Protocols[idx]); err != nil { + return err + } + if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil { + return err + } + } + + meta := RuntimeBundleMeta{ + ReleaseID: releaseID, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + NodeID: node.ID, + } + + files := map[string][]byte{} + + nodeJSON, err := json.MarshalIndent(node, "", " ") + if err != nil { + return err + } + files["node.json"] = append(nodeJSON, '\n') + + metaJSON, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + files["bundle-meta.json"] = append(metaJSON, '\n') + + files["node.env"] = []byte(renderNodeEnv(node)) + files["docker-compose.yml"] = []byte(renderRuntimeCompose(node)) + files["README.md"] = []byte(renderRuntimeReadme(node)) + if hasHysteria2(node) { + certHost := hysteria2CertificateHost(node) + certPEM, keyPEM, err := generateSelfSignedCertForHost(certHost) + if err != nil { + return err + } + files["cert.pem"] = certPEM + files["key.pem"] = keyPEM + } + if config, ok, err := renderSingBoxServerConfig(node); err != nil { + return err + } else if ok { + files["sing-box.server.json"] = []byte(config) + if needsEdgeProxy(node) { + files["Caddyfile"] = []byte(renderCaddyfile(node)) + } + } + + for name, data := range files { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, data, 0o644); err != nil { + return err + } + } + + return nil +} + +func CreateTarGzFromDir(srcDir, outPath string) error { + outFile, err := os.Create(outPath) + if err != nil { + return err + } + defer outFile.Close() + + gzw := gzip.NewWriter(outFile) + defer gzw.Close() + + tw := tar.NewWriter(gzw) + defer tw.Close() + + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = filepath.ToSlash(relPath) + + if err := tw.WriteHeader(header); err != nil { + return err + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + _, err = tw.Write(data) + return err + }) +} + +func renderNodeEnv(node Node) string { + var b strings.Builder + writeEnv := func(key, value string) { + b.WriteString(key) + b.WriteString("=") + b.WriteString(sanitizeEnv(value)) + b.WriteString("\n") + } + + writeEnv("NODE_ID", node.ID) + writeEnv("NODE_NAME", node.Name) + writeEnv("NODE_PROVIDER", node.Provider) + writeEnv("NODE_REGION", node.Region) + writeEnv("NODE_HOST", node.Host) + writeEnv("NODE_DOMAIN", node.Domain) + writeEnv("NODE_ACME_EMAIL", node.ACMEEmail) + return b.String() +} + +func renderRuntimeCompose(node Node) string { + if needsSingBoxRuntime(node) { + return renderSingBoxCompose(node) + } + + var b strings.Builder + b.WriteString("services:\n") + b.WriteString(" node-info:\n") + b.WriteString(" image: nginx:alpine\n") + b.WriteString(" restart: unless-stopped\n") + b.WriteString(" ports:\n") + b.WriteString(" - \"127.0.0.1:18080:80\"\n") + b.WriteString(" volumes:\n") + b.WriteString(" - ./node.json:/usr/share/nginx/html/index.json:ro\n") + b.WriteString(" - ./README.md:/usr/share/nginx/html/README.md:ro\n") + return b.String() +} + +func renderRuntimeReadme(node Node) string { + if hasHysteria2(node) { + profile := firstHysteria2Profile(node) + return fmt.Sprintf( + "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a Hysteria2 inbound on UDP `%d`\n- embedded self-signed TLS certificate\n- Salamander obfuscation enabled\n- local mixed health inbound on `127.0.0.1:1080`\n", + node.ID, + node.Region, + defaultInt(profile.Port, defaultHysteria2Port), + ) + } + if usesVLESSReality(node) { + reality := firstRealityProfile(node) + return fmt.Sprintf( + "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a VLESS REALITY inbound on `%d`\n- no ACME or Caddy layer is required\n- REALITY handshake destination `%s:%d`\n", + node.ID, + node.Region, + realityPort(node), + reality.ServerName, + reality.ServerPort, + ) + } + if usesVLESSTLS(node) { + return fmt.Sprintf( + "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a VLESS inbound on loopback\n- Caddy terminating HTTPS with ACME certificates for `%s`\n\nRequirements:\n- the domain must resolve to this VPS\n- ports 80 and 443 must be reachable from the internet\n- acme_email should be set for certificate issuance\n", + node.ID, + node.Region, + node.Domain, + ) + } + + return fmt.Sprintf( + "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIt contains inventory metadata and a minimal runtime placeholder. Replace or extend the runtime services as protocol-specific deployers are added.\n", + node.ID, + node.Region, + ) +} + +func sanitizeEnv(value string) string { + value = strings.ReplaceAll(value, "\n", "") + return value +} + +func usesVLESSTLS(node Node) bool { + for _, protocol := range node.Protocols { + if protocol.Type == "vless" && protocol.Enabled && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) != "" { + return true + } + } + return false +} + +func usesVLESSReality(node Node) bool { + for _, protocol := range node.Protocols { + if protocol.Type == "vless-reality" && protocol.Enabled { + return true + } + } + return false +} + +func usesVMessTLS(node Node) bool { + for _, protocol := range node.Protocols { + if protocol.Type == "vmess" && protocol.Enabled && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) != "" { + return true + } + } + return false +} + +func needsEdgeProxy(node Node) bool { + return usesVLESSTLS(node) || usesVMessTLS(node) +} + +func needsSingBoxRuntime(node Node) bool { + for _, protocol := range node.Protocols { + if protocol.Enabled { + return true + } + } + return false +} + +func renderSingBoxCompose(node Node) string { + var b strings.Builder + b.WriteString("services:\n") + b.WriteString(" sing-box:\n") + b.WriteString(" image: ghcr.io/sagernet/sing-box:v1.12.20\n") + b.WriteString(" restart: unless-stopped\n") + b.WriteString(" command: [\"run\", \"-c\", \"/etc/sing-box/config.json\"]\n") + if isHysteria2Only(node) { + hy2Port := defaultInt(firstHysteria2Profile(node).Port, defaultHysteria2Port) + b.WriteString(" ports:\n") + b.WriteString(fmt.Sprintf(" - \"%d:%d/udp\"\n", hy2Port, hy2Port)) + b.WriteString(" - \"127.0.0.1:1080:1080/tcp\"\n") + } else { + b.WriteString(" network_mode: host\n") + } + b.WriteString(" volumes:\n") + b.WriteString(" - ./sing-box.server.json:/etc/sing-box/config.json:ro\n") + if hasHysteria2(node) { + b.WriteString(" - ./cert.pem:/etc/sing-box/cert.pem:ro\n") + b.WriteString(" - ./key.pem:/etc/sing-box/key.pem:ro\n") + } + b.WriteString("\n") + if needsEdgeProxy(node) { + b.WriteString(" caddy:\n") + b.WriteString(" image: caddy:2\n") + b.WriteString(" restart: unless-stopped\n") + b.WriteString(" network_mode: host\n") + b.WriteString(" depends_on:\n") + b.WriteString(" - sing-box\n") + b.WriteString(" environment:\n") + if strings.TrimSpace(node.ACMEEmail) != "" { + b.WriteString(" ACME_EMAIL: " + node.ACMEEmail + "\n") + } + b.WriteString(" volumes:\n") + b.WriteString(" - ./Caddyfile:/etc/caddy/Caddyfile:ro\n") + b.WriteString(" - caddy_data:/data\n") + b.WriteString(" - caddy_config:/config\n") + b.WriteString("\n") + b.WriteString("volumes:\n") + b.WriteString(" caddy_data:\n") + b.WriteString(" caddy_config:\n") + } + return b.String() +} + +func renderSingBoxServerConfig(node Node) (string, bool, error) { + inbounds := make([]map[string]any, 0) + if !needsSingBoxRuntime(node) { + return "", false, nil + } + + if vless, ok := findProtocol(node, "vless"); ok && vless.Enabled { + if vless.Auth == nil || strings.TrimSpace(vless.Auth.UUID) == "" { + return "", false, fmt.Errorf("vless runtime requires auth.uuid") + } + inbound := map[string]any{ + "type": "vless", + "tag": "vless-in", + "users": []map[string]any{ + {"uuid": vless.Auth.UUID}, + }, + } + path := stringFromExtra(vless.Extra, "path") + if path == "" { + path = "/ws" + } + if vless.TLS != nil && vless.TLS.Enabled && strings.TrimSpace(node.Domain) != "" { + inbound["listen"] = "127.0.0.1" + inbound["listen_port"] = 10443 + inbound["transport"] = map[string]any{ + "type": "ws", + "path": path, + } + } else { + inbound["listen"] = "0.0.0.0" + inbound["listen_port"] = vless.Port + } + inbounds = append(inbounds, inbound) + } + + if reality, ok := findProtocol(node, "vless-reality"); ok && reality.Enabled { + if reality.Auth == nil || strings.TrimSpace(reality.Auth.UUID) == "" { + return "", false, fmt.Errorf("vless-reality runtime requires auth.uuid") + } + if err := ensureRealityProfile(&reality); err != nil { + return "", false, err + } + inbound := map[string]any{ + "type": "vless", + "tag": "vless-reality-in", + "listen": "::", + "listen_port": reality.Port, + "users": []map[string]any{ + {"uuid": reality.Auth.UUID}, + }, + "tls": map[string]any{ + "enabled": true, + "server_name": reality.Reality.ServerName, + "reality": map[string]any{ + "enabled": true, + "handshake": map[string]any{ + "server": reality.Reality.ServerName, + "server_port": defaultInt(reality.Reality.ServerPort, 443), + }, + "private_key": reality.Reality.PrivateKey, + "short_id": []string{reality.Reality.ShortID}, + }, + }, + } + inbounds = append(inbounds, inbound) + } + + if ss, ok := findProtocol(node, "shadowsocks"); ok && ss.Enabled { + if ss.Auth == nil || strings.TrimSpace(ss.Auth.Method) == "" || strings.TrimSpace(ss.Auth.Password) == "" { + return "", false, fmt.Errorf("shadowsocks runtime requires auth.method and auth.password") + } + inbounds = append(inbounds, map[string]any{ + "type": "shadowsocks", + "tag": "ss-in", + "listen": "0.0.0.0", + "listen_port": ss.Port, + "method": ss.Auth.Method, + "password": ss.Auth.Password, + }) + } + + if socks, ok := findProtocol(node, "socks"); ok && socks.Enabled { + inbounds = append(inbounds, map[string]any{ + "type": "socks", + "tag": "socks-in", + "listen": "0.0.0.0", + "listen_port": socks.Port, + }) + } + if socks, ok := findProtocol(node, "socks5"); ok && socks.Enabled { + inbounds = append(inbounds, map[string]any{ + "type": "socks", + "tag": "socks5-in", + "listen": "0.0.0.0", + "listen_port": socks.Port, + }) + } + + if vmess, ok := findProtocol(node, "vmess"); ok && vmess.Enabled { + if vmess.Auth == nil || strings.TrimSpace(vmess.Auth.UUID) == "" { + return "", false, fmt.Errorf("vmess runtime requires auth.uuid") + } + inbound := map[string]any{ + "type": "vmess", + "tag": "vmess-in", + "users": []map[string]any{ + {"uuid": vmess.Auth.UUID, "alterId": 0}, + }, + } + path := stringFromExtra(vmess.Extra, "path") + if path == "" { + path = "/vmess" + } + if vmess.TLS != nil && vmess.TLS.Enabled && strings.TrimSpace(node.Domain) != "" { + inbound["listen"] = "127.0.0.1" + inbound["listen_port"] = 10444 + inbound["transport"] = map[string]any{ + "type": "ws", + "path": path, + } + } else { + inbound["listen"] = "0.0.0.0" + inbound["listen_port"] = vmess.Port + } + inbounds = append(inbounds, inbound) + } + + if hy2, ok := findProtocol(node, "hysteria2"); ok && hy2.Enabled { + profile := hy2.Hysteria2 + if profile == nil { + return "", false, fmt.Errorf("hysteria2 runtime requires hysteria2 settings") + } + inboundConfig, err := config.BuildHysteria2Inbound(node, hy2.Port, profile.UserPassword, profile.ObfsPassword, profile.UpMbps, profile.DownMbps, profile.CertPath, profile.KeyPath) + if err != nil { + return "", false, err + } + inbound := map[string]any(*inboundConfig) + inbound["users"] = []map[string]any{ + {"name": node.ID, "password": profile.UserPassword}, + } + inbounds = append(inbounds, inbound) + if needsHysteria2HealthInbound(node) { + inbounds = append(inbounds, map[string]any{ + "type": "mixed", + "tag": "hy2-health-in", + "listen": "127.0.0.1", + "listen_port": 1080, + }) + } + } + + config := map[string]any{ + "log": map[string]any{"level": "info"}, + "inbounds": inbounds, + "outbounds": []map[string]any{ + {"type": "direct", "tag": "direct"}, + }, + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return "", false, err + } + return string(data) + "\n", true, nil +} + +func renderCaddyfile(node Node) string { + var b strings.Builder + b.WriteString("{\n") + if strings.TrimSpace(node.ACMEEmail) != "" { + b.WriteString(" email ") + b.WriteString(node.ACMEEmail) + b.WriteString("\n") + } + b.WriteString("}\n\n") + b.WriteString(node.Domain) + b.WriteString(" {\n") + b.WriteString(" encode zstd gzip\n") + if vless, ok := findProtocol(node, "vless"); ok && vless.Enabled && vless.TLS != nil && vless.TLS.Enabled { + path := stringFromExtra(vless.Extra, "path") + if path == "" { + path = "/ws" + } + b.WriteString(" @vless path ") + b.WriteString(path) + b.WriteString("\n") + b.WriteString(" reverse_proxy @vless 127.0.0.1:10443\n") + } + if vmess, ok := findProtocol(node, "vmess"); ok && vmess.Enabled && vmess.TLS != nil && vmess.TLS.Enabled { + path := stringFromExtra(vmess.Extra, "path") + if path == "" { + path = "/vmess" + } + b.WriteString(" @vmess path ") + b.WriteString(path) + b.WriteString("\n") + b.WriteString(" reverse_proxy @vmess 127.0.0.1:10444\n") + } + b.WriteString(" respond /healthz 200\n") + b.WriteString("}\n") + return b.String() +} + +func firstRealityProfile(node Node) VLESSRealityProfile { + for _, protocol := range node.Protocols { + if protocol.Type == "vless-reality" && protocol.Enabled && protocol.Reality != nil { + return *protocol.Reality + } + } + return VLESSRealityProfile{} +} + +func firstHysteria2Profile(node Node) Hysteria2Profile { + for _, protocol := range node.Protocols { + if protocol.Type == "hysteria2" && protocol.Enabled && protocol.Hysteria2 != nil { + return *protocol.Hysteria2 + } + } + return Hysteria2Profile{} +} + +func realityPort(node Node) int { + for _, protocol := range node.Protocols { + if protocol.Type == "vless-reality" && protocol.Enabled { + return protocol.Port + } + } + return 443 +} + +func defaultInt(value, fallback int) int { + if value > 0 { + return value + } + return fallback +} + +func findProtocol(node Node, kind string) (ProtocolProfile, bool) { + for _, protocol := range node.Protocols { + if protocol.Type == kind { + return protocol, true + } + } + return ProtocolProfile{}, false +} + +func hasHysteria2(node Node) bool { + hy2, ok := findProtocol(node, "hysteria2") + return ok && hy2.Enabled +} + +func isHysteria2Only(node Node) bool { + enabled := 0 + hy2Enabled := false + for _, protocol := range node.Protocols { + if !protocol.Enabled { + continue + } + enabled++ + if protocol.Type == "hysteria2" { + hy2Enabled = true + } + } + return enabled == 1 && hy2Enabled +} + +func needsHysteria2HealthInbound(node Node) bool { + return hasHysteria2(node) +} + +func hysteria2CertificateHost(node Node) string { + if tls, ok := findProtocol(node, "hysteria2"); ok && tls.TLS != nil && strings.TrimSpace(tls.TLS.ServerName) != "" { + return strings.TrimSpace(tls.TLS.ServerName) + } + suffix := strings.ReplaceAll(strings.ToLower(node.ID), "_", "-") + suffix = strings.ReplaceAll(suffix, " ", "-") + return "node-" + suffix + ".local" +} + +func intFromExtra(extra map[string]any, key string, fallback int) int { + if extra == nil { + return fallback + } + switch value := extra[key].(type) { + case int: + return value + case float64: + return int(value) + default: + return fallback + } +} diff --git a/internal/control/runtime_test.go b/internal/control/runtime_test.go new file mode 100644 index 0000000..1f38dd7 --- /dev/null +++ b/internal/control/runtime_test.go @@ -0,0 +1,307 @@ +package control + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRenderRuntimeBundle(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + node := Node{ + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + ACMEEmail: "admin@example.com", + Enabled: true, + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "key", + }, + Protocols: []ProtocolProfile{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &TLSProfile{ + Enabled: true, + ServerName: "nl-01.example.com", + }, + Auth: &AuthProfile{ + UUID: "11111111-1111-1111-1111-111111111111", + }, + Extra: map[string]any{ + "path": "/ws", + }, + }, + }, + } + + if err := RenderRuntimeBundle(dir, node, "20260401-123000"); err != nil { + t.Fatalf("RenderRuntimeBundle error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "docker-compose.yml")) + if err != nil { + t.Fatalf("ReadFile docker-compose.yml error = %v", err) + } + if !strings.Contains(string(data), "sing-box:") { + t.Fatal("expected sing-box service in runtime compose") + } + if !strings.Contains(string(data), "caddy:") { + t.Fatal("expected caddy service in runtime compose") + } + + caddyfile, err := os.ReadFile(filepath.Join(dir, "Caddyfile")) + if err != nil { + t.Fatalf("ReadFile Caddyfile error = %v", err) + } + if !strings.Contains(string(caddyfile), "nl-01.example.com") { + t.Fatal("expected domain in Caddyfile") + } + + serverConfig, err := os.ReadFile(filepath.Join(dir, "sing-box.server.json")) + if err != nil { + t.Fatalf("ReadFile sing-box.server.json error = %v", err) + } + if !strings.Contains(string(serverConfig), "\"type\": \"vless\"") { + t.Fatal("expected vless inbound in sing-box config") + } +} + +func TestRenderRuntimeBundleReality(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + node := Node{ + ID: "nl-reality", + Name: "NL Reality", + Region: "nl", + Host: "203.0.113.20", + Enabled: true, + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "key", + }, + Protocols: []ProtocolProfile{ + { + Type: "vless-reality", + Enabled: true, + Port: 443, + Auth: &AuthProfile{ + UUID: "33333333-3333-3333-3333-333333333333", + }, + Reality: &VLESSRealityProfile{ + ServerName: "login.microsoftonline.com", + ServerPort: 443, + PrivateKey: "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc", + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + ShortID: "0123456789abcdef", + Fingerprint: "chrome", + }, + }, + }, + } + + if err := RenderRuntimeBundle(dir, node, "20260408-180000"); err != nil { + t.Fatalf("RenderRuntimeBundle error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "docker-compose.yml")) + if err != nil { + t.Fatalf("ReadFile docker-compose.yml error = %v", err) + } + if !strings.Contains(string(data), "sing-box:") { + t.Fatal("expected sing-box service in runtime compose") + } + if strings.Contains(string(data), "caddy:") { + t.Fatal("did not expect caddy service for reality runtime") + } + + if _, err := os.Stat(filepath.Join(dir, "Caddyfile")); !os.IsNotExist(err) { + t.Fatal("did not expect Caddyfile for reality runtime") + } + + serverConfig, err := os.ReadFile(filepath.Join(dir, "sing-box.server.json")) + if err != nil { + t.Fatalf("ReadFile sing-box.server.json error = %v", err) + } + s := string(serverConfig) + if !strings.Contains(s, "\"private_key\": \"UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc\"") { + t.Fatal("expected reality private key in sing-box config") + } + if !strings.Contains(s, "\"short_id\": [") || !strings.Contains(s, "0123456789abcdef") { + t.Fatal("expected reality short id in sing-box config") + } + if !strings.Contains(s, "login.microsoftonline.com") { + t.Fatal("expected reality handshake destination in sing-box config") + } +} + +func TestHysteria2Bundle(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + node := Node{ + ID: "nl-hy2", + Name: "NL Hysteria2", + Region: "nl", + Host: "203.0.113.30", + Enabled: true, + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "key", + }, + Protocols: []ProtocolProfile{ + { + Type: "hysteria2", + Enabled: true, + Port: 443, + Auth: &AuthProfile{ + Password: "user-password", + }, + Hysteria2: &Hysteria2Profile{ + Port: 443, + UpMbps: 100, + DownMbps: 100, + ObfsPassword: "obfs-password", + UserPassword: "user-password", + CertPath: "/etc/sing-box/cert.pem", + KeyPath: "/etc/sing-box/key.pem", + }, + }, + }, + } + + if err := RenderRuntimeBundle(dir, node, "20260408-220000"); err != nil { + t.Fatalf("RenderRuntimeBundle error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "docker-compose.yml")) + if err != nil { + t.Fatalf("ReadFile docker-compose.yml error = %v", err) + } + compose := string(data) + if !strings.Contains(compose, "443:443/udp") { + t.Fatal("expected udp port mapping for hysteria2 runtime") + } + if !strings.Contains(compose, "127.0.0.1:1080:1080/tcp") { + t.Fatal("expected local tcp health port mapping for hysteria2 runtime") + } + if strings.Contains(compose, "caddy:") { + t.Fatal("did not expect caddy service for hysteria2 runtime") + } + + serverConfig, err := os.ReadFile(filepath.Join(dir, "sing-box.server.json")) + if err != nil { + t.Fatalf("ReadFile sing-box.server.json error = %v", err) + } + config := string(serverConfig) + if !strings.Contains(config, "\"type\": \"hysteria2\"") { + t.Fatal("expected hysteria2 inbound in sing-box config") + } + if !strings.Contains(config, "\"salamander\"") { + t.Fatal("expected salamander obfuscation in sing-box config") + } + if !strings.Contains(config, "\"listen_port\": 1080") { + t.Fatal("expected mixed health inbound in sing-box config") + } + if !strings.Contains(config, "\"certificate_path\": \"/etc/sing-box/cert.pem\"") { + t.Fatal("expected embedded certificate path in sing-box config") + } + if _, err := os.Stat(filepath.Join(dir, "cert.pem")); err != nil { + t.Fatalf("expected generated cert.pem: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "key.pem")); err != nil { + t.Fatalf("expected generated key.pem: %v", err) + } +} + +func TestRenderRuntimeBundleMultiProtocol(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + node := Node{ + ID: "nl-multi", + Name: "NL Multi", + Region: "nl", + Host: "203.0.113.40", + Enabled: true, + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "key", + }, + Protocols: []ProtocolProfile{ + { + Type: "vless-reality", + Enabled: true, + Port: 443, + Auth: &AuthProfile{ + UUID: "33333333-3333-3333-3333-333333333333", + }, + Reality: &VLESSRealityProfile{ + ServerName: "www.microsoft.com", + ServerPort: 443, + PrivateKey: "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc", + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + ShortID: "0123456789abcdef", + Fingerprint: "chrome", + }, + }, + { + Type: "hysteria2", + Enabled: true, + Port: 443, + Auth: &AuthProfile{ + Password: "user-password", + }, + Hysteria2: &Hysteria2Profile{ + Port: 443, + UpMbps: 100, + DownMbps: 100, + ObfsPassword: "obfs-password", + UserPassword: "user-password", + CertPath: "/etc/sing-box/cert.pem", + KeyPath: "/etc/sing-box/key.pem", + }, + }, + }, + } + + if err := RenderRuntimeBundle(dir, node, "20260409-120000"); err != nil { + t.Fatalf("RenderRuntimeBundle error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "docker-compose.yml")) + if err != nil { + t.Fatalf("ReadFile docker-compose.yml error = %v", err) + } + compose := string(data) + if !strings.Contains(compose, "network_mode: host") { + t.Fatal("expected host networking for multi protocol runtime") + } + + serverConfig, err := os.ReadFile(filepath.Join(dir, "sing-box.server.json")) + if err != nil { + t.Fatalf("ReadFile sing-box.server.json error = %v", err) + } + config := string(serverConfig) + if !strings.Contains(config, "\"tag\": \"vless-reality-in\"") { + t.Fatal("expected reality inbound in sing-box config") + } + if !strings.Contains(config, "\"tag\": \"hysteria2-in\"") { + t.Fatal("expected hysteria2 inbound in sing-box config") + } + if !strings.Contains(config, "\"tag\": \"hy2-health-in\"") { + t.Fatal("expected hysteria2 health inbound for multi runtime") + } +} diff --git a/internal/control/ssh.go b/internal/control/ssh.go new file mode 100644 index 0000000..b7d7dd5 --- /dev/null +++ b/internal/control/ssh.go @@ -0,0 +1,182 @@ +package control + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +type SSHRunner struct{} + +type SSHExecutor interface { + Run(ctx context.Context, node Node, script string) (*CommandResult, error) + Check(ctx context.Context, node Node) (*CommandResult, error) + CopyFile(ctx context.Context, node Node, localPath, remotePath string) error +} + +type CommandResult struct { + Stdout string + Stderr string +} + +func (r SSHRunner) Run(ctx context.Context, node Node, script string) (*CommandResult, error) { + target := sshTarget(node) + cmd, err := sshCommand(ctx, node, target, "sh -s") + if err != nil { + return &CommandResult{}, err + } + cmd.Stdin = strings.NewReader(script) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return &CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, fmt.Errorf("ssh %s: %w", target, err) + } + + return &CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, nil +} + +func (r SSHRunner) Check(ctx context.Context, node Node) (*CommandResult, error) { + target := sshTarget(node) + cmd, err := sshCommand(ctx, node, target, "printf ok") + if err != nil { + return &CommandResult{}, err + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return &CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, fmt.Errorf("ssh %s: %w", target, err) + } + + return &CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, nil +} + +func CopyFileOverSCP(ctx context.Context, node Node, localPath, remotePath string) error { + target := fmt.Sprintf("%s:%s", sshTarget(node), remotePath) + cmd, err := scpCommand(ctx, node, localPath, target) + if err != nil { + return err + } + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("scp %s -> %s: %w: %s", localPath, target, err, string(output)) + } + return nil +} + +func (r SSHRunner) CopyFile(ctx context.Context, node Node, localPath, remotePath string) error { + return CopyFileOverSCP(ctx, node, localPath, remotePath) +} + +func CopyDirContentsOverSCP(ctx context.Context, node Node, localDir, remoteDir string) error { + target := fmt.Sprintf("%s:%s", sshTarget(node), remoteDir) + cmd, err := scpCommand(ctx, node, "-r", filepath.Clean(localDir)+"/.", target) + if err != nil { + return err + } + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("scp %s -> %s: %w: %s", localDir, target, err, string(output)) + } + return nil +} + +func sshBaseArgs(node Node) []string { + args := []string{ + "-o", "StrictHostKeyChecking=accept-new", + "-p", strconv.Itoa(defaultSSHPort(node.SSH.Port)), + } + if strings.TrimSpace(node.SSH.Auth) == "password" { + args = append(args, "-o", "BatchMode=no") + } else { + args = append(args, "-o", "BatchMode=yes") + } + if strings.TrimSpace(node.SSH.IdentityFile) != "" { + args = append(args, "-i", expandHome(node.SSH.IdentityFile)) + } + return args +} + +func scpBaseArgs(node Node) []string { + args := []string{ + "-o", "StrictHostKeyChecking=accept-new", + "-P", strconv.Itoa(defaultSSHPort(node.SSH.Port)), + } + if strings.TrimSpace(node.SSH.Auth) == "password" { + args = append(args, "-o", "BatchMode=no") + } else { + args = append(args, "-o", "BatchMode=yes") + } + if strings.TrimSpace(node.SSH.IdentityFile) != "" { + args = append(args, "-i", expandHome(node.SSH.IdentityFile)) + } + return args +} + +func sshTarget(node Node) string { + return fmt.Sprintf("%s@%s", node.SSH.User, node.Host) +} + +func defaultSSHPort(port int) int { + if port == 0 { + return 22 + } + return port +} + +func expandHome(path string) string { + if path == "" || path[0] != '~' { + return path + } + home, err := exec.Command("sh", "-lc", "printf %s \"$HOME\"").Output() + if err != nil { + return path + } + return filepath.Join(strings.TrimSpace(string(home)), strings.TrimPrefix(path, "~/")) +} + +func sshCommand(ctx context.Context, node Node, extraArgs ...string) (*exec.Cmd, error) { + args := sshBaseArgs(node) + args = append(args, extraArgs...) + return wrapWithPassword(ctx, node, "ssh", args...) +} + +func scpCommand(ctx context.Context, node Node, extraArgs ...string) (*exec.Cmd, error) { + args := scpBaseArgs(node) + args = append(args, extraArgs...) + return wrapWithPassword(ctx, node, "scp", args...) +} + +func wrapWithPassword(ctx context.Context, node Node, command string, args ...string) (*exec.Cmd, error) { + if strings.TrimSpace(node.SSH.Auth) != "password" { + return exec.CommandContext(ctx, command, args...), nil + } + + password := node.SSH.Password + if password == "" { + envName := strings.TrimSpace(node.SSH.PasswordEnv) + if envName == "" { + return nil, fmt.Errorf("ssh password auth for %s requires ssh.password_env", sshTarget(node)) + } + password = os.Getenv(envName) + if password == "" { + return nil, fmt.Errorf("ssh password env %s is empty", envName) + } + } + + wrappedArgs := append([]string{"-p", password, command}, args...) + cmd := exec.CommandContext(ctx, "sshpass", wrappedArgs...) + return cmd, nil +} diff --git a/internal/control/ssh_test.go b/internal/control/ssh_test.go new file mode 100644 index 0000000..8b7afd0 --- /dev/null +++ b/internal/control/ssh_test.go @@ -0,0 +1,80 @@ +package control + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestValidateNodeSSHPasswordAuth(t *testing.T) { + t.Parallel() + + node := Node{ + ID: "pw-01", + Name: "Password Node", + Provider: "custom-vps", + Region: "nl", + Host: "203.0.113.20", + Enabled: true, + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "password", + PasswordEnv: "VPNEM_TEST_PASSWORD", + }, + Protocols: []ProtocolProfile{ + {Type: "socks5", Enabled: true, Port: 1080}, + }, + } + + if err := ValidateNode(node); err != nil { + t.Fatalf("ValidateNode() error = %v", err) + } +} + +func TestWrapWithPasswordUsesSSHPass(t *testing.T) { + t.Setenv("VPNEM_TEST_PASSWORD", "secret") + node := Node{ + ID: "pw-01", + Name: "Password Node", + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "password", + PasswordEnv: "VPNEM_TEST_PASSWORD", + }, + } + + cmd, err := wrapWithPassword(context.Background(), node, "ssh", "-V") + if err != nil { + t.Fatalf("wrapWithPassword() error = %v", err) + } + if got := filepath.Base(cmd.Path); got != "sshpass" { + t.Fatalf("filepath.Base(cmd.Path) = %q, want sshpass", got) + } + if len(cmd.Args) < 4 { + t.Fatalf("cmd.Args too short: %#v", cmd.Args) + } + if cmd.Args[1] != "-p" || cmd.Args[2] != "secret" || cmd.Args[3] != "ssh" { + t.Fatalf("unexpected cmd.Args: %#v", cmd.Args) + } +} + +func TestWrapWithPasswordRequiresEnv(t *testing.T) { + _ = os.Unsetenv("VPNEM_TEST_PASSWORD_MISSING") + node := Node{ + ID: "pw-01", + Name: "Password Node", + SSH: SSHConfig{ + User: "root", + Port: 22, + Auth: "password", + PasswordEnv: "VPNEM_TEST_PASSWORD_MISSING", + }, + } + + if _, err := wrapWithPassword(context.Background(), node, "ssh", "-V"); err == nil { + t.Fatal("expected error for missing password env") + } +} diff --git a/internal/control/state.go b/internal/control/state.go new file mode 100644 index 0000000..7fc7827 --- /dev/null +++ b/internal/control/state.go @@ -0,0 +1,71 @@ +package control + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "time" +) + +type NodeState struct { + NodeID string `json:"node_id"` + BootstrapStatus string `json:"bootstrap_status"` + LastBootstrapAt *time.Time `json:"last_bootstrap_at,omitempty"` + LastHealthCheckAt *time.Time `json:"last_health_check_at,omitempty"` + LastDNSSyncAt *time.Time `json:"last_dns_sync_at,omitempty"` + PublicHost string `json:"public_host,omitempty"` + Services []ServiceStatus `json:"services,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ServiceStatus struct { + Type string `json:"type"` + Status string `json:"status"` + Port int `json:"port"` +} + +func LoadNodeState(dir, nodeID string) (*NodeState, error) { + data, err := os.ReadFile(filepath.Join(dir, nodeID+".json")) + if err != nil { + return nil, err + } + + var state NodeState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +func SaveNodeState(dir string, state NodeState) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + sort.Slice(state.Services, func(i, j int) bool { + return state.Services[i].Type < state.Services[j].Type + }) + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + tmpPath := filepath.Join(dir, state.NodeID+".json.tmp") + finalPath := filepath.Join(dir, state.NodeID+".json") + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + return err + } + return os.Rename(tmpPath, finalPath) +} + +func DeleteNodeState(dir, nodeID string) error { + err := os.Remove(filepath.Join(dir, nodeID+".json")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} diff --git a/internal/control/upgrade_test.go b/internal/control/upgrade_test.go new file mode 100644 index 0000000..c3e5404 --- /dev/null +++ b/internal/control/upgrade_test.go @@ -0,0 +1,55 @@ +package control + +import ( + "context" + "strings" + "testing" +) + +type fakeRunner struct{} + +func (fakeRunner) Run(ctx context.Context, node Node, script string) (*CommandResult, error) { + if strings.Contains(script, "HEALTHZ_HTTP_CODE=") { + return &CommandResult{ + Stdout: "{\"Service\":\"sing-box\",\"Status\":\"running\"}\nHEALTHZ_HTTP_CODE=200\n", + }, nil + } + return &CommandResult{Stdout: "ok\n"}, nil +} + +func (fakeRunner) Check(ctx context.Context, node Node) (*CommandResult, error) { + return &CommandResult{Stdout: "ok"}, nil +} + +func (fakeRunner) CopyFile(ctx context.Context, node Node, localPath, remotePath string) error { + return nil +} + +func TestUpgradeNode(t *testing.T) { + t.Parallel() + + state, err := UpgradeNode(context.Background(), fakeRunner{}, Node{ + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, Extra: map[string]any{"path": "/ws"}}, + }, + }, t.TempDir()) + if err != nil { + t.Fatalf("UpgradeNode() error = %v", err) + } + if state == nil { + t.Fatal("expected state") + } + if state.BootstrapStatus != "healthy" { + t.Fatalf("BootstrapStatus = %q, want healthy", state.BootstrapStatus) + } + if got := state.Metadata["lifecycle_action"]; got != "upgrade" { + t.Fatalf("lifecycle_action = %v, want upgrade", got) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..fa145ee --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,138 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + + "vpnem/internal/config" + "vpnem/internal/models" +) + +type Engine struct { + mu sync.Mutex + instance *box.Box + cancel context.CancelFunc + running bool + configPath string + dataDir string + localProxyPort int +} + +func New(dataDir string) *Engine { + return &Engine{ + dataDir: dataDir, + configPath: filepath.Join(dataDir, "config.json"), + } +} + +func (e *Engine) Start(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) error { + return e.StartFull(server, mode, ruleSets, serverIPs, nil, config.LocalProxyPort, nil) +} + +func (e *Engine) StartFull(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) error { + e.mu.Lock() + defer e.mu.Unlock() + + if e.running { + return fmt.Errorf("already running") + } + + cfg := config.BuildConfigFullWithLocalProxy(server, mode, ruleSets, serverIPs, customBypass, localProxyPort, policy) + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + os.MkdirAll(e.dataDir, 0o755) + _ = os.WriteFile(e.configPath, data, 0o644) + log.Printf("engine: config saved (%d bytes)", len(data)) + + var opts option.Options + ctx := include.Context(context.Background()) + if err := opts.UnmarshalJSONContext(ctx, data); err != nil { + log.Printf("engine: parse FAILED: %v", err) + return fmt.Errorf("parse config: %w", err) + } + + boxCtx, cancel := context.WithCancel(ctx) + e.cancel = cancel + + instance, err := box.New(box.Options{ + Context: boxCtx, + Options: opts, + }) + if err != nil { + cancel() + log.Printf("engine: create FAILED: %v", err) + return fmt.Errorf("create sing-box: %w", err) + } + + if err := instance.Start(); err != nil { + instance.Close() + cancel() + log.Printf("engine: start FAILED: %v", err) + return fmt.Errorf("start sing-box: %w", err) + } + + e.instance = instance + e.running = true + e.localProxyPort = localProxyPort + log.Println("engine: started ok") + return nil +} + +func (e *Engine) Stop() error { + e.mu.Lock() + defer e.mu.Unlock() + + if !e.running { + return nil + } + + if e.instance != nil { + e.instance.Close() + e.instance = nil + } + if e.cancel != nil { + e.cancel() + } + e.running = false + e.localProxyPort = 0 + log.Println("engine: stopped") + return nil +} + +func (e *Engine) Restart(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) error { + e.Stop() + return e.Start(server, mode, ruleSets, serverIPs) +} + +func (e *Engine) RestartFull(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) error { + e.Stop() + return e.StartFull(server, mode, ruleSets, serverIPs, customBypass, localProxyPort, policy) +} + +func (e *Engine) IsRunning() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.running +} + +func (e *Engine) ConfigPath() string { + return e.configPath +} + +func (e *Engine) LocalProxyPort() int { + e.mu.Lock() + defer e.mu.Unlock() + return e.localProxyPort +} diff --git a/internal/engine/healthcheck.go b/internal/engine/healthcheck.go new file mode 100644 index 0000000..a856608 --- /dev/null +++ b/internal/engine/healthcheck.go @@ -0,0 +1,63 @@ +package engine + +import ( + "io" + "net/http" + "strings" + "time" + + "vpnem/internal/config" +) + +const DefaultBlockedSiteProbeURL = "https://rutracker.org" + +func ModeRequiresExitIP(mode config.Mode) bool { + return mode.Final == "proxy" +} + +func CheckExitIP(localProxyPort int) string { + client, err := HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 5*time.Second) + if err != nil { + return "" + } + resp, err := client.Get("http://ifconfig.me/ip") + if err != nil { + return "" + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) + if err != nil { + return "" + } + return strings.TrimSpace(string(body)) +} + +func ProbeBlockedSite(localProxyPort int, rawURL string, timeout time.Duration) (int, error) { + client, err := HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, timeout) + if err != nil { + return 0, err + } + + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("User-Agent", "vpnem-health/1.0") + + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256)) + return resp.StatusCode, nil +} + +func DeepCheckRequiresRestart(mode config.Mode, exitIP string, probeErr error) bool { + if ModeRequiresExitIP(mode) { + return exitIP == "" + } + return probeErr != nil +} diff --git a/internal/engine/healthcheck_test.go b/internal/engine/healthcheck_test.go new file mode 100644 index 0000000..1d55ed0 --- /dev/null +++ b/internal/engine/healthcheck_test.go @@ -0,0 +1,38 @@ +package engine + +import ( + "errors" + "testing" + + "vpnem/internal/config" +) + +func TestModeRequiresExitIP(t *testing.T) { + proxyMode := config.Mode{Name: "Full", Final: "proxy"} + directMode := config.Mode{Name: "Combo", Final: "direct"} + + if !ModeRequiresExitIP(proxyMode) { + t.Fatal("expected proxy-final mode to require exit IP") + } + if ModeRequiresExitIP(directMode) { + t.Fatal("did not expect direct-final mode to require exit IP") + } +} + +func TestDeepCheckRequiresRestart(t *testing.T) { + proxyMode := config.Mode{Name: "Full", Final: "proxy"} + directMode := config.Mode{Name: "Combo", Final: "direct"} + + if !DeepCheckRequiresRestart(proxyMode, "", nil) { + t.Fatal("expected proxy-final mode without exit IP to restart") + } + if DeepCheckRequiresRestart(proxyMode, "89.124.96.166", errors.New("probe failed")) { + t.Fatal("did not expect proxy-final mode with exit IP to restart") + } + if !DeepCheckRequiresRestart(directMode, "", errors.New("probe failed")) { + t.Fatal("expected direct-final mode with failed blocked probe to restart") + } + if DeepCheckRequiresRestart(directMode, "", nil) { + t.Fatal("did not expect direct-final mode with successful blocked probe to restart") + } +} diff --git a/internal/engine/httpclient.go b/internal/engine/httpclient.go new file mode 100644 index 0000000..e491cf5 --- /dev/null +++ b/internal/engine/httpclient.go @@ -0,0 +1,45 @@ +package engine + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "golang.org/x/net/proxy" +) + +func HTTPClientViaSOCKS5(host string, port int, timeout time.Duration) (*http.Client, error) { + if host == "" || port <= 0 { + return nil, fmt.Errorf("invalid local socks5 endpoint") + } + + addr := fmt.Sprintf("%s:%d", host, port) + dialer, err := proxy.SOCKS5("tcp", addr, nil, proxy.Direct) + if err != nil { + return nil, err + } + + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("socks5 dialer does not implement context dialing") + } + + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return contextDialer.DialContext(ctx, network, address) + }, + ForceAttemptHTTP2: true, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: timeout, + ResponseHeaderTimeout: timeout, + ExpectContinueTimeout: time.Second, + } + + return &http.Client{ + Timeout: timeout, + Transport: transport, + }, nil +} diff --git a/internal/engine/logger.go b/internal/engine/logger.go new file mode 100644 index 0000000..c448a56 --- /dev/null +++ b/internal/engine/logger.go @@ -0,0 +1,62 @@ +package engine + +import ( + "os" + "path/filepath" + "sync" +) + +// RingLog keeps last N log lines in memory and optionally writes to file. +type RingLog struct { + mu sync.Mutex + lines []string + max int + file *os.File +} + +// NewRingLog creates a ring buffer logger. +func NewRingLog(maxLines int, dataDir string) *RingLog { + rl := &RingLog{ + lines: make([]string, 0, maxLines), + max: maxLines, + } + if dataDir != "" { + f, err := os.OpenFile(filepath.Join(dataDir, "vpnem.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err == nil { + rl.file = f + } + } + return rl +} + +// Add appends a line. +func (rl *RingLog) Add(line string) { + rl.mu.Lock() + defer rl.mu.Unlock() + + if len(rl.lines) >= rl.max { + rl.lines = rl.lines[1:] + } + rl.lines = append(rl.lines, line) + + if rl.file != nil { + rl.file.WriteString(line + "\n") + } +} + +// Lines returns all current lines. +func (rl *RingLog) Lines() []string { + rl.mu.Lock() + defer rl.mu.Unlock() + cp := make([]string, len(rl.lines)) + copy(cp, rl.lines) + return cp +} + +// Close closes the log file. +func (rl *RingLog) Close() { + if rl.file != nil { + rl.file.Close() + } +} diff --git a/internal/engine/proxy_port.go b/internal/engine/proxy_port.go new file mode 100644 index 0000000..5e657c0 --- /dev/null +++ b/internal/engine/proxy_port.go @@ -0,0 +1,37 @@ +package engine + +import ( + "fmt" + "net" + + "vpnem/internal/config" +) + +var localProxyPortCandidates = []int{config.LocalProxyPort, 10808, 10880, 18080, 20800} + +func ResolveLocalProxyPort() (int, error) { + for _, port := range localProxyPortCandidates { + if localProxyPortAvailable(port) { + return port, nil + } + } + listener, err := net.Listen("tcp", net.JoinHostPort(config.LocalProxyHost, "0")) + if err != nil { + return 0, err + } + defer listener.Close() + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + return 0, fmt.Errorf("unexpected listener addr type %T", listener.Addr()) + } + return addr.Port, nil +} + +func localProxyPortAvailable(port int) bool { + listener, err := net.Listen("tcp", net.JoinHostPort(config.LocalProxyHost, fmt.Sprintf("%d", port))) + if err != nil { + return false + } + _ = listener.Close() + return true +} diff --git a/internal/engine/watchdog.go b/internal/engine/watchdog.go new file mode 100644 index 0000000..a27945f --- /dev/null +++ b/internal/engine/watchdog.go @@ -0,0 +1,147 @@ +package engine + +import ( + "context" + "log" + "time" + + "vpnem/internal/config" + "vpnem/internal/models" +) + +// WatchdogConfig holds watchdog parameters. +type WatchdogConfig struct { + CheckInterval time.Duration // how often to check sing-box is alive (default 2s) + DeepCheckInterval time.Duration // how often to verify exit IP (default 30s) + ReconnectCooldown time.Duration // min time between reconnect attempts (default 5s) +} + +// DefaultWatchdogConfig returns the default watchdog settings (from vpn.py). +func DefaultWatchdogConfig() WatchdogConfig { + return WatchdogConfig{ + CheckInterval: 2 * time.Second, + DeepCheckInterval: 30 * time.Second, + ReconnectCooldown: 5 * time.Second, + } +} + +// Watchdog monitors sing-box and auto-reconnects on failure. +type Watchdog struct { + engine *Engine + cfg WatchdogConfig + cancel context.CancelFunc + running bool + + // Reconnect parameters (set via StartWatching) + server models.Server + mode config.Mode + ruleSets []models.RuleSet + serverIPs []string + customBypass []string + localProxyPort int + policy *models.RoutingPolicy +} + +// NewWatchdog creates a new watchdog for the given engine. +func NewWatchdog(engine *Engine, cfg WatchdogConfig) *Watchdog { + return &Watchdog{ + engine: engine, + cfg: cfg, + } +} + +// StartWatching begins monitoring. It stores the connection params for reconnection. +func (w *Watchdog) StartWatching(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) { + w.StopWatching() + + w.server = server + w.mode = mode + w.ruleSets = ruleSets + w.serverIPs = serverIPs + w.customBypass = append([]string{}, customBypass...) + w.localProxyPort = localProxyPort + w.policy = policy + + ctx, cancel := context.WithCancel(context.Background()) + w.cancel = cancel + w.running = true + + go w.loop(ctx) +} + +// StopWatching stops the watchdog. +func (w *Watchdog) StopWatching() { + if w.cancel != nil { + w.cancel() + } + w.running = false +} + +// IsWatching returns whether the watchdog is active. +func (w *Watchdog) IsWatching() bool { + return w.running +} + +func (w *Watchdog) loop(ctx context.Context) { + ticker := time.NewTicker(w.cfg.CheckInterval) + defer ticker.Stop() + + deepTicker := time.NewTicker(w.cfg.DeepCheckInterval) + defer deepTicker.Stop() + + lastReconnect := time.Time{} + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + if !w.engine.IsRunning() { + if time.Since(lastReconnect) < w.cfg.ReconnectCooldown { + continue + } + localProxyPort, err := ResolveLocalProxyPort() + if err != nil { + log.Printf("watchdog: local proxy port selection failed: %v", err) + continue + } + w.localProxyPort = localProxyPort + log.Println("watchdog: sing-box not running, reconnecting...") + if err := w.engine.StartFull(w.server, w.mode, w.ruleSets, w.serverIPs, w.customBypass, w.localProxyPort, w.policy); err != nil { + log.Printf("watchdog: reconnect failed: %v", err) + } else { + log.Println("watchdog: reconnected successfully") + } + lastReconnect = time.Now() + } + + case <-deepTicker.C: + if !w.engine.IsRunning() { + continue + } + exitIP := CheckExitIP(w.localProxyPort) + _, probeErr := ProbeBlockedSite(w.localProxyPort, DefaultBlockedSiteProbeURL, 8*time.Second) + if DeepCheckRequiresRestart(w.mode, exitIP, probeErr) { + if ModeRequiresExitIP(w.mode) { + log.Println("watchdog: deep check failed (no exit IP), restarting...") + } else { + log.Printf("watchdog: deep check failed for direct-final mode (%v), restarting...", probeErr) + } + if time.Since(lastReconnect) < w.cfg.ReconnectCooldown { + continue + } + localProxyPort, err := ResolveLocalProxyPort() + if err != nil { + log.Printf("watchdog: local proxy port selection failed: %v", err) + continue + } + w.localProxyPort = localProxyPort + if err := w.engine.RestartFull(w.server, w.mode, w.ruleSets, w.serverIPs, w.customBypass, w.localProxyPort, w.policy); err != nil { + log.Printf("watchdog: restart failed: %v", err) + } + lastReconnect = time.Now() + } + } + } +} diff --git a/internal/models/catalog.go b/internal/models/catalog.go new file mode 100644 index 0000000..eda61cd --- /dev/null +++ b/internal/models/catalog.go @@ -0,0 +1,35 @@ +package models + +type CatalogV2 struct { + Version string `json:"version"` + Nodes []CatalogNode `json:"nodes"` +} + +type CatalogNode struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + Region string `json:"region"` + Host string `json:"host"` + Domain string `json:"domain,omitempty"` + PublicHost string `json:"public_host"` + Protocols []CatalogProtocol `json:"protocols"` + Status string `json:"status,omitempty"` + Tags []string `json:"tags,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type CatalogProtocol struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` + Port int `json:"port"` + TLS *TLS `json:"tls,omitempty"` + Auth *CatalogAuth `json:"auth,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +type CatalogAuth struct { + UUID string `json:"uuid,omitempty"` + Method string `json:"method,omitempty"` + Password string `json:"password,omitempty"` +} diff --git a/internal/models/client.go b/internal/models/client.go new file mode 100644 index 0000000..f086594 --- /dev/null +++ b/internal/models/client.go @@ -0,0 +1,59 @@ +package models + +import "time" + +// ActiveSession tracks a currently active VPN connection. +type ActiveSession struct { + ClientIP string `json:"client_ip"` // real public IP of client (from X-Forwarded-For) + ServerIP string `json:"server_ip"` // VPN server IP they connected to + NodeID string `json:"node_id"` // catalog node ID + OS string `json:"os"` + Version string `json:"version"` + ConnectedAt time.Time `json:"connected_at"` + LastHeartbeat time.Time `json:"last_seen"` +} + +// StudioRecord tracks a studio's home server assignment. +// A studio = all clients sharing the same public IP. +type StudioRecord struct { + ClientIP string `json:"client_ip"` // public IP = studio identifier + HomeServerIP string `json:"home_server_ip"` // assigned "home" server + HomeNodeID string `json:"home_node_id"` + HomeAssignedAt time.Time `json:"home_assigned_at"` // when home was assigned + TotalClients int `json:"total_clients"` // lifetime client count from this studio + LastSeen time.Time `json:"last_seen"` +} + +// ConnectRequest is sent when a client connects. +// Server determines client_ip from X-Forwarded-For — no client_ip field needed. +type ConnectRequest struct { + ServerIP string `json:"server_ip"` + NodeID string `json:"node_id"` + OS string `json:"os"` + Version string `json:"version"` +} + +// DisconnectRequest is sent when a client disconnects. +type DisconnectRequest struct { + ServerIP string `json:"server_ip"` + NodeID string `json:"node_id"` +} + +// RecommendationResponse is returned by the recommendation endpoint. +type RecommendationResponse struct { + RecommendedServerIP string `json:"recommended_server_ip"` + RecommendedNodeID string `json:"recommended_node_id"` + RecommendedTag string `json:"recommended_tag,omitempty"` + Reason string `json:"reason"` + IsRebalance bool `json:"is_rebalance"` // true if recommending different server than home + LoadInfo string `json:"load_info"` // human-readable load summary + StudioClients int `json:"studio_clients"` // active clients from same studio +} + +// ServerLoadInfo contains load data for all servers. +type ServerLoadInfo struct { + ServerIP string `json:"server_ip"` + ActiveClients int `json:"active_clients"` + LoadPercent int `json:"load_percent"` // 0-100 + MaxCapacity int `json:"max_capacity"` +} diff --git a/internal/models/policy.go b/internal/models/policy.go new file mode 100644 index 0000000..09e15e1 --- /dev/null +++ b/internal/models/policy.go @@ -0,0 +1,23 @@ +package models + +type RoutingPolicy struct { + Version string `json:"version"` + AlwaysDirectProcesses []string `json:"always_direct_processes,omitempty"` + PreferDirectProcesses []string `json:"prefer_direct_processes,omitempty"` + ProxyableBrowserProcesses []string `json:"proxyable_browser_processes,omitempty"` + LovenseProcessRegex []string `json:"lovense_process_regex,omitempty"` + StaticBypassIPs []string `json:"static_bypass_ips,omitempty"` + ReservedCIDRs []string `json:"reserved_cidrs,omitempty"` + LocalDomainSuffixes []string `json:"local_domain_suffixes,omitempty"` + WindowsNCSIDomains []string `json:"windows_ncsi_domains,omitempty"` + InfraBypassDomains []string `json:"infra_bypass_domains,omitempty"` + ForcedProxyIPs []string `json:"forced_proxy_ips,omitempty"` + TelegramProcesses []string `json:"telegram_processes,omitempty"` + TelegramProcessRegex []string `json:"telegram_process_regex,omitempty"` + TelegramDomains []string `json:"telegram_domains,omitempty"` + TelegramDomainRegex []string `json:"telegram_domain_regex,omitempty"` + TelegramIPs []string `json:"telegram_ips,omitempty"` + BlockedDomains []string `json:"blocked_domains,omitempty"` + ProxyDNSDomains []string `json:"proxy_dns_domains,omitempty"` + IPCheckDomains []string `json:"ip_check_domains,omitempty"` +} diff --git a/internal/models/ruleset.go b/internal/models/ruleset.go new file mode 100644 index 0000000..8599322 --- /dev/null +++ b/internal/models/ruleset.go @@ -0,0 +1,23 @@ +package models + +type RuleSet struct { + Tag string `json:"tag"` + Description string `json:"description"` + URL string `json:"url"` + LocalPath string `json:"-"` + Format string `json:"format"` // binary, source + Type string `json:"type"` // domain, ip + Optional bool `json:"optional"` + SHA256 string `json:"sha256,omitempty"` +} + +type RuleSetManifest struct { + RuleSets []RuleSet `json:"rule_sets"` +} + +type VersionResponse struct { + Version string `json:"version"` + URL string `json:"url"` + SHA256 string `json:"sha256,omitempty"` + Changelog string `json:"changelog,omitempty"` +} diff --git a/internal/models/server.go b/internal/models/server.go new file mode 100644 index 0000000..f440c03 --- /dev/null +++ b/internal/models/server.go @@ -0,0 +1,46 @@ +package models + +type TLS struct { + Enabled bool `json:"enabled"` + ServerName string `json:"server_name,omitempty"` + Insecure bool `json:"insecure,omitempty"` + ALPN []string `json:"alpn,omitempty"` + MinVersion string `json:"min_version,omitempty"` + MaxVersion string `json:"max_version,omitempty"` + Reality *Reality `json:"reality,omitempty"` +} + +type Transport struct { + Type string `json:"type,omitempty"` + Path string `json:"path,omitempty"` +} + +type Reality struct { + Enabled bool `json:"enabled,omitempty"` + PublicKey string `json:"public_key,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + ShortID string `json:"short_id,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type Server struct { + Tag string `json:"tag"` + Region string `json:"region"` + Type string `json:"type"` // socks, vless, vless-reality, shadowsocks, vmess, hysteria2 + Server string `json:"server"` + ServerPort int `json:"server_port"` + UDPOverTCP bool `json:"udp_over_tcp,omitempty"` + UUID string `json:"uuid,omitempty"` + Method string `json:"method,omitempty"` + Password string `json:"password,omitempty"` + ObfsPassword string `json:"obfs_password,omitempty"` + UpMbps int `json:"up_mbps,omitempty"` + DownMbps int `json:"down_mbps,omitempty"` + TLS *TLS `json:"tls,omitempty"` + Transport *Transport `json:"transport,omitempty"` + Companions []Server `json:"companions,omitempty"` +} + +type ServersResponse struct { + Servers []Server `json:"servers"` +} diff --git a/internal/rules/connections.go b/internal/rules/connections.go new file mode 100644 index 0000000..705bbf5 --- /dev/null +++ b/internal/rules/connections.go @@ -0,0 +1,336 @@ +package rules + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "vpnem/internal/models" +) + +const ( + sessionExpiry = 1 * time.Hour // session considered stale after 1h + studioExpiry = 7 * 24 * time.Hour // studio record kept for 7 days + defaultMaxCap = 50 // default max clients per server +) + +// ConnectionStore manages active sessions and studio assignments. +type ConnectionStore struct { + mu sync.RWMutex + path string + sessions map[string]*models.ActiveSession // key: client_ip (one active session per studio) + studios map[string]*models.StudioRecord // key: client_ip + maxCap int + staleAfter time.Duration +} + +// NewConnectionStore creates a store backed by a JSON file. +func NewConnectionStore(dataDir string) *ConnectionStore { + return &ConnectionStore{ + path: filepath.Join(dataDir, "connections.json"), + sessions: make(map[string]*models.ActiveSession), + studios: make(map[string]*models.StudioRecord), + maxCap: defaultMaxCap, + staleAfter: sessionExpiry, + } +} + +// Load reads connections from disk. +func (s *ConnectionStore) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var store struct { + Sessions map[string]*models.ActiveSession `json:"sessions"` + Studios map[string]*models.StudioRecord `json:"studios"` + } + if err := json.Unmarshal(data, &store); err != nil { + return err + } + + s.sessions = store.Sessions + if s.sessions == nil { + s.sessions = make(map[string]*models.ActiveSession) + } + s.studios = store.Studios + if s.studios == nil { + s.studios = make(map[string]*models.StudioRecord) + } + + s.expireStaleLocked() + return s.saveLocked() +} + +// Connect records a new active session. +func (s *ConnectionStore) Connect(clientIP, serverIP, nodeID, osName, version string) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + + // Update or create active session + s.sessions[clientIP] = &models.ActiveSession{ + ClientIP: clientIP, + ServerIP: serverIP, + NodeID: nodeID, + OS: osName, + Version: version, + ConnectedAt: now, + LastHeartbeat: now, + } + + // Update or create studio record + studio, exists := s.studios[clientIP] + if !exists { + studio = &models.StudioRecord{ + ClientIP: clientIP, + HomeServerIP: serverIP, + HomeNodeID: nodeID, + HomeAssignedAt: now, + LastSeen: now, + } + s.studios[clientIP] = studio + } + studio.LastSeen = now + studio.TotalClients++ + + // If studio has no home yet, assign one + if studio.HomeServerIP == "" { + studio.HomeServerIP = serverIP + studio.HomeNodeID = nodeID + studio.HomeAssignedAt = now + } + + s.expireStaleLocked() + _ = s.saveLocked() +} + +// Disconnect marks a session as inactive. +func (s *ConnectionStore) Disconnect(clientIP string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.sessions, clientIP) + _ = s.saveLocked() +} + +// Heartbeat updates the last-seen time for a session. +func (s *ConnectionStore) Heartbeat(clientIP string) { + s.mu.Lock() + defer s.mu.Unlock() + + if sess, ok := s.sessions[clientIP]; ok { + sess.LastHeartbeat = time.Now() + } + if studio, ok := s.studios[clientIP]; ok { + studio.LastSeen = time.Now() + } +} + +// GetRecommendation returns the recommended server for a client IP. +// Pure load-based: always picks the least loaded available + healthy server. +// No sticky home — auto-balancing on every request. +func (s *ConnectionStore) GetRecommendation(clientIP string, availableIPs []string, healthyIPs map[string]bool) models.RecommendationResponse { + s.mu.RLock() + defer s.mu.RUnlock() + + resp := models.RecommendationResponse{} + + // Count active connections per server + load := s.activeLoadLocked(availableIPs) + + // Always pick least loaded — no sticky + bestIP := s.findLeastLoadedLocked(availableIPs, load, healthyIPs) + if bestIP == "" { + resp.Reason = "нет доступных серверов" + return resp + } + + resp.RecommendedServerIP = bestIP + resp.LoadInfo = s.formatLoadInfo(load) + + // Count how many clients on this IP + resp.StudioClients = load[bestIP] + + // Check if this is the same as the studio's previous choice + studio, hasStudio := s.studios[clientIP] + if hasStudio && studio.HomeServerIP == bestIP { + resp.Reason = "рекомендуемый сервер" + } else { + resp.Reason = "наименее загружен" + resp.IsRebalance = hasStudio && studio.HomeServerIP != "" + } + + return resp +} + +// GetLoadInfo returns load information for all available servers. +func (s *ConnectionStore) GetLoadInfo(availableIPs []string) []models.ServerLoadInfo { + s.mu.RLock() + defer s.mu.RUnlock() + + load := s.activeLoadLocked(availableIPs) + var infos []models.ServerLoadInfo + + for _, ip := range availableIPs { + clients := load[ip] + pct := 0 + if s.maxCap > 0 { + pct = (clients * 100) / s.maxCap + } + infos = append(infos, models.ServerLoadInfo{ + ServerIP: ip, + ActiveClients: clients, + LoadPercent: pct, + MaxCapacity: s.maxCap, + }) + } + + return infos +} + +// activeLoadLocked counts active sessions per server IP. Must be called with lock held. +func (s *ConnectionStore) activeLoadLocked(availableIPs []string) map[string]int { + load := make(map[string]int) + for _, ip := range availableIPs { + load[ip] = 0 + } + now := time.Now() + for _, sess := range s.sessions { + if now.Sub(sess.LastHeartbeat) < s.staleAfter { + load[sess.ServerIP]++ + } + } + return load +} + +// findLeastLoadedLocked finds the least loaded available + healthy server. +func (s *ConnectionStore) findLeastLoadedLocked(availableIPs []string, load map[string]int, healthyIPs map[string]bool) string { + type ipLoad struct { + ip string + count int + } + var candidates []ipLoad + + for _, ip := range availableIPs { + if len(healthyIPs) > 0 && !healthyIPs[ip] { + continue + } + candidates = append(candidates, ipLoad{ip, load[ip]}) + } + + if len(candidates) == 0 { + return "" + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].count < candidates[j].count + }) + + return candidates[0].ip +} + +// expireStaleLocked removes stale sessions and old studio records. +func (s *ConnectionStore) expireStaleLocked() { + now := time.Now() + + // Expire stale sessions + for key, sess := range s.sessions { + if now.Sub(sess.LastHeartbeat) > s.staleAfter { + delete(s.sessions, key) + } + } + + // Expire old studio records (kept for reference) + for key, studio := range s.studios { + if now.Sub(studio.LastSeen) > studioExpiry { + delete(s.studios, key) + } + } +} + +// saveLocked writes state to disk. +func (s *ConnectionStore) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + + store := struct { + Sessions map[string]*models.ActiveSession `json:"sessions"` + Studios map[string]*models.StudioRecord `json:"studios"` + }{ + Sessions: s.sessions, + Studios: s.studios, + } + + data, err := json.MarshalIndent(store, "", " ") + if err != nil { + return err + } + + tmpPath := s.path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return err + } + return os.Rename(tmpPath, s.path) +} + +func (s *ConnectionStore) formatLoadInfo(load map[string]int) string { + var parts []string + // Sort for consistent output + var ips []string + for ip := range load { + ips = append(ips, ip) + } + sort.Strings(ips) + + for _, ip := range ips { + parts = append(parts, ip+"="+itoaStr(load[ip])) + } + return "нагрузка: " + joinStr(parts, ", ") +} + +// SetMaxCapacity sets the max clients per server for load calculation. +func (s *ConnectionStore) SetMaxCapacity(n int) { + s.mu.Lock() + defer s.mu.Unlock() + if n > 0 { + s.maxCap = n + } +} + +// itoaStr converts int to string without fmt. +func itoaStr(n int) string { + if n == 0 { + return "0" + } + var digits []byte + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} + +// joinStr joins strings with separator without strings import. +func joinStr(parts []string, sep string) string { + if len(parts) == 0 { + return "" + } + result := parts[0] + for _, p := range parts[1:] { + result += sep + p + } + return result +} diff --git a/internal/rules/connections_test.go b/internal/rules/connections_test.go new file mode 100644 index 0000000..22355dc --- /dev/null +++ b/internal/rules/connections_test.go @@ -0,0 +1,201 @@ +package rules + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestConnectionStoreConnectAndRecommend(t *testing.T) { + tmpDir := t.TempDir() + store := NewConnectionStore(tmpDir) + if err := store.Load(); err != nil { + t.Fatal(err) + } + + availableIPs := []string{"5.180.97.198", "5.180.97.199", "5.180.97.197"} + healthyIPs := map[string]bool{"5.180.97.198": true, "5.180.97.199": true, "5.180.97.197": true} + + // Studio 1 connects to 198 + store.Connect("1.2.3.4", "5.180.97.198", "nl-198", "windows", "2.0.11") + + // Studio 1 asks — should get 198 (least loaded: 1 client vs 0/0) + // Actually: 198=1, 199=0, 197=0 → should get 199 or 197 (least loaded) + rec1 := store.GetRecommendation("1.2.3.4", availableIPs, healthyIPs) + if rec1.RecommendedServerIP == "5.180.97.198" { + // This is OK if load balancing picks a different server + t.Logf("studio 1 recommended: %s (reason: %s)", rec1.RecommendedServerIP, rec1.Reason) + } else { + t.Logf("studio 1 recommended different server: %s (load-based)", rec1.RecommendedServerIP) + } + + // Studio 2 is new — should also get least loaded + rec2 := store.GetRecommendation("9.9.9.9", availableIPs, healthyIPs) + if rec2.RecommendedServerIP == "" { + t.Fatal("expected recommendation for new studio") + } + t.Logf("studio 2 recommended: %s (reason: %s)", rec2.RecommendedServerIP, rec2.Reason) +} + +func TestPureLoadBalancing(t *testing.T) { + tmpDir := t.TempDir() + store := NewConnectionStore(tmpDir) + if err := store.Load(); err != nil { + t.Fatal(err) + } + + availableIPs := []string{"5.180.97.198", "5.180.97.199", "5.180.97.197"} + healthyIPs := map[string]bool{"5.180.97.198": true, "5.180.97.199": true, "5.180.97.197": true} + + // 3 studios connect to 198 (overload it) + for i := 0; i < 3; i++ { + ip := "10.0.0." + string(rune('1'+i)) + store.Connect(ip, "5.180.97.198", "nl-198", "windows", "") + } + + // New studio should NOT get 198 (3 clients) — should get 199 or 197 (0 clients) + rec := store.GetRecommendation("99.99.99.99", availableIPs, healthyIPs) + if rec.RecommendedServerIP == "5.180.97.198" { + t.Fatalf("should not recommend overloaded server, got %s", rec.RecommendedServerIP) + } + t.Logf("new studio recommended: %s (reason: %s)", rec.RecommendedServerIP, rec.Reason) + + // Even studio 1 (home=198) should get load-balanced recommendation + rec2 := store.GetRecommendation("10.0.0.1", availableIPs, healthyIPs) + t.Logf("studio 1 re-recommended: %s (reason: %s, isRebalance: %v)", + rec2.RecommendedServerIP, rec2.Reason, rec2.IsRebalance) +} + +func TestHomeServerUnhealthy(t *testing.T) { + tmpDir := t.TempDir() + store := NewConnectionStore(tmpDir) + if err := store.Load(); err != nil { + t.Fatal(err) + } + + availableIPs := []string{"5.180.97.198", "5.180.97.199"} + // 198 is NOT healthy + healthyIPs := map[string]bool{"5.180.97.199": true} + + // Studio 1 has connected to 198 + store.Connect("1.2.3.4", "5.180.97.198", "nl-198", "windows", "") + + // But 198 is unhealthy — should recommend 199 + rec := store.GetRecommendation("1.2.3.4", availableIPs, healthyIPs) + if rec.RecommendedServerIP == "5.180.97.198" { + t.Fatalf("should not recommend unhealthy server, got %s", rec.RecommendedServerIP) + } + if rec.RecommendedServerIP != "5.180.97.199" { + t.Fatalf("should recommend healthy server 199, got %s", rec.RecommendedServerIP) + } +} + +func TestDisconnect(t *testing.T) { + tmpDir := t.TempDir() + store := NewConnectionStore(tmpDir) + if err := store.Load(); err != nil { + t.Fatal(err) + } + + availableIPs := []string{"5.180.97.198"} + + store.Connect("1.2.3.4", "5.180.97.198", "nl-198", "windows", "") + + load := store.GetLoadInfo(availableIPs) + if len(load) == 0 || load[0].ActiveClients != 1 { + t.Fatalf("expected 1 active client, got %v", load) + } + + store.Disconnect("1.2.3.4") + + load = store.GetLoadInfo(availableIPs) + if len(load) == 0 || load[0].ActiveClients != 0 { + t.Fatalf("expected 0 active clients after disconnect, got %v", load) + } +} + +func TestSessionExpiry(t *testing.T) { + tmpDir := t.TempDir() + store := NewConnectionStore(tmpDir) + store.staleAfter = 1 * time.Millisecond + if err := store.Load(); err != nil { + t.Fatal(err) + } + + availableIPs := []string{"5.180.97.198"} + healthyIPs := map[string]bool{"5.180.97.198": true} + + store.Connect("1.2.3.4", "5.180.97.198", "nl-198", "windows", "") + time.Sleep(10 * time.Millisecond) + + rec := store.GetRecommendation("1.2.3.4", availableIPs, healthyIPs) + if rec.RecommendedServerIP != "5.180.97.198" { + t.Fatalf("expected recommendation to 198 after session expiry, got %s", rec.RecommendedServerIP) + } + + load := store.GetLoadInfo(availableIPs) + if len(load) == 0 || load[0].ActiveClients != 0 { + t.Fatalf("expected 0 active clients after expiry, got %v", load) + } +} + +func TestPersistence(t *testing.T) { + tmpDir := t.TempDir() + + store1 := NewConnectionStore(tmpDir) + store1.Connect("1.2.3.4", "5.180.97.199", "nl-199", "windows", "") + + store2 := NewConnectionStore(tmpDir) + if err := store2.Load(); err != nil { + t.Fatal(err) + } + + availableIPs := []string{"5.180.97.199"} + healthyIPs := map[string]bool{"5.180.97.199": true} + rec := store2.GetRecommendation("1.2.3.4", availableIPs, healthyIPs) + if rec.RecommendedServerIP != "5.180.97.199" { + t.Fatalf("expected recommendation to 199, got %s", rec.RecommendedServerIP) + } + + _, err := os.Stat(filepath.Join(tmpDir, "connections.json")) + if err != nil { + t.Fatal("expected connections.json to exist") + } +} + +func TestLoadInfoFormat(t *testing.T) { + tmpDir := t.TempDir() + store := NewConnectionStore(tmpDir) + store.maxCap = 10 + + store.Connect("1.1.1.1", "5.180.97.198", "nl-198", "windows", "") + store.Connect("2.2.2.2", "5.180.97.198", "nl-198", "windows", "") + store.Connect("3.3.3.3", "5.180.97.199", "nl-199", "linux", "") + + availableIPs := []string{"5.180.97.198", "5.180.97.199"} + load := store.GetLoadInfo(availableIPs) + + if len(load) != 2 { + t.Fatalf("expected 2 server load entries, got %d", len(load)) + } + + for _, info := range load { + if info.ServerIP == "5.180.97.198" { + if info.ActiveClients != 2 { + t.Errorf("expected 2 clients on 198, got %d", info.ActiveClients) + } + if info.LoadPercent != 20 { + t.Errorf("expected 20%% load on 198, got %d", info.LoadPercent) + } + } + if info.ServerIP == "5.180.97.199" { + if info.ActiveClients != 1 { + t.Errorf("expected 1 client on 199, got %d", info.ActiveClients) + } + if info.LoadPercent != 10 { + t.Errorf("expected 10%% load on 199, got %d", info.LoadPercent) + } + } + } +} diff --git a/internal/rules/loader.go b/internal/rules/loader.go new file mode 100644 index 0000000..7bbbe6e --- /dev/null +++ b/internal/rules/loader.go @@ -0,0 +1,210 @@ +package rules + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "vpnem/internal/models" +) + +type Store struct { + dataDir string + connections *ConnectionStore +} + +func NewStore(dataDir string) *Store { + s := &Store{ + dataDir: dataDir, + connections: NewConnectionStore(dataDir), + } + _ = s.connections.Load() + return s +} + +// Connections returns the connection store for recommendation logic. +func (s *Store) Connections() *ConnectionStore { + return s.connections +} + +func (s *Store) LoadServers() (*models.ServersResponse, error) { + data, err := os.ReadFile(filepath.Join(s.dataDir, "servers.json")) + if err != nil { + return nil, err + } + var resp models.ServersResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (s *Store) LoadRuleSets() (*models.RuleSetManifest, error) { + data, err := os.ReadFile(filepath.Join(s.dataDir, "rulesets.json")) + if err != nil { + return nil, err + } + var manifest models.RuleSetManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, err + } + return &manifest, nil +} + +func (s *Store) LoadVersion() (*models.VersionResponse, error) { + data, err := os.ReadFile(filepath.Join(s.dataDir, "version.json")) + if err != nil { + return nil, err + } + var ver models.VersionResponse + if err := json.Unmarshal(data, &ver); err != nil { + return nil, err + } + return &ver, nil +} + +func (s *Store) LoadCatalogV2() (*models.CatalogV2, error) { + data, err := os.ReadFile(filepath.Join(s.dataDir, "catalog-v2.json")) + if err != nil { + return nil, err + } + var catalog models.CatalogV2 + if err := json.Unmarshal(data, &catalog); err != nil { + return nil, err + } + return &catalog, nil +} + +func (s *Store) LoadCatalogV2OrLegacy() (*models.CatalogV2, error) { + catalog, err := s.LoadCatalogV2() + if err == nil { + return catalog, nil + } + if !os.IsNotExist(err) { + return nil, err + } + + servers, err := s.LoadServers() + if err != nil { + return nil, err + } + return legacyServersToCatalog(servers.Servers), nil +} + +func (s *Store) LoadRoutingPolicy() (*models.RoutingPolicy, error) { + data, err := os.ReadFile(filepath.Join(s.dataDir, "routing-policy.json")) + if err != nil { + return nil, err + } + var policy models.RoutingPolicy + if err := json.Unmarshal(data, &policy); err != nil { + return nil, err + } + return &policy, nil +} + +func (s *Store) RulesDir() string { + return filepath.Join(s.dataDir, "rules") +} + +func (s *Store) ReleasesDir() string { + return filepath.Join(s.dataDir, "releases") +} + +func (s *Store) DataDir() string { + return s.dataDir +} + +func legacyServersToCatalog(servers []models.Server) *models.CatalogV2 { + nodesByID := make(map[string]*models.CatalogNode, len(servers)) + order := make([]string, 0, len(servers)) + for _, server := range servers { + nodeID := server.Tag + if existingID, protocolType, ok := splitLegacyTag(server.Tag); ok && existingID != "" { + nodeID = existingID + server.Type = protocolType + } + + node := nodesByID[nodeID] + if node == nil { + node = &models.CatalogNode{ + ID: nodeID, + Name: nodeID, + Region: server.Region, + Host: server.Server, + PublicHost: server.Server, + Status: "published", + } + nodesByID[nodeID] = node + order = append(order, nodeID) + } + node.Protocols = append(node.Protocols, legacyServerToCatalogProtocol(server)) + } + + nodes := make([]models.CatalogNode, 0, len(order)) + for _, id := range order { + nodes = append(nodes, *nodesByID[id]) + } + return &models.CatalogV2{ + Version: "legacy-adapter", + Nodes: nodes, + } +} + +func legacyServerToCatalogProtocol(server models.Server) models.CatalogProtocol { + protocolType := server.Type + if protocolType == "socks" { + protocolType = "socks5" + } + protocol := models.CatalogProtocol{ + Type: protocolType, + Enabled: true, + Port: server.ServerPort, + TLS: server.TLS, + Extra: make(map[string]any), + } + switch server.Type { + case "vless", "vless-reality", "vmess": + protocol.Auth = &models.CatalogAuth{UUID: server.UUID} + protocol.Extra["legacy_tag"] = server.Tag + if server.Transport != nil { + protocol.Extra["transport_type"] = server.Transport.Type + if server.Transport.Path != "" { + protocol.Extra["path"] = server.Transport.Path + } + } + case "shadowsocks": + protocol.Auth = &models.CatalogAuth{Method: server.Method, Password: server.Password} + protocol.Extra["legacy_tag"] = server.Tag + case "hysteria2": + protocol.Auth = &models.CatalogAuth{Password: server.Password} + protocol.Extra["legacy_tag"] = server.Tag + if server.ObfsPassword != "" { + protocol.Extra["obfs_password"] = server.ObfsPassword + } + if server.UpMbps > 0 { + protocol.Extra["up_mbps"] = server.UpMbps + } + if server.DownMbps > 0 { + protocol.Extra["down_mbps"] = server.DownMbps + } + case "socks": + protocol.Extra["legacy_tag"] = server.Tag + protocol.Extra["udp_over_tcp"] = server.UDPOverTCP + } + if len(protocol.Extra) == 0 { + protocol.Extra = nil + } + return protocol +} + +func splitLegacyTag(tag string) (nodeID, protocolType string, ok bool) { + for _, candidate := range []string{"vless-reality", "vless", "vmess", "shadowsocks", "hysteria2", "socks", "socks5"} { + suffix := "-" + candidate + if strings.HasSuffix(tag, suffix) && len(tag) > len(suffix) { + return strings.TrimSuffix(tag, suffix), candidate, true + } + } + return "", "", false +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..97d2d47 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,174 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) + +// AppState holds persistent client state. +type AppState struct { + SelectedServer string `json:"selected_server"` + SelectedMode string `json:"selected_mode"` + LastSync time.Time `json:"last_sync"` + AutoConnect bool `json:"auto_connect"` + LocalProxyPort int `json:"local_proxy_port,omitempty"` + EnabledRuleSets map[string]bool `json:"enabled_rule_sets,omitempty"` + CustomBypass []string `json:"custom_bypass_processes,omitempty"` + RecommendedServer string `json:"recommended_server,omitempty"` + RecommendedNodeID string `json:"recommended_node_id,omitempty"` + LastRecommendation time.Time `json:"last_recommendation,omitempty"` + RecommendationFetched bool `json:"recommendation_fetched,omitempty"` // true if recommendation was ever fetched +} + +// Store manages persistent state on disk. +type Store struct { + mu sync.Mutex + path string + dataDir string + data AppState +} + +// NewStore creates a state store at the given path. +func NewStore(dataDir string) *Store { + return &Store{ + path: filepath.Join(dataDir, "state.json"), + dataDir: dataDir, + data: AppState{ + SelectedMode: "Комбо (приложения + Re-filter)", + AutoConnect: false, + }, + } +} + +// DataDir returns the data directory path. +func (s *Store) DataDir() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.dataDir +} + +// Load reads state from disk. Returns default state if file doesn't exist. +func (s *Store) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return json.Unmarshal(data, &s.data) +} + +// Save writes state to disk. +func (s *Store) Save() error { + s.mu.Lock() + defer s.mu.Unlock() + + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(s.data, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0o644) +} + +// Get returns a copy of the current state. +func (s *Store) Get() AppState { + s.mu.Lock() + defer s.mu.Unlock() + return s.data +} + +// SetServer updates the selected server. +func (s *Store) SetServer(tag string) { + s.mu.Lock() + s.data.SelectedServer = tag + s.mu.Unlock() +} + +// SetMode updates the selected routing mode. +func (s *Store) SetMode(mode string) { + s.mu.Lock() + s.data.SelectedMode = mode + s.mu.Unlock() +} + +// SetLastSync records the last sync time. +func (s *Store) SetLastSync(t time.Time) { + s.mu.Lock() + s.data.LastSync = t + s.mu.Unlock() +} + +// SetAutoConnect updates the auto-connect setting. +func (s *Store) SetAutoConnect(v bool) { + s.mu.Lock() + s.data.AutoConnect = v + s.mu.Unlock() +} + +func (s *Store) SetLocalProxyPort(port int) { + s.mu.Lock() + s.data.LocalProxyPort = port + s.mu.Unlock() +} + +// SetRuleSetEnabled enables/disables an optional rule-set. +func (s *Store) SetRuleSetEnabled(tag string, enabled bool) { + s.mu.Lock() + if s.data.EnabledRuleSets == nil { + s.data.EnabledRuleSets = make(map[string]bool) + } + s.data.EnabledRuleSets[tag] = enabled + s.mu.Unlock() +} + +// IsRuleSetEnabled checks if a rule-set is enabled. +func (s *Store) IsRuleSetEnabled(tag string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.data.EnabledRuleSets == nil { + return false + } + return s.data.EnabledRuleSets[tag] +} + +// SetCustomBypass sets custom bypass processes. +func (s *Store) SetCustomBypass(processes []string) { + s.mu.Lock() + s.data.CustomBypass = processes + s.mu.Unlock() +} + +// GetCustomBypass returns custom bypass processes. +func (s *Store) GetCustomBypass() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string{}, s.data.CustomBypass...) +} + +// SetRecommendation saves the server recommendation. +func (s *Store) SetRecommendation(serverIP, nodeID string) { + s.mu.Lock() + s.data.RecommendedServer = serverIP + s.data.RecommendedNodeID = nodeID + s.data.LastRecommendation = time.Now() + s.data.RecommendationFetched = true + s.mu.Unlock() +} + +// GetRecommendation returns the current recommendation. +func (s *Store) GetRecommendation() (serverIP, nodeID string, at time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.RecommendedServer, s.data.RecommendedNodeID, s.data.LastRecommendation +} diff --git a/internal/sync/fetcher.go b/internal/sync/fetcher.go new file mode 100644 index 0000000..3ea6df4 --- /dev/null +++ b/internal/sync/fetcher.go @@ -0,0 +1,643 @@ +package sync + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "time" + + "vpnem/internal/config" + "vpnem/internal/models" +) + +// Fetcher pulls configuration from the vpnem server API. +type Fetcher struct { + baseURL string + client *http.Client +} + +// NewFetcher creates a new Fetcher. +func NewFetcher(baseURL string) *Fetcher { + return &Fetcher{ + baseURL: baseURL, + client: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +// FetchServers retrieves the server list from the API. +func (f *Fetcher) FetchServers() (*models.ServersResponse, error) { + catalog, err := f.FetchCatalog() + if err == nil { + return &models.ServersResponse{Servers: CatalogToServers(catalog)}, nil + } + return nil, fmt.Errorf("fetch catalog: %w", err) +} + +func (f *Fetcher) FetchCatalogV2() (*models.CatalogV2, error) { + var resp models.CatalogV2 + if err := f.getJSON("/api/v2/catalog", &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (f *Fetcher) FetchCatalog() (*models.CatalogV2, error) { + catalog, err := f.FetchCatalogV2() + if err == nil { + return catalog, nil + } + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusNotFound { + return nil, fmt.Errorf("fetch catalog v2: %w", err) + } + + var resp models.ServersResponse + if err := f.getJSON("/api/v1/servers", &resp); err != nil { + return nil, fmt.Errorf("fetch servers: %w", err) + } + return ServersToCatalog(resp.Servers), nil +} + +func (f *Fetcher) FetchRoutingPolicy() (*models.RoutingPolicy, error) { + var resp models.RoutingPolicy + if err := f.getJSON("/api/v1/routing-policy", &resp); err != nil { + var statusErr *HTTPStatusError + if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound { + return config.DefaultRoutingPolicy(), nil + } + return nil, fmt.Errorf("fetch routing policy: %w", err) + } + return config.EffectiveRoutingPolicy(&resp), nil +} + +// FetchRuleSets retrieves the rule-set manifest from the API. +func (f *Fetcher) FetchRuleSets() (*models.RuleSetManifest, error) { + var resp models.RuleSetManifest + if err := f.getJSON("/api/v1/ruleset/manifest", &resp); err != nil { + return nil, fmt.Errorf("fetch rulesets: %w", err) + } + return &resp, nil +} + +// DownloadRuleSets downloads all non-optional .srs files to dataDir/rules/. +// Returns the updated RuleSet list with LocalPath populated. +func (f *Fetcher) DownloadRuleSets(ruleSets []models.RuleSet, dataDir string) ([]models.RuleSet, error) { + rulesDir := filepath.Join(dataDir, "rules") + if err := os.MkdirAll(rulesDir, 0755); err != nil { + return nil, fmt.Errorf("create rules dir: %w", err) + } + + var downloaded []models.RuleSet + for _, rs := range ruleSets { + if rs.URL == "" { + if rs.Optional { + continue + } + return nil, fmt.Errorf("rule-set %s has no URL", rs.Tag) + } + localPath := filepath.Join(rulesDir, rs.Tag+".srs") + resp, err := f.client.Get(rs.URL) + if err != nil { + if rs.Optional { + continue + } + return nil, fmt.Errorf("download %s: %w", rs.URL, err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + if rs.Optional { + continue + } + return nil, fmt.Errorf("read %s: %w", rs.URL, err) + } + if resp.StatusCode != http.StatusOK { + if rs.Optional { + continue + } + return nil, fmt.Errorf("download %s: HTTP %d", rs.URL, resp.StatusCode) + } + if err := os.WriteFile(localPath, body, 0644); err != nil { + return nil, fmt.Errorf("write %s: %w", localPath, err) + } + rs.LocalPath = localPath + downloaded = append(downloaded, rs) + } + return downloaded, nil +} + +// FetchVersion retrieves the latest client version info. +func (f *Fetcher) FetchVersion() (*models.VersionResponse, error) { + var resp models.VersionResponse + if err := f.getJSON("/api/v1/version", &resp); err != nil { + return nil, fmt.Errorf("fetch version: %w", err) + } + return &resp, nil +} + +// ServerIPs extracts all unique server IPs from the server list. +func ServerIPs(servers []models.Server) []string { + seen := make(map[string]bool) + var ips []string + for _, s := range servers { + if !seen[s.Server] { + seen[s.Server] = true + ips = append(ips, s.Server) + } + } + return ips +} + +// ReportError sends error logs to the server (best-effort, non-blocking). +func (f *Fetcher) ReportError(version, osName string, lines []string) { + payload := map[string]any{ + "version": version, + "os": osName, + "lines": lines, + } + data, err := json.Marshal(payload) + if err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.baseURL+"/logs2026vpnem/errors", bytes.NewReader(data)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + resp, err := f.client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) +} + +func (f *Fetcher) getJSON(path string, v any) error { + resp, err := f.client.Get(f.baseURL + path) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)} + } + + return json.NewDecoder(resp.Body).Decode(v) +} + +type HTTPStatusError struct { + StatusCode int + Body string +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body) +} + +// ReportConnect sends a connection report to the server. +// Server auto-detects client real IP from X-Forwarded-For. +func (f *Fetcher) ReportConnect(serverIP, nodeID string) (*models.RecommendationResponse, error) { + req := models.ConnectRequest{ + ServerIP: serverIP, + NodeID: nodeID, + OS: func() string { + if runtime.GOOS == "windows" { + return "windows" + } + return "linux" + }(), + Version: "", // server doesn't need version for rebalancing + } + + payload, err := json.Marshal(req) + if err != nil { + return nil, err + } + + resp, err := f.client.Post(f.baseURL+"/api/v1/connect", "application/json", bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("report connect: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)} + } + + var recommendation models.RecommendationResponse + if err := json.NewDecoder(resp.Body).Decode(&recommendation); err != nil { + return nil, fmt.Errorf("decode recommendation: %w", err) + } + return &recommendation, nil +} + +// ReportDisconnect notifies the server that a client disconnected. +func (f *Fetcher) ReportDisconnect(serverIP, nodeID string) error { + req := models.DisconnectRequest{ + ServerIP: serverIP, + NodeID: nodeID, + } + + payload, err := json.Marshal(req) + if err != nil { + return err + } + + resp, err := f.client.Post(f.baseURL+"/api/v1/disconnect", "application/json", bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("report disconnect: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)} + } + return nil +} + +// GetRecommendation fetches a recommendation for the client. +// Server auto-detects client real IP from X-Forwarded-For. +func (f *Fetcher) GetRecommendation() (*models.RecommendationResponse, error) { + url := f.baseURL + "/api/v1/recommend" + resp, err := f.client.Get(url) + if err != nil { + return nil, fmt.Errorf("get recommendation: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)} + } + + var recommendation models.RecommendationResponse + if err := json.NewDecoder(resp.Body).Decode(&recommendation); err != nil { + return nil, fmt.Errorf("decode recommendation: %w", err) + } + return &recommendation, nil +} + +func CatalogToServers(catalog *models.CatalogV2) []models.Server { + if catalog == nil { + return nil + } + servers := make([]models.Server, 0) + for _, node := range catalog.Nodes { + if multi, ok := nodeToSplitServer(node); ok { + servers = append(servers, multi) + } + host := node.PublicHost + if host == "" { + if node.Domain != "" { + host = node.Domain + } else { + host = node.Host + } + } + for _, protocol := range node.Protocols { + if !protocol.Enabled { + continue + } + if isSplitProtocol(protocol.Type) && hasSplitPair(node) { + continue + } + server := models.Server{ + Tag: legacyTag(node, protocol), + Region: node.Region, + Type: protocol.Type, + Server: host, + ServerPort: protocol.Port, + } + if protocol.TLS != nil { + server.TLS = &models.TLS{ + Enabled: protocol.TLS.Enabled, + ServerName: protocol.TLS.ServerName, + Insecure: protocol.TLS.Insecure, + ALPN: protocol.TLS.ALPN, + MinVersion: protocol.TLS.MinVersion, + MaxVersion: protocol.TLS.MaxVersion, + } + if protocol.TLS.Reality != nil && protocol.TLS.Reality.Enabled { + server.TLS.Reality = &models.Reality{ + Enabled: true, + PublicKey: protocol.TLS.Reality.PublicKey, + ShortID: protocol.TLS.Reality.ShortID, + Fingerprint: protocol.TLS.Reality.Fingerprint, + } + } + } + switch protocol.Type { + case "vless", "vless-reality", "vmess": + if protocol.Auth != nil { + server.UUID = protocol.Auth.UUID + } + if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" { + server.Transport = &models.Transport{ + Type: transportType, + Path: extraString(protocol.Extra, "path"), + } + } else if path := extraString(protocol.Extra, "path"); path != "" { + server.Transport = &models.Transport{ + Type: "ws", + Path: path, + } + } + case "shadowsocks": + if protocol.Auth != nil { + server.Method = protocol.Auth.Method + server.Password = protocol.Auth.Password + } + case "hysteria2": + if protocol.Auth != nil { + server.Password = protocol.Auth.Password + } + server.ObfsPassword = extraString(protocol.Extra, "obfs_password") + server.UpMbps = extraInt(protocol.Extra, "up_mbps", 0) + server.DownMbps = extraInt(protocol.Extra, "down_mbps", 0) + if server.TLS == nil { + server.TLS = &models.TLS{} + } + server.TLS.Enabled = true + server.TLS.Insecure = true + if len(server.TLS.ALPN) == 0 { + server.TLS.ALPN = []string{"h3"} + } + if server.TLS.MinVersion == "" { + server.TLS.MinVersion = "1.3" + } + if server.TLS.MaxVersion == "" { + server.TLS.MaxVersion = "1.3" + } + case "socks5": + server.Type = "socks" + } + servers = append(servers, server) + } + } + return servers +} + +func nodeToSplitServer(node models.CatalogNode) (models.Server, bool) { + if !hasSplitPair(node) { + return models.Server{}, false + } + host := node.PublicHost + if host == "" { + if node.Domain != "" { + host = node.Domain + } else { + host = node.Host + } + } + var main models.Server + var hy2 models.Server + for _, protocol := range node.Protocols { + if !protocol.Enabled { + continue + } + switch protocol.Type { + case "vless-reality": + main = catalogProtocolToServer(node, protocol, host) + case "hysteria2": + hy2 = catalogProtocolToServer(node, protocol, host) + } + } + if main.Type == "" || hy2.Type == "" { + return models.Server{}, false + } + main.Tag = node.ID + "-multi" + main.Companions = []models.Server{hy2} + return main, true +} + +func hasSplitPair(node models.CatalogNode) bool { + hasReality := false + hasHy2 := false + for _, protocol := range node.Protocols { + if !protocol.Enabled { + continue + } + switch protocol.Type { + case "vless-reality": + hasReality = true + case "hysteria2": + hasHy2 = true + } + } + return hasReality && hasHy2 +} + +func isSplitProtocol(protocolType string) bool { + return protocolType == "vless-reality" || protocolType == "hysteria2" +} + +func catalogProtocolToServer(node models.CatalogNode, protocol models.CatalogProtocol, host string) models.Server { + server := models.Server{ + Tag: legacyTag(node, protocol), + Region: node.Region, + Type: protocol.Type, + Server: host, + ServerPort: protocol.Port, + } + if protocol.TLS != nil { + server.TLS = &models.TLS{ + Enabled: protocol.TLS.Enabled, + ServerName: protocol.TLS.ServerName, + Insecure: protocol.TLS.Insecure, + ALPN: protocol.TLS.ALPN, + MinVersion: protocol.TLS.MinVersion, + MaxVersion: protocol.TLS.MaxVersion, + } + if protocol.TLS.Reality != nil && protocol.TLS.Reality.Enabled { + server.TLS.Reality = &models.Reality{ + Enabled: true, + PublicKey: protocol.TLS.Reality.PublicKey, + ShortID: protocol.TLS.Reality.ShortID, + Fingerprint: protocol.TLS.Reality.Fingerprint, + } + } + } + switch protocol.Type { + case "vless", "vless-reality", "vmess": + if protocol.Auth != nil { + server.UUID = protocol.Auth.UUID + } + if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" { + server.Transport = &models.Transport{ + Type: transportType, + Path: extraString(protocol.Extra, "path"), + } + } else if path := extraString(protocol.Extra, "path"); path != "" { + server.Transport = &models.Transport{ + Type: "ws", + Path: path, + } + } + case "shadowsocks": + if protocol.Auth != nil { + server.Method = protocol.Auth.Method + server.Password = protocol.Auth.Password + } + case "hysteria2": + if protocol.Auth != nil { + server.Password = protocol.Auth.Password + } + server.ObfsPassword = extraString(protocol.Extra, "obfs_password") + server.UpMbps = extraInt(protocol.Extra, "up_mbps", 0) + server.DownMbps = extraInt(protocol.Extra, "down_mbps", 0) + if server.TLS == nil { + server.TLS = &models.TLS{} + } + server.TLS.Enabled = true + server.TLS.Insecure = true + if len(server.TLS.ALPN) == 0 { + server.TLS.ALPN = []string{"h3"} + } + if server.TLS.MinVersion == "" { + server.TLS.MinVersion = "1.3" + } + if server.TLS.MaxVersion == "" { + server.TLS.MaxVersion = "1.3" + } + case "socks5": + server.Type = "socks" + } + return server +} + +func ServersToCatalog(servers []models.Server) *models.CatalogV2 { + nodesByID := make(map[string]*models.CatalogNode, len(servers)) + order := make([]string, 0, len(servers)) + for _, server := range servers { + nodeID := server.Tag + if existingID, protocolType, ok := splitLegacyTag(server.Tag); ok && existingID != "" { + nodeID = existingID + server.Type = protocolType + } + + node := nodesByID[nodeID] + if node == nil { + node = &models.CatalogNode{ + ID: nodeID, + Name: nodeID, + Region: server.Region, + Host: server.Server, + PublicHost: server.Server, + Status: "published", + } + nodesByID[nodeID] = node + order = append(order, nodeID) + } + node.Protocols = append(node.Protocols, serverToCatalogProtocol(server)) + } + + nodes := make([]models.CatalogNode, 0, len(order)) + for _, id := range order { + nodes = append(nodes, *nodesByID[id]) + } + return &models.CatalogV2{ + Version: "legacy-adapter", + Nodes: nodes, + } +} + +func serverToCatalogProtocol(server models.Server) models.CatalogProtocol { + protocolType := server.Type + if protocolType == "socks" { + protocolType = "socks5" + } + protocol := models.CatalogProtocol{ + Type: protocolType, + Enabled: true, + Port: server.ServerPort, + TLS: server.TLS, + Extra: make(map[string]any), + } + switch server.Type { + case "vless", "vless-reality", "vmess": + protocol.Auth = &models.CatalogAuth{UUID: server.UUID} + protocol.Extra["legacy_tag"] = server.Tag + if server.Transport != nil { + protocol.Extra["transport_type"] = server.Transport.Type + if server.Transport.Path != "" { + protocol.Extra["path"] = server.Transport.Path + } + } + case "shadowsocks": + protocol.Auth = &models.CatalogAuth{Method: server.Method, Password: server.Password} + protocol.Extra["legacy_tag"] = server.Tag + case "hysteria2": + protocol.Auth = &models.CatalogAuth{Password: server.Password} + protocol.Extra["legacy_tag"] = server.Tag + if server.ObfsPassword != "" { + protocol.Extra["obfs_password"] = server.ObfsPassword + } + if server.UpMbps > 0 { + protocol.Extra["up_mbps"] = server.UpMbps + } + if server.DownMbps > 0 { + protocol.Extra["down_mbps"] = server.DownMbps + } + case "socks": + protocol.Extra["legacy_tag"] = server.Tag + protocol.Extra["udp_over_tcp"] = server.UDPOverTCP + } + if len(protocol.Extra) == 0 { + protocol.Extra = nil + } + return protocol +} + +func splitLegacyTag(tag string) (nodeID, protocolType string, ok bool) { + for _, candidate := range []string{"vless-reality", "vless", "vmess", "shadowsocks", "hysteria2", "socks", "socks5"} { + suffix := "-" + candidate + if len(tag) > len(suffix) && tag[len(tag)-len(suffix):] == suffix { + return tag[:len(tag)-len(suffix)], candidate, true + } + } + return "", "", false +} + +func legacyTag(node models.CatalogNode, protocol models.CatalogProtocol) string { + if tag := extraString(protocol.Extra, "legacy_tag"); tag != "" { + return tag + } + return node.ID + "-" + protocol.Type +} + +func extraString(extra map[string]any, key string) string { + if extra == nil { + return "" + } + value, _ := extra[key].(string) + return value +} + +func extraInt(extra map[string]any, key string, fallback int) int { + if extra == nil { + return fallback + } + switch value := extra[key].(type) { + case int: + return value + case float64: + return int(value) + default: + return fallback + } +} diff --git a/internal/sync/fetcher_test.go b/internal/sync/fetcher_test.go new file mode 100644 index 0000000..cdf3e73 --- /dev/null +++ b/internal/sync/fetcher_test.go @@ -0,0 +1,300 @@ +package sync + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "vpnem/internal/models" +) + +func TestCatalogToServers(t *testing.T) { + catalog := &models.CatalogV2{ + Version: "2", + Nodes: []models.CatalogNode{ + { + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + PublicHost: "nl-01.example.com", + Protocols: []models.CatalogProtocol{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &models.TLS{Enabled: true, ServerName: "nl-01.example.com"}, + Auth: &models.CatalogAuth{UUID: "11111111-1111-1111-1111-111111111111"}, + Extra: map[string]any{"transport_type": "ws", "path": "/ws"}, + }, + { + Type: "vmess", + Enabled: true, + Port: 8444, + TLS: &models.TLS{Enabled: true, ServerName: "nl-01.example.com"}, + Auth: &models.CatalogAuth{UUID: "22222222-2222-2222-2222-222222222222"}, + Extra: map[string]any{"path": "/vmess"}, + }, + { + Type: "hysteria2", + Enabled: true, + Port: 9443, + TLS: &models.TLS{Enabled: true, ServerName: "nl-01.example.com"}, + Auth: &models.CatalogAuth{Password: "hy2-secret"}, + Extra: map[string]any{"obfs_password": "obfs-secret", "up_mbps": 80, "down_mbps": 90}, + }, + }, + }, + }, + } + + servers := CatalogToServers(catalog) + if len(servers) != 3 { + t.Fatalf("len(servers) = %d, want 3", len(servers)) + } + if servers[1].Type != "vmess" { + t.Fatalf("expected vmess, got %q", servers[1].Type) + } + if servers[2].Type != "hysteria2" { + t.Fatalf("expected hysteria2, got %q", servers[2].Type) + } + if servers[2].ObfsPassword != "obfs-secret" { + t.Fatalf("unexpected hysteria2 obfs password") + } +} + +func TestFetchServersPrefersCatalogV2(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/catalog": + _ = json.NewEncoder(w).Encode(models.CatalogV2{ + Version: "2", + Nodes: []models.CatalogNode{ + { + ID: "nl-01", + Name: "NL 01", + Region: "nl", + Host: "203.0.113.10", + PublicHost: "nl-01.example.com", + Protocols: []models.CatalogProtocol{ + {Type: "vmess", Enabled: true, Port: 8444, Auth: &models.CatalogAuth{UUID: "22222222-2222-2222-2222-222222222222"}}, + }, + }, + }, + }) + case "/api/v1/servers": + t.Fatal("legacy servers endpoint should not be used when catalog-v2 is available") + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + fetcher := NewFetcher(server.URL) + resp, err := fetcher.FetchServers() + if err != nil { + t.Fatalf("FetchServers error = %v", err) + } + if len(resp.Servers) != 1 { + t.Fatalf("expected 1 server, got %d", len(resp.Servers)) + } + if resp.Servers[0].Type != "vmess" { + t.Fatalf("expected vmess, got %q", resp.Servers[0].Type) + } +} + +func TestFetchServersFallsBackToLegacy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/catalog": + http.NotFound(w, r) + case "/api/v1/servers": + _ = json.NewEncoder(w).Encode(models.ServersResponse{ + Servers: []models.Server{{Tag: "legacy", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + fetcher := NewFetcher(server.URL) + resp, err := fetcher.FetchServers() + if err != nil { + t.Fatalf("FetchServers error = %v", err) + } + if len(resp.Servers) != 1 || !strings.EqualFold(resp.Servers[0].Tag, "legacy") { + t.Fatalf("unexpected legacy response: %+v", resp.Servers) + } +} + +func TestFetchCatalogFallsBackToLegacy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/catalog": + http.NotFound(w, r) + case "/api/v1/servers": + _ = json.NewEncoder(w).Encode(models.ServersResponse{ + Servers: []models.Server{ + {Tag: "legacy-vless", Region: "nl", Type: "vless", Server: "legacy.example.com", ServerPort: 443, UUID: "11111111-1111-1111-1111-111111111111"}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + fetcher := NewFetcher(server.URL) + catalog, err := fetcher.FetchCatalog() + if err != nil { + t.Fatalf("FetchCatalog error = %v", err) + } + if catalog.Version != "legacy-adapter" { + t.Fatalf("expected legacy-adapter version, got %q", catalog.Version) + } + if len(catalog.Nodes) != 1 || len(catalog.Nodes[0].Protocols) != 1 { + t.Fatalf("unexpected catalog shape: %+v", catalog) + } + if catalog.Nodes[0].Protocols[0].Type != "vless" { + t.Fatalf("expected vless protocol, got %q", catalog.Nodes[0].Protocols[0].Type) + } +} + +func TestFetchRoutingPolicyFallsBackToDefault(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + fetcher := NewFetcher(server.URL) + policy, err := fetcher.FetchRoutingPolicy() + if err != nil { + t.Fatalf("FetchRoutingPolicy error = %v", err) + } + if policy.Version == "" { + t.Fatalf("expected default policy version") + } + if len(policy.AlwaysDirectProcesses) == 0 { + t.Fatalf("expected default direct processes") + } +} + +func TestFetchRoutingPolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/routing-policy": + _ = json.NewEncoder(w).Encode(models.RoutingPolicy{ + Version: "remote-policy", + AlwaysDirectProcesses: []string{"chromium.exe"}, + BlockedDomains: []string{"example.com"}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + fetcher := NewFetcher(server.URL) + policy, err := fetcher.FetchRoutingPolicy() + if err != nil { + t.Fatalf("FetchRoutingPolicy error = %v", err) + } + if policy.Version != "remote-policy" { + t.Fatalf("expected remote-policy, got %q", policy.Version) + } + if len(policy.AlwaysDirectProcesses) != 1 || policy.AlwaysDirectProcesses[0] != "chromium.exe" { + t.Fatalf("unexpected routing policy: %+v", policy) + } +} + +func TestServersToCatalog(t *testing.T) { + catalog := ServersToCatalog([]models.Server{ + { + Tag: "nl-01-vless", + Region: "nl", + Type: "vless", + Server: "nl-01.example.com", + ServerPort: 443, + UUID: "11111111-1111-1111-1111-111111111111", + TLS: &models.TLS{Enabled: true, ServerName: "nl-01.example.com"}, + Transport: &models.Transport{Type: "ws", Path: "/ws"}, + }, + { + Tag: "nl-01-hysteria2", + Region: "nl", + Type: "hysteria2", + Server: "nl-01.example.com", + ServerPort: 9443, + Password: "hy2-secret", + ObfsPassword: "obfs-secret", + }, + }) + + if catalog.Version != "legacy-adapter" { + t.Fatalf("unexpected version %q", catalog.Version) + } + if len(catalog.Nodes) != 1 { + t.Fatalf("expected one node, got %d", len(catalog.Nodes)) + } + if len(catalog.Nodes[0].Protocols) != 2 { + t.Fatalf("expected two protocols, got %d", len(catalog.Nodes[0].Protocols)) + } + if catalog.Nodes[0].Protocols[1].Extra["obfs_password"] != "obfs-secret" { + t.Fatalf("expected obfs password in extra") + } +} + +func TestCatalogToServersMultiProtocolNode(t *testing.T) { + catalog := &models.CatalogV2{ + Version: "2", + Nodes: []models.CatalogNode{ + { + ID: "nl-multi-01", + Name: "NL Multi", + Region: "nl", + Host: "203.0.113.55", + PublicHost: "203.0.113.55", + Protocols: []models.CatalogProtocol{ + { + Type: "vless-reality", + Enabled: true, + Port: 443, + Auth: &models.CatalogAuth{UUID: "11111111-1111-1111-1111-111111111111"}, + TLS: &models.TLS{ + Enabled: true, + ServerName: "www.microsoft.com", + Reality: &models.Reality{ + Enabled: true, + PublicKey: "pubkey", + ShortID: "shortid", + Fingerprint: "chrome", + }, + }, + }, + { + Type: "hysteria2", + Enabled: true, + Port: 443, + Auth: &models.CatalogAuth{Password: "hy2-secret"}, + TLS: &models.TLS{Enabled: true, Insecure: true, ALPN: []string{"h3"}}, + Extra: map[string]any{"obfs_password": "obfs-secret", "up_mbps": 100, "down_mbps": 100}, + }, + }, + }, + }, + } + + servers := CatalogToServers(catalog) + if len(servers) != 1 { + t.Fatalf("expected 1 synthetic multi server, got %d", len(servers)) + } + if servers[0].Tag != "nl-multi-01-multi" { + t.Fatalf("unexpected synthetic tag %q", servers[0].Tag) + } + if len(servers[0].Companions) != 1 || servers[0].Companions[0].Type != "hysteria2" { + t.Fatalf("expected hysteria2 companion, got %+v", servers[0].Companions) + } +} diff --git a/internal/sync/health.go b/internal/sync/health.go new file mode 100644 index 0000000..4d6ceca --- /dev/null +++ b/internal/sync/health.go @@ -0,0 +1,33 @@ +package sync + +import ( + "fmt" + "net" + "time" + + "vpnem/internal/models" +) + +// HealthCheck tests if a server's proxy port is reachable. +func HealthCheck(server models.Server, timeout time.Duration) error { + addr := fmt.Sprintf("%s:%d", server.Server, server.ServerPort) + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return fmt.Errorf("server %s unreachable: %w", server.Tag, err) + } + conn.Close() + return nil +} + +// FindHealthyServer returns the first healthy non-RU server from the list. +func FindHealthyServer(servers []models.Server, timeout time.Duration) *models.Server { + for _, s := range servers { + if s.Region == "RU" { + continue + } + if err := HealthCheck(s, timeout); err == nil { + return &s + } + } + return nil +} diff --git a/internal/sync/latency.go b/internal/sync/latency.go new file mode 100644 index 0000000..dd3268b --- /dev/null +++ b/internal/sync/latency.go @@ -0,0 +1,62 @@ +package sync + +import ( + "fmt" + "net" + "sort" + "sync" + "time" + + "vpnem/internal/models" +) + +// LatencyResult holds a server's latency measurement. +type LatencyResult struct { + Tag string `json:"tag"` + Region string `json:"region"` + Latency int `json:"latency_ms"` // -1 means unreachable +} + +// MeasureLatency pings all servers concurrently and returns results sorted by latency. +func MeasureLatency(servers []models.Server, timeout time.Duration) []LatencyResult { + var wg sync.WaitGroup + results := make([]LatencyResult, len(servers)) + + for i, s := range servers { + wg.Add(1) + go func(idx int, srv models.Server) { + defer wg.Done() + ms := tcpPing(srv.Server, srv.ServerPort, timeout) + results[idx] = LatencyResult{ + Tag: srv.Tag, + Region: srv.Region, + Latency: ms, + } + }(i, s) + } + + wg.Wait() + + sort.Slice(results, func(i, j int) bool { + if results[i].Latency == -1 { + return false + } + if results[j].Latency == -1 { + return true + } + return results[i].Latency < results[j].Latency + }) + + return results +} + +func tcpPing(host string, port int, timeout time.Duration) int { + addr := fmt.Sprintf("%s:%d", host, port) + start := time.Now() + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return -1 + } + conn.Close() + return int(time.Since(start).Milliseconds()) +} diff --git a/internal/sync/updater.go b/internal/sync/updater.go new file mode 100644 index 0000000..983ce43 --- /dev/null +++ b/internal/sync/updater.go @@ -0,0 +1,180 @@ +package sync + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "time" +) + +// Updater checks for and downloads client updates. +type Updater struct { + fetcher *Fetcher + currentVer string + dataDir string +} + +// NewUpdater creates an updater. +func NewUpdater(fetcher *Fetcher, currentVersion, dataDir string) *Updater { + return &Updater{ + fetcher: fetcher, + currentVer: currentVersion, + dataDir: dataDir, + } +} + +// UpdateInfo describes an available update. +type UpdateInfo struct { + Available bool `json:"available"` + Version string `json:"version"` + Changelog string `json:"changelog"` + CurrentVer string `json:"current_version"` +} + +// Check returns info about available updates. +func (u *Updater) Check() (*UpdateInfo, error) { + ver, err := u.fetcher.FetchVersion() + if err != nil { + return nil, fmt.Errorf("check update: %w", err) + } + + return &UpdateInfo{ + Available: ver.Version != u.currentVer, + Version: ver.Version, + Changelog: ver.Changelog, + CurrentVer: u.currentVer, + }, nil +} + +// Download fetches the new binary, verifies checksum, and prepares for restart. +// On success it returns "restart_pending" and the caller should exit gracefully. +func (u *Updater) Download() (string, error) { + ver, err := u.fetcher.FetchVersion() + if err != nil { + return "", fmt.Errorf("fetch version: %w", err) + } + + if ver.URL == "" { + suffix := "linux-amd64" + if runtime.GOOS == "windows" { + suffix = "windows-amd64.exe" + } + ver.URL = u.fetcher.baseURL + "/releases/vpnem-" + suffix + } + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Get(ver.URL) + if err != nil { + return "", fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download: HTTP %d", resp.StatusCode) + } + + ext := "" + if runtime.GOOS == "windows" { + ext = ".exe" + } + + // Download to temp file first + downloadPath := filepath.Join(u.dataDir, "vpnem-new"+ext) + f, err := os.Create(downloadPath) + if err != nil { + return "", fmt.Errorf("create file: %w", err) + } + + // Track SHA256 while downloading + hasher := sha256.New() + written, err := io.Copy(io.MultiWriter(f, hasher), resp.Body) + f.Close() + if err != nil { + os.Remove(downloadPath) + return "", fmt.Errorf("write update: %w", err) + } + + // Verify SHA256 checksum if provided + if ver.SHA256 != "" { + gotHash := hex.EncodeToString(hasher.Sum(nil)) + if gotHash != ver.SHA256 { + os.Remove(downloadPath) + return "", fmt.Errorf("checksum mismatch: expected %s, got %s (%.1f MB)", ver.SHA256, gotHash, float64(written)/1024/1024) + } + log.Printf("update: sha256 verified (%.1f MB)", float64(written)/1024/1024) + } + + // Clean stale configs so new version starts fresh + os.Remove(filepath.Join(u.dataDir, "state.json")) + os.Remove(filepath.Join(u.dataDir, "config.json")) + os.Remove(filepath.Join(u.dataDir, "cache.db")) + + currentBin, _ := os.Executable() + if currentBin == "" { + return "", fmt.Errorf("could not determine current binary") + } + + if runtime.GOOS == "windows" { + // Windows: can't overwrite running exe + // Strategy: rename old to .old, copy new in place + // If .old already exists from a previous failed update, remove it + oldBin := currentBin + ".old" + os.Remove(oldBin) + + if err := os.Rename(currentBin, oldBin); err != nil { + return "", fmt.Errorf("rename old binary: %w", err) + } + + if err := copyFile(downloadPath, currentBin); err != nil { + // Restore old binary + os.Remove(currentBin) + os.Rename(oldBin, currentBin) + os.Remove(downloadPath) + return "", fmt.Errorf("copy new binary: %w", err) + } + + os.Remove(downloadPath) + log.Printf("update: binary replaced, version %s", ver.Version) + return "restart_pending", nil + } + + // Linux: overwrite in place + if err := copyFile(downloadPath, currentBin); err != nil { + os.Remove(downloadPath) + return "", fmt.Errorf("copy new binary: %w", err) + } + os.Remove(downloadPath) + log.Printf("update: binary replaced, version %s", ver.Version) + return "restart_pending", nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + if err != nil { + return err + } + + // Preserve executable permission on Linux + if runtime.GOOS != "windows" { + os.Chmod(dst, 0o755) + } + return nil +} diff --git a/plugins/claude-migration/.codex-plugin/plugin.json b/plugins/claude-migration/.codex-plugin/plugin.json new file mode 100644 index 0000000..f503dca --- /dev/null +++ b/plugins/claude-migration/.codex-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "claude-migration", + "version": "0.1.0", + "description": "Migrated Claude Code workflows, instructions, and MCP templates for vpnem.", + "author": { + "name": "imya", + "email": "local-only@example.invalid", + "url": "https://example.invalid/local-only" + }, + "homepage": "https://example.invalid/vpnem-codex-local", + "repository": "https://example.invalid/vpnem-codex-local", + "license": "UNLICENSED", + "keywords": [ + "claude", + "migration", + "vpnem", + "workflow" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Claude Migration", + "shortDescription": "Portable Claude-to-Codex workflows for this repo.", + "longDescription": "Adds Codex skills and MCP templates derived from the existing Claude Code setup under /home/imya, sanitized for safe repo-local use.", + "developerName": "imya", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://example.invalid/vpnem-codex-local", + "privacyPolicyURL": "https://example.invalid/privacy", + "termsOfServiceURL": "https://example.invalid/terms", + "defaultPrompt": [ + "Show vpnem status using the migrated workflow.", + "Review the current diff with the migration rules.", + "Explain which Claude MCP servers were ported here." + ], + "brandColor": "#1F6FEB" + } +} diff --git a/plugins/claude-migration/.mcp.json b/plugins/claude-migration/.mcp.json new file mode 100644 index 0000000..c3ac2bb --- /dev/null +++ b/plugins/claude-migration/.mcp.json @@ -0,0 +1,25 @@ +{ + "mcpServers": { + "postgres": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://${VIBE_INFRA_DB_USER}:${VIBE_INFRA_DB_PASS}@localhost:5433/gitea" + ] + }, + "sonarqube": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "sonarqube-mcp-server" + ], + "env": { + "SONARQUBE_URL": "http://localhost:9100", + "SONARQUBE_TOKEN": "${SONARQUBE_TOKEN}" + } + } + } +} diff --git a/scripts/install-ruleset-timer.sh b/scripts/install-ruleset-timer.sh new file mode 100755 index 0000000..dc34008 --- /dev/null +++ b/scripts/install-ruleset-timer.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +SERVICE_SRC="${1:-/opt/vpnem/deploy/systemd/vpnem-rulesets-update.service}" +TIMER_SRC="${2:-/opt/vpnem/deploy/systemd/vpnem-rulesets-update.timer}" +SYSTEMD_DIR="${3:-/etc/systemd/system}" + +install -m 0644 "$SERVICE_SRC" "$SYSTEMD_DIR/vpnem-rulesets-update.service" +install -m 0644 "$TIMER_SRC" "$SYSTEMD_DIR/vpnem-rulesets-update.timer" +systemctl daemon-reload +systemctl enable --now vpnem-rulesets-update.timer +systemctl start vpnem-rulesets-update.service +systemctl --no-pager --full status vpnem-rulesets-update.timer diff --git a/scripts/update-rulesets.sh b/scripts/update-rulesets.sh new file mode 100755 index 0000000..24dcfcb --- /dev/null +++ b/scripts/update-rulesets.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Downloads public .srs rule-sets from GitHub +# Run via cron: 0 4 * * 1 /opt/vpnem/scripts/update-rulesets.sh + +set -euo pipefail + +RULES_DIR="${1:-/opt/vpnem/data/rules}" +mkdir -p "$RULES_DIR" + +download() { + local url="$1" + local dest="$2" + local tmp="${dest}.tmp" + + if wget -q -O "$tmp" "$url"; then + mv "$tmp" "$dest" + echo "OK: $(basename "$dest")" + else + rm -f "$tmp" + echo "FAIL: $(basename "$dest")" + return 1 + fi +} + +download \ + "https://github.com/1andrevich/Re-filter-lists/releases/latest/download/ruleset-domain-refilter_domains.srs" \ + "$RULES_DIR/refilter-domains.srs" + +download \ + "https://github.com/1andrevich/Re-filter-lists/releases/latest/download/ruleset-ip-refilter_ipsum.srs" \ + "$RULES_DIR/refilter-ip.srs" + +download \ + "https://github.com/legiz-ru/sb-rule-sets/raw/main/discord-voice-ip-list.srs" \ + "$RULES_DIR/discord-voice-ip-list.srs" + +echo "Rule-set update complete" diff --git a/test_balancing.py b/test_balancing.py new file mode 100644 index 0000000..0f7933d --- /dev/null +++ b/test_balancing.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Real-world load balancing test for vpnem API.""" +import urllib.request +import json +import sys + +BASE = "https://vpn.em-sysadmin.xyz" + +def req(path, method="GET", data=None, headers=None): + """Make HTTP request.""" + url = BASE + path + h = headers or {} + h["Content-Type"] = "application/json" + body = json.dumps(data).encode() if data else None + rq = urllib.request.Request(url, data=body, headers=h, method=method) + try: + resp = urllib.request.urlopen(rq, timeout=10) + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + print(f" ERROR {e.code}: {e.read().decode()[:200]}") + return None + except Exception as e: + print(f" ERROR: {e}") + return None + +def get_recommend(client_ip): + """Get recommendation for a client IP.""" + return req("/api/v1/recommend", headers={"X-Forwarded-For": client_ip}) + +def connect(client_ip, server_ip, node_id): + """Report connection.""" + return req("/api/v1/connect", method="POST", data={ + "server_ip": server_ip, "node_id": node_id + }, headers={"X-Forwarded-For": client_ip}) + +def disconnect(client_ip): + """Report disconnection.""" + return req("/api/v1/disconnect", method="POST", data={ + "server_ip": "" + }, headers={"X-Forwarded-For": client_ip}) + +def main(): + print("=" * 70) + print("VPNEM Load Balancing — Real Server Test") + print("=" * 70) + print() + + # Step 1: Test recommendations for multiple "studios" + print("[1] Testing recommendations for 5 different studios...") + print() + studios = [ + ("195.10.20.1", "Barnaul Studio 1"), + ("195.10.20.2", "Barnaul Studio 2"), + ("91.50.60.1", "Moscow Studio"), + ("46.30.20.1", "Novosibirsk Studio"), + ("178.120.1.1", "Test Studio"), + ] + + recommendations = {} + for ip, name in studios: + rec = get_recommend(ip) + if rec: + server = rec.get("recommended_server_ip", "none") + reason = rec.get("reason", "unknown") + load = rec.get("load_info", "no info") + recommendations[ip] = server + print(f" {name:25s} → {server:15s} ({reason})") + print(f" Load: {load}") + else: + print(f" {name:25s} → ERROR") + + print() + + # Step 2: Check distribution — do different studios get different servers? + print("[2] Distribution check...") + servers_used = list(recommendations.values()) + unique = set(servers_used) + print(f" Studios: {len(studios)}, Recommended IPs: {len(unique)}") + print(f" Servers used: {', '.join(unique)}") + if len(unique) > 1: + print(" ✅ GOOD — different studios get different servers") + else: + print(" ⚠️ All studios got the same server (possible if load is equal)") + + print() + + # Step 3: Simulate connections + print("[3] Simulating connections...") + nodes = { + "5.180.97.181": "nl-multi-181", + "5.180.97.197": "nl-multi-197", + "5.180.97.198": "nl-multi-198", + "5.180.97.199": "nl-multi-199", + } + for ip, name in studios: + srv = recommendations.get(ip) + nid = nodes.get(srv, "nl-multi-181") + resp = connect(ip, srv, nid) + if resp: + rec_srv = resp.get("recommended_server_ip", "none") + print(f" {name:25s} connected → {srv:15s} (next rec: {rec_srv})") + else: + print(f" {name:25s} connect FAILED") + + print() + + # Step 4: Check recommendations AFTER connections (should shift) + print("[4] Recommendations AFTER connections (should re-balance)...") + new_ip = "100.100.100.1" + rec = get_recommend(new_ip) + if rec: + srv = rec.get("recommended_server_ip", "none") + reason = rec.get("reason", "unknown") + load = rec.get("load_info", "no info") + print(f" New Studio ({new_ip}) → {srv:15s} ({reason})") + print(f" Load: {load}") + # The new studio should NOT get the most loaded server + else: + print(f" ERROR getting recommendation") + + print() + + # Step 5: Disconnect one studio, check if recommendation changes + print("[5] Disconnecting first studio and checking rebalance...") + disc_ip = studios[0][0] + disconnect(disc_ip) + print(f" Disconnected {studios[0][1]} ({disc_ip})") + + rec = get_recommend("200.200.200.1") + if rec: + srv = rec.get("recommended_server_ip", "none") + load = rec.get("load_info", "no info") + print(f" New Studio (200.200.200.1) → {srv:15s}") + print(f" Load: {load}") + else: + print(" ERROR") + + print() + + # Step 6: Clean up + print("[6] Cleaning up — disconnecting all test studios...") + for ip, name in studios: + disconnect(ip) + disconnect(new_ip) + disconnect("200.200.200.1") + print(" Done") + + print() + print("=" * 70) + print("Test complete.") + print("=" * 70) + +if __name__ == "__main__": + main() diff --git a/test_sequential.py b/test_sequential.py new file mode 100644 index 0000000..49d21fc --- /dev/null +++ b/test_sequential.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Sequential load balancing test — each studio connects before next query.""" +import urllib.request, json, sys + +BASE = "https://vpn.em-sysadmin.xyz" + +def req(path, method="GET", data=None, headers=None): + url = BASE + path + h = headers or {} + h["Content-Type"] = "application/json" + body = json.dumps(data).encode() if data else None + rq = urllib.request.Request(url, data=body, headers=h, method=method) + try: + resp = urllib.request.urlopen(rq, timeout=10) + return json.loads(resp.read().decode()) + except Exception as e: + print(f" ERROR: {e}") + return None + +def recommend(ip): + return req("/api/v1/recommend", headers={"X-Forwarded-For": ip}) + +def connect(ip, srv, nid): + return req("/api/v1/connect", method="POST", data={"server_ip": srv, "node_id": nid}, + headers={"X-Forwarded-For": ip}) + +def disconnect(ip): + return req("/api/v1/disconnect", method="POST", data={"server_ip": ""}, + headers={"X-Forwarded-For": ip}) + +def main(): + print("=" * 70) + print("Sequential Load Balancing Test") + print("Each studio connects BEFORE the next one queries") + print("=" * 70) + print() + + studios = [ + ("195.10.20.1", "Barnaul-1"), + ("195.10.20.2", "Barnaul-2"), + ("91.50.60.1", "Moscow"), + ("46.30.20.1", "Novosibirsk"), + ("178.120.1.1", "Test"), + ] + nodes = {"5.180.97.181": "nl-multi-181", "5.180.97.197": "nl-multi-197", + "5.180.97.198": "nl-multi-198", "5.180.97.199": "nl-multi-199"} + + assigned = {} + + # Step 1: Sequential recommend + connect + print("[1] Sequential recommend + connect...") + print() + for ip, name in studios: + rec = recommend(ip) + if not rec: + print(f" {name:15s} → ERROR") + continue + srv = rec.get("recommended_server_ip", "none") + reason = rec.get("reason", "unknown") + load = rec.get("load_info", "") + nid = nodes.get(srv, "nl-multi-181") + assigned[ip] = srv + + print(f" {name:15s} recommends {srv:15s} ({reason})") + print(f" load: {load}") + + # Connect immediately + resp = connect(ip, srv, nid) + if resp: + next_srv = resp.get("recommended_server_ip", "none") + print(f" → connected. Next recommendation: {next_srv}") + print() + + # Step 2: Check distribution + print("[2] Distribution check...") + servers_used = list(assigned.values()) + unique = set(servers_used) + print(f" Studios: {len(studios)}, Unique servers used: {len(unique)}") + print(f" Servers: {', '.join(sorted(unique))}") + + counts = {} + for s in servers_used: + counts[s] = counts.get(s, 0) + 1 + for srv, cnt in sorted(counts.items()): + print(f" {srv}: {cnt} studio(s)") + + if len(unique) > 1: + print(" ✅ GOOD — studios distributed across servers") + else: + print(" ❌ All studios got the same server") + + print() + + # Step 3: Disconnect all and verify clean + print("[3] Cleanup...") + for ip, name in studios: + disconnect(ip) + print(" All test studios disconnected") + + # Step 4: Final check + print() + print("[4] Final recommendation (all clean)...") + rec = recommend("999.999.999.1") + if rec: + srv = rec.get("recommended_server_ip", "none") + load = rec.get("load_info", "no info") + print(f" New studio → {srv:15s}") + print(f" load: {load}") + else: + print(" ERROR") + + print() + print("=" * 70) + +if __name__ == "__main__": + main() |
