- ed25519-deploy-nyckel genereras av servern (config-volymen, 0600), GIT_SSH_COMMAND med egen known_hosts (accept-new) - inställningar: remote-URL, live-gren, read-only, auto-synk-intervall - bakgrundssynk: endast fast-forward, flaggar attention vid merge-behov - pull/push, skapa/byt gren, merge-popup (ours/theirs per fil eller allt), abort, reset från remote med automatisk backup-gren - headless-konfig via config.json eller updateGitSync-mutationen - openssh-client tillagd i runtime-imagen; mutex kring alla git-operationer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
605 lines
16 KiB
Go
605 lines
16 KiB
Go
package graph
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/brasse-b/archivum/internal/auth"
|
|
"github.com/brasse-b/archivum/internal/config"
|
|
"github.com/brasse-b/archivum/internal/git"
|
|
)
|
|
|
|
// The identity used for commits that the sync engine itself creates
|
|
// (merge commits, pre-reset snapshots).
|
|
const (
|
|
syncAuthor = "Archivum Sync"
|
|
syncEmail = "sync@archivum.local"
|
|
)
|
|
|
|
// ── SSH key management ────────────────────────────────────────────────────────
|
|
|
|
// sshDir returns the directory holding the sync keypair and known_hosts,
|
|
// next to config.json so it lives on the persistent /config volume.
|
|
func (s *Server) sshDir() string {
|
|
return filepath.Join(filepath.Dir(s.configPath), "ssh")
|
|
}
|
|
|
|
// ensureSSHKey generates an ed25519 keypair on first use and returns
|
|
// (keyPath, knownHostsPath, publicKey). The private key never leaves
|
|
// the server; the public key is what gets registered as a deploy key.
|
|
func (s *Server) ensureSSHKey() (string, string, string, error) {
|
|
dir := s.sshDir()
|
|
keyPath := filepath.Join(dir, "id_ed25519")
|
|
pubPath := keyPath + ".pub"
|
|
khPath := filepath.Join(dir, "known_hosts")
|
|
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
if f, err := os.OpenFile(khPath, os.O_CREATE, 0644); err == nil {
|
|
f.Close()
|
|
}
|
|
|
|
if pub, err := os.ReadFile(pubPath); err == nil {
|
|
if _, err := os.Stat(keyPath); err == nil {
|
|
return keyPath, khPath, strings.TrimSpace(string(pub)), nil
|
|
}
|
|
}
|
|
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
block, err := ssh.MarshalPrivateKey(priv, "archivum-git-sync")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
sshPub, err := ssh.NewPublicKey(pub)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
pubLine := bytes.TrimSpace(ssh.MarshalAuthorizedKey(sshPub))
|
|
pubLine = append(pubLine, []byte(" archivum-git-sync\n")...)
|
|
if err := os.WriteFile(pubPath, pubLine, 0644); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
log.Printf("[gitsync] generated new ssh keypair at %s", keyPath)
|
|
return keyPath, khPath, strings.TrimSpace(string(pubLine)), nil
|
|
}
|
|
|
|
// ── Engine wiring ─────────────────────────────────────────────────────────────
|
|
|
|
// configureGitSync (re)applies git-sync settings: key, remote and the
|
|
// background ticker. Called from initRuntime and after updateGitSync.
|
|
func (s *Server) configureGitSync(cfg *config.Config) {
|
|
s.syncMu.Lock()
|
|
if s.syncStop != nil {
|
|
close(s.syncStop)
|
|
s.syncStop = nil
|
|
}
|
|
s.syncMu.Unlock()
|
|
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
gs := cfg.GitSync
|
|
gs.Normalize()
|
|
|
|
s.mu.RLock()
|
|
repo := s.gitRepo
|
|
s.mu.RUnlock()
|
|
if repo == nil || !gs.Enabled || gs.RemoteURL == "" {
|
|
return
|
|
}
|
|
|
|
keyPath, khPath, _, err := s.ensureSSHKey()
|
|
if err != nil {
|
|
log.Printf("[gitsync] ssh key setup failed: %v", err)
|
|
return
|
|
}
|
|
repo.SetSSHKey(keyPath, khPath)
|
|
if err := repo.SetRemote(gs.RemoteURL); err != nil {
|
|
log.Printf("[gitsync] setting remote failed: %v", err)
|
|
return
|
|
}
|
|
log.Printf("[gitsync] enabled — remote=%s live=%s readOnly=%v auto=%dmin",
|
|
gs.RemoteURL, gs.LiveBranch, gs.ReadOnly, gs.AutoSyncMinutes)
|
|
|
|
stop := make(chan struct{})
|
|
s.syncMu.Lock()
|
|
s.syncStop = stop
|
|
s.syncMu.Unlock()
|
|
|
|
go func() {
|
|
// One eager sync shortly after (re)configuration, then on the
|
|
// configured interval. Manual-only mode still gets the eager
|
|
// run so status is populated after a restart.
|
|
timer := time.NewTimer(10 * time.Second)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-timer.C:
|
|
s.backgroundSync()
|
|
}
|
|
if gs.AutoSyncMinutes <= 0 {
|
|
return
|
|
}
|
|
ticker := time.NewTicker(time.Duration(gs.AutoSyncMinutes) * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-ticker.C:
|
|
s.backgroundSync()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// backgroundSync fetches and, when the live branch is checked out,
|
|
// fast-forwards it and pushes local commits. It never merges: anything
|
|
// that needs a real merge raises the attention flag for an admin.
|
|
func (s *Server) backgroundSync() {
|
|
s.mu.RLock()
|
|
repo := s.gitRepo
|
|
cfg := s.cfg
|
|
s.mu.RUnlock()
|
|
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
|
|
return
|
|
}
|
|
gs := cfg.GitSync
|
|
|
|
record := func(err error, attention bool) {
|
|
s.syncMu.Lock()
|
|
s.lastSyncAt = time.Now().Format(time.RFC3339)
|
|
if err != nil {
|
|
s.lastSyncErr = err.Error()
|
|
} else {
|
|
s.lastSyncErr = ""
|
|
}
|
|
s.syncAttention = attention
|
|
s.syncMu.Unlock()
|
|
}
|
|
|
|
if repo.MergeInProgress() {
|
|
record(errors.New("merge väntar på att lösas i admin-panelen"), true)
|
|
return
|
|
}
|
|
if err := repo.Fetch(); err != nil {
|
|
record(fmt.Errorf("fetch: %w", err), false)
|
|
return
|
|
}
|
|
current, err := repo.CurrentBranch()
|
|
if err != nil || current != gs.LiveBranch {
|
|
// Someone is working on another branch; leave it alone.
|
|
record(nil, false)
|
|
return
|
|
}
|
|
|
|
ahead, behind, hasUpstream := repo.AheadBehind(gs.LiveBranch)
|
|
if !hasUpstream {
|
|
if gs.ReadOnly {
|
|
record(nil, false)
|
|
return
|
|
}
|
|
record(repo.Push(gs.LiveBranch), false)
|
|
return
|
|
}
|
|
if behind > 0 {
|
|
if err := repo.Pull(gs.LiveBranch, syncAuthor, syncEmail, true); err != nil {
|
|
// Not fast-forwardable (or unrelated histories): an admin
|
|
// must pull/merge or reset from the UI.
|
|
record(fmt.Errorf("kan inte snabbspola — manuell merge eller reset krävs (%w)", err), true)
|
|
return
|
|
}
|
|
}
|
|
if ahead > 0 && !gs.ReadOnly {
|
|
if err := repo.Push(gs.LiveBranch); err != nil {
|
|
record(fmt.Errorf("push: %w", err), false)
|
|
return
|
|
}
|
|
}
|
|
record(nil, false)
|
|
}
|
|
|
|
// gitSyncState returns the shared handles used by every handler below.
|
|
func (s *Server) gitSyncState() (*git.Repo, *config.Config) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.gitRepo, s.cfg
|
|
}
|
|
|
|
// ── GraphQL handlers (admin only) ─────────────────────────────────────────────
|
|
|
|
func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
_, cfg := s.gitSyncState()
|
|
if cfg == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
gs := cfg.GitSync
|
|
gs.Normalize()
|
|
publicKey := ""
|
|
if pub, err := os.ReadFile(filepath.Join(s.sshDir(), "id_ed25519.pub")); err == nil {
|
|
publicKey = strings.TrimSpace(string(pub))
|
|
}
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"gitSyncSettings": map[string]interface{}{
|
|
"enabled": gs.Enabled,
|
|
"remoteUrl": gs.RemoteURL,
|
|
"liveBranch": gs.LiveBranch,
|
|
"readOnly": gs.ReadOnly,
|
|
"autoSyncMinutes": gs.AutoSyncMinutes,
|
|
"publicKey": publicKey,
|
|
"keySet": publicKey != "",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleUpdateGitSync(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
input, ok := req.Variables["input"].(map[string]interface{})
|
|
if !ok {
|
|
input, _ = req.Variables["i"].(map[string]interface{})
|
|
}
|
|
if input == nil {
|
|
writeGQLError(w, "missing input")
|
|
return
|
|
}
|
|
s.mu.RLock()
|
|
cfg := s.cfg
|
|
s.mu.RUnlock()
|
|
if cfg == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
|
|
newCfg := *cfg
|
|
gs := config.GitSyncConfig{
|
|
Enabled: boolVal(input, "enabled"),
|
|
RemoteURL: strings.TrimSpace(strVal(input, "remoteUrl")),
|
|
LiveBranch: strings.TrimSpace(strVal(input, "liveBranch")),
|
|
ReadOnly: boolVal(input, "readOnly"),
|
|
AutoSyncMinutes: intVal(input, "autoSyncMinutes"),
|
|
}
|
|
gs.Normalize()
|
|
newCfg.GitSync = gs
|
|
|
|
if err := config.Save(s.configPath, &newCfg); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.cfg = &newCfg
|
|
s.mu.Unlock()
|
|
s.configureGitSync(&newCfg)
|
|
|
|
log.Printf("[admin] git sync updated by %s (enabled=%v remote=%s)",
|
|
sess.Username, gs.Enabled, gs.RemoteURL)
|
|
writeJSON(w, `{"data":{"updateGitSync":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleGitRegenerateKey(w http.ResponseWriter, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
dir := s.sshDir()
|
|
os.Remove(filepath.Join(dir, "id_ed25519"))
|
|
os.Remove(filepath.Join(dir, "id_ed25519.pub"))
|
|
_, _, publicKey, err := s.ensureSSHKey()
|
|
if err != nil {
|
|
writeGQLError(w, fmt.Sprintf("key generation failed: %v", err))
|
|
return
|
|
}
|
|
s.mu.RLock()
|
|
cfg := s.cfg
|
|
s.mu.RUnlock()
|
|
if cfg != nil {
|
|
s.configureGitSync(cfg)
|
|
}
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"gitRegenerateKey": map[string]interface{}{"publicKey": publicKey},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGitSyncStatus(w http.ResponseWriter, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, cfg := s.gitSyncState()
|
|
if repo == nil || cfg == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
gs := cfg.GitSync
|
|
gs.Normalize()
|
|
|
|
current, _ := repo.CurrentBranch()
|
|
ahead, behind, hasUpstream := 0, 0, false
|
|
if gs.Enabled {
|
|
ahead, behind, hasUpstream = repo.AheadBehind(current)
|
|
}
|
|
conflicts := repo.ConflictFiles()
|
|
s.syncMu.Lock()
|
|
lastAt, lastErr, attention := s.lastSyncAt, s.lastSyncErr, s.syncAttention
|
|
s.syncMu.Unlock()
|
|
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"gitSyncStatus": map[string]interface{}{
|
|
"enabled": gs.Enabled,
|
|
"currentBranch": current,
|
|
"liveBranch": gs.LiveBranch,
|
|
"readOnly": gs.ReadOnly,
|
|
"ahead": ahead,
|
|
"behind": behind,
|
|
"hasUpstream": hasUpstream,
|
|
"dirty": repo.HasUncommitted(),
|
|
"mergeInProgress": repo.MergeInProgress(),
|
|
"conflicts": conflicts,
|
|
"lastSyncAt": lastAt,
|
|
"lastSyncError": lastErr,
|
|
"attention": attention || repo.MergeInProgress(),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGitBranches(w http.ResponseWriter, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, _ := s.gitSyncState()
|
|
if repo == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
local, remote, err := repo.Branches()
|
|
if err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
current, _ := repo.CurrentBranch()
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"gitBranches": map[string]interface{}{
|
|
"current": current,
|
|
"local": local,
|
|
"remote": remote,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGitCreateBranch(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, _ := s.gitSyncState()
|
|
name := strings.TrimSpace(strVal(req.Variables, "name"))
|
|
if repo == nil || name == "" {
|
|
writeGQLError(w, "missing branch name")
|
|
return
|
|
}
|
|
if err := repo.CreateBranch(name); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
log.Printf("[gitsync] %s created branch %s", sess.Username, name)
|
|
writeJSON(w, `{"data":{"gitCreateBranch":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleGitSwitchBranch(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, _ := s.gitSyncState()
|
|
name := strings.TrimSpace(strVal(req.Variables, "name"))
|
|
if repo == nil || name == "" {
|
|
writeGQLError(w, "missing branch name")
|
|
return
|
|
}
|
|
if repo.MergeInProgress() {
|
|
writeGQLError(w, "merge in progress — resolve or abort it first")
|
|
return
|
|
}
|
|
if err := repo.SwitchBranch(name); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
log.Printf("[gitsync] %s switched to branch %s", sess.Username, name)
|
|
writeJSON(w, `{"data":{"gitSwitchBranch":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleGitPull(w http.ResponseWriter, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, cfg := s.gitSyncState()
|
|
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
|
|
writeGQLError(w, "git sync is not enabled")
|
|
return
|
|
}
|
|
if err := repo.Fetch(); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
current, err := repo.CurrentBranch()
|
|
if err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
result := "OK"
|
|
err = repo.Pull(current, sess.Username, sess.Username+"@archivum.local", false)
|
|
switch {
|
|
case errors.Is(err, git.ErrMergeConflict):
|
|
result = "MERGE_CONFLICT"
|
|
case errors.Is(err, git.ErrUnrelatedHistories):
|
|
result = "UNRELATED_HISTORIES"
|
|
case err != nil:
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
s.syncMu.Lock()
|
|
s.lastSyncAt = time.Now().Format(time.RFC3339)
|
|
s.lastSyncErr = ""
|
|
s.syncAttention = result != "OK"
|
|
s.syncMu.Unlock()
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"gitPull": map[string]interface{}{
|
|
"result": result,
|
|
"conflicts": repo.ConflictFiles(),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGitPush(w http.ResponseWriter, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, cfg := s.gitSyncState()
|
|
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
|
|
writeGQLError(w, "git sync is not enabled")
|
|
return
|
|
}
|
|
if cfg.GitSync.ReadOnly {
|
|
writeGQLError(w, "repository is configured read-only — pushing is disabled")
|
|
return
|
|
}
|
|
current, err := repo.CurrentBranch()
|
|
if err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
if err := repo.Push(current); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
log.Printf("[gitsync] %s pushed %s", sess.Username, current)
|
|
writeJSON(w, `{"data":{"gitPush":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleGitResolveMerge(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, _ := s.gitSyncState()
|
|
if repo == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
strategy := strVal(req.Variables, "strategy")
|
|
paths := strSlice(req.Variables, "paths")
|
|
if err := repo.ResolveConflicts(strategy, paths, sess.Username, sess.Username+"@archivum.local"); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
remaining := repo.ConflictFiles()
|
|
if len(remaining) == 0 {
|
|
s.syncMu.Lock()
|
|
s.syncAttention = false
|
|
s.syncMu.Unlock()
|
|
}
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"gitResolveMerge": map[string]interface{}{
|
|
"resolved": len(remaining) == 0,
|
|
"remaining": remaining,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGitAbortMerge(w http.ResponseWriter, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, _ := s.gitSyncState()
|
|
if repo == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
if err := repo.AbortMerge(); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
s.syncMu.Lock()
|
|
s.syncAttention = false
|
|
s.syncMu.Unlock()
|
|
writeJSON(w, `{"data":{"gitAbortMerge":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleGitResetFromRemote(w http.ResponseWriter, sess *auth.Session) {
|
|
if !s.requireAdmin(w, sess) {
|
|
return
|
|
}
|
|
repo, cfg := s.gitSyncState()
|
|
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
|
|
writeGQLError(w, "git sync is not enabled")
|
|
return
|
|
}
|
|
if err := repo.Fetch(); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
// Reset the current branch when it exists on the remote; otherwise
|
|
// adopt the live branch (bootstrap case: local repo predates the
|
|
// remote and is on an unrelated branch like master).
|
|
current, err := repo.CurrentBranch()
|
|
if err != nil || current == "" {
|
|
current = cfg.GitSync.LiveBranch
|
|
} else if _, _, hasUpstream := repo.AheadBehind(current); !hasUpstream {
|
|
current = cfg.GitSync.LiveBranch
|
|
}
|
|
backup, err := repo.ResetToRemote(current, syncAuthor, syncEmail)
|
|
if err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
s.syncMu.Lock()
|
|
s.syncAttention = false
|
|
s.lastSyncErr = ""
|
|
s.syncMu.Unlock()
|
|
log.Printf("[gitsync] %s reset %s from remote (backup: %s)", sess.Username, current, backup)
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"gitResetFromRemote": map[string]interface{}{"backupBranch": backup},
|
|
},
|
|
})
|
|
}
|
|
|
|
func intVal(m map[string]interface{}, key string) int {
|
|
if f, ok := m[key].(float64); ok {
|
|
return int(f)
|
|
}
|
|
return 0
|
|
}
|