84b185be39
Phase A - Stability: - Engine.Start(): recovers orphaned jobs (running/queued/waking_up) after crash - Engine.Stop(): graceful shutdown - cancels all in-flight jobs and waits - Queue keyed by jobID (not syncPairID): cancel now targets exact job - Local rsync uses jobCtx (context.Background() replaced) - Migrations wrapped in transactions; checksums stored Phase B - Security: - admin/admin default removed: SYNCSERVER_ADMIN_PASSWORD required on first run - Path validation: rejects .., leading -, null bytes in sync pair paths - Rsync flags allowlist: dangerous flags blocked (--rsync-path, -e, --files-from) - Shell concat in RunRemote replaced with proper sh -c escaping - knownhosts: replaced custom parser with golang.org/x/crypto/ssh/knownhosts - RequireAdmin wired: machine CRUD, SSH key ops, settings require admin role - deploy-keys: uses authorized_keys only (no private key upload) - Hardcoded /var/lib/syncserver/ssh paths replaced with cfg.SSHDir() Phase C - Operational: - /readyz health check: DB query + SSH dir accessibility - /metrics endpoint: Prometheus text format (jobs, queue, machines) - Event struct JSON tags: job_id, machine_id, type (snake_case) - EventBus broadcast: fanned out to all subscribers - SQLite VACUUM INTO backup: scheduled before cleanup if BackupDir set - Filesystem job log cleanup: removes .log files for purged jobs - Backup retention: old backups auto-purged Phase D - Frontend: - Schedules page: REST API + full CRUD UI for cron schedules - Dashboard: cancel button for running/queued jobs - JobDetail: server-side log download via API - Settings: displays data_dir from server - 404 page: proper NotFound component Phase E - Tests: - auth_test.go: JWT, bcrypt, middleware, seed (18 tests) - models_test.go: Job, Machine, SyncPair, Schedule repos (18 tests) - go test -race: no data races found
160 lines
3.9 KiB
Go
160 lines
3.9 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"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"`
|
|
RetentionDays int `yaml:"retention_days" env:"SYNCSERVER_RETENTION_DAYS" default:"30"`
|
|
BackupDir string `yaml:"backup_dir" env:"SYNCSERVER_BACKUP_DIR"`
|
|
BackupRetentionDays int `yaml:"backup_retention_days" env:"SYNCSERVER_BACKUP_RETENTION_DAYS" default:"7"`
|
|
}
|
|
|
|
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",
|
|
RetentionDays: 30,
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
secretPath := filepath.Join(cfg.DataDir, ".jwt_secret")
|
|
if cfg.Auth.JWTSecret == "" {
|
|
if data, err := os.ReadFile(secretPath); err == nil && len(data) >= 32 {
|
|
cfg.Auth.JWTSecret = strings.TrimSpace(string(data))
|
|
}
|
|
}
|
|
if cfg.Auth.JWTSecret == "" {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err == nil {
|
|
cfg.Auth.JWTSecret = hex.EncodeToString(b)
|
|
}
|
|
if cfg.Auth.JWTSecret == "" {
|
|
cfg.Auth.JWTSecret = "insecure-dev-secret-change-in-production"
|
|
}
|
|
if dirErr := os.MkdirAll(cfg.DataDir, 0700); dirErr == nil {
|
|
_ = os.WriteFile(secretPath, []byte(cfg.Auth.JWTSecret+"\n"), 0600)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|