234 lines
5.2 KiB
Go
234 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const defaultVersion = "0.2.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
|
|
}
|