feat(docker-watcher): phase 2 - crypto & config seed loader

AES-256-GCM encryption for credential storage, YAML seed config
parser with validation, and transactional import into SQLite.
Credentials (registry tokens, NPM password) encrypted before storage.
This commit is contained in:
2026-03-27 21:01:16 +03:00
parent d63c831d15
commit cdf21682d6
10 changed files with 602 additions and 19 deletions
+1 -1
View File
@@ -250,7 +250,7 @@ docker-watcher/
## Implementation Phases ## Implementation Phases
### Phase 1: Foundation ### Phase 1: Foundation
Core infrastructure — store, config import, Docker client, NPM client. Core infrastructure — store, config import, Docker client, NPM client.
+7
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/alexei/docker-watcher/internal/config"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/store"
) )
@@ -23,6 +24,12 @@ func main() {
} }
defer db.Close() defer db.Close()
// Import seed config on first launch (idempotent — skipped if DB has data).
seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml")
if err := config.ImportSeed(db, seedPath); err != nil {
log.Fatalf("seed import: %v", err)
}
fmt.Printf("Docker Watcher started. Database: %s\n", dbPath) fmt.Printf("Docker Watcher started. Database: %s\n", dbPath)
// Future phases will wire up the HTTP server, deployer, poller, etc. // Future phases will wire up the HTTP server, deployer, poller, etc.
+78
View File
@@ -0,0 +1,78 @@
# Docker Watcher — Seed Configuration
#
# This file is read ONCE on first launch to populate the SQLite database.
# After import, all configuration is managed via the Web UI.
# The only required env var is ENCRYPTION_KEY (used to encrypt credentials in DB).
#
# Place this file as ./docker-watcher.yaml (or set SEED_FILE env var)
# and start Docker Watcher. Once imported, the file is never read again.
global:
# Your base domain — must have a Cloudflare wildcard DNS record (*.domain)
domain: example.com
# The IP address of your Docker host
server_ip: 192.168.1.100
# Docker network that containers will be attached to
network: staging-net
# Pattern for generating subdomains. Available placeholders: {stage}, {project}
subdomain_pattern: "stage-{stage}-{project}"
# Webhook URL for deploy notifications (optional)
notification_url: https://notify.example.com/webhook
# Nginx Proxy Manager connection
npm:
url: http://npm:81
email: admin@example.com
password: "your-npm-password-here"
# Container registries — referenced by name in project definitions
registries:
gitea:
url: https://git.example.com
type: gitea
token: "your-registry-token-here"
# github:
# url: https://ghcr.io
# type: github
# token: "ghp_your-github-token-here"
# Projects to deploy — each project has an image and one or more stages
projects:
my-web-app:
registry: gitea
image: git.example.com/org/my-web-app
port: 3000
healthcheck: /api/health
# Environment variables passed to the container
env:
NODE_ENV: production
# Volume mounts (host:container)
# volumes:
# /data/uploads: /app/uploads
stages:
dev:
tag_pattern: "dev-*"
auto_deploy: true
max_instances: 5
rel:
tag_pattern: "v*"
auto_deploy: false
max_instances: 2
prod:
tag_pattern: "v*"
auto_deploy: false
confirm: true
promote_from: rel
max_instances: 2
# Custom subdomain (instead of the pattern-generated one)
subdomain: my-app
+112
View File
@@ -0,0 +1,112 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// SeedConfig represents the top-level YAML seed configuration.
type SeedConfig struct {
Global GlobalConfig `yaml:"global"`
Registries map[string]RegistryDef `yaml:"registries"`
Projects map[string]ProjectDef `yaml:"projects"`
}
// GlobalConfig holds domain-wide settings from the seed file.
type GlobalConfig struct {
Domain string `yaml:"domain"`
ServerIP string `yaml:"server_ip"`
Network string `yaml:"network"`
SubdomainPattern string `yaml:"subdomain_pattern"`
NotificationURL string `yaml:"notification_url"`
Npm NpmConfig `yaml:"npm"`
}
// NpmConfig holds Nginx Proxy Manager connection details.
type NpmConfig struct {
URL string `yaml:"url"`
Email string `yaml:"email"`
Password string `yaml:"password"`
}
// RegistryDef defines a container registry from the seed file.
type RegistryDef struct {
URL string `yaml:"url"`
Type string `yaml:"type"`
Token string `yaml:"token"`
}
// ProjectDef defines a project from the seed file.
type ProjectDef struct {
Registry string `yaml:"registry"`
Image string `yaml:"image"`
Port int `yaml:"port"`
Healthcheck string `yaml:"healthcheck"`
Env map[string]string `yaml:"env"`
Volumes map[string]string `yaml:"volumes"`
Stages map[string]StageDef `yaml:"stages"`
}
// StageDef defines a deployment stage from the seed file.
type StageDef struct {
TagPattern string `yaml:"tag_pattern"`
AutoDeploy bool `yaml:"auto_deploy"`
MaxInstances int `yaml:"max_instances"`
Confirm bool `yaml:"confirm"`
PromoteFrom string `yaml:"promote_from"`
Subdomain string `yaml:"subdomain"`
}
// LoadSeedFile reads and parses the YAML seed config from the given path.
func LoadSeedFile(path string) (SeedConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return SeedConfig{}, fmt.Errorf("read seed file: %w", err)
}
return ParseSeed(data)
}
// ParseSeed parses raw YAML bytes into a SeedConfig.
func ParseSeed(data []byte) (SeedConfig, error) {
var cfg SeedConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return SeedConfig{}, fmt.Errorf("parse yaml: %w", err)
}
if err := validate(cfg); err != nil {
return SeedConfig{}, fmt.Errorf("validate seed config: %w", err)
}
return cfg, nil
}
// validate checks that required fields are present in the seed config.
func validate(cfg SeedConfig) error {
if cfg.Global.Domain == "" {
return fmt.Errorf("global.domain is required")
}
for name, proj := range cfg.Projects {
if proj.Image == "" {
return fmt.Errorf("project %q: image is required", name)
}
if proj.Registry != "" {
if _, ok := cfg.Registries[proj.Registry]; !ok {
return fmt.Errorf("project %q: references unknown registry %q", name, proj.Registry)
}
}
for stageName, stage := range proj.Stages {
if stage.TagPattern == "" {
return fmt.Errorf("project %q stage %q: tag_pattern is required", name, stageName)
}
if stage.MaxInstances < 0 {
return fmt.Errorf("project %q stage %q: max_instances must be >= 0", name, stageName)
}
}
}
return nil
}
+194
View File
@@ -0,0 +1,194 @@
package config
import (
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/store"
"github.com/google/uuid"
)
// ImportSeed loads the seed YAML file and imports its contents into the store.
// Import is idempotent: it is skipped if any projects or registries already exist.
// Credential fields (registry tokens, NPM password) are encrypted before storage.
func ImportSeed(db *store.Store, seedPath string) error {
if _, err := os.Stat(seedPath); os.IsNotExist(err) {
log.Printf("No seed file at %s, skipping import", seedPath)
return nil
}
populated, err := isPopulated(db)
if err != nil {
return fmt.Errorf("check if db is populated: %w", err)
}
if populated {
log.Println("Database already has data, skipping seed import")
return nil
}
cfg, err := LoadSeedFile(seedPath)
if err != nil {
return fmt.Errorf("load seed file: %w", err)
}
encKey, err := crypto.KeyFromEnv()
if err != nil {
return fmt.Errorf("encryption key: %w", err)
}
if err := importAll(db, cfg, encKey); err != nil {
return fmt.Errorf("import seed: %w", err)
}
log.Printf("Seed config imported from %s", seedPath)
return nil
}
// isPopulated returns true if the store already contains projects or registries.
func isPopulated(db *store.Store) (bool, error) {
projects, err := db.GetAllProjects()
if err != nil {
return false, fmt.Errorf("get projects: %w", err)
}
if len(projects) > 0 {
return true, nil
}
registries, err := db.GetAllRegistries()
if err != nil {
return false, fmt.Errorf("get registries: %w", err)
}
return len(registries) > 0, nil
}
// now returns the current time formatted for SQLite storage.
func now() string {
return time.Now().UTC().Format("2006-01-02 15:04:05")
}
// importAll runs the full seed import inside a database transaction.
// Uses raw SQL within the transaction so all inserts are atomic.
func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
tx, err := db.DB().Begin()
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback after commit is a no-op
timestamp := now()
// Import registries first — projects reference them by name.
for name, regDef := range cfg.Registries {
encToken, err := crypto.EncryptIfNotEmpty(encKey, regDef.Token)
if err != nil {
return fmt.Errorf("encrypt registry %q token: %w", name, err)
}
id := uuid.New().String()
_, err = tx.Exec(
`INSERT INTO registries (id, name, url, type, token, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, name, regDef.URL, regDef.Type, encToken, timestamp, timestamp,
)
if err != nil {
return fmt.Errorf("insert registry %q: %w", name, err)
}
}
// Import projects and their stages.
for name, projDef := range cfg.Projects {
envJSON, err := mapToJSON(projDef.Env)
if err != nil {
return fmt.Errorf("encode env for project %q: %w", name, err)
}
volJSON, err := mapToJSON(projDef.Volumes)
if err != nil {
return fmt.Errorf("encode volumes for project %q: %w", name, err)
}
projectID := uuid.New().String()
_, err = tx.Exec(
`INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, name, projDef.Registry, projDef.Image, projDef.Port,
projDef.Healthcheck, envJSON, volJSON, timestamp, timestamp,
)
if err != nil {
return fmt.Errorf("insert project %q: %w", name, err)
}
for stageName, stageDef := range projDef.Stages {
maxInstances := stageDef.MaxInstances
if maxInstances == 0 {
maxInstances = 1
}
stageID := uuid.New().String()
_, err = tx.Exec(
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
stageID, projectID, stageName, stageDef.TagPattern,
boolToInt(stageDef.AutoDeploy), maxInstances,
boolToInt(stageDef.Confirm), stageDef.PromoteFrom,
stageDef.Subdomain, timestamp, timestamp,
)
if err != nil {
return fmt.Errorf("insert stage %q for project %q: %w", stageName, name, err)
}
}
}
// Import global settings — encrypt NPM password.
encNpmPassword, err := crypto.EncryptIfNotEmpty(encKey, cfg.Global.Npm.Password)
if err != nil {
return fmt.Errorf("encrypt npm password: %w", err)
}
subdomainPattern := cfg.Global.SubdomainPattern
if subdomainPattern == "" {
subdomainPattern = "stage-{stage}-{project}"
}
_, err = tx.Exec(
`UPDATE settings SET
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
npm_url=?, npm_email=?, npm_password=?, updated_at=?
WHERE id = 1`,
cfg.Global.Domain, cfg.Global.ServerIP, cfg.Global.Network,
subdomainPattern, cfg.Global.NotificationURL,
cfg.Global.Npm.URL, cfg.Global.Npm.Email, encNpmPassword, timestamp,
)
if err != nil {
return fmt.Errorf("update settings: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
// boolToInt converts a bool to an integer for SQLite storage.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// mapToJSON encodes a string map to JSON. Returns "{}" for nil maps.
func mapToJSON(m map[string]string) (string, error) {
if m == nil {
return "{}", nil
}
b, err := json.Marshal(m)
if err != nil {
return "", err
}
return string(b), nil
}
+96
View File
@@ -0,0 +1,96 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
)
// ErrNoKey is returned when ENCRYPTION_KEY is not set.
var ErrNoKey = errors.New("ENCRYPTION_KEY environment variable is not set")
// DeriveKey computes a 32-byte AES-256 key from the given passphrase using SHA-256.
// This is acceptable when ENCRYPTION_KEY is a high-entropy random string (e.g., 32+ hex chars).
// For human-chosen passphrases, consider Argon2id or PBKDF2 with a salt instead.
func DeriveKey(passphrase string) [32]byte {
return sha256.Sum256([]byte(passphrase))
}
// KeyFromEnv reads ENCRYPTION_KEY from the environment and derives a 32-byte key.
func KeyFromEnv() ([32]byte, error) {
raw := os.Getenv("ENCRYPTION_KEY")
if raw == "" {
return [32]byte{}, ErrNoKey
}
return DeriveKey(raw), nil
}
// Encrypt encrypts plaintext using AES-256-GCM with a random nonce.
// The returned ciphertext is hex-encoded: nonce || ciphertext+tag.
func Encrypt(key [32]byte, plaintext string) (string, error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return "", fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create gcm: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return hex.EncodeToString(sealed), nil
}
// Decrypt decrypts a hex-encoded ciphertext produced by Encrypt.
func Decrypt(key [32]byte, ciphertextHex string) (string, error) {
data, err := hex.DecodeString(ciphertextHex)
if err != nil {
return "", fmt.Errorf("decode hex: %w", err)
}
block, err := aes.NewCipher(key[:])
if err != nil {
return "", fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create gcm: %w", err)
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce := data[:nonceSize]
ciphertext := data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
return string(plaintext), nil
}
// EncryptIfNotEmpty encrypts the value only if it is non-empty.
// Returns empty string for empty input.
func EncryptIfNotEmpty(key [32]byte, value string) (string, error) {
if value == "" {
return "", nil
}
return Encrypt(key, value)
}
+5
View File
@@ -51,6 +51,11 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
// DB returns the underlying *sql.DB for advanced operations like transactions.
func (s *Store) DB() *sql.DB {
return s.db
}
// migrate creates all tables if they do not already exist. // migrate creates all tables if they do not already exist.
func (s *Store) migrate() error { func (s *Store) migrate() error {
_, err := s.db.Exec(schema) _, err := s.db.Exec(schema)
+24 -3
View File
@@ -13,6 +13,7 @@
A self-hosted tool that automates Docker container deployment with Nginx Proxy Manager integration. Detects new images from Gitea/GitHub registries, deploys containers, and configures reverse proxy routing — all from a web dashboard. Supports multiple simultaneous versions of the same project. A self-hosted tool that automates Docker container deployment with Nginx Proxy Manager integration. Detects new images from Gitea/GitHub registries, deploys containers, and configures reverse proxy routing — all from a web dashboard. Supports multiple simultaneous versions of the same project.
## Build & Test Commands ## Build & Test Commands
- **Build (Go):** `go build ./cmd/server/` - **Build (Go):** `go build ./cmd/server/`
- **Test (Go):** `go test ./...` - **Test (Go):** `go test ./...`
- **Lint (Go):** `golangci-lint run` - **Lint (Go):** `golangci-lint run`
@@ -34,16 +35,18 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M
- [ ] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md) - [ ] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md)
- [ ] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md) - [ ] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md)
- [ ] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md) - [ ] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md)
- [ ] Phase 13: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md)
### Parallel Execution Notes ### Parallel Execution Notes
- Phases 3 and 4 are independent (Docker client vs NPM client) — can run in parallel - Phases 3 and 4 are independent (Docker client vs NPM client) — can run in parallel
- Phases 9 and 10 are independent (dashboard vs settings pages) — can run in parallel - Phases 9 and 10 are independent (dashboard vs settings pages) — can run in parallel
## Phase Progress Log ## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed | | Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------| | ----- | ------ | ------ | ------ | ----- | --------- |
| Phase 1: Scaffold & Store | backend | ✅ Complete | | ⏭️ Skip (Big Bang) | | | Phase 1: Scaffold & Store | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | |
| Phase 2: Crypto & Config | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 2: Crypto & Config | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 3: Docker Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 3: Docker Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 4: NPM Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 4: NPM Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
@@ -54,9 +57,27 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M
| Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 11: Embed & SSE | fullstack | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 11: Embed & SSE | fullstack | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 12: Hardening | backend | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ | | Phase 12: Hardening | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 13: UI Polish | frontend | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ |
## Amendment Log
### Amendment 1 — 2026-03-27
**Type:** Added phase
**What changed:** Added Phase 13: Frontend Polish & Modern UI after Phase 12
**Why:** User wants modern look & feel with SVG icons and polished frontend
**Impact on existing phases:** None — Phase 13 runs after all functionality is complete. Build/tests now required on Phase 13 (final) instead of Phase 12.
### Amendment 2 — 2026-03-27
**Type:** Modified phase
**What changed:** Added Task 13 (EN/RU localization) to Phase 13: Frontend Polish & Modern UI
**Why:** User wants bilingual support (English and Russian) in the dashboard
**Impact on existing phases:** None — contained within Phase 13
## Final Review ## Final Review
- [ ] Comprehensive code review - [ ] Comprehensive code review
- [ ] Full build passes - [ ] Full build passes
- [ ] Full test suite passes - [ ] Full test suite passes
@@ -0,0 +1,59 @@
# Phase 13: Frontend Polish & Modern UI
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Enhance the web UI with a modern, polished look and feel — custom SVG icons, refined typography, consistent color palette, smooth transitions, and overall professional frontend quality.
## Tasks
- [ ] Task 1: Design system foundations — define color palette (dark/light), spacing scale, typography scale, border radius tokens as CSS custom properties
- [ ] Task 2: SVG icon set — create or integrate a consistent icon library (Lucide, Heroicons, or custom SVGs) for all UI actions (deploy, stop, start, restart, remove, settings, registry, etc.)
- [ ] Task 3: Refine layout — polished sidebar/topnav with active state indicators, smooth transitions, responsive breakpoints
- [ ] Task 4: Dashboard cards — redesign project cards with status indicators, instance count badges, sparkline activity, hover effects
- [ ] Task 5: Project detail view — clean table/card layout for instances, inline status badges with pulse animation for "running", deploy history timeline
- [ ] Task 6: Form styling — consistent input fields, select dropdowns, toggle switches (replace checkboxes), button hierarchy (primary/secondary/danger)
- [ ] Task 7: Toast/notification system — slide-in toasts with icons, auto-dismiss, stacking
- [ ] Task 8: Loading states — skeleton loaders for data fetching, spinner for actions, progress indicator for deploys
- [ ] Task 9: Empty states — illustrated empty states with call-to-action for "no projects", "no instances", "no deploys"
- [ ] Task 10: Responsive design — mobile-friendly layout, collapsible sidebar, touch-friendly controls
- [ ] Task 11: Micro-interactions — button press feedback, status transition animations, deploy progress animation
- [ ] Task 12: Dark mode support (optional) — toggle in settings, respect system preference
- [ ] Task 13: Localization (EN/RU) — i18n setup with locale switcher, translate all UI strings to English and Russian, persist language preference
## Files to Modify/Create
- `web/src/lib/styles/` — design tokens, global styles
- `web/src/lib/components/icons/` — SVG icon components
- `web/src/lib/components/` — enhanced existing components
- `web/src/lib/i18n/` — locale files (en.json, ru.json), i18n helper, locale switcher component
- All route files — refined layouts and styling, replace hardcoded strings with i18n keys
## Acceptance Criteria
- UI looks modern and professional — not "default framework" appearance
- Consistent icon language throughout the app
- Smooth transitions and meaningful animations (not gratuitous)
- Responsive down to mobile viewport
- Loading and empty states provide good UX
- Color palette works well in both light and dark contexts
- All UI strings available in English and Russian, switchable via locale picker
## Notes
- This phase runs AFTER all functionality is complete — pure visual/UX enhancement
- Do not change any functionality or API contracts
- Prefer CSS custom properties for theming over hardcoded values
- Keep bundle size reasonable — inline SVGs preferred over icon font libraries
- Animations should be tasteful and serve UX, not decoration
- For i18n, use a lightweight approach (JSON locale files + Svelte store) — no heavy i18n framework needed
- Default language: English. Russian as secondary. Locale persisted to localStorage
## Review Checklist
- [ ] All tasks completed
- [ ] Visual consistency across all pages
- [ ] No functionality regressions
- [ ] Responsive on mobile/tablet/desktop
- [ ] Accessible (proper contrast ratios, focus states, aria labels on icons)
## Handoff to Next Phase
<!-- This is the final UI phase — no handoff needed. -->
@@ -1,6 +1,6 @@
# Phase 2: Crypto & Config Seed Loader # Phase 2: Crypto & Config Seed Loader
**Status:** ⬜ Not Started **Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend **Domain:** backend
@@ -9,14 +9,14 @@ Implement AES-256 encryption for credential storage and the YAML seed config par
## Tasks ## Tasks
- [ ] Task 1: Implement AES-256-GCM encrypt/decrypt functions using Go stdlib `crypto/aes` + `crypto/cipher` - [x] Task 1: Implement AES-256-GCM encrypt/decrypt functions using Go stdlib `crypto/aes` + `crypto/cipher`
- [ ] Task 2: Key derivation from ENCRYPTION_KEY env var (SHA-256 hash to get 32 bytes) - [x] Task 2: Key derivation from ENCRYPTION_KEY env var (SHA-256 hash to get 32 bytes)
- [ ] Task 3: Define YAML config structs matching the seed format from PLAN.md - [x] Task 3: Define YAML config structs matching the seed format from PLAN.md
- [ ] Task 4: Implement YAML parser — read and validate seed file - [x] Task 4: Implement YAML parser — read and validate seed file
- [ ] Task 5: Implement seed importer — checks if DB is empty, if so imports YAML into SQLite via store CRUD - [x] Task 5: Implement seed importer — checks if DB is empty, if so imports YAML into SQLite via store CRUD
- [ ] Task 6: Encrypt credential fields (registry tokens, NPM password) during import - [x] Task 6: Encrypt credential fields (registry tokens, NPM password) during import
- [ ] Task 7: Create `docker-watcher.example.yaml` with documented example config - [x] Task 7: Create `docker-watcher.example.yaml` with documented example config
- [ ] Task 8: Wire seed import into `cmd/server/main.go` startup sequence - [x] Task 8: Wire seed import into `cmd/server/main.go` startup sequence
## Files to Modify/Create ## Files to Modify/Create
- `internal/crypto/crypto.go` — AES-256-GCM encrypt/decrypt - `internal/crypto/crypto.go` — AES-256-GCM encrypt/decrypt
@@ -40,11 +40,22 @@ Implement AES-256 encryption for credential storage and the YAML seed config par
- The example YAML should have placeholder values, not real credentials - The example YAML should have placeholder values, not real credentials
## Review Checklist ## Review Checklist
- [ ] All tasks completed - [x] All tasks completed
- [ ] Crypto uses secure practices (random nonce, GCM, no ECB) - [x] Crypto uses secure practices (random nonce, GCM, no ECB)
- [ ] No hardcoded keys or secrets - [x] No hardcoded keys or secrets
- [ ] YAML parsing validates required fields - [x] YAML parsing validates required fields
- [ ] Import is transactional - [x] Import is transactional
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Filled in by the implementation agent after completing this phase. -->
- `crypto.Encrypt(key, plaintext)` and `crypto.Decrypt(key, ciphertextHex)` handle AES-256-GCM encryption; ciphertext is hex-encoded with prepended nonce
- `crypto.KeyFromEnv()` derives a `[32]byte` key from the `ENCRYPTION_KEY` env var via SHA-256
- `crypto.EncryptIfNotEmpty(key, value)` is a convenience wrapper that passes through empty strings unchanged
- `config.ImportSeed(db, seedPath)` is the single entry point for seed import — called from `main.go` at startup
- Import is idempotent: skipped if the DB already has projects or registries
- Import is transactional: all inserts happen within a single SQLite transaction (rollback on any failure)
- Registry `token` and settings `npm_password` are now stored encrypted in SQLite — later phases that read these fields must decrypt with `crypto.Decrypt(key, value)`
- `store.DB()` method was added to expose the underlying `*sql.DB` for transaction use
- Seed file path is configurable via `SEED_FILE` env var (default: `./docker-watcher.yaml`)
- YAML validation ensures: `global.domain` is required, every project needs `image`, project registry references must exist, stages need `tag_pattern`
- `go.sum` still does not exist — run `go mod tidy` when Go toolchain is available