Major refactoring, updated config structure

This commit is contained in:
2026-02-28 00:59:11 +01:00
parent 2e52eae151
commit 2cbab1a0a2
18 changed files with 876 additions and 753 deletions

View File

@@ -1,12 +1,13 @@
VERSION := 1.0.0 VERSION := 1.0.0-beta
BUILD := $(shell git rev-parse --short HEAD)
GO := go GO := go
BUILD_FLAGS := -buildmode=pie -trimpath BUILD_FLAGS := -buildmode=pie -trimpath
LDFLAGS := -linkmode=external -extldflags="-Wl,-z,relro,-z,now" LDFLAGS := -linkmode=external -extldflags="-Wl,-z,relro,-z,now" -X git.nevets.tech/Keys/CertManager/internal.Version=$(VERSION) -X git.nevets.tech/Keys/CertManager/internal.Build=$(BUILD)
build: build:
$(GO) build $(BUILD_FLAGS) -ldflags='$(LDFLAGS)' -o ./certman . $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o ./certman .
@cp ./certman ./certman-$(VERSION)-amd64 @cp ./certman ./certman-$(VERSION)-amd64
stage: build stage: build

View File

@@ -1,4 +1,4 @@
package main package client
import ( import (
"fmt" "fmt"
@@ -7,24 +7,26 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"git.nevets.tech/Keys/CertManager/internal"
"github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5/storage/memory" "github.com/go-git/go-git/v5/storage/memory"
) )
func initClient() { func Init() {
err := LoadDomainConfigs() err := internal.LoadDomainConfigs()
if err != nil { if err != nil {
log.Fatalf("Error loading domain configs: %v", err) log.Fatalf("Error loading domain configs: %v", err)
} }
clientTick() Tick()
} }
func clientTick() { func Tick() {
fmt.Println("Tick!") fmt.Println("Tick!")
// Get local copy of domain configs // Get local copy of configs
localDomainConfigs := domainStore.Snapshot() config := internal.Config()
localDomainConfigs := internal.DomainStore().Snapshot()
// Loop over all domain configs (domains) // Loop over all domain configs (domains)
for domainStr, domainConfig := range localDomainConfigs { for domainStr, domainConfig := range localDomainConfigs {
@@ -37,16 +39,16 @@ func clientTick() {
// If the repo doesn't exist, we can't check for a remote commit, so stop the rest of the check // If the repo doesn't exist, we can't check for a remote commit, so stop the rest of the check
repoExists := domainConfig.GetBool("Internal.repo_exists") repoExists := domainConfig.GetBool("Internal.repo_exists")
if repoExists { if repoExists {
localHash, err := getLocalCommitHash(domainStr) localHash, err := internal.LocalCommitHash(domainStr)
if err != nil { if err != nil {
fmt.Printf("No local commit hash found for domain %s\n", domainStr) fmt.Printf("No local commit hash found for domain %s\n", domainStr)
} }
gitSource, err := strToGitSource(config.GetString("Git.host")) gitSource, err := internal.StrToGitSource(internal.Config().GetString("Git.host"))
if err != nil { if err != nil {
fmt.Printf("Error getting git source for domain %s: %v\n", domainStr, err) fmt.Printf("Error getting git source for domain %s: %v\n", domainStr, err)
continue continue
} }
remoteHash, err := getRemoteCommitHash(domainStr, gitSource) remoteHash, err := internal.RemoteCommitHash(domainStr, gitSource)
if err != nil { if err != nil {
fmt.Printf("Error getting remote commit hash for domain %s: %v\n", domainStr, err) fmt.Printf("Error getting remote commit hash for domain %s: %v\n", domainStr, err)
} }
@@ -58,20 +60,20 @@ func clientTick() {
} }
} }
gitWorkspace := &GitWorkspace{ gitWorkspace := &internal.GitWorkspace{
Storage: memory.NewStorage(), Storage: memory.NewStorage(),
FS: memfs.New(), FS: memfs.New(),
} }
// Ex: https://git.example.com/Org/Repo-suffix.git // Ex: https://git.example.com/Org/Repo-suffix.git
// Clones repo and stores in gitWorkspace, skip if clone fails (doesn't exist?) // Clones repo and stores in gitWorkspace, skip if clone fails (doesn't exist?)
repoUrl := config.GetString("Git.server") + "/" + config.GetString("Git.org_name") + "/" + domainStr + domainConfig.GetString("Repo.repo_suffix") + ".git" repoUrl := internal.Config().GetString("Git.server") + "/" + config.GetString("Git.org_name") + "/" + domainStr + domainConfig.GetString("Repo.repo_suffix") + ".git"
err := cloneRepo(repoUrl, gitWorkspace) err := internal.CloneRepo(repoUrl, gitWorkspace, internal.Client)
if err != nil { if err != nil {
fmt.Printf("Error cloning domain repo %s: %v\n", domainStr, err) fmt.Printf("Error cloning domain repo %s: %v\n", domainStr, err)
continue continue
} }
certsDir, err := getDomainCertsDirWConf(domainStr, domainConfig) certsDir, err := internal.DomainCertsDirWConf(domainStr, domainConfig)
if err != nil { if err != nil {
fmt.Printf("Error getting certificates dir for domain %s: %v\n", domainStr, err) fmt.Printf("Error getting certificates dir for domain %s: %v\n", domainStr, err)
continue continue
@@ -104,7 +106,7 @@ func clientTick() {
continue continue
} }
err = DecryptFileFromBytes(domainConfig.GetString("Certificates.crypto_key"), fileBytes, filepath.Join(certsDir, filename), nil) err = internal.DecryptFileFromBytes(domainConfig.GetString("Certificates.crypto_key"), fileBytes, filepath.Join(certsDir, filename), nil)
if err != nil { if err != nil {
fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, domainStr, err) fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, domainStr, err)
continue continue
@@ -116,7 +118,7 @@ func clientTick() {
continue continue
} }
err = writeCommitHash(headRef.Hash().String(), domainConfig) err = internal.WriteCommitHash(headRef.Hash().String(), domainConfig)
if err != nil { if err != nil {
fmt.Printf("Error writing commit hash: %v\n", err) fmt.Printf("Error writing commit hash: %v\n", err)
continue continue
@@ -124,7 +126,7 @@ func clientTick() {
certLinks := domainConfig.GetStringSlice("Certificates.cert_symlinks") certLinks := domainConfig.GetStringSlice("Certificates.cert_symlinks")
for _, certLink := range certLinks { for _, certLink := range certLinks {
err = linkFile(filepath.Join(certsDir, domainStr+".crt"), certLink, domainStr, ".crt") err = internal.LinkFile(filepath.Join(certsDir, domainStr+".crt"), certLink, domainStr, ".crt")
if err != nil { if err != nil {
fmt.Printf("Error linking cert %s to %s: %v\n", certLink, domainStr, err) fmt.Printf("Error linking cert %s to %s: %v\n", certLink, domainStr, err)
continue continue
@@ -133,7 +135,7 @@ func clientTick() {
keyLinks := domainConfig.GetStringSlice("Certificates.key_symlinks") keyLinks := domainConfig.GetStringSlice("Certificates.key_symlinks")
for _, keyLink := range keyLinks { for _, keyLink := range keyLinks {
err = linkFile(filepath.Join(certsDir, domainStr+".crt"), keyLink, domainStr, ".key") err = internal.LinkFile(filepath.Join(certsDir, domainStr+".crt"), keyLink, domainStr, ".key")
if err != nil { if err != nil {
fmt.Printf("Error linking cert %s to %s: %v\n", keyLink, domainStr, err) fmt.Printf("Error linking cert %s to %s: %v\n", keyLink, domainStr, err)
continue continue
@@ -144,16 +146,16 @@ func clientTick() {
} }
} }
func reloadClient() { func Reload() {
fmt.Println("Reloading configs...") fmt.Println("Reloading configs...")
err := LoadDomainConfigs() err := internal.LoadDomainConfigs()
if err != nil { if err != nil {
fmt.Printf("Error loading domain configs: %v\n", err) fmt.Printf("Error loading domain configs: %v\n", err)
return return
} }
} }
func stopClient() { func Stop() {
fmt.Println("Shutting down client") fmt.Println("Shutting down client")
} }

View File

@@ -1,494 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/spf13/cobra"
)
func devFunc(cmd *cobra.Command, args []string) {
testDomain := "lunamc.org"
//config, err = ezconf.LoadConfiguration("/etc/certman/certman.conf")
err := LoadConfig("/etc/certman/certman.conf")
if err != nil {
log.Fatalf("Error loading configuration: %v\n", err)
}
err = LoadDomainConfigs()
if err != nil {
log.Fatalf("Error loading configs: %v\n", err)
}
fmt.Println(testDomain)
}
func versionResponse(cmd *cobra.Command, args []string) {
fmt.Println("CertManager (certman) - Steven Tracey\nVersion: " + version + " build-" + build)
}
func newKey(cmd *cobra.Command, args []string) {
key, err := GenerateKey()
if err != nil {
log.Fatalf("%v", err)
}
fmt.Printf(key)
}
func newDomain(domain, domainDir string, dirOverridden bool) error {
//TODO add config option for "overriden dir"
fmt.Printf("Creating new domain %s\n", domain)
err := createNewDomainConfig(domain)
if err != nil {
return err
}
createNewDomainCertsDir(domain, domainDir, dirOverridden)
certmanUser, err := user.Lookup("certman")
if err != nil {
return fmt.Errorf("error getting user certman: %v", err)
}
uid, err := strconv.Atoi(strings.TrimSpace(certmanUser.Uid))
if err != nil {
return err
}
gid, err := strconv.Atoi(strings.TrimSpace(certmanUser.Gid))
if err != nil {
return err
}
err = ChownRecursive("/etc/certman/domains", uid, gid)
if err != nil {
return err
}
err = ChownRecursive("/var/local/certman", uid, gid)
if err != nil {
return err
}
fmt.Println("Successfully created domain entry for " + domain + "\nUpdate config file as needed in /etc/certman/domains/" + domain + ".conf\n")
return nil
}
func install(isThin bool, mode string) error {
if !isThin {
if os.Geteuid() != 0 {
return fmt.Errorf("installation must be run as root")
}
makeDirs()
createNewConfig(mode)
f, err := os.OpenFile("/var/run/certman.pid", os.O_RDONLY|os.O_CREATE, 0755)
if err != nil {
return fmt.Errorf("error creating pid file: %v", err)
}
err = f.Close()
if err != nil {
return fmt.Errorf("error closing pid file: %v", err)
}
newUserCmd := exec.Command("useradd", "-d", "/var/local/certman", "-U", "-r", "-s", "/sbin/nologin", "certman")
if output, err := newUserCmd.CombinedOutput(); err != nil {
return fmt.Errorf("error creating user: %v: output %s", err, output)
}
certmanUser, err := user.Lookup("certman")
if err != nil {
return fmt.Errorf("error getting user certman: %v", err)
}
uid, err := strconv.Atoi(strings.TrimSpace(certmanUser.Uid))
if err != nil {
return err
}
gid, err := strconv.Atoi(strings.TrimSpace(certmanUser.Gid))
if err != nil {
return err
}
err = ChownRecursive("/etc/certman", uid, gid)
if err != nil {
return fmt.Errorf("error changing uid/gid: %v", err)
}
err = ChownRecursive("/var/local/certman", uid, gid)
if err != nil {
return fmt.Errorf("error changing uid/gid: %v", err)
}
err = os.Chown("/var/run/certman.pid", uid, gid)
if err != nil {
return fmt.Errorf("error changing uid/gid: %v", err)
}
} else {
createNewConfig(mode)
}
return nil
}
func renewCertFunc(domain string, noPush bool) error {
err := LoadConfig("/etc/certman/certman.conf")
if err != nil {
return err
}
err = LoadDomainConfigs()
if err != nil {
return err
}
switch config.GetString("App.mode") {
case "server":
mgr, err = NewACMEManager()
if err != nil {
return err
}
err = renewCerts(domain, noPush)
if err != nil {
return err
}
return reloadDaemon()
case "client":
return pullCerts(domain)
default:
return fmt.Errorf("invalid operating mode %s", config.GetString("App.mode"))
}
}
func renewCerts(domain string, noPush bool) error {
_, err := mgr.RenewForDomain(domain)
if err != nil {
// if no existing cert, obtain instead
_, err = mgr.ObtainForDomain(domain)
if err != nil {
return fmt.Errorf("error obtaining domain certificates for domain %s: %v", domain, err)
}
}
domainConfig, exists := domainStore.Get(domain)
if !exists {
return fmt.Errorf("domain %s does not exist", domain)
}
domainConfig.Set("Internal.last_issued", time.Now().UTC().Unix())
err = WriteDomainConfig(domainConfig)
if err != nil {
return fmt.Errorf("error saving domain config %s: %v", domain, err)
}
err = EncryptFileXChaCha(domainConfig.GetString("Certificates.crypto_key"), filepath.Join(mgr.certsRoot, domain, domain+".crt"), filepath.Join(mgr.certsRoot, domain, domain+".crt.crpt"), nil)
if err != nil {
return fmt.Errorf("error encrypting domain cert for domain %s: %v", domain, err)
}
err = EncryptFileXChaCha(domainConfig.GetString("Certificates.crypto_key"), filepath.Join(mgr.certsRoot, domain, domain+".key"), filepath.Join(mgr.certsRoot, domain, domain+".key.crpt"), nil)
if err != nil {
return fmt.Errorf("error encrypting domain key for domain %s: %v", domain, err)
}
if !noPush {
giteaClient := createGiteaClient()
if giteaClient == nil {
return fmt.Errorf("error creating gitea client for domain %s: %v", domain, err)
}
gitWorkspace := &GitWorkspace{
Storage: memory.NewStorage(),
FS: memfs.New(),
}
var repoUrl string
if !domainConfig.GetBool("Internal.repo_exists") {
repoUrl = createGiteaRepo(domain, giteaClient)
if repoUrl == "" {
return fmt.Errorf("error creating Gitea repo for domain %s", domain)
}
domainConfig.Set("Internal.repo_exists", true)
err = WriteDomainConfig(domainConfig)
if err != nil {
return fmt.Errorf("error saving domain config %s: %v", domain, err)
}
err = initRepo(repoUrl, gitWorkspace)
if err != nil {
return fmt.Errorf("error initializing repo for domain %s: %v", domain, err)
}
} else {
repoUrl = config.GetString("Git.server") + "/" + config.GetString("Git.org_name") + "/" + domain + domainConfig.GetString("Repo.repo_suffix") + ".git"
err = cloneRepo(repoUrl, gitWorkspace)
if err != nil {
return fmt.Errorf("error cloning repo for domain %s: %v", domain, err)
}
}
err = addAndPushCerts(domain, gitWorkspace)
if err != nil {
return fmt.Errorf("error pushing certificates for domain %s: %v", domain, err)
}
fmt.Printf("Successfully pushed certificates for domain %s\n", domain)
}
return nil
}
func pullCerts(domain string) error {
gitWorkspace := &GitWorkspace{
Storage: memory.NewStorage(),
FS: memfs.New(),
}
domainConfig, exists := domainStore.Get(domain)
if !exists {
return fmt.Errorf("domain %s does not exist", domain)
}
// Ex: https://git.example.com/Org/Repo-suffix.git
// Clones repo and stores in gitWorkspace, skip if clone fails (doesn't exist?)
repoUrl := config.GetString("Git.server") + "/" + config.GetString("Git.org_name") + "/" + domain + domainConfig.GetString("Repo.repo_suffix") + ".git"
err := cloneRepo(repoUrl, gitWorkspace)
if err != nil {
return fmt.Errorf("Error cloning domain repo %s: %v\n", domain, err)
}
certsDir, err := getDomainCertsDirWConf(domain, domainConfig)
if err != nil {
return fmt.Errorf("Error getting certificates dir for domain %s: %v\n", domain, err)
}
// Get files in repo
fileInfos, err := gitWorkspace.FS.ReadDir("/")
if err != nil {
return fmt.Errorf("Error reading directory in memFS on domain %s: %v\n", domain, err)
}
// Iterate over files, filtering by .crpt (encrypted) files in case other files were accidentally added
for _, fileInfo := range fileInfos {
if strings.HasSuffix(fileInfo.Name(), ".crpt") {
filename, _ := strings.CutSuffix(fileInfo.Name(), ".crpt")
file, err := gitWorkspace.FS.Open(fileInfo.Name())
if err != nil {
fmt.Printf("Error opening file in memFS on domain %s: %v\n", domain, err)
continue
}
fileBytes, err := io.ReadAll(file)
if err != nil {
fmt.Printf("Error reading file in memFS on domain %s: %v\n", domain, err)
file.Close()
continue
}
err = file.Close()
if err != nil {
fmt.Printf("Error closing file on domain %s: %v\n", domain, err)
continue
}
err = DecryptFileFromBytes(domainConfig.GetString("Certificates.crypto_key"), fileBytes, filepath.Join(certsDir, filename), nil)
if err != nil {
fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, domain, err)
continue
}
headRef, err := gitWorkspace.Repo.Head()
if err != nil {
fmt.Printf("Error getting head reference for domain %s: %v\n", domain, err)
continue
}
err = writeCommitHash(headRef.Hash().String(), domainConfig)
if err != nil {
fmt.Printf("Error writing commit hash: %v\n", err)
continue
}
certLinks := domainConfig.GetStringSlice("Certificates.cert_symlinks")
for _, certLink := range certLinks {
if certLink == "" {
continue
}
linkInfo, err := os.Stat(certLink)
if err != nil {
if !os.IsNotExist(err) {
fmt.Printf("Error stating cert link %s: %v\n", certLink, err)
continue
}
}
if linkInfo.IsDir() {
certLink = filepath.Join(certLink, domain+".crt")
}
err = os.Link(filepath.Join(certsDir, domain+".crt"), certLink)
if err != nil {
fmt.Printf("Error linking cert %s to %s: %v\n", certLink, domain, err)
continue
}
}
keyLinks := domainConfig.GetStringSlice("Certificates.key_symlinks")
for _, keyLink := range keyLinks {
if keyLink == "" {
continue
}
linkInfo, err := os.Stat(keyLink)
if err != nil {
if !os.IsNotExist(err) {
fmt.Printf("Error stating key link %s: %v\n", keyLink, err)
continue
}
}
if linkInfo.IsDir() {
keyLink = filepath.Join(keyLink, domain+".crt")
}
err = os.Link(filepath.Join(certsDir, domain+".crt"), keyLink)
if err != nil {
fmt.Printf("Error linking cert %s to %s: %v\n", keyLink, domain, err)
continue
}
}
}
}
return nil
}
func runDaemon() error {
err := createOrUpdatePIDFile("/var/run/certman.pid")
if err != nil {
if errors.Is(err, ErrorPIDInUse) {
return fmt.Errorf("daemon process is already running")
}
return fmt.Errorf("error creating pidfile: %v", err)
}
ctx, cancel = context.WithCancel(context.Background())
// Check if main config exists
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return fmt.Errorf("main config file not found, please run 'certman --install', then properly configure /etc/certman/certman.conf")
} else if err != nil {
return fmt.Errorf("error opening %s: %v", configFile, err)
}
err = LoadConfig(configFile)
if err != nil {
return fmt.Errorf("error loading configuration: %v", err)
}
// Setup SIGINT and SIGTERM listeners
sigChannel := make(chan os.Signal, 1)
signal.Notify(sigChannel, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChannel)
reloadSigChan := make(chan os.Signal, 1)
signal.Notify(reloadSigChan, syscall.SIGHUP)
defer signal.Stop(reloadSigChan)
tickSigChan := make(chan os.Signal, 1)
signal.Notify(tickSigChan, syscall.SIGUSR1)
defer signal.Stop(tickSigChan)
tickRate := config.GetInt("App.tick_rate")
ticker := time.NewTicker(time.Duration(tickRate) * time.Hour)
defer ticker.Stop()
wg.Add(1)
if config.GetString("App.mode") == "server" {
fmt.Println("Starting CertManager in server mode...")
// Server Task loop
go func() {
initServer()
defer wg.Done()
for {
select {
case <-ctx.Done():
stopServer()
return
case <-reloadSigChan:
reloadServer()
case <-ticker.C:
serverTick()
case <-tickSigChan:
serverTick()
}
}
}()
} else if config.GetString("App.mode") == "client" {
fmt.Println("Starting CertManager in client mode...")
// Client Task loop
go func() {
initClient()
defer wg.Done()
for {
select {
case <-ctx.Done():
stopClient()
return
case <-reloadSigChan:
reloadClient()
case <-ticker.C:
clientTick()
}
}
}()
} else {
return fmt.Errorf("invalid operating mode \"" + config.GetString("App.mode") + "\"")
}
// Cleanup on stop
sig := <-sigChannel
fmt.Printf("Program terminated with %v\n", sig.String())
stop()
wg.Wait()
return nil
}
func stop() {
cancel()
clearPIDFile()
}
func stopDaemon() error {
proc, err := getDaemonProcess()
if err != nil {
return fmt.Errorf("error getting daemon process: %v", err)
}
err = proc.Signal(syscall.SIGTERM)
if err != nil {
return fmt.Errorf("error sending SIGTERM to daemon PID: %v", err)
}
return nil
}
func reloadDaemon() error {
proc, err := getDaemonProcess()
if err != nil {
return fmt.Errorf("error getting daemon process: %v", err)
}
err = proc.Signal(syscall.SIGHUP)
if err != nil {
return fmt.Errorf("error sending SIGHUP to daemon PID: %v", err)
}
return nil
}
func tickDaemon() error {
proc, err := getDaemonProcess()
if err != nil {
return fmt.Errorf("error getting daemon process: %v", err)
}
err = proc.Signal(syscall.SIGUSR1)
if err != nil {
return fmt.Errorf("error sending SIGUSR1 to daemon PID: %v", err)
}
return nil
}
func statusDaemon() error {
fmt.Println("Not implemented :/")
return nil
}

37
commands/basic.go Normal file
View File

@@ -0,0 +1,37 @@
package commands
import (
"fmt"
"log"
"git.nevets.tech/Keys/CertManager/internal"
"github.com/spf13/cobra"
)
func DevCmd(cmd *cobra.Command, args []string) {
testDomain := "lunamc.org"
err := internal.LoadConfig()
if err != nil {
log.Fatalf("Error loading configuration: %v\n", err)
}
err = internal.LoadDomainConfigs()
if err != nil {
log.Fatalf("Error loading configs: %v\n", err)
}
fmt.Println(testDomain)
}
func VersionCmd(cmd *cobra.Command, args []string) {
fmt.Printf("CertManager (certman) - Steven Tracey\nVersion: %s build-%s\n",
internal.Version, internal.Build,
)
}
func NewKeyCmd(cmd *cobra.Command, args []string) {
key, err := internal.GenerateKey()
if err != nil {
log.Fatalf("%v", err)
}
fmt.Printf(key)
}

266
commands/certs.go Normal file
View File

@@ -0,0 +1,266 @@
package commands
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.nevets.tech/Keys/CertManager/internal"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5/storage/memory"
)
var mgr *internal.ACMEManager
func RenewCertCmd(domain string, noPush bool, certmanMode internal.CertManMode) error {
err := internal.LoadConfig()
if err != nil {
return err
}
err = internal.LoadDomainConfigs()
if err != nil {
return err
}
switch internal.Config().GetString("App.mode") {
case "server":
mgr, err = internal.NewACMEManager()
if err != nil {
return err
}
err = renewCerts(domain, noPush, certmanMode)
if err != nil {
return err
}
return ReloadDaemonCmd()
case "client":
return pullCerts(domain, certmanMode)
default:
return fmt.Errorf("invalid operating mode %s", internal.Config().GetString("App.mode"))
}
}
func renewCerts(domain string, noPush bool, certmanMode internal.CertManMode) error {
_, err := mgr.RenewForDomain(domain)
if err != nil {
// if no existing cert, obtain instead
_, err = mgr.ObtainForDomain(domain)
if err != nil {
return fmt.Errorf("error obtaining domain certificates for domain %s: %v", domain, err)
}
}
domainConfig, exists := internal.DomainStore().Get(domain)
if !exists {
return fmt.Errorf("domain %s does not exist", domain)
}
domainConfig.Set("Internal.last_issued", time.Now().UTC().Unix())
err = internal.WriteDomainConfig(domainConfig)
if err != nil {
return fmt.Errorf("error saving domain config %s: %v", domain, err)
}
err = internal.EncryptFileXChaCha(domainConfig.GetString("Certificates.crypto_key"), filepath.Join(mgr.CertsRoot, domain, domain+".crt"), filepath.Join(mgr.CertsRoot, domain, domain+".crt.crpt"), nil)
if err != nil {
return fmt.Errorf("error encrypting domain cert for domain %s: %v", domain, err)
}
err = internal.EncryptFileXChaCha(domainConfig.GetString("Certificates.crypto_key"), filepath.Join(mgr.CertsRoot, domain, domain+".key"), filepath.Join(mgr.CertsRoot, domain, domain+".key.crpt"), nil)
if err != nil {
return fmt.Errorf("error encrypting domain key for domain %s: %v", domain, err)
}
if !noPush {
giteaClient := internal.CreateGiteaClient()
if giteaClient == nil {
return fmt.Errorf("error creating gitea client for domain %s: %v", domain, err)
}
gitWorkspace := &internal.GitWorkspace{
Storage: memory.NewStorage(),
FS: memfs.New(),
}
var repoUrl string
if !domainConfig.GetBool("Internal.repo_exists") {
repoUrl = internal.CreateGiteaRepo(domain, giteaClient)
if repoUrl == "" {
return fmt.Errorf("error creating Gitea repo for domain %s", domain)
}
domainConfig.Set("Internal.repo_exists", true)
err = internal.WriteDomainConfig(domainConfig)
if err != nil {
return fmt.Errorf("error saving domain config %s: %v", domain, err)
}
err = internal.InitRepo(repoUrl, gitWorkspace)
if err != nil {
return fmt.Errorf("error initializing repo for domain %s: %v", domain, err)
}
} else {
repoUrl = internal.Config().GetString("Git.server") + "/" + internal.Config().GetString("Git.org_name") + "/" + domain + domainConfig.GetString("Repo.repo_suffix") + ".git"
err = internal.CloneRepo(repoUrl, gitWorkspace, certmanMode)
if err != nil {
return fmt.Errorf("error cloning repo for domain %s: %v", domain, err)
}
}
err = internal.AddAndPushCerts(domain, gitWorkspace)
if err != nil {
return fmt.Errorf("error pushing certificates for domain %s: %v", domain, err)
}
fmt.Printf("Successfully pushed certificates for domain %s\n", domain)
}
return nil
}
func pullCerts(domain string, certmanMode internal.CertManMode) error {
gitWorkspace := &internal.GitWorkspace{
Storage: memory.NewStorage(),
FS: memfs.New(),
}
domainConfig, exists := internal.DomainStore().Get(domain)
if !exists {
return fmt.Errorf("domain %s does not exist", domain)
}
// Ex: https://git.example.com/Org/Repo-suffix.git
// Clones repo and stores in gitWorkspace, skip if clone fails (doesn't exist?)
repoUrl := internal.Config().GetString("Git.server") + "/" + internal.Config().GetString("Git.org_name") + "/" + domain + domainConfig.GetString("Repo.repo_suffix") + ".git"
err := internal.CloneRepo(repoUrl, gitWorkspace, certmanMode)
if err != nil {
return fmt.Errorf("Error cloning domain repo %s: %v\n", domain, err)
}
certsDir, err := internal.DomainCertsDirWConf(domain, domainConfig)
if err != nil {
return fmt.Errorf("Error getting certificates dir for domain %s: %v\n", domain, err)
}
// Get files in repo
fileInfos, err := gitWorkspace.FS.ReadDir("/")
if err != nil {
return fmt.Errorf("Error reading directory in memFS on domain %s: %v\n", domain, err)
}
// Iterate over files, filtering by .crpt (encrypted) files in case other files were accidentally added
for _, fileInfo := range fileInfos {
if strings.HasSuffix(fileInfo.Name(), ".crpt") {
filename, _ := strings.CutSuffix(fileInfo.Name(), ".crpt")
file, err := gitWorkspace.FS.Open(fileInfo.Name())
if err != nil {
fmt.Printf("Error opening file in memFS on domain %s: %v\n", domain, err)
continue
}
fileBytes, err := io.ReadAll(file)
if err != nil {
fmt.Printf("Error reading file in memFS on domain %s: %v\n", domain, err)
file.Close()
continue
}
err = file.Close()
if err != nil {
fmt.Printf("Error closing file on domain %s: %v\n", domain, err)
continue
}
err = internal.DecryptFileFromBytes(domainConfig.GetString("Certificates.crypto_key"), fileBytes, filepath.Join(certsDir, filename), nil)
if err != nil {
fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, domain, err)
continue
}
headRef, err := gitWorkspace.Repo.Head()
if err != nil {
fmt.Printf("Error getting head reference for domain %s: %v\n", domain, err)
continue
}
err = internal.WriteCommitHash(headRef.Hash().String(), domainConfig)
if err != nil {
fmt.Printf("Error writing commit hash: %v\n", err)
continue
}
certLinks := domainConfig.GetStringSlice("Certificates.cert_symlinks")
for _, certLink := range certLinks {
if certLink == "" {
continue
}
linkInfo, err := os.Stat(certLink)
if err != nil {
if !os.IsNotExist(err) {
fmt.Printf("Error stating cert link %s: %v\n", certLink, err)
continue
}
}
if linkInfo.IsDir() {
certLink = filepath.Join(certLink, domain+".crt")
}
err = os.Link(filepath.Join(certsDir, domain+".crt"), certLink)
if err != nil {
fmt.Printf("Error linking cert %s to %s: %v\n", certLink, domain, err)
continue
}
}
keyLinks := domainConfig.GetStringSlice("Certificates.key_symlinks")
for _, keyLink := range keyLinks {
if keyLink == "" {
continue
}
linkInfo, err := os.Stat(keyLink)
if err != nil {
if !os.IsNotExist(err) {
fmt.Printf("Error stating key link %s: %v\n", keyLink, err)
continue
}
}
if linkInfo.IsDir() {
keyLink = filepath.Join(keyLink, domain+".crt")
}
err = os.Link(filepath.Join(certsDir, domain+".crt"), keyLink)
if err != nil {
fmt.Printf("Error linking cert %s to %s: %v\n", keyLink, domain, err)
continue
}
}
}
}
return nil
}
func UpdateLinksCmd(domain string) error {
domainConfig, exists := internal.DomainStore().Get(domain)
if !exists {
return fmt.Errorf("domain %s does not exist", domain)
}
certsDir, err := internal.DomainCertsDirWConf(domain, domainConfig)
if err != nil {
return fmt.Errorf("error getting certificates dir for domain %s: %v", domain, err)
}
certLinks := domainConfig.GetStringSlice("Certificates.cert_symlinks")
for _, certLink := range certLinks {
err = internal.LinkFile(filepath.Join(certsDir, domain+".crt"), certLink, domain, ".crt")
if err != nil {
fmt.Printf("Error linking cert %s to %s: %v", certLink, domain, err)
continue
}
}
keyLinks := domainConfig.GetStringSlice("Certificates.key_symlinks")
for _, keyLink := range keyLinks {
err = internal.LinkFile(filepath.Join(certsDir, domain+".crt"), keyLink, domain, ".key")
if err != nil {
fmt.Printf("Error linking cert %s to %s: %v", keyLink, domain, err)
continue
}
}
return nil
}

164
commands/daemon.go Normal file
View File

@@ -0,0 +1,164 @@
package commands
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
"git.nevets.tech/Keys/CertManager/client"
"git.nevets.tech/Keys/CertManager/internal"
"git.nevets.tech/Keys/CertManager/server"
)
var (
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
)
func RunDaemonCmd() error {
err := internal.CreateOrUpdatePIDFile("/var/run/certman.pid")
if err != nil {
if errors.Is(err, internal.ErrorPIDInUse) {
return fmt.Errorf("daemon process is already running")
}
return fmt.Errorf("error creating pidfile: %v", err)
}
ctx, cancel = context.WithCancel(context.Background())
// Check if main config exists
if _, err := os.Stat("/etc/certman/certman.conf"); os.IsNotExist(err) {
return fmt.Errorf("main config file not found, please run 'certman --install', then properly configure /etc/certman/certman.conf")
} else if err != nil {
return fmt.Errorf("error opening /etc/certman/certman.conf: %v", err)
}
err = internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading configuration: %v", err)
}
// Setup SIGINT and SIGTERM listeners
sigChannel := make(chan os.Signal, 1)
signal.Notify(sigChannel, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChannel)
reloadSigChan := make(chan os.Signal, 1)
signal.Notify(reloadSigChan, syscall.SIGHUP)
defer signal.Stop(reloadSigChan)
tickSigChan := make(chan os.Signal, 1)
signal.Notify(tickSigChan, syscall.SIGUSR1)
defer signal.Stop(tickSigChan)
tickRate := internal.Config().GetInt("App.tick_rate")
ticker := time.NewTicker(time.Duration(tickRate) * time.Hour)
defer ticker.Stop()
wg.Add(1)
if internal.Config().GetString("App.mode") == "server" {
fmt.Println("Starting CertManager in server mode...")
// Server Task loop
go func() {
server.Init()
defer wg.Done()
for {
select {
case <-ctx.Done():
server.Stop()
return
case <-reloadSigChan:
server.Reload()
case <-ticker.C:
server.Tick()
case <-tickSigChan:
server.Tick()
}
}
}()
} else if internal.Config().GetString("App.mode") == "client" {
fmt.Println("Starting CertManager in client mode...")
// Client Task loop
go func() {
client.Init()
defer wg.Done()
for {
select {
case <-ctx.Done():
client.Stop()
return
case <-reloadSigChan:
client.Reload()
case <-ticker.C:
client.Tick()
case <-tickSigChan:
client.Tick()
}
}
}()
} else {
return fmt.Errorf("invalid operating mode \"" + internal.Config().GetString("App.mode") + "\"")
}
// Cleanup on stop
sig := <-sigChannel
fmt.Printf("Program terminated with %v\n", sig.String())
stop()
wg.Wait()
return nil
}
func stop() {
cancel()
internal.ClearPIDFile()
}
func StopDaemonCmd() error {
proc, err := internal.DaemonProcess()
if err != nil {
return fmt.Errorf("error getting daemon process: %v", err)
}
err = proc.Signal(syscall.SIGTERM)
if err != nil {
return fmt.Errorf("error sending SIGTERM to daemon PID: %v", err)
}
return nil
}
func ReloadDaemonCmd() error {
proc, err := internal.DaemonProcess()
if err != nil {
return fmt.Errorf("error getting daemon process: %v", err)
}
err = proc.Signal(syscall.SIGHUP)
if err != nil {
return fmt.Errorf("error sending SIGHUP to daemon PID: %v", err)
}
return nil
}
func TickDaemonCmd() error {
proc, err := internal.DaemonProcess()
if err != nil {
return fmt.Errorf("error getting daemon process: %v", err)
}
err = proc.Signal(syscall.SIGUSR1)
if err != nil {
return fmt.Errorf("error sending SIGUSR1 to daemon PID: %v", err)
}
return nil
}
func DaemonStatusCmd() error {
fmt.Println("Not implemented :/")
return nil
}

98
commands/install.go Normal file
View File

@@ -0,0 +1,98 @@
package commands
import (
"fmt"
"os"
"os/exec"
"os/user"
"strconv"
"strings"
"git.nevets.tech/Keys/CertManager/internal"
)
func NewDomainCmd(domain, domainDir string, dirOverridden bool) error {
//TODO add config option for "overriden dir"
fmt.Printf("Creating new domain %s\n", domain)
err := internal.CreateDomainConfig(domain)
if err != nil {
return err
}
internal.CreateDomainCertsDir(domain, domainDir, dirOverridden)
certmanUser, err := user.Lookup("certman")
if err != nil {
return fmt.Errorf("error getting user certman: %v", err)
}
uid, err := strconv.Atoi(strings.TrimSpace(certmanUser.Uid))
if err != nil {
return err
}
gid, err := strconv.Atoi(strings.TrimSpace(certmanUser.Gid))
if err != nil {
return err
}
err = internal.ChownRecursive("/etc/certman/domains", uid, gid)
if err != nil {
return err
}
err = internal.ChownRecursive("/var/local/certman", uid, gid)
if err != nil {
return err
}
fmt.Println("Successfully created domain entry for " + domain + "\nUpdate config file as needed in /etc/certman/domains/" + domain + ".conf\n")
return nil
}
func InstallCmd(isThin bool, mode string) error {
if !isThin {
if os.Geteuid() != 0 {
return fmt.Errorf("installation must be run as root")
}
internal.MakeDirs()
internal.CreateConfig(mode)
f, err := os.OpenFile("/var/run/certman.pid", os.O_RDONLY|os.O_CREATE, 0755)
if err != nil {
return fmt.Errorf("error creating pid file: %v", err)
}
err = f.Close()
if err != nil {
return fmt.Errorf("error closing pid file: %v", err)
}
newUserCmd := exec.Command("useradd", "-d", "/var/local/certman", "-U", "-r", "-s", "/sbin/nologin", "certman")
if output, err := newUserCmd.CombinedOutput(); err != nil {
return fmt.Errorf("error creating user: %v: output %s", err, output)
}
certmanUser, err := user.Lookup("certman")
if err != nil {
return fmt.Errorf("error getting user certman: %v", err)
}
uid, err := strconv.Atoi(strings.TrimSpace(certmanUser.Uid))
if err != nil {
return err
}
gid, err := strconv.Atoi(strings.TrimSpace(certmanUser.Gid))
if err != nil {
return err
}
err = internal.ChownRecursive("/etc/certman", uid, gid)
if err != nil {
return fmt.Errorf("error changing uid/gid: %v", err)
}
err = internal.ChownRecursive("/var/local/certman", uid, gid)
if err != nil {
return fmt.Errorf("error changing uid/gid: %v", err)
}
err = os.Chown("/var/run/certman.pid", uid, gid)
if err != nil {
return fmt.Errorf("error changing uid/gid: %v", err)
}
} else {
internal.CreateConfig(mode)
}
return nil
}

View File

@@ -1,17 +0,0 @@
[App]
mode = {mode}
[Git]
host = gitea
server = https://gitea.instance.com
username = user
org_name = org
template_name = template
[Certificates]
email = user@example.com
data_root = /var/local/certman
request_method = dns
[Cloudflare]
cf_email = email@example.com

View File

@@ -1,19 +0,0 @@
[Domain]
domain_name = {domain}
; default (use system dns) or IPv4 Address (1.1.1.1)
dns_server = default
; optionally use /path/to/directory
file_location = default
[Certificates]
subdomains =
expiry = 90
cert_symlink =
key_symlink =
[Repo]
repo_suffix = -certificates
; Don't change setting below here unless you know what you're doing!
[Internal]
last_issued = never

4
go.mod
View File

@@ -1,4 +1,4 @@
module main module git.nevets.tech/Keys/CertManager
go 1.25.0 go 1.25.0
@@ -7,7 +7,6 @@ require (
github.com/go-acme/lego/v4 v4.32.0 github.com/go-acme/lego/v4 v4.32.0
github.com/go-git/go-billy/v5 v5.8.0 github.com/go-git/go-billy/v5 v5.8.0
github.com/go-git/go-git/v5 v5.17.0 github.com/go-git/go-git/v5 v5.17.0
github.com/google/go-github/v55 v55.0.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
@@ -30,7 +29,6 @@ require (
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect

View File

@@ -1,5 +1,4 @@
// acme_manager.go package internal
package main
import ( import (
"crypto" "crypto"
@@ -28,14 +27,14 @@ import (
// Thread safety for your domain config map // Thread safety for your domain config map
// (assumes you already have these globals elsewhere) // (assumes you already have these globals elsewhere)
// --------------------------------------------- // ---------------------------------------------
// var mu sync.RWMutex // var MU sync.RWMutex
// var domainConfigs map[string]*ezconf.Configuration // var domainConfigs map[string]*ezconf.Configuration
// var config *ezconf.Configuration // var config *ezconf.Configuration
// //
// func getDomainConfig(domain string) (*ezconf.Configuration, bool) { ... } // func getDomainConfig(domain string) (*ezconf.Configuration, bool) { ... }
// --------------------------------------------- // ---------------------------------------------
// ACME account user (file-backed) // ACME account User (file-backed)
// --------------------------------------------- // ---------------------------------------------
type fileUser struct { type fileUser struct {
@@ -54,14 +53,14 @@ func (u *fileUser) GetPrivateKey() crypto.PrivateKey { return u.privateKe
// --------------------------------------------- // ---------------------------------------------
type ACMEManager struct { type ACMEManager struct {
mu sync.Mutex // serializes lego client ops + account writes MU sync.Mutex // serializes lego Client ops + account writes
client *lego.Client Client *lego.Client
user *fileUser User *fileUser
// root dirs // root dirs
dataRoot string // e.g. /var/local/certman dataRoot string // e.g. /var/local/certman
accountRoot string // e.g. /var/local/certman/accounts accountRoot string // e.g. /var/local/certman/accounts
certsRoot string // e.g. /var/local/certman/certificates CertsRoot string // e.g. /var/local/certman/certificates
} }
// DomainRuntimeConfig has domain-specific runtime settings derived from main+domain config. // DomainRuntimeConfig has domain-specific runtime settings derived from main+domain config.
@@ -89,7 +88,7 @@ type StoredCertMeta struct {
// Public API // Public API
// --------------------------------------------- // ---------------------------------------------
// NewACMEManager initializes a long-lived lego client using: // NewACMEManager initializes a long-lived lego Client using:
// - file-backed account // - file-backed account
// - persistent ECDSA P-256 account key // - persistent ECDSA P-256 account key
// - Lets Encrypt production by default (from config fallback) // - Lets Encrypt production by default (from config fallback)
@@ -104,20 +103,20 @@ func NewACMEManager() (*ACMEManager, error) {
mgr := &ACMEManager{ mgr := &ACMEManager{
dataRoot: dataRoot, dataRoot: dataRoot,
accountRoot: filepath.Join(dataRoot, "accounts"), accountRoot: filepath.Join(dataRoot, "accounts"),
certsRoot: filepath.Join(dataRoot, "certificates"), CertsRoot: filepath.Join(dataRoot, "certificates"),
} }
if err := os.MkdirAll(mgr.accountRoot, 0o700); err != nil { if err := os.MkdirAll(mgr.accountRoot, 0o700); err != nil {
return nil, fmt.Errorf("create account root: %w", err) return nil, fmt.Errorf("create account root: %w", err)
} }
if err := os.MkdirAll(mgr.certsRoot, 0o700); err != nil { if err := os.MkdirAll(mgr.CertsRoot, 0o700); err != nil {
return nil, fmt.Errorf("create certs root: %w", err) return nil, fmt.Errorf("create certs root: %w", err)
} }
// Create/load file-backed account user // Create/load file-backed account User
user, err := loadOrCreateACMEUser(mgr.accountRoot, email) user, err := loadOrCreateACMEUser(mgr.accountRoot, email)
if err != nil { if err != nil {
return nil, fmt.Errorf("load/create acme user: %w", err) return nil, fmt.Errorf("load/create acme User: %w", err)
} }
// Cloudflare provider (DNS-01 only). // Cloudflare provider (DNS-01 only).
@@ -147,20 +146,20 @@ func NewACMEManager() (*ACMEManager, error) {
return nil, fmt.Errorf("set dns-01 provider: %w", err) return nil, fmt.Errorf("set dns-01 provider: %w", err)
} }
mgr.client = client mgr.Client = client
mgr.user = user mgr.User = user
// Register account only on first run // Register account only on first run
if mgr.user.Registration == nil { if mgr.User.Registration == nil {
reg, err := mgr.client.Registration.Register(registration.RegisterOptions{ reg, err := mgr.Client.Registration.Register(registration.RegisterOptions{
TermsOfServiceAgreed: true, TermsOfServiceAgreed: true,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("acme registration: %w", err) return nil, fmt.Errorf("acme registration: %w", err)
} }
mgr.user.Registration = reg mgr.User.Registration = reg
if err := saveACMEUser(mgr.accountRoot, mgr.user); err != nil { if err := saveACMEUser(mgr.accountRoot, mgr.User); err != nil {
return nil, fmt.Errorf("save acme user registration: %w", err) return nil, fmt.Errorf("save acme User registration: %w", err)
} }
} }
@@ -184,10 +183,10 @@ func (m *ACMEManager) ObtainForDomain(domainKey string) (*certificate.Resource,
Bundle: true, Bundle: true,
} }
m.mu.Lock() m.MU.Lock()
defer m.mu.Unlock() defer m.MU.Unlock()
res, err := m.client.Certificate.Obtain(req) res, err := m.Client.Certificate.Obtain(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("obtain %q: %w", domainKey, err) return nil, fmt.Errorf("obtain %q: %w", domainKey, err)
} }
@@ -201,8 +200,8 @@ func (m *ACMEManager) ObtainForDomain(domainKey string) (*certificate.Resource,
// RenewForDomain renews an existing stored cert for a domain key. // RenewForDomain renews an existing stored cert for a domain key.
func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, error) { func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, error) {
m.mu.Lock() m.MU.Lock()
defer m.mu.Unlock() defer m.MU.Unlock()
existing, err := m.loadStoredResource(domainKey) existing, err := m.loadStoredResource(domainKey)
if err != nil { if err != nil {
@@ -210,7 +209,7 @@ func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, e
} }
// RenewWithOptions is preferred in newer lego versions. // RenewWithOptions is preferred in newer lego versions.
renewed, err := m.client.Certificate.RenewWithOptions(*existing, &certificate.RenewOptions{ renewed, err := m.Client.Certificate.RenewWithOptions(*existing, &certificate.RenewOptions{
Bundle: true, Bundle: true,
}) })
if err != nil { if err != nil {
@@ -226,8 +225,8 @@ func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, e
// GetCertPaths returns disk paths for the domain's cert material. // GetCertPaths returns disk paths for the domain's cert material.
func (m *ACMEManager) GetCertPaths(domainKey string) (certPEM, keyPEM string) { func (m *ACMEManager) GetCertPaths(domainKey string) (certPEM, keyPEM string) {
base := sanitizeDomainKey(domainKey) base := SanitizeDomainKey(domainKey)
dir := filepath.Join(m.certsRoot, base) dir := filepath.Join(m.CertsRoot, base)
return filepath.Join(dir, base+".crt"), return filepath.Join(dir, base+".crt"),
filepath.Join(dir, base+".key") filepath.Join(dir, base+".key")
} }
@@ -450,7 +449,7 @@ func loadACMEUser(accountRoot string) (*fileUser, error) {
func saveACMEUser(accountRoot string, u *fileUser) error { func saveACMEUser(accountRoot string, u *fileUser) error {
if u == nil { if u == nil {
return errors.New("nil user") return errors.New("nil User")
} }
if err := os.MkdirAll(accountRoot, 0o700); err != nil { if err := os.MkdirAll(accountRoot, 0o700); err != nil {
return err return err
@@ -525,8 +524,8 @@ func (m *ACMEManager) saveCertFiles(domainKey string, res *certificate.Resource,
return errors.New("nil certificate resource") return errors.New("nil certificate resource")
} }
base := sanitizeDomainKey(domainKey) base := SanitizeDomainKey(domainKey)
dir := filepath.Join(m.certsRoot, base) dir := filepath.Join(m.CertsRoot, base)
if err := os.MkdirAll(dir, 0o700); err != nil { if err := os.MkdirAll(dir, 0o700); err != nil {
return err return err
} }
@@ -599,8 +598,8 @@ func (m *ACMEManager) saveCertFiles(domainKey string, res *certificate.Resource,
} }
func (m *ACMEManager) loadStoredResource(domainKey string) (*certificate.Resource, error) { func (m *ACMEManager) loadStoredResource(domainKey string) (*certificate.Resource, error) {
base := sanitizeDomainKey(domainKey) base := SanitizeDomainKey(domainKey)
dir := filepath.Join(m.certsRoot, base) dir := filepath.Join(m.CertsRoot, base)
raw, err := os.ReadFile(filepath.Join(dir, base+".json")) raw, err := os.ReadFile(filepath.Join(dir, base+".json"))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -623,8 +622,8 @@ func (m *ACMEManager) loadStoredResource(domainKey string) (*certificate.Resourc
} }
func (m *ACMEManager) loadMeta(domainKey string) (*StoredCertMeta, error) { func (m *ACMEManager) loadMeta(domainKey string) (*StoredCertMeta, error) {
base := sanitizeDomainKey(domainKey) base := SanitizeDomainKey(domainKey)
dir := filepath.Join(m.certsRoot, base) dir := filepath.Join(m.CertsRoot, base)
raw, err := os.ReadFile(filepath.Join(dir, base+".meta.json")) raw, err := os.ReadFile(filepath.Join(dir, base+".meta.json"))
if err != nil { if err != nil {
return nil, err return nil, err

6
internal/buildinfo.go Normal file
View File

@@ -0,0 +1,6 @@
package internal
var (
Version = "dev"
Build = "local"
)

View File

@@ -1,4 +1,4 @@
package main package internal
import ( import (
"bytes" "bytes"
@@ -67,19 +67,48 @@ func (s *DomainConfigStore) Snapshot() map[string]*viper.Viper {
var ( var (
config *viper.Viper config *viper.Viper
configMu sync.RWMutex
domainStore = NewDomainConfigStore() domainStore = NewDomainConfigStore()
) )
func Config() *viper.Viper {
configMu.RLock()
defer configMu.RUnlock()
return config
}
func DomainStore() *DomainConfigStore {
domainStore.mu.RLock()
defer domainStore.mu.RUnlock()
return domainStore
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Loading // Loading
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LoadConfig reads the main certman.conf into config. // LoadConfig reads the main certman.conf into config.
func LoadConfig(path string) error { func LoadConfig() error {
config = viper.New() config = viper.New()
config.SetConfigFile(path) config.SetConfigFile("/etc/certman/certman.conf")
config.SetConfigType("toml") config.SetConfigType("toml")
return config.ReadInConfig() err := config.ReadInConfig()
if err != nil {
return err
}
switch config.GetString("App.mode") {
case "server":
config.SetConfigType("toml")
config.SetConfigFile("server.conf")
return config.MergeInConfig()
case "Client":
config.SetConfigType("toml")
config.SetConfigFile("Client.conf")
return config.MergeInConfig()
}
return nil
} }
// LoadDomainConfigs reads every .conf file in the domains directory. // LoadDomainConfigs reads every .conf file in the domains directory.
@@ -223,7 +252,7 @@ func EffectiveBool(domainCfg *viper.Viper, key string) bool {
// Directory bootstrapping // Directory bootstrapping
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func makeDirs() { func MakeDirs() {
dirs := []struct { dirs := []struct {
path string path string
perm os.FileMode perm os.FileMode
@@ -240,31 +269,49 @@ func makeDirs() {
} }
} }
func createNewConfig(mode string) { func CreateConfig(mode string) {
content := strings.NewReplacer( content := strings.NewReplacer(
"{mode}", mode, "{mode}", mode,
"{uuid}", uuid.New().String(),
).Replace(defaultConfig) ).Replace(defaultConfig)
createFile("/etc/certman/certman.conf", 0640, []byte(content)) createFile("/etc/certman/certman.conf", 0640, []byte(content))
switch mode {
case "server":
content = strings.NewReplacer(
"{uuid}", uuid.New().String(),
).Replace(defaultServerConfig)
createFile("/etc/certman/server.conf", 640, []byte(content))
}
} }
func createNewDomainConfig(domain string) error { func CreateDomainConfig(domain string) error {
key, err := GenerateKey() key, err := GenerateKey()
if err != nil { if err != nil {
return fmt.Errorf("unable to generate key: %v", err) return fmt.Errorf("unable to generate key: %v", err)
} }
content := strings.NewReplacer( var content string
"{domain}", domain, switch Config().GetString("App.mode") {
"{key}", key, case "server":
).Replace(defaultDomainConfig) content = strings.NewReplacer(
"{domain}", domain,
"{key}", key,
).Replace(defaultServerDomainConfig)
case "Client":
content = strings.NewReplacer(
"{domain}", domain,
"{key}", key,
).Replace(defaultClientDomainConfig)
default:
return fmt.Errorf("unknown certman mode: %v", Config().GetString("App.mode"))
}
path := filepath.Join("/etc/certman/domains", domain+".conf") path := filepath.Join("/etc/certman/domains", domain+".conf")
createFile(path, 0640, []byte(content)) createFile(path, 0640, []byte(content))
return nil return nil
} }
func createNewDomainCertsDir(domain string, dir string, dirOverride bool) { func CreateDomainCertsDir(domain string, dir string, dirOverride bool) {
var target string var target string
if dirOverride { if dirOverride {
target = filepath.Join(dir, domain) target = filepath.Join(dir, domain)
@@ -286,49 +333,68 @@ func createNewDomainCertsDir(domain string, dir string, dirOverride bool) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const defaultConfig = `[App] const defaultConfig = `[App]
mode = "{mode}" mode = '{mode}'
tick_rate = 2 tick_rate = 2
uuid = "{uuid}"
[Git] [Git]
host = "gitea" host = 'gitea'
server = "https://gitea.instance.com" server = 'https://gitea.instance.com'
username = "user" username = 'User'
api_token = "xxxxxxxxxxxxxxxxxxxxxxxxx" api_token = 'xxxxxxxxxxxxxxxxxxxxxxxxx'
org_name = "org" org_name = 'org'
[Certificates] [Certificates]
email = "user@example.com" data_root = '/var/local/certman'
data_root = "/var/local/certman"
ca_dir_url = "https://acme-v02.api.letsencrypt.org/directory"
[Cloudflare]
cf_email = "email@example.com"
cf_api_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
` `
const defaultDomainConfig = `[Domain] const defaultServerConfig = `[App]
domain_name = "{domain}" uuid = '{uuid}'
enabled = true
dns_server = "default"
[Certificates] [Certificates]
data_root = "" email = 'User@example.com'
data_root = '/var/local/certman'
ca_dir_url = 'https://acme-v02.api.letsencrypt.org/directory'
[Cloudflare]
cf_email = 'email@example.com'
cf_api_key = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxx'`
const defaultClientConfig = ``
const defaultServerDomainConfig = `[Domain]
domain_name = '{domain}'
enabled = true
dns_server = 'default'
[Certificates]
data_root = ''
expiry = 90 expiry = 90
request_method = "dns-01" request_method = 'dns-01'
renew_period = 30 renew_period = 30
subdomains = [] subdomains = []
cert_symlinks = [] crypto_key = '{key}'
key_symlinks = []
crypto_key = "{key}"
[Repo] [Repo]
repo_suffix = "-certificates" repo_suffix = '-certificates'
[Internal] [Internal]
last_issued = 0 last_issued = 0
repo_exists = false repo_exists = false
status = "clean" status = 'clean'
`
const defaultClientDomainConfig = `[Certificates]
data_root = ''
cert_symlinks = []
key_symlinks = []
crypto_key = '{key}'
[Domain]
domain_name = '{domain}'
enabled = true
[Repo]
repo_suffix = '-certificates'
` `
const readme = `` const readme = ``

View File

@@ -1,4 +1,4 @@
package main package internal
import ( import (
"crypto/rand" "crypto/rand"

View File

@@ -1,7 +1,6 @@
package main package internal
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -17,10 +16,16 @@ import (
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/storage/memory" "github.com/go-git/go-git/v5/storage/memory"
"github.com/google/go-github/v55/github"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
type CertManMode int
const (
Server CertManMode = iota
Client
)
type GitWorkspace struct { type GitWorkspace struct {
Repo *git.Repository Repo *git.Repository
Storage *memory.Storage Storage *memory.Storage
@@ -48,7 +53,7 @@ var GitSourceName = map[GitSource]string{
CodeCommit: "code-commit", CodeCommit: "code-commit",
} }
func strToGitSource(s string) (GitSource, error) { func StrToGitSource(s string) (GitSource, error) {
for k, v := range GitSourceName { for k, v := range GitSourceName {
if v == s { if v == s {
return k, nil return k, nil
@@ -57,11 +62,11 @@ func strToGitSource(s string) (GitSource, error) {
return GitSource(0), errors.New("invalid gitsource name") return GitSource(0), errors.New("invalid gitsource name")
} }
func createGithubClient() *github.Client { //func createGithubClient() *github.Client {
return github.NewClient(nil).WithAuthToken(config.GetString("Git.api_token")) // return github.NewClient(nil).WithAuthToken(config.GetString("Git.api_token"))
} //}
func createGiteaClient() *gitea.Client { func CreateGiteaClient() *gitea.Client {
client, err := gitea.NewClient(config.GetString("Git.server"), gitea.SetToken(config.GetString("Git.api_token"))) client, err := gitea.NewClient(config.GetString("Git.server"), gitea.SetToken(config.GetString("Git.api_token")))
if err != nil { if err != nil {
fmt.Printf("Error connecting to gitea instance: %v\n", err) fmt.Printf("Error connecting to gitea instance: %v\n", err)
@@ -70,30 +75,30 @@ func createGiteaClient() *gitea.Client {
return client return client
} }
func createGithubRepo(domain *Domain, client *github.Client) string { //func createGithubRepo(domain *Domain, Client *github.Client) string {
name := domain.name // name := domain.name
owner := domain.config.GetString("Repo.owner") // owner := domain.config.GetString("Repo.owner")
description := domain.description // description := domain.description
private := true // private := true
includeAllBranches := false // includeAllBranches := false
//
// ctx := context.Background()
// template := &github.TemplateRepoRequest{
// Name: name,
// Owner: &owner,
// Description: description,
// Private: &private,
// IncludeAllBranches: &includeAllBranches,
// }
// repo, _, err := Client.Repositories.CreateFromTemplate(ctx, config.GetString("Git.org_name"), config.GetString("Git.template_name"), template)
// if err != nil {
// fmt.Println("Error creating repository from template,", err)
// return ""
// }
// return *repo.CloneURL
//}
ctx := context.Background() func CreateGiteaRepo(domain string, giteaClient *gitea.Client) string {
template := &github.TemplateRepoRequest{
Name: name,
Owner: &owner,
Description: description,
Private: &private,
IncludeAllBranches: &includeAllBranches,
}
repo, _, err := client.Repositories.CreateFromTemplate(ctx, config.GetString("Git.org_name"), config.GetString("Git.template_name"), template)
if err != nil {
fmt.Println("Error creating repository from template,", err)
return ""
}
return *repo.CloneURL
}
func createGiteaRepo(domain string, giteaClient *gitea.Client) string {
domainConfig, exists := domainStore.Get(domain) domainConfig, exists := domainStore.Get(domain)
if !exists { if !exists {
fmt.Printf("Domain %s config does not exist\n", domain) fmt.Printf("Domain %s config does not exist\n", domain)
@@ -121,7 +126,7 @@ func createGiteaRepo(domain string, giteaClient *gitea.Client) string {
return giteaRepo.CloneURL return giteaRepo.CloneURL
} }
func initRepo(url string, ws *GitWorkspace) error { func InitRepo(url string, ws *GitWorkspace) error {
var err error var err error
ws.Repo, err = git.Init(ws.Storage, ws.FS) ws.Repo, err = git.Init(ws.Storage, ws.FS)
if err != nil { if err != nil {
@@ -147,7 +152,7 @@ func initRepo(url string, ws *GitWorkspace) error {
return nil return nil
} }
func cloneRepo(url string, ws *GitWorkspace) error { func CloneRepo(url string, ws *GitWorkspace, certmanMode CertManMode) error {
creds := &http.BasicAuth{ creds := &http.BasicAuth{
Username: config.GetString("Git.username"), Username: config.GetString("Git.username"),
Password: config.GetString("Git.api_token"), Password: config.GetString("Git.api_token"),
@@ -163,33 +168,35 @@ func cloneRepo(url string, ws *GitWorkspace) error {
fmt.Printf("Error getting worktree from cloned repo: %v\n", err) fmt.Printf("Error getting worktree from cloned repo: %v\n", err)
return err return err
} }
serverIdFile, err := ws.FS.OpenFile("/SERVER_ID", os.O_RDWR, 0640) if certmanMode == Server {
if err != nil { serverIdFile, err := ws.FS.OpenFile("/SERVER_ID", os.O_RDWR, 0640)
if os.IsNotExist(err) { if err != nil {
fmt.Printf("Server ID file not found for %s, adopting domain\n", url) if os.IsNotExist(err) {
return nil fmt.Printf("Server ID file not found for %s, adopting domain\n", url)
return nil
}
return err
}
serverIdBytes, err := io.ReadAll(serverIdFile)
if err != nil {
return err
}
serverId := strings.TrimSpace(string(serverIdBytes))
if serverId != config.GetString("App.uuid") {
return fmt.Errorf("domain is already managed by server with uuid %s", serverId)
} }
return err
}
serverIdBytes, err := io.ReadAll(serverIdFile)
if err != nil {
return err
}
serverId := strings.TrimSpace(string(serverIdBytes))
if serverId != config.GetString("App.uuid") {
return fmt.Errorf("domain is already managed by server with uuid %s", serverId)
} }
return nil return nil
} }
func addAndPushCerts(domain string, ws *GitWorkspace) error { func AddAndPushCerts(domain string, ws *GitWorkspace) error {
domainConfig, exists := domainStore.Get(domain) domainConfig, exists := domainStore.Get(domain)
if !exists { if !exists {
fmt.Printf("Domain %s config does not exist\n", domain) fmt.Printf("Domain %s config does not exist\n", domain)
return ErrConfigNotFound return ErrConfigNotFound
} }
certsDir, err := getDomainCertsDirWConf(domain, domainConfig) certsDir, err := DomainCertsDirWConf(domain, domainConfig)
if err != nil { if err != nil {
if errors.Is(err, ErrConfigNotFound) { if errors.Is(err, ErrConfigNotFound) {
fmt.Printf("Domain %s config not found: %v\n", domain, err) fmt.Printf("Domain %s config not found: %v\n", domain, err)
@@ -301,8 +308,8 @@ func addAndPushCerts(domain string, ws *GitWorkspace) error {
return nil return nil
} }
func writeCommitHash(hash string, domainConfig *viper.Viper) error { func WriteCommitHash(hash string, domainConfig *viper.Viper) error {
certsDir, err := getDomainCertsDirWOnlyConf(domainConfig) certsDir, err := DomainCertsDirWOnlyConf(domainConfig)
if err != nil { if err != nil {
if errors.Is(err, ErrConfigNotFound) { if errors.Is(err, ErrConfigNotFound) {
return err return err
@@ -318,8 +325,8 @@ func writeCommitHash(hash string, domainConfig *viper.Viper) error {
return nil return nil
} }
func getLocalCommitHash(domain string) (string, error) { func LocalCommitHash(domain string) (string, error) {
certsDir, err := getDomainCertsDir(domain) certsDir, err := DomainCertsDir(domain)
if err != nil { if err != nil {
if errors.Is(err, ErrConfigNotFound) { if errors.Is(err, ErrConfigNotFound) {
fmt.Printf("Domain %s config not found: %v\n", domain, err) fmt.Printf("Domain %s config not found: %v\n", domain, err)
@@ -330,15 +337,17 @@ func getLocalCommitHash(domain string) (string, error) {
data, err := os.ReadFile(filepath.Join(certsDir, "hash")) data, err := os.ReadFile(filepath.Join(certsDir, "hash"))
if err != nil { if err != nil {
fmt.Printf("Error reading file for domain %s: %v\n", domain, err) if !os.IsNotExist(err) {
return "", err fmt.Printf("Error reading file for domain %s: %v\n", domain, err)
return "", err
}
} }
return strings.TrimSpace(string(data)), nil return strings.TrimSpace(string(data)), nil
} }
func getRemoteCommitHash(domain string, gitSource GitSource) (string, error) { func RemoteCommitHash(domain string, gitSource GitSource) (string, error) {
domainConfig, exists := domainStore.Get(domain) domainConfig, exists := DomainStore().Get(domain)
if !exists { if !exists {
fmt.Printf("Domain %s config does not exist\n", domain) fmt.Printf("Domain %s config does not exist\n", domain)
return "", ErrConfigNotFound return "", ErrConfigNotFound
@@ -354,7 +363,7 @@ func getRemoteCommitHash(domain string, gitSource GitSource) (string, error) {
} }
func getRemoteCommitHashGitea(org, repo, branchName string) (string, error) { func getRemoteCommitHashGitea(org, repo, branchName string) (string, error) {
giteaClient := createGiteaClient() giteaClient := CreateGiteaClient()
branch, _, err := giteaClient.GetRepoBranch(org, repo, branchName) branch, _, err := giteaClient.GetRepoBranch(org, repo, branchName)
if err != nil { if err != nil {
fmt.Printf("Error getting repo branch: %v\n", err) fmt.Printf("Error getting repo branch: %v\n", err)

View File

@@ -1,4 +1,4 @@
package main package internal
import ( import (
"errors" "errors"
@@ -11,7 +11,6 @@ import (
"syscall" "syscall"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/google/go-github/v55/github"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -25,7 +24,6 @@ type Domain struct {
name *string name *string
config *viper.Viper config *viper.Viper
description *string description *string
ghClient *github.Client
gtClient *gitea.Client gtClient *gitea.Client
} }
@@ -44,7 +42,7 @@ func createPIDFile() {
} }
// 0x02 // 0x02
func clearPIDFile() { func ClearPIDFile() {
file, err := os.OpenFile("/var/run/certman.pid", os.O_RDWR|os.O_CREATE, 0644) file, err := os.OpenFile("/var/run/certman.pid", os.O_RDWR|os.O_CREATE, 0644)
if err != nil { if err != nil {
fmt.Printf("0x02: Error opening PID file: %v\n", err) fmt.Printf("0x02: Error opening PID file: %v\n", err)
@@ -60,7 +58,7 @@ func clearPIDFile() {
} }
// 0x03 // 0x03
func createOrUpdatePIDFile(filename string) error { func CreateOrUpdatePIDFile(filename string) error {
pidBytes, err := os.ReadFile(filename) pidBytes, err := os.ReadFile(filename)
if err != nil { if err != nil {
fmt.Printf("0x03: Error reading PID file: %v\n", err) fmt.Printf("0x03: Error reading PID file: %v\n", err)
@@ -142,7 +140,7 @@ func isProcessActive(pid int) (bool, error) {
} }
// 0x05 // 0x05
func getDaemonProcess() (*os.Process, error) { func DaemonProcess() (*os.Process, error) {
pidBytes, err := os.ReadFile("/var/run/certman.pid") pidBytes, err := os.ReadFile("/var/run/certman.pid")
if err != nil { if err != nil {
fmt.Printf("0x05: Error getting PID from /var/run/certman.pid: %v\n", err) fmt.Printf("0x05: Error getting PID from /var/run/certman.pid: %v\n", err)
@@ -209,15 +207,20 @@ func createFile(fileName string, filePermission os.FileMode, data []byte) {
} }
} }
func linkFile(source, target, domain, extension string) error { func LinkFile(source, target, domain, extension string) error {
if target == "" { if target == "" {
return ErrBlankCert return ErrBlankCert
} }
linkInfo, err := os.Stat(target) linkInfo, err := os.Stat(target)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if os.IsNotExist(err) {
return err err = os.Symlink(source, target)
if err != nil {
return err
}
return nil
} }
return err
} }
if linkInfo.IsDir() { if linkInfo.IsDir() {
target = filepath.Join(target, domain+extension) target = filepath.Join(target, domain+extension)
@@ -252,24 +255,24 @@ func insert(a []string, index int, value string) []string {
return a return a
} }
func sanitizeDomainKey(s string) string { func SanitizeDomainKey(s string) string {
s = strings.TrimSpace(strings.ToLower(s)) s = strings.TrimSpace(strings.ToLower(s))
r := strings.NewReplacer("/", "_", "\\", "_", " ", "_", ":", "_") r := strings.NewReplacer("/", "_", "\\", "_", " ", "_", ":", "_")
return r.Replace(s) return r.Replace(s)
} }
// getDomainCertsDir Can return ErrBlankConfigEntry, ErrConfigNotFound, or other errors // DomainCertsDir Can return ErrBlankConfigEntry, ErrConfigNotFound, or other errors
func getDomainCertsDir(domain string) (string, error) { func DomainCertsDir(domain string) (string, error) {
domainConfig, exists := domainStore.Get(domain) domainConfig, exists := domainStore.Get(domain)
if !exists { if !exists {
return "", ErrConfigNotFound return "", ErrConfigNotFound
} }
return getDomainCertsDirWConf(domain, domainConfig) return DomainCertsDirWConf(domain, domainConfig)
} }
// getDomainCertsDir Can return ErrBlankConfigEntry or other errors // DomainCertsDirWConf Can return ErrBlankConfigEntry or other errors
func getDomainCertsDirWConf(domain string, domainConfig *viper.Viper) (string, error) { func DomainCertsDirWConf(domain string, domainConfig *viper.Viper) (string, error) {
effectiveDataRoot, err := EffectiveString(domainConfig, "Certificates.data_root") effectiveDataRoot, err := EffectiveString(domainConfig, "Certificates.data_root")
if err != nil { if err != nil {
return "", err return "", err
@@ -278,9 +281,9 @@ func getDomainCertsDirWConf(domain string, domainConfig *viper.Viper) (string, e
return filepath.Join(effectiveDataRoot, "certificates", domain), nil return filepath.Join(effectiveDataRoot, "certificates", domain), nil
} }
func getDomainCertsDirWOnlyConf(domainConfig *viper.Viper) (string, error) { func DomainCertsDirWOnlyConf(domainConfig *viper.Viper) (string, error) {
domain := domainConfig.GetString("Domain.domain_name") domain := domainConfig.GetString("Domain.domain_name")
return getDomainCertsDirWConf(domain, domainConfig) return DomainCertsDirWConf(domain, domainConfig)
} }
func ChownRecursive(path string, uid, gid int) error { func ChownRecursive(path string, uid, gid int) error {

46
main.go
View File

@@ -1,24 +1,16 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"regexp" "regexp"
"sync"
"git.nevets.tech/Keys/CertManager/commands"
"git.nevets.tech/Keys/CertManager/internal"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var version = "1.0.0" var configFile string
var build = "1"
var (
configFile string
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
)
var fqdnRegex = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`) var fqdnRegex = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
@@ -36,9 +28,9 @@ func main() {
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "/etc/certman/certman.conf", "Configuration file") rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "/etc/certman/certman.conf", "Configuration file")
rootCmd.AddCommand(basicCmd("version", "Show version", versionResponse)) rootCmd.AddCommand(basicCmd("version", "Show version", commands.VersionCmd))
rootCmd.AddCommand(basicCmd("gen-key", "Generates encryption key", newKey)) rootCmd.AddCommand(basicCmd("gen-key", "Generates encryption key", commands.NewKeyCmd))
rootCmd.AddCommand(basicCmd("dev", "Dev Function", devFunc)) rootCmd.AddCommand(basicCmd("dev", "Dev Function", commands.DevCmd))
var domainCertDir string var domainCertDir string
newDomainCmd := &cobra.Command{ newDomainCmd := &cobra.Command{
@@ -49,7 +41,7 @@ func main() {
SilenceErrors: true, SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
dirOverridden := cmd.Flags().Changed("dir") dirOverridden := cmd.Flags().Changed("dir")
return newDomain(args[0], domainCertDir, dirOverridden) return commands.NewDomainCmd(args[0], domainCertDir, dirOverridden)
}, },
} }
newDomainCmd.Flags().StringVar(&domainCertDir, "dir", "/var/local/certman/certificates/", "Alternate directory for certificates") newDomainCmd.Flags().StringVar(&domainCertDir, "dir", "/var/local/certman/certificates/", "Alternate directory for certificates")
@@ -65,7 +57,7 @@ func main() {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
switch modeFlag { switch modeFlag {
case "server", "client": case "server", "client":
return install(thinInstallFlag, modeFlag) return commands.InstallCmd(thinInstallFlag, modeFlag)
default: default:
return fmt.Errorf("invalid --mode %q (must be server or client)", modeFlag) return fmt.Errorf("invalid --mode %q (must be server or client)", modeFlag)
} }
@@ -89,12 +81,22 @@ func main() {
Short: "Renews a domains certificate", Short: "Renews a domains certificate",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return renewCertFunc(args[0], noPush) return commands.RenewCertCmd(args[0], noPush, internal.Server)
}, },
} }
renewCertCmd.Flags().BoolVar(&noPush, "no-push", false, "Don't push certs to repo, renew locally only [server mode only]") renewCertCmd.Flags().BoolVar(&noPush, "no-push", false, "Don't push certs to repo, renew locally only [server mode only]")
certCmd.AddCommand(renewCertCmd) certCmd.AddCommand(renewCertCmd)
updateCertLinkCmd := &cobra.Command{
Use: "update-link",
Short: "Update linked certificates",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return commands.UpdateLinksCmd(args[0])
},
}
certCmd.AddCommand(updateCertLinkCmd)
rootCmd.AddCommand(certCmd) rootCmd.AddCommand(certCmd)
daemonCmd := &cobra.Command{ daemonCmd := &cobra.Command{
@@ -110,7 +112,7 @@ func main() {
Short: "Start the daemon", Short: "Start the daemon",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runDaemon() return commands.RunDaemonCmd()
}, },
}) })
@@ -119,7 +121,7 @@ func main() {
Short: "Stop the daemon", Short: "Stop the daemon",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return stopDaemon() return commands.StopDaemonCmd()
}, },
}) })
@@ -128,7 +130,7 @@ func main() {
Short: "Reload daemon configs", Short: "Reload daemon configs",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return reloadDaemon() return commands.ReloadDaemonCmd()
}, },
}) })
@@ -137,7 +139,7 @@ func main() {
Short: "Manually triggers daemon tick", Short: "Manually triggers daemon tick",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return tickDaemon() return commands.TickDaemonCmd()
}, },
}) })
@@ -146,7 +148,7 @@ func main() {
Short: "Show daemon status", Short: "Show daemon status",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return statusDaemon() return commands.DaemonStatusCmd()
}, },
}) })

View File

@@ -1,4 +1,4 @@
package main package server
import ( import (
"fmt" "fmt"
@@ -7,23 +7,24 @@ import (
"sync" "sync"
"time" "time"
"git.nevets.tech/Keys/CertManager/internal"
"github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5/storage/memory" "github.com/go-git/go-git/v5/storage/memory"
) )
var ( var (
tickMu sync.Mutex tickMu sync.Mutex
mgr *ACMEManager mgr *internal.ACMEManager
mgrMu sync.Mutex mgrMu sync.Mutex
) )
func getACMEManager() (*ACMEManager, error) { func getACMEManager() (*internal.ACMEManager, error) {
mgrMu.Lock() mgrMu.Lock()
defer mgrMu.Unlock() defer mgrMu.Unlock()
if mgr == nil { if mgr == nil {
var err error var err error
mgr, err = NewACMEManager() mgr, err = internal.NewACMEManager()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -31,21 +32,22 @@ func getACMEManager() (*ACMEManager, error) {
return mgr, nil return mgr, nil
} }
func initServer() { func Init() {
err := LoadDomainConfigs() err := internal.LoadDomainConfigs()
if err != nil { if err != nil {
log.Fatalf("Error loading domain configs: %v", err) log.Fatalf("Error loading domain configs: %v", err)
} }
serverTick() Tick()
} }
func serverTick() { func Tick() {
tickMu.Lock() tickMu.Lock()
defer tickMu.Unlock() defer tickMu.Unlock()
fmt.Println("Tick!") fmt.Println("Tick!")
mgr, err := getACMEManager() var err error
mgr, err = getACMEManager()
if err != nil { if err != nil {
fmt.Printf("Error getting acme manager: %v\n", err) fmt.Printf("Error getting acme manager: %v\n", err)
return return
@@ -53,7 +55,7 @@ func serverTick() {
now := time.Now().UTC() now := time.Now().UTC()
localDomainConfigs := domainStore.Snapshot() localDomainConfigs := internal.DomainStore().Snapshot()
for domainStr, domainConfig := range localDomainConfigs { for domainStr, domainConfig := range localDomainConfigs {
if !domainConfig.GetBool("Domain.enabled") { if !domainConfig.GetBool("Domain.enabled") {
@@ -75,62 +77,62 @@ func serverTick() {
} }
domainConfig.Set("Internal.last_issued", time.Now().UTC().Unix()) domainConfig.Set("Internal.last_issued", time.Now().UTC().Unix())
err = WriteDomainConfig(domainConfig) err = internal.WriteDomainConfig(domainConfig)
if err != nil { if err != nil {
fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) fmt.Printf("Error saving domain config %s: %v\n", domainStr, err)
continue continue
} }
err = EncryptFileXChaCha(domainConfig.GetString("Certificates.crypto_key"), filepath.Join(mgr.certsRoot, domainStr, domainStr+".crt"), filepath.Join(mgr.certsRoot, domainStr, domainStr+".crt.crpt"), nil) err = internal.EncryptFileXChaCha(domainConfig.GetString("Certificates.crypto_key"), filepath.Join(mgr.CertsRoot, domainStr, domainStr+".crt"), filepath.Join(mgr.CertsRoot, domainStr, domainStr+".crt.crpt"), nil)
if err != nil { if err != nil {
fmt.Printf("Error encrypting domain cert for domain %s: %v\n", domainStr, err) fmt.Printf("Error encrypting domain cert for domain %s: %v\n", domainStr, err)
continue continue
} }
err = EncryptFileXChaCha(domainConfig.GetString("Certificates.crypto_key"), filepath.Join(mgr.certsRoot, domainStr, domainStr+".key"), filepath.Join(mgr.certsRoot, domainStr, domainStr+".key.crpt"), nil) err = internal.EncryptFileXChaCha(domainConfig.GetString("Certificates.crypto_key"), filepath.Join(mgr.CertsRoot, domainStr, domainStr+".key"), filepath.Join(mgr.CertsRoot, domainStr, domainStr+".key.crpt"), nil)
if err != nil { if err != nil {
fmt.Printf("Error encrypting domain key for domain %s: %v\n", domainStr, err) fmt.Printf("Error encrypting domain key for domain %s: %v\n", domainStr, err)
continue continue
} }
giteaClient := createGiteaClient() giteaClient := internal.CreateGiteaClient()
if giteaClient == nil { if giteaClient == nil {
fmt.Printf("Error creating gitea client for domain %s: %v\n", domainStr, err) fmt.Printf("Error creating gitea client for domain %s: %v\n", domainStr, err)
continue continue
} }
gitWorkspace := &GitWorkspace{ gitWorkspace := &internal.GitWorkspace{
Storage: memory.NewStorage(), Storage: memory.NewStorage(),
FS: memfs.New(), FS: memfs.New(),
} }
var repoUrl string var repoUrl string
if !domainConfig.GetBool("Internal.repo_exists") { if !domainConfig.GetBool("Internal.repo_exists") {
repoUrl = createGiteaRepo(domainStr, giteaClient) repoUrl = internal.CreateGiteaRepo(domainStr, giteaClient)
if repoUrl == "" { if repoUrl == "" {
fmt.Printf("Error creating Gitea repo for domain %s\n", domainStr) fmt.Printf("Error creating Gitea repo for domain %s\n", domainStr)
continue continue
} }
domainConfig.Set("Internal.repo_exists", true) domainConfig.Set("Internal.repo_exists", true)
err = WriteDomainConfig(domainConfig) err = internal.WriteDomainConfig(domainConfig)
if err != nil { if err != nil {
fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) fmt.Printf("Error saving domain config %s: %v\n", domainStr, err)
continue continue
} }
err = initRepo(repoUrl, gitWorkspace) err = internal.InitRepo(repoUrl, gitWorkspace)
if err != nil { if err != nil {
fmt.Printf("Error initializing repo for domain %s: %v\n", domainStr, err) fmt.Printf("Error initializing repo for domain %s: %v\n", domainStr, err)
continue continue
} }
} else { } else {
repoUrl = config.GetString("Git.server") + "/" + config.GetString("Git.org_name") + "/" + domainStr + domainConfig.GetString("Repo.repo_suffix") + ".git" repoUrl = internal.Config().GetString("Git.server") + "/" + internal.Config().GetString("Git.org_name") + "/" + domainStr + domainConfig.GetString("Repo.repo_suffix") + ".git"
err = cloneRepo(repoUrl, gitWorkspace) err = internal.CloneRepo(repoUrl, gitWorkspace, internal.Server)
if err != nil { if err != nil {
fmt.Printf("Error cloning repo for domain %s: %v\n", domainStr, err) fmt.Printf("Error cloning repo for domain %s: %v\n", domainStr, err)
continue continue
} }
} }
err = addAndPushCerts(domainStr, gitWorkspace) err = internal.AddAndPushCerts(domainStr, gitWorkspace)
if err != nil { if err != nil {
fmt.Printf("Error pushing certificates for domain %s: %v\n", domainStr, err) fmt.Printf("Error pushing certificates for domain %s: %v\n", domainStr, err)
continue continue
@@ -138,15 +140,15 @@ func serverTick() {
fmt.Printf("Successfully pushed certificates for domain %s\n", domainStr) fmt.Printf("Successfully pushed certificates for domain %s\n", domainStr)
} }
} }
err = SaveDomainConfigs() err = internal.SaveDomainConfigs()
if err != nil { if err != nil {
fmt.Printf("Error saving domain configs: %v\n", err) fmt.Printf("Error saving domain configs: %v\n", err)
} }
} }
func reloadServer() { func Reload() {
fmt.Println("Reloading configs...") fmt.Println("Reloading configs...")
err := LoadDomainConfigs() err := internal.LoadDomainConfigs()
if err != nil { if err != nil {
fmt.Printf("Error loading domain configs: %v\n", err) fmt.Printf("Error loading domain configs: %v\n", err)
@@ -160,6 +162,6 @@ func reloadServer() {
fmt.Println("Successfully reloaded configs") fmt.Println("Successfully reloaded configs")
} }
func stopServer() { func Stop() {
fmt.Println("Shutting down server") fmt.Println("Shutting down server")
} }