feat: complete SyncServer implementation
Full-stack Go monolith with embedded React frontend for orchestrating rsync-over-SSH file synchronization with Wake-on-LAN support. Features: - JWT auth (HS256) with bcrypt password hashing - CRUD for machines (with WoL config) and sync_pairs - Ed25519 SSH key generation and known_hosts management - WoL magic packet sender + TCP-connect waiter with backoff - Sync engine: rsync subprocess, per-pair job queue, progress parsing - Homebrew cron parser for scheduled syncs - SSE stream for live job status (queued/waking_up/running/success/failed) - React+TS+Vite+Tailwind SPA embedded via embed.FS - Debian packaging with systemd unit, postinst/prerm/postrm Tech stack: - Go 1.22+ (CGO_ENABLED=0, pure SQLite via modernc.org/sqlite) - chi router for HTTP API - TypeScript + React 18 + Tailwind CSS frontend - Cross-compiled to Linux amd64 for Proxmox LXC deployment Tests: wol (MAC parsing, magic packet), syncengine/queue, scheduler/cron
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Version string `yaml:"-" json:"-"`
|
||||
DataDir string `yaml:"data_dir" env:"SYNCSERVER_DATA_DIR" default:"./data"`
|
||||
ConfigDir string `yaml:"config_dir" env:"SYNCSERVER_CONFIG_DIR" default:"./etc/syncserver"`
|
||||
Addr string `yaml:"addr" env:"SYNCSERVER_ADDR" default:":8080"`
|
||||
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
|
||||
Scheduler SchedulerConfig `yaml:"scheduler"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"SYNCSERVER_JWT_SECRET"`
|
||||
JWTExpiryH int `yaml:"jwt_expiry_hours" env:"SYNCSERVER_JWT_EXPIRY_HOURS" default:"24"`
|
||||
AdminUser string `yaml:"-" env:"SYNCSERVER_ADMIN_USER"`
|
||||
AdminPass string `yaml:"-" env:"SYNCSERVER_ADMIN_PASSWORD"`
|
||||
}
|
||||
|
||||
type SchedulerConfig struct {
|
||||
Timezone string `yaml:"timezone" env:"SYNCSERVER_SCHEDULER_TZ" default:"UTC"`
|
||||
}
|
||||
|
||||
var globalCfg *Config
|
||||
|
||||
func Load(configPath, dataDir, addr string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
DataDir: dataDir,
|
||||
ConfigDir: "./etc/syncserver",
|
||||
Addr: addr,
|
||||
Auth: AuthConfig{
|
||||
JWTExpiryH: 24,
|
||||
},
|
||||
Scheduler: SchedulerConfig{
|
||||
Timezone: "UTC",
|
||||
},
|
||||
}
|
||||
|
||||
if configPath != "" {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prefix := "SYNCSERVER_"
|
||||
envs := []struct {
|
||||
key *string
|
||||
envName string
|
||||
required bool
|
||||
}{
|
||||
{&cfg.Auth.JWTSecret, prefix + "JWT_SECRET", false},
|
||||
{&cfg.Auth.AdminUser, prefix + "ADMIN_USER", false},
|
||||
{&cfg.Auth.AdminPass, prefix + "ADMIN_PASSWORD", false},
|
||||
{&cfg.DataDir, prefix + "DATA_DIR", false},
|
||||
{&cfg.Addr, prefix + "ADDR", false},
|
||||
{&cfg.Scheduler.Timezone, prefix + "SCHEDULER_TZ", false},
|
||||
}
|
||||
|
||||
for _, e := range envs {
|
||||
if v := os.Getenv(e.envName); v != "" {
|
||||
*e.key = v
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Auth.JWTSecret == "" {
|
||||
b := make([]byte, 32)
|
||||
f, err := os.Open("/dev/urandom")
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
n, _ := f.Read(b)
|
||||
if n == 32 {
|
||||
cfg.Auth.JWTSecret = fmt.Sprintf("%x", b)
|
||||
}
|
||||
}
|
||||
if cfg.Auth.JWTSecret == "" {
|
||||
cfg.Auth.JWTSecret = "insecure-dev-secret-change-in-production"
|
||||
}
|
||||
}
|
||||
|
||||
if dataDir := os.Getenv("SYNCSERVER_DATA_DIR"); dataDir != "" {
|
||||
cfg.DataDir = dataDir
|
||||
}
|
||||
if addr := os.Getenv("SYNCSERVER_ADDR"); addr != "" {
|
||||
cfg.Addr = addr
|
||||
}
|
||||
|
||||
absDataDir, err := filepath.Abs(cfg.DataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.DataDir = absDataDir
|
||||
|
||||
globalCfg = cfg
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Get() *Config {
|
||||
return globalCfg
|
||||
}
|
||||
|
||||
func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.DataDir, "app.db")
|
||||
}
|
||||
|
||||
func (c *Config) SSHDir() string {
|
||||
return filepath.Join(c.DataDir, "ssh")
|
||||
}
|
||||
|
||||
func (c *Config) LogsDir() string {
|
||||
return filepath.Join(c.DataDir, "logs")
|
||||
}
|
||||
|
||||
func (c *Config) EnsureDirs() error {
|
||||
dirs := []string{c.DataDir, c.SSHDir(), c.LogsDir()}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0700); err != nil {
|
||||
return fmt.Errorf("creating dir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) LogPath() string {
|
||||
return filepath.Join(c.LogsDir(), "app.log")
|
||||
}
|
||||
|
||||
func NormalizeAddr(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr = ":" + addr
|
||||
}
|
||||
return addr
|
||||
}
|
||||
Reference in New Issue
Block a user