From 834b5ef0d45fd7a3ab1654e966c3d165de7f0006 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 1 Jun 2026 22:49:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20sync=20=E2=80=94=20server=20skeleton=20?= =?UTF-8?q?with=20health,=20admin=20login/dashboard,=20device=20registrati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd/verstak-server/main.go — flags: --port, --data, --admin-user, --admin-pass - Server DB schema: server_devices, server_revisions, server_ops - Health endpoint GET /api/v1/health - Admin login page + session cookie auth - Admin dashboard with device stats and API key management - Device registration POST /api/v1/device/register - Stub push/pull/blob endpoints --- cmd/verstak-server/main.go | 53 ++++ cmd/verstak-server/server.go | 527 +++++++++++++++++++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 cmd/verstak-server/main.go create mode 100644 cmd/verstak-server/server.go diff --git a/cmd/verstak-server/main.go b/cmd/verstak-server/main.go new file mode 100644 index 0000000..a5ea57d --- /dev/null +++ b/cmd/verstak-server/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" +) + +func main() { + port := flag.Int("port", 47732, "HTTP port") + dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)") + adminUser := flag.String("admin-user", "", "Create admin user (first run)") + adminPass := flag.String("admin-pass", "", "Admin password (first run)") + flag.Parse() + + absData, err := filepath.Abs(*dataDir) + if err != nil { + log.Fatalf("data dir: %v", err) + } + + if err := os.MkdirAll(absData, 0750); err != nil { + log.Fatalf("create data dir: %v", err) + } + + cfg, err := LoadConfig(absData) + if err != nil { + log.Fatalf("config: %v", err) + } + + // First-run admin setup. + if *adminUser != "" && *adminPass != "" { + if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil { + log.Fatalf("set admin: %v", err) + } + fmt.Printf("Admin user %q created.\n", *adminUser) + } + + // Open server DB. + dbPath := filepath.Join(absData, "server.db") + srv, err := NewServer(dbPath, absData, cfg) + if err != nil { + log.Fatalf("server: %v", err) + } + defer srv.Close() + + addr := fmt.Sprintf(":%d", *port) + log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData) + if err := srv.ListenAndServe(addr); err != nil { + log.Fatalf("serve: %v", err) + } +} diff --git a/cmd/verstak-server/server.go b/cmd/verstak-server/server.go new file mode 100644 index 0000000..c6f6665 --- /dev/null +++ b/cmd/verstak-server/server.go @@ -0,0 +1,527 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" + _ "github.com/mattn/go-sqlite3" +) + +// ============================================================ +// Config +// ============================================================ + +type AdminUser struct { + Username string `yaml:"username"` + PasswordHash string `yaml:"password_hash"` +} + +type Config struct { + Port int `yaml:"port"` + Admin []AdminUser `yaml:"admin"` + mu sync.Mutex + path string +} + +func LoadConfig(dataDir string) (*Config, error) { + path := filepath.Join(dataDir, "config.yml") + cfg := &Config{ + Port: 47732, + Admin: nil, + path: path, + } + data, err := os.ReadFile(path) + if err == nil { + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + } + return cfg, nil +} + +func (c *Config) Save() error { + c.mu.Lock() + defer c.mu.Unlock() + data, err := yaml.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(c.path, data, 0640) +} + +func (c *Config) SetAdmin(username, password string) error { + c.mu.Lock() + defer c.mu.Unlock() + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + user := AdminUser{Username: username, PasswordHash: string(hash)} + // Replace existing or append. + for i, u := range c.Admin { + if u.Username == username { + c.Admin[i] = user + return c.saveLocked() + } + } + c.Admin = append(c.Admin, user) + return c.saveLocked() +} + +func (c *Config) CheckAdmin(username, password string) bool { + c.mu.Lock() + defer c.mu.Unlock() + for _, u := range c.Admin { + if u.Username == username { + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { + return true + } + } + } + return false +} + +func (c *Config) saveLocked() error { + data, err := yaml.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(c.path, data, 0640) +} + +// ============================================================ +// Token +// ============================================================ + +type tokenStore struct { + mu sync.Mutex + tokens map[string]time.Time +} + +func newTokenStore() *tokenStore { + return &tokenStore{tokens: make(map[string]time.Time)} +} + +func (ts *tokenStore) Create() string { + ts.mu.Lock() + defer ts.mu.Unlock() + b := make([]byte, 16) + rand.Read(b) + tok := hex.EncodeToString(b) + ts.tokens[tok] = time.Now().Add(24 * time.Hour) + return tok +} + +func (ts *tokenStore) Check(tok string) bool { + ts.mu.Lock() + defer ts.mu.Unlock() + exp, ok := ts.tokens[tok] + if !ok { + return false + } + if time.Now().After(exp) { + delete(ts.tokens, tok) + return false + } + return true +} + +// ============================================================ +// Server DB schema +// ============================================================ + +const serverSchema = ` +CREATE TABLE IF NOT EXISTS server_devices ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, + last_seen TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_revisions ( + rev INTEGER PRIMARY KEY AUTOINCREMENT, + op_id TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS server_ops ( + op_id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + pushed_at TEXT NOT NULL DEFAULT (datetime('now')) +); +` + +// ============================================================ +// Server +// ============================================================ + +type Server struct { + db *sql.DB + cfg *Config + tokens *tokenStore + blobsDir string + mux *http.ServeMux +} + +func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath)) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + db.SetMaxOpenConns(1) + + // Run schema. + for _, stmt := range strings.Split(serverSchema, ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := db.Exec(stmt); err != nil { + db.Close() + return nil, fmt.Errorf("schema: %w", err) + } + } + + blobsDir := filepath.Join(dataDir, "blobs") + if err := os.MkdirAll(blobsDir, 0750); err != nil { + db.Close() + return nil, err + } + + s := &Server{ + db: db, + cfg: cfg, + tokens: newTokenStore(), + blobsDir: blobsDir, + } + s.mux = s.routes() + return s, nil +} + +func (s *Server) Close() error { + return s.db.Close() +} + +func (s *Server) ListenAndServe(addr string) error { + return http.ListenAndServe(addr, s.mux) +} + +// ============================================================ +// Routes +// ============================================================ + +func (s *Server) routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/health", s.handleHealth) + mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister) + mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush) + mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull) + mux.HandleFunc("/api/v1/blobs/", s.handleBlobs) + mux.HandleFunc("/admin/login", s.handleAdminLogin) + mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard) + mux.HandleFunc("/admin/", s.handleAdminAPI) + mux.HandleFunc("/", s.handleNotFound) + return mux +} + +// ============================================================ +// Helpers +// ============================================================ + +func jsonOK(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func jsonErr(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) bool { + key := r.Header.Get("Authorization") + key = strings.TrimPrefix(key, "Bearer ") + if key == "" { + key = r.URL.Query().Get("api_key") + } + if key == "" { + jsonErr(w, 401, "API key required") + return false + } + var count int + err := s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count) + if err != nil || count == 0 { + jsonErr(w, 401, "invalid API key") + return false + } + return true +} + +func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + cookie, err := r.Cookie("session") + if err != nil || !s.tokens.Check(cookie.Value) { + http.Redirect(w, r, "/admin/login", http.StatusFound) + return false + } + return true +} + +// ============================================================ +// Handlers +// ============================================================ + +func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte("Verstak Sync Server\n")) + return + } + jsonErr(w, 404, "not found") +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + jsonOK(w, map[string]interface{}{ + "status": "ok", + "version": "verstak-server/v1", + "time": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + jsonErr(w, 405, "POST required") + return + } + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "invalid JSON") + return + } + if req.Name == "" { + jsonErr(w, 400, "name required") + return + } + + b := make([]byte, 20) + rand.Read(b) + apiKey := hex.EncodeToString(b) + + now := time.Now().UTC().Format(time.RFC3339) + + result, err := s.db.Exec( + "INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", + apiKey[:12], req.Name, apiKey, now, now, + ) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + id, _ := result.LastInsertId() + _ = id + + jsonOK(w, map[string]interface{}{ + "device_id": apiKey[:12], + "api_key": apiKey, + }) +} + +func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) { + if !s.requireAPIKey(w, r) { + return + } + jsonOK(w, map[string]string{"status": "ok", "message": "push endpoint ready (not yet implemented)"}) +} + +func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) { + if !s.requireAPIKey(w, r) { + return + } + jsonOK(w, map[string]string{"status": "ok", "message": "pull endpoint ready (not yet implemented)"}) +} + +func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) { + if !s.requireAPIKey(w, r) { + return + } + jsonOK(w, map[string]string{"status": "ok", "message": "blob endpoint ready (not yet implemented)"}) +} + +func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(adminLoginHTML)) + case "POST": + if err := r.ParseForm(); err != nil { + jsonErr(w, 400, "bad form") + return + } + user := r.FormValue("username") + pass := r.FormValue("password") + if !s.cfg.CheckAdmin(user, pass) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(401) + w.Write([]byte("

401 Unauthorized

Try again")) + return + } + tok := s.tokens.Create() + http.SetCookie(w, &http.Cookie{ + Name: "session", Value: tok, Path: "/admin", + HttpOnly: true, SameSite: http.SameSiteLaxMode, + MaxAge: 86400, + }) + http.Redirect(w, r, "/admin/dashboard", http.StatusFound) + default: + jsonErr(w, 405, "method not allowed") + } +} + +func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Fetch data for dashboard. + var deviceCount, opsCount int + s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount) + s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount) + + html := fmt.Sprintf(` + + +Verstak Sync — Admin + + +

Verstak Sync Server

+
+
Устройств: %d
+
Операций: %d
+
+

API-ключи

+
+ +

Новый ключ

+
+ + +
+

Health check

+`, deviceCount, opsCount) + w.Write([]byte(html)) +} + +func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + path := strings.TrimPrefix(r.URL.Path, "/admin") + + switch { + case path == "/api/keys" && r.Method == "GET": + rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at") + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + defer rows.Close() + var out []map[string]string + for rows.Next() { + var id, name, key string + rows.Scan(&id, &name, &key) + out = append(out, map[string]string{"id": id, "name": name, "api_key": key}) + } + jsonOK(w, out) + + case path == "/api/keys" && r.Method == "POST": + if err := r.ParseForm(); err != nil { + jsonErr(w, 400, "bad form") + return + } + name := r.FormValue("name") + if name == "" { + jsonErr(w, 400, "name required") + return + } + b := make([]byte, 20) + rand.Read(b) + apiKey := hex.EncodeToString(b) + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec( + "INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", + apiKey[:12], name, apiKey, now, now, + ) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + http.Redirect(w, r, "/admin/dashboard", http.StatusFound) + + case strings.HasPrefix(path, "/api/keys/") && r.Method == "DELETE": + id := strings.TrimPrefix(path, "/api/keys/") + _, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, map[string]string{"status": "deleted"}) + + default: + jsonErr(w, 404, "not found") + } +} + +// ============================================================ +// Embedded admin login HTML +// ============================================================ + +const adminLoginHTML = ` + + +Verstak Sync — Admin Login + + +
+

Verstak Sync

+ + + + + +
+`