157 lines
4.8 KiB
Go
157 lines
4.8 KiB
Go
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/<pushBranch>. 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
|
|
}
|