package main import ( "encoding/json" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" ) const defaultVersion = "0.3.0" type Agent struct { name string secret string version string dataDir string logPath string startedAt time.Time mu sync.RWMutex config map[string]any } func main() { port := env("NODE_PORT", "2222") secret := os.Getenv("SECRET_KEY") if secret == "" { log.Fatal("SECRET_KEY is required") } name := env("NODE_NAME", "vpn-node") version := env("AGENT_VERSION", defaultVersion) dataDir := env("DATA_DIR", "/var/lib/vpn-node") _ = os.MkdirAll(dataDir, 0o755) logPath := filepath.Join(dataDir, "agent.log") logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err == nil { log.SetOutput(io.MultiWriter(os.Stderr, logFile)) defer logFile.Close() } a := &Agent{ name: name, secret: secret, version: version, dataDir: dataDir, logPath: logPath, startedAt: time.Now().UTC(), config: map[string]any{}, } a.loadConfig(filepath.Join(dataDir, "config.json")) a.applyXray(a.config) mux := http.NewServeMux() mux.HandleFunc("/health", a.auth(a.health)) mux.HandleFunc("/api/v1/config", a.auth(a.configHandler)) mux.HandleFunc("/api/v1/status", a.auth(a.status)) mux.HandleFunc("/api/v1/logs", a.auth(a.logs)) addr := ":" + port log.Printf("vpn-node %s listening on %s name=%s", version, addr, name) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatal(err) } } func env(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func (a *Agent) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") token := strings.TrimPrefix(h, "Bearer ") if token == "" { token = r.Header.Get("X-Node-Secret") } if token != a.secret { http.Error(w, "unauthorized", http.StatusUnauthorized) return } next(w, r) } } func stringList(cfg map[string]any, key string) []string { out := []string{} raw, ok := cfg[key] if !ok { return out } switch v := raw.(type) { case []any: for _, item := range v { if s, ok := item.(string); ok && s != "" { out = append(out, s) } } case []string: out = append(out, v...) } return out } func (a *Agent) health(w http.ResponseWriter, _ *http.Request) { a.mu.RLock() defer a.mu.RUnlock() enabled := stringList(a.config, "protocols_enabled") if len(enabled) == 0 { enabled = stringList(a.config, "protocols") } installed := stringList(a.config, "protocols_installed") if len(installed) == 0 { installed = append([]string{}, enabled...) } writeJSON(w, map[string]any{ "ok": true, "name": a.name, "version": a.version, "uptime_sec": int64(time.Since(a.startedAt).Seconds()), "protocols": enabled, "protocols_installed": installed, "protocols_enabled": enabled, }) } func (a *Agent) status(w http.ResponseWriter, _ *http.Request) { a.mu.RLock() defer a.mu.RUnlock() writeJSON(w, map[string]any{ "ok": true, "name": a.name, "version": a.version, "config": a.config, }) } func (a *Agent) logs(w http.ResponseWriter, r *http.Request) { lines := 300 if v := r.URL.Query().Get("lines"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 { lines = n } } content, err := tailFile(a.logPath, lines) if err != nil { writeJSON(w, map[string]any{"ok": true, "logs": "(no agent.log yet)"}) return } writeJSON(w, map[string]any{"ok": true, "logs": content}) } func tailFile(path string, maxLines int) (string, error) { b, err := os.ReadFile(path) if err != nil { return "", err } text := string(b) parts := strings.Split(text, "\n") if len(parts) > maxLines { parts = parts[len(parts)-maxLines:] } return strings.Join(parts, "\n"), nil } func (a *Agent) configHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.mu.RLock() defer a.mu.RUnlock() writeJSON(w, a.config) case http.MethodPost: var cfg map[string]any if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } a.mu.Lock() a.config = cfg a.mu.Unlock() _ = a.saveConfig(filepath.Join(a.dataDir, "config.json")) a.applyXray(cfg) log.Printf("config updated: installed=%v enabled=%v inbounds=%d", stringList(cfg, "protocols_installed"), stringList(cfg, "protocols_enabled"), len(anySlice(cfg["inbounds"])), ) writeJSON(w, map[string]any{"ok": true}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (a *Agent) loadConfig(path string) { b, err := os.ReadFile(path) if err != nil { return } var cfg map[string]any if json.Unmarshal(b, &cfg) == nil { a.config = cfg } } func (a *Agent) saveConfig(path string) error { a.mu.RLock() defer a.mu.RUnlock() b, err := json.MarshalIndent(a.config, "", " ") if err != nil { return err } return os.WriteFile(path, b, 0o600) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func anySlice(v any) []any { if arr, ok := v.([]any); ok { return arr } return nil }