package server import ( "fmt" "io" "os" "path/filepath" "strings" "time" "git.nevets.tech/Steven/certman/common" "github.com/go-git/go-git/v5" gitconf "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport/http" ) // serverIDFile is the filename inside the domain's git repo that records // which server (by config.App.UUID) owns the repo. Only the owning server // pushes to it; other servers must refuse. const serverIDFile = "SERVER_ID" // pushBranch is the branch server mode pushes to. The Gitea repo is created // with this as its default branch; the client tracks the same name. const pushBranch = "master" // CreateRepo provisions the domain's remote repo via the configured provider // and returns its clone URL. func CreateRepo(config *common.AppConfig, domainConfig *common.DomainConfig, domain string) (string, error) { provider, err := common.ProviderFor(config) if err != nil { return "", err } return provider.CreateRepo(domain, domainConfig) } // VerifyOwnership reads SERVER_ID from the cloned workspace and compares it // against uuid. It returns: // // (true, nil) — SERVER_ID matches uuid (we own this repo). // (false, nil) — SERVER_ID is missing (repo is unclaimed; safe to adopt). // (false, err) — SERVER_ID names a different server (refuse to push). // // The caller decides what to do with an unclaimed repo; adoption must be an // explicit decision, not a silent fall-through. AddAndPushCerts re-writes // SERVER_ID on every push, so the first successful push after adoption // claims the repo for this server. func VerifyOwnership(ws *common.GitWorkspace, uuid string) (bool, error) { f, err := ws.FS.Open(serverIDFile) if err != nil { if os.IsNotExist(err) { return false, nil } return false, fmt.Errorf("open SERVER_ID: %w", err) } defer f.Close() raw, err := io.ReadAll(f) if err != nil { return false, fmt.Errorf("read SERVER_ID: %w", err) } existing := strings.TrimSpace(string(raw)) if existing == uuid { return true, nil } return false, fmt.Errorf("domain is owned by server %q", existing) } // AddAndPushCerts stages every *.crpt file from dataRoot into the workspace, // (re-)writes SERVER_ID with config.App.UUID, commits any resulting change, // and pushes to origin/. If nothing changed the call is a no-op // and returns nil without pushing. func AddAndPushCerts(ws *common.GitWorkspace, dataRoot string, config *common.AppConfig) error { if err := stageCerts(ws, dataRoot); err != nil { return err } if err := stageFile(ws, serverIDFile, []byte(config.App.UUID)); err != nil { return fmt.Errorf("stage SERVER_ID: %w", err) } status, err := ws.WorkTree.Status() if err != nil { return fmt.Errorf("get worktree status: %w", err) } if status.IsClean() { return nil } sig := &object.Signature{ Name: "Cert Manager", Email: config.Certificates.Email, When: time.Now(), } msg := fmt.Sprintf("Update %s @ %s", ws.Domain, time.Now().Format("Mon Jan _2 2006 15:04:05 MST")) if _, err := ws.WorkTree.Commit(msg, &git.CommitOptions{Author: sig, Committer: sig}); err != nil { return fmt.Errorf("commit: %w", err) } err = ws.Repo.Push(&git.PushOptions{ Auth: &http.BasicAuth{ Username: config.Git.Username, Password: config.Git.APIToken, }, Force: true, RemoteName: "origin", RefSpecs: []gitconf.RefSpec{gitconf.RefSpec("refs/heads/" + pushBranch + ":refs/heads/" + pushBranch)}, }) if err != nil { return fmt.Errorf("push %s: %w", ws.URL, err) } return nil } // stageCerts copies every *.crpt file in dataRoot into the workspace // filesystem and adds it to the work tree. func stageCerts(ws *common.GitWorkspace, dataRoot string) error { entries, err := os.ReadDir(dataRoot) if err != nil { return fmt.Errorf("read %s: %w", dataRoot, err) } for _, entry := range entries { name := entry.Name() if !strings.HasSuffix(name, ".crpt") { continue } body, err := os.ReadFile(filepath.Join(dataRoot, name)) if err != nil { return fmt.Errorf("read %s: %w", name, err) } if err := stageFile(ws, name, body); err != nil { return fmt.Errorf("stage %s: %w", name, err) } } return nil } // stageFile writes body to name in the workspace filesystem and adds it to // the work tree. It is the single point where workspace-relative paths are // constructed, so Create and Add always agree on the path. func stageFile(ws *common.GitWorkspace, name string, body []byte) error { f, err := ws.FS.Create(name) if err != nil { return fmt.Errorf("create: %w", err) } if _, err := f.Write(body); err != nil { f.Close() return fmt.Errorf("write: %w", err) } if err := f.Close(); err != nil { return fmt.Errorf("close: %w", err) } if _, err := ws.WorkTree.Add(f.Name()); err != nil { return fmt.Errorf("git add: %w", err) } return nil }