diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index f1130a3..955cd48 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -50,11 +50,13 @@ jobs: uses: https://github.com/actions/upload-artifact@v3 with: name: certman-${{ env.VERSION }}-amd64.zip - path: certman-${{ env.VERSION }}-amd64 + path: bin/ if-no-files-found: error - name: Create release and upload binary run: | + BODY=$(jq -n --arg tag "v${{ env.VERSION }}" --arg msg "$COMMIT_MSG" \ + '{tag_name: $tag, name: $tag, body: $msg, draft: false, prerelease: false}') # Create the release RELEASE_RESPONSE=$(curl --fail --silent --show-error \ -X POST \ @@ -63,7 +65,7 @@ jobs: -d '{ "tag_name": "v${{ env.VERSION }}", "name": "v${{ env.VERSION }}", - "body": "${{ env.COMMIT_MSG }}", + "body": $BODY, "draft": false, "prerelease": false }' \ @@ -77,10 +79,14 @@ jobs: exit 1 fi - # Upload the binary as a release attachment - curl --fail --silent --show-error \ - -X POST \ - -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ - -H "Content-Type: application/octet-stream" \ - --upload-file "certman-${{ env.VERSION }}-amd64" \ - "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=certman-${{ env.VERSION }}-amd64" \ No newline at end of file + # Upload the binaries as release attachments + for binary in bin/*; do + FILENAME=$(basename "$binary") + echo "Uploading $FILENAME..." + curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --upload-file "$binary" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=$FILENAME" + done \ No newline at end of file diff --git a/.gitignore b/.gitignore index b9c42e5..8b74189 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,5 @@ $RECYCLE.BIN/ config.ini certman -certman-*-amd64 \ No newline at end of file +certman-*-amd64 +bin/ \ No newline at end of file diff --git a/Makefile b/Makefile index 953f626..efcb7eb 100644 --- a/Makefile +++ b/Makefile @@ -4,17 +4,32 @@ BUILD := $(shell git rev-parse --short HEAD) GO := go BUILD_FLAGS := -buildmode=pie -trimpath -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) +LDFLAGS := -linkmode=external -extldflags="-Wl,-z,relro,-z,now" -X git.nevets.tech/Keys/certman/common.Version=$(VERSION) -X git.nevets.tech/Keys/certman/common.Build=$(BUILD) -.PHONY: proto build debug stage +.PHONY: proto bundle client server executor build debug stage proto: @protoc --go_out=./proto --go-grpc_out=./proto proto/hook.proto @protoc --go_out=./proto --go-grpc_out=./proto proto/symlink.proto -build: proto - $(GO) build $(BUILD_FLAGS) -ldflags="-s -w $(LDFLAGS)" -o ./certman . - @cp ./certman ./certman-$(VERSION)-amd64 +bundle: proto + @echo "Building Bundled Certman" + $(GO) build $(BUILD_FLAGS) -ldflags="-s -w $(LDFLAGS)" -o ./bin/certman-$(VERSION)-amd64 ./cmd/bundle + +client: proto + @echo "Building Certman Client" + $(GO) build $(BUILD_FLAGS) -ldflags="-s -w $(LDFLAGS)" -o ./bin/certman-client-$(VERSION)-amd64 ./cmd/client + +server: proto + @echo "Building Certman Server" + $(GO) build $(BUILD_FLAGS) -ldflags="-s -w $(LDFLAGS)" -o ./bin/certman-server-$(VERSION)-amd64 ./cmd/server + +executor: proto + @echo "Building Certman Executor" + $(GO) build $(BUILD_FLAGS) -ldflags="-s -w $(LDFLAGS)" -o ./bin/certman-executor-$(VERSION)-amd64 ./cmd/executor + +build: proto bundle client server executor + @echo "All binaries successfully built" debug: proto $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o ./certman . diff --git a/app/client/certs.go b/app/client/certs.go new file mode 100644 index 0000000..03736e8 --- /dev/null +++ b/app/client/certs.go @@ -0,0 +1,93 @@ +package client + +import ( + "fmt" + "path/filepath" + + "git.nevets.tech/Keys/certman/app/shared" + "git.nevets.tech/Keys/certman/client" + "git.nevets.tech/Keys/certman/common" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/spf13/cobra" +) + +var ( + renewCertSubCmd = &cobra.Command{ + Use: "renew", + Short: "Renews a domains certificate", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return renewCert(args[0]) + }, + } + + updateCertLinkSubCmd = &cobra.Command{ + Use: "update-link", + Short: "Update linked certificates", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return updateLinks(args[0]) + }, + } + + decryptCertsSubCmd = &cobra.Command{ + Use: "decrypt [certPath] [cryptoKey]", + Short: "Decrypt certificates", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return client.DecryptCertificates(args[0], args[1]) + }, + } +) + +func init() { + renewCertSubCmd.AddCommand(updateCertLinkSubCmd, decryptCertsSubCmd) + shared.CertCmd.AddCommand(renewCertSubCmd) +} + +func renewCert(domain string) error { + gitWorkspace := &common.GitWorkspace{ + Domain: domain, + Storage: memory.NewStorage(), + FS: memfs.New(), + } + config := shared.Config() + domainConfig, exists := shared.DomainStore().Get(domain) + if !exists { + return shared.ErrConfigNotFound + } + if err := client.PullCerts(config, domainConfig, gitWorkspace); err != nil { + return err + } + certsDir := common.CertsDir(config, domainConfig) + return client.DecryptAndWriteCertificates(certsDir, config, domainConfig, gitWorkspace) +} + +func updateLinks(domain string) error { + domainConfig, exists := shared.DomainStore().Get(domain) + if !exists { + return fmt.Errorf("domain %s does not exist", domain) + } + + certsDir := shared.DomainCertsDirWConf(domain, domainConfig) + + certLinks := domainConfig.Certificates.CertSymlinks + for _, certLink := range certLinks { + err := common.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.Certificates.KeySymlinks + for _, keyLink := range keyLinks { + err := common.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 +} diff --git a/app/client/client.go b/app/client/client.go new file mode 100644 index 0000000..da13c8e --- /dev/null +++ b/app/client/client.go @@ -0,0 +1 @@ +package client diff --git a/client/client.go b/app/client/daemon.go similarity index 64% rename from client/client.go rename to app/client/daemon.go index 08775f2..3ea7947 100644 --- a/client/client.go +++ b/app/client/daemon.go @@ -7,48 +7,59 @@ import ( "path/filepath" "strings" - "git.nevets.tech/Keys/CertManager/internal" + appShared "git.nevets.tech/Keys/certman/app/shared" + "git.nevets.tech/Keys/certman/client" + "git.nevets.tech/Keys/certman/common" "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-git/v5/storage/memory" ) -func Init() { - err := internal.LoadDomainConfigs() +type Daemon struct{} + +func (d *Daemon) Init() { + fmt.Println("Starting CertManager in client mode...") + err := appShared.LoadDomainConfigs() if err != nil { log.Fatalf("Error loading domain configs: %v", err) } - Tick() + d.Tick() } -func Tick() { - fmt.Println("Tick!") +func (d *Daemon) Tick() { + fmt.Println("tick!") // Get local copy of configs - config := internal.Config() - localDomainConfigs := internal.DomainStore().Snapshot() + config := appShared.Config() + localDomainConfigs := appShared.DomainStore().Snapshot() // Loop over all domain configs (domains) for domainStr, domainConfig := range localDomainConfigs { // Skip non-enabled domains - if !domainConfig.GetBool("Domain.enabled") { + if !domainConfig.Domain.Enabled { continue } // Skip domains with up-to-date commit hashes // 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.Internal.RepoExists if repoExists { - localHash, err := internal.LocalCommitHash(domainStr) + var dataRoot string + if domainConfig.Certificates.DataRoot == "" { + config.Certificates.DataRoot = domainStr + } else { + dataRoot = domainConfig.Certificates.DataRoot + } + localHash, err := client.LocalCommitHash(domainStr, dataRoot) if err != nil { fmt.Printf("No local commit hash found for domain %s\n", domainStr) } - gitSource, err := internal.StrToGitSource(internal.Config().GetString("Git.host")) + gitSource, err := common.StrToGitSource(appShared.Config().Git.Host) if err != nil { fmt.Printf("Error getting git source for domain %s: %v\n", domainStr, err) continue } - remoteHash, err := internal.RemoteCommitHash(domainStr, gitSource) + remoteHash, err := client.RemoteCommitHash(domainStr, gitSource, config, domainConfig) if err != nil { fmt.Printf("Error getting remote commit hash for domain %s: %v\n", domainStr, err) } @@ -60,24 +71,20 @@ func Tick() { } } - gitWorkspace := &internal.GitWorkspace{ + gitWorkspace := &common.GitWorkspace{ Storage: memory.NewStorage(), FS: memfs.New(), } // 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") + "/" + config.GetString("Git.org_name") + "/" + domainStr + domainConfig.GetString("Repo.repo_suffix") + ".git" - err := internal.CloneRepo(repoUrl, gitWorkspace, internal.Client) + repoUrl := appShared.Config().Git.Server + "/" + config.Git.OrgName + "/" + domainStr + domainConfig.Repo.RepoSuffix + ".git" + err := common.CloneRepo(repoUrl, gitWorkspace, common.Client, config) if err != nil { fmt.Printf("Error cloning domain repo %s: %v\n", domainStr, err) continue } - certsDir, err := internal.DomainCertsDirWConf(domainStr, domainConfig) - if err != nil { - fmt.Printf("Error getting certificates dir for domain %s: %v\n", domainStr, err) - continue - } + certsDir := appShared.DomainCertsDirWConf(domainStr, domainConfig) // Get files in repo fileInfos, err := gitWorkspace.FS.ReadDir("/") @@ -106,7 +113,7 @@ func Tick() { continue } - err = internal.DecryptFileFromBytes(domainConfig.GetString("Certificates.crypto_key"), fileBytes, filepath.Join(certsDir, filename), nil) + err = common.DecryptFileFromBytes(domainConfig.Certificates.CryptoKey, fileBytes, filepath.Join(certsDir, filename), nil) if err != nil { fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, domainStr, err) continue @@ -118,24 +125,24 @@ func Tick() { continue } - err = internal.WriteCommitHash(headRef.Hash().String(), domainConfig) + err = common.WriteCommitHash(headRef.Hash().String(), config, domainConfig) if err != nil { fmt.Printf("Error writing commit hash: %v\n", err) continue } - certLinks := domainConfig.GetStringSlice("Certificates.cert_symlinks") + certLinks := domainConfig.Certificates.CertSymlinks for _, certLink := range certLinks { - err = internal.LinkFile(filepath.Join(certsDir, domainStr+".crt"), certLink, domainStr, ".crt") + err = common.LinkFile(filepath.Join(certsDir, domainStr+".crt"), certLink, domainStr, ".crt") if err != nil { fmt.Printf("Error linking cert %s to %s: %v\n", certLink, domainStr, err) continue } } - keyLinks := domainConfig.GetStringSlice("Certificates.key_symlinks") + keyLinks := domainConfig.Certificates.KeySymlinks for _, keyLink := range keyLinks { - err = internal.LinkFile(filepath.Join(certsDir, domainStr+".key"), keyLink, domainStr, ".key") + err = common.LinkFile(filepath.Join(certsDir, domainStr+".key"), keyLink, domainStr, ".key") if err != nil { fmt.Printf("Error linking cert %s to %s: %v\n", keyLink, domainStr, err) continue @@ -146,16 +153,16 @@ func Tick() { } } -func Reload() { +func (d *Daemon) Reload() { fmt.Println("Reloading configs...") - err := internal.LoadDomainConfigs() + err := appShared.LoadDomainConfigs() if err != nil { fmt.Printf("Error loading domain configs: %v\n", err) return } } -func Stop() { +func (d *Daemon) Stop() { fmt.Println("Shutting down client") } diff --git a/client/grpc.go b/app/client/grpc.go similarity index 87% rename from client/grpc.go rename to app/client/grpc.go index c692e48..47bd8ef 100644 --- a/client/grpc.go +++ b/app/client/grpc.go @@ -6,8 +6,8 @@ import ( "log" "time" - "git.nevets.tech/Keys/CertManager/internal" - pb "git.nevets.tech/Keys/CertManager/proto/v1" + "git.nevets.tech/Keys/certman/app/shared" + pb "git.nevets.tech/Keys/certman/proto/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) @@ -23,7 +23,7 @@ func SendHook(domain string) { defer conn.Close() client := pb.NewHookServiceClient(conn) - hooks, err := internal.PostPullHooks(domain) + hooks, err := shared.PostPullHooks(domain) if err != nil { fmt.Printf("Error getting hooks: %v\n", err) return diff --git a/commands/executor.go b/app/executor/commands.go similarity index 50% rename from commands/executor.go rename to app/executor/commands.go index a8dffb3..45a9f27 100644 --- a/commands/executor.go +++ b/app/executor/commands.go @@ -1,4 +1,4 @@ -package commands +package executor import ( "fmt" @@ -6,13 +6,23 @@ import ( "os/signal" "syscall" - "git.nevets.tech/Keys/CertManager/executor" + "github.com/spf13/cobra" ) -var executorServer *executor.Server +var ( + executorServer *Server -func StartExecutorCmd() error { - executorServer = &executor.Server{} + ExecutorCmd = &cobra.Command{ + Use: "executor", + Short: "Privileged daemon", + RunE: func(cmd *cobra.Command, args []string) error { + return startExecutorCmd() + }, + } +) + +func startExecutorCmd() error { + executorServer = &Server{} sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) diff --git a/executor/executor.go b/app/executor/executor.go similarity index 94% rename from executor/executor.go rename to app/executor/executor.go index 73ec903..1096307 100644 --- a/executor/executor.go +++ b/app/executor/executor.go @@ -5,7 +5,7 @@ import ( "net" "sync" - pb "git.nevets.tech/Keys/CertManager/proto/v1" + pb "git.nevets.tech/Keys/certman/proto/v1" "github.com/coreos/go-systemd/v22/activation" "google.golang.org/grpc" ) diff --git a/executor/hook.go b/app/executor/hook.go similarity index 91% rename from executor/hook.go rename to app/executor/hook.go index 318620d..3f38baf 100644 --- a/executor/hook.go +++ b/app/executor/hook.go @@ -8,8 +8,8 @@ import ( "syscall" "time" - "git.nevets.tech/Keys/CertManager/internal" - pb "git.nevets.tech/Keys/CertManager/proto/v1" + "git.nevets.tech/Keys/certman/common" + pb "git.nevets.tech/Keys/certman/proto/v1" ) type hookServer struct { @@ -51,7 +51,7 @@ func (s *hookServer) ExecuteHook(ctx context.Context, req *pb.ExecuteHookRequest // Run as user/group if specified (Linux/Unix) if h.GetUser() != "" || h.GetGroup() != "" { - cred, err := internal.MakeCredential(h.GetUser(), h.GetGroup()) + cred, err := common.MakeCredential(h.GetUser(), h.GetGroup()) if err != nil { return &pb.ExecuteHookResponse{Error: brief(err)}, nil } diff --git a/executor/util.go b/app/executor/util.go similarity index 100% rename from executor/util.go rename to app/executor/util.go diff --git a/app/server/certs.go b/app/server/certs.go new file mode 100644 index 0000000..833adb9 --- /dev/null +++ b/app/server/certs.go @@ -0,0 +1,125 @@ +package server + +import ( + "fmt" + "path/filepath" + "time" + + "git.nevets.tech/Keys/certman/app/shared" + "git.nevets.tech/Keys/certman/common" + "git.nevets.tech/Keys/certman/server" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/spf13/cobra" +) + +var ( + noPush bool + renewCertSubCmd = &cobra.Command{ + Use: "renew", + Short: "Renews a domains certificate", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return renewCertCmd(args[0], noPush) + }, + } +) + +func init() { + renewCertSubCmd.Flags().BoolVar(&noPush, "no-push", false, "Don't push certs to repo, renew locally only [server mode only]") + shared.CertCmd.AddCommand(renewCertSubCmd) +} + +func renewCertCmd(domain string, noPush bool) error { + if err := shared.LoadConfig(); err != nil { + return err + } + if err := shared.LoadDomainConfigs(); err != nil { + return err + } + mgr, err := server.NewACMEManager(shared.Config()) + if err != nil { + return err + } + err = renewCerts(domain, noPush, mgr) + if err != nil { + return err + } + // return ReloadDaemonCmd() // Not sure if this is necessary + return nil +} + +func renewCerts(domain string, noPush bool, mgr *server.ACMEManager) error { + config := shared.Config() + domainConfig, exists := shared.DomainStore().Get(domain) + if !exists { + return fmt.Errorf("domain %s does not exist", domain) + } + + _, err := mgr.RenewForDomain(domain) + if err != nil { + // if no existing cert, obtain instead + _, err = mgr.ObtainForDomain(domain, config, domainConfig) + if err != nil { + return fmt.Errorf("error obtaining domain certificates for domain %s: %v", domain, err) + } + } + + domainConfig.Internal.LastIssued = time.Now().UTC().Unix() + err = shared.WriteDomainConfig(domainConfig) + if err != nil { + return fmt.Errorf("error saving domain config %s: %v", domain, err) + } + + err = common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, 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 = common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, 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 := common.CreateGiteaClient(config) + if giteaClient == nil { + return fmt.Errorf("error creating gitea client for domain %s: %v", domain, err) + } + gitWorkspace := &common.GitWorkspace{ + Storage: memory.NewStorage(), + FS: memfs.New(), + } + + var repoUrl string + if !domainConfig.Internal.RepoExists { + repoUrl = common.CreateGiteaRepo(domain, giteaClient, config, domainConfig) + if repoUrl == "" { + return fmt.Errorf("error creating Gitea repo for domain %s", domain) + } + domainConfig.Internal.RepoExists = true + err = shared.WriteDomainConfig(domainConfig) + if err != nil { + return fmt.Errorf("error saving domain config %s: %v", domain, err) + } + + err = common.InitRepo(repoUrl, gitWorkspace) + if err != nil { + return fmt.Errorf("error initializing repo for domain %s: %v", domain, err) + } + } else { + repoUrl = config.Git.Server + "/" + config.Git.OrgName + "/" + domain + domainConfig.Repo.RepoSuffix + ".git" + err = common.CloneRepo(repoUrl, gitWorkspace, common.Server, config) + if err != nil { + return fmt.Errorf("error cloning repo for domain %s: %v", domain, err) + } + } + + err = common.AddAndPushCerts(domain, gitWorkspace, config, domainConfig) + 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 +} diff --git a/app/server/daemon.go b/app/server/daemon.go new file mode 100644 index 0000000..3f7ea95 --- /dev/null +++ b/app/server/daemon.go @@ -0,0 +1,168 @@ +package server + +import ( + "fmt" + "log" + "path/filepath" + "sync" + "time" + + appShared "git.nevets.tech/Keys/certman/app/shared" + "git.nevets.tech/Keys/certman/common" + "git.nevets.tech/Keys/certman/server" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5/storage/memory" +) + +type Daemon struct { + ACMEManager *server.ACMEManager + TickMu sync.Mutex + MgrMu sync.Mutex +} + +func (d *Daemon) loadACMEManager() error { + d.MgrMu.Lock() + defer d.MgrMu.Unlock() + + if d.ACMEManager == nil { + var err error + d.ACMEManager, err = server.NewACMEManager(appShared.Config()) + if err != nil { + return err + } + } + return nil +} + +func (d *Daemon) Init() { + fmt.Println("Starting CertManager in server mode...") + err := appShared.LoadDomainConfigs() + if err != nil { + log.Fatalf("Error loading domain configs: %v", err) + } + + d.Tick() +} + +func (d *Daemon) Tick() { + d.TickMu.Lock() + defer d.TickMu.Unlock() + fmt.Println("Tick!") + + if err := d.loadACMEManager(); err != nil { + fmt.Printf("Error getting acme manager: %v\n", err) + return + } + + now := time.Now().UTC() + + config := appShared.Config() + localDomainConfigs := appShared.DomainStore().Snapshot() + + for domainStr, domainConfig := range localDomainConfigs { + if !domainConfig.Domain.Enabled { + continue + } + renewPeriod := domainConfig.Certificates.RenewPeriod + lastIssued := time.Unix(domainConfig.Internal.LastIssued, 0).UTC() + renewalDue := lastIssued.AddDate(0, 0, renewPeriod) + if now.After(renewalDue) { + //TODO extra check if certificate expiry (create cache?) + _, err := d.ACMEManager.RenewForDomain(domainStr) + if err != nil { + // if no existing cert, obtain instead + _, err = d.ACMEManager.ObtainForDomain(domainStr, appShared.Config(), domainConfig) + if err != nil { + fmt.Printf("Error obtaining domain certificates for domain %s: %v\n", domainStr, err) + continue + } + } + + domainConfig.Internal.LastIssued = time.Now().UTC().Unix() + err = appShared.WriteDomainConfig(domainConfig) + if err != nil { + fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) + continue + } + + err = common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(d.ACMEManager.CertsRoot, domainStr, domainStr+".crt"), filepath.Join(d.ACMEManager.CertsRoot, domainStr, domainStr+".crt.crpt"), nil) + if err != nil { + fmt.Printf("Error encrypting domain cert for domain %s: %v\n", domainStr, err) + continue + } + err = common.EncryptFileXChaCha(domainConfig.Certificates.CryptoKey, filepath.Join(d.ACMEManager.CertsRoot, domainStr, domainStr+".key"), filepath.Join(d.ACMEManager.CertsRoot, domainStr, domainStr+".key.crpt"), nil) + if err != nil { + fmt.Printf("Error encrypting domain key for domain %s: %v\n", domainStr, err) + continue + } + + giteaClient := common.CreateGiteaClient(config) + if giteaClient == nil { + fmt.Printf("Error creating gitea client for domain %s: %v\n", domainStr, err) + continue + } + gitWorkspace := &common.GitWorkspace{ + Storage: memory.NewStorage(), + FS: memfs.New(), + } + + var repoUrl string + if !domainConfig.Internal.RepoExists { + repoUrl = common.CreateGiteaRepo(domainStr, giteaClient, config, domainConfig) + if repoUrl == "" { + fmt.Printf("Error creating Gitea repo for domain %s\n", domainStr) + continue + } + domainConfig.Internal.RepoExists = true + err = appShared.WriteDomainConfig(domainConfig) + if err != nil { + fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) + continue + } + + err = common.InitRepo(repoUrl, gitWorkspace) + if err != nil { + fmt.Printf("Error initializing repo for domain %s: %v\n", domainStr, err) + continue + } + } else { + repoUrl = appShared.Config().Git.Server + "/" + appShared.Config().Git.OrgName + "/" + domainStr + domainConfig.Repo.RepoSuffix + ".git" + err = common.CloneRepo(repoUrl, gitWorkspace, common.Server, config) + if err != nil { + fmt.Printf("Error cloning repo for domain %s: %v\n", domainStr, err) + continue + } + } + + err = common.AddAndPushCerts(domainStr, gitWorkspace, config, domainConfig) + if err != nil { + fmt.Printf("Error pushing certificates for domain %s: %v\n", domainStr, err) + continue + } + fmt.Printf("Successfully pushed certificates for domain %s\n", domainStr) + } + } + if err := appShared.SaveDomainConfigs(); err != nil { + fmt.Printf("Error saving domain configs: %v\n", err) + } +} + +func (d *Daemon) Reload() { + fmt.Println("Reloading configs...") + err := appShared.LoadDomainConfigs() + + if err != nil { + fmt.Printf("Error loading domain configs: %v\n", err) + return + } + + d.MgrMu.Lock() + d.ACMEManager = nil + d.MgrMu.Unlock() + + fmt.Println("Successfully reloaded configs") +} + +func (d *Daemon) Stop() { + fmt.Println("Shutting down server") +} diff --git a/app/server/server.go b/app/server/server.go new file mode 100644 index 0000000..abb4e43 --- /dev/null +++ b/app/server/server.go @@ -0,0 +1 @@ +package server diff --git a/app/shared/certs.go b/app/shared/certs.go new file mode 100644 index 0000000..5737428 --- /dev/null +++ b/app/shared/certs.go @@ -0,0 +1,15 @@ +package shared + +import ( + "github.com/spf13/cobra" +) + +var ( + CertCmd = &cobra.Command{ + Use: "cert", + Short: "Certificate management", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } +) diff --git a/app/shared/commands.go b/app/shared/commands.go new file mode 100644 index 0000000..fb0397a --- /dev/null +++ b/app/shared/commands.go @@ -0,0 +1,191 @@ +package shared + +import ( + "fmt" + "log" + "os" + "os/exec" + "os/user" + "strconv" + "strings" + + "git.nevets.tech/Keys/certman/common" + "github.com/spf13/cobra" +) + +var ( + VersionCmd = basicCmd("version", "Show version", versionCmd) + NewKeyCmd = basicCmd("gen-key", "Generates encryption key", newKeyCmd) + DevCmd = basicCmd("dev", "Dev Function", devCmd) + + domainCertDir string + NewDomainCmd = &cobra.Command{ + Use: "new-domain", + Short: "Create config and directories for new domain", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + dirOverridden := cmd.Flags().Changed("dir") + return newDomainCmd(args[0], domainCertDir, dirOverridden) + }, + } + + modeFlag string + thinInstallFlag bool + InstallCmd = &cobra.Command{ + Use: "install", + Short: "Create certman files and directories", + RunE: func(cmd *cobra.Command, args []string) error { + switch modeFlag { + case "server", "client": + return installCmd(thinInstallFlag, modeFlag) + default: + return fmt.Errorf("invalid --mode %q (must be server or client)", modeFlag) + } + }, + } +) + +func init() { + NewDomainCmd.Flags().StringVar(&domainCertDir, "dir", "/var/local/certman/certificates/", "Alternate directory for certificates") + + InstallCmd.Flags().StringVar(&modeFlag, "mode", "client", "CertManager mode [server, client]") + InstallCmd.Flags().BoolVarP(&thinInstallFlag, "thin", "t", false, "Thin install (skip creating dirs)") +} + +func devCmd(cmd *cobra.Command, args []string) { + testDomain := "lunamc.org" + err := LoadConfig() + 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 versionCmd(cmd *cobra.Command, args []string) { + fmt.Printf("CertManager (certman) - Steven Tracey\nVersion: %s build-%s\n", + common.Version, common.Build, + ) +} + +func newKeyCmd(cmd *cobra.Command, args []string) { + key, err := common.GenerateKey() + if err != nil { + log.Fatalf("%v", err) + } + fmt.Printf(key) +} + +func newDomainCmd(domain, domainDir string, dirOverridden bool) error { + //TODO add config option for "overridden dir" + if !common.IsValidFQDN(domain) { + return fmt.Errorf("invalid FQDN: %q", domain) + } + + err := LoadConfig() + if err != nil { + return err + } + + fmt.Printf("Creating new domain %s\n", domain) + err = CreateDomainConfig(domain) + if err != nil { + return err + } + 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 = common.ChownRecursive("/etc/certman/domains", uid, gid) + if err != nil { + return err + } + err = common.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") + } + + MakeDirs() + CreateConfig(mode) + + err := LoadConfig() + if err != nil { + return err + } + + 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 { + if !strings.Contains(err.Error(), "exit status 9") { + return fmt.Errorf("error creating user: %v: output %s", err, output) + } + } + newGroupCmd := exec.Command("groupadd", "-r", "-U", "certman", "certsock") + if output, err := newGroupCmd.CombinedOutput(); err != nil { + if !strings.Contains(err.Error(), "exit status 9") { + return fmt.Errorf("error creating group: %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 = common.ChownRecursive("/etc/certman", uid, gid) + if err != nil { + return fmt.Errorf("error changing uid/gid: %v", err) + } + err = common.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 { + CreateConfig(mode) + } + return nil +} diff --git a/internal/config.go b/app/shared/config.go similarity index 63% rename from internal/config.go rename to app/shared/config.go index cb3988b..45605d5 100644 --- a/internal/config.go +++ b/app/shared/config.go @@ -1,7 +1,6 @@ -package internal +package shared import ( - "bytes" "errors" "fmt" "log" @@ -10,8 +9,10 @@ import ( "strings" "sync" - pb "git.nevets.tech/Keys/CertManager/proto/v1" + "git.nevets.tech/Keys/certman/common" + pb "git.nevets.tech/Keys/certman/proto/v1" "github.com/google/uuid" + "github.com/pelletier/go-toml/v2" "github.com/spf13/viper" ) @@ -22,40 +23,40 @@ var ( type DomainConfigStore struct { mu sync.RWMutex - configs map[string]*viper.Viper + configs map[string]*common.DomainConfig } func NewDomainConfigStore() *DomainConfigStore { return &DomainConfigStore{ - configs: make(map[string]*viper.Viper), + configs: make(map[string]*common.DomainConfig), } } -func (s *DomainConfigStore) Get(domain string) (*viper.Viper, bool) { +func (s *DomainConfigStore) Get(domain string) (*common.DomainConfig, bool) { s.mu.RLock() defer s.mu.RUnlock() v, ok := s.configs[domain] return v, ok } -func (s *DomainConfigStore) Set(domain string, v *viper.Viper) { +func (s *DomainConfigStore) Set(domain string, v *common.DomainConfig) { s.mu.Lock() defer s.mu.Unlock() s.configs[domain] = v } // Swap atomically replaces the entire config map (used during reload). -func (s *DomainConfigStore) Swap(newConfigs map[string]*viper.Viper) { +func (s *DomainConfigStore) Swap(newConfigs map[string]*common.DomainConfig) { s.mu.Lock() defer s.mu.Unlock() s.configs = newConfigs } // Snapshot returns a shallow copy safe to iterate without holding the lock. -func (s *DomainConfigStore) Snapshot() map[string]*viper.Viper { +func (s *DomainConfigStore) Snapshot() map[string]*common.DomainConfig { s.mu.RLock() defer s.mu.RUnlock() - snap := make(map[string]*viper.Viper, len(s.configs)) + snap := make(map[string]*common.DomainConfig, len(s.configs)) for k, v := range s.configs { snap[k] = v } @@ -67,12 +68,12 @@ func (s *DomainConfigStore) Snapshot() map[string]*viper.Viper { // --------------------------------------------------------------------------- var ( - config *viper.Viper + config *common.AppConfig configMu sync.RWMutex domainStore = NewDomainConfigStore() ) -func Config() *viper.Viper { +func Config() *common.AppConfig { configMu.RLock() defer configMu.RUnlock() return config @@ -90,18 +91,22 @@ func DomainStore() *DomainConfigStore { // LoadConfig reads the main certman.conf into config. func LoadConfig() error { - config = viper.New() - config.SetConfigFile("/etc/certman/certman.conf") - config.SetConfigType("toml") - err := config.ReadInConfig() - if err != nil { + vConfig := viper.New() + vConfig.SetConfigFile("/etc/certman/certman.conf") + vConfig.SetConfigType("toml") + if err := vConfig.ReadInConfig(); err != nil { return err } - if config.GetString("App.mode") == "server" { - config.SetConfigType("toml") - config.SetConfigFile("/etc/certman/server.conf") - return config.MergeInConfig() + if vConfig.GetString("App.mode") == "server" { + vConfig.SetConfigType("toml") + vConfig.SetConfigFile("/etc/certman/server.conf") + if err := vConfig.MergeInConfig(); err != nil { + return err + } + } + if err := vConfig.Unmarshal(&config); err != nil { + return err } return nil @@ -115,7 +120,7 @@ func LoadDomainConfigs() error { return fmt.Errorf("reading domain config dir: %w", err) } - temp := make(map[string]*viper.Viper) + temp := make(map[string]*common.DomainConfig) for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".conf" { @@ -140,7 +145,11 @@ func LoadDomainConfigs() error { fmt.Printf("Duplicate domain in %s, skipping...\n", path) continue } - temp[domain] = v + cfg := &common.DomainConfig{} + if err = v.Unmarshal(cfg); err != nil { + return fmt.Errorf("unmarshaling %s: %w", path, err) + } + temp[domain] = cfg } domainStore.Swap(temp) @@ -151,26 +160,35 @@ func LoadDomainConfigs() error { // Saving // --------------------------------------------------------------------------- -func WriteConfig(filePath string, config *viper.Viper) error { - var buf bytes.Buffer - if err := config.WriteConfigTo(&buf); err != nil { - return fmt.Errorf("marshal config: %w", err) +func WriteConfig(filePath string, config *common.AppConfig) error { + buf, err := toml.Marshal(&config) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) } - - if err := os.WriteFile(filePath, buf.Bytes(), 0640); err != nil { + if err = os.WriteFile(filePath, buf, 0640); err != nil { return fmt.Errorf("write config file: %w", err) } + return nil } -func WriteDomainConfig(config *viper.Viper) error { - return WriteConfig(filepath.Join("/etc/certman/domains", config.GetString("Domain.domain_name")+".conf"), config) +func WriteDomainConfig(config *common.DomainConfig) error { + buf, err := toml.Marshal(config) + if err != nil { + return fmt.Errorf("marshaling domain config: %w", err) + } + configPath := filepath.Join("/etc/certman/domains", config.Domain.DomainName+".conf") + if err = os.WriteFile(configPath, buf, 0640); err != nil { + return fmt.Errorf("write config file: %w", err) + } + + return nil } // SaveDomainConfigs writes every loaded domain config back to disk. func SaveDomainConfigs() error { - for domain, v := range domainStore.Snapshot() { - err := WriteConfig("/etc/certman/domains/"+domain+".conf", v) + for _, v := range domainStore.Snapshot() { + err := WriteDomainConfig(v) if err != nil { return err } @@ -190,68 +208,6 @@ func PostPullHooks(domain string) ([]*pb.Hook, error) { return hooks, nil } -// --------------------------------------------------------------------------- -// Effective lookups (domain → global fallback) -// --------------------------------------------------------------------------- - -// EffectiveString looks up a key in the domain config first, falling back to -// the global config. Keys use dot notation matching INI sections, e.g. -// "certificates.data_root". -func EffectiveString(domainCfg *viper.Viper, key string) (string, error) { - if domainCfg != nil { - val := strings.TrimSpace(domainCfg.GetString(key)) - if val != "" { - return val, nil - } - } - - if config == nil { - return "", ErrConfigNotFound - } - - val := strings.TrimSpace(config.GetString(key)) - if val == "" { - return "", ErrBlankConfigEntry - } - return val, nil -} - -// MustEffectiveString is like EffectiveString but logs a fatal error on failure. -func MustEffectiveString(domainCfg *viper.Viper, key string) string { - val, err := EffectiveString(domainCfg, key) - if err != nil { - log.Fatalf("Config key %q: %v", key, err) - } - return val -} - -// EffectiveInt returns an int with domain → global fallback. Returns the -// fallback value if the key is missing or zero in both configs. -func EffectiveInt(domainCfg *viper.Viper, key string, fallback int) int { - if domainCfg != nil { - if val := domainCfg.GetInt(key); val != 0 { - return val - } - } - if config != nil { - if val := config.GetInt(key); val != 0 { - return val - } - } - return fallback -} - -// EffectiveBool returns a bool with domain → global fallback. -func EffectiveBool(domainCfg *viper.Viper, key string) bool { - if domainCfg != nil && domainCfg.IsSet(key) { - return domainCfg.GetBool(key) - } - if config != nil { - return config.GetBool(key) - } - return false -} - // --------------------------------------------------------------------------- // Directory bootstrapping // --------------------------------------------------------------------------- @@ -289,13 +245,14 @@ func CreateConfig(mode string) { } func CreateDomainConfig(domain string) error { - key, err := GenerateKey() + key, err := common.GenerateKey() if err != nil { return fmt.Errorf("unable to generate key: %v", err) } + localConfig := Config() var content string - switch Config().GetString("App.mode") { + switch localConfig.App.Mode { case "server": content = strings.NewReplacer( "{domain}", domain, @@ -307,7 +264,7 @@ func CreateDomainConfig(domain string) error { "{key}", key, ).Replace(defaultClientDomainConfig) default: - return fmt.Errorf("unknown certman mode: %v", Config().GetString("App.mode")) + return fmt.Errorf("unknown certman mode: %v", localConfig.App.Mode) } path := filepath.Join("/etc/certman/domains", domain+".conf") @@ -332,10 +289,6 @@ func CreateDomainCertsDir(domain string, dir string, dirOverride bool) { } } -// --------------------------------------------------------------------------- -// Default config templates -// --------------------------------------------------------------------------- - const defaultConfig = `[App] mode = '{mode}' tick_rate = 2 @@ -356,21 +309,13 @@ uuid = '{uuid}' [Certificates] 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] +const defaultServerDomainConfig = `[Certificates] data_root = '' expiry = 90 request_method = 'dns-01' @@ -378,6 +323,11 @@ renew_period = 30 subdomains = [] crypto_key = '{key}' +[Domain] +domain_name = '{domain}' +enabled = true +dns_server = 'default' + [Repo] repo_suffix = '-certificates' @@ -406,5 +356,3 @@ env = { "FOO" = "bar" } [Repo] repo_suffix = '-certificates' ` - -const readme = `` diff --git a/commands/daemon.go b/app/shared/daemon.go similarity index 53% rename from commands/daemon.go rename to app/shared/daemon.go index e23cae7..c3ef705 100644 --- a/commands/daemon.go +++ b/app/shared/daemon.go @@ -1,4 +1,4 @@ -package commands +package shared import ( "context" @@ -10,21 +10,73 @@ import ( "syscall" "time" - "git.nevets.tech/Keys/CertManager/client" - "git.nevets.tech/Keys/CertManager/internal" - "git.nevets.tech/Keys/CertManager/server" + "git.nevets.tech/Keys/certman/common" + "github.com/spf13/cobra" ) +type Daemon interface { + Init() + Tick() + Reload() + Stop() +} + var ( ctx context.Context cancel context.CancelFunc wg sync.WaitGroup + + DaemonCmd = &cobra.Command{ + Use: "daemon", + Short: "Daemon management", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } ) -func RunDaemonCmd() error { - err := internal.CreateOrUpdatePIDFile("/var/run/certman.pid") +func init() { + DaemonCmd.AddCommand(&cobra.Command{ + Use: "stop", + Short: "stop the daemon", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return stopDaemonCmd() + }, + }) + + DaemonCmd.AddCommand(&cobra.Command{ + Use: "reload", + Short: "reload daemon configs", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return reloadDaemonCmd() + }, + }) + + DaemonCmd.AddCommand(&cobra.Command{ + Use: "tick", + Short: "Manually triggers daemon tick", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return tickDaemonCmd() + }, + }) + + DaemonCmd.AddCommand(&cobra.Command{ + Use: "status", + Short: "Show daemon status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return daemonStatusCmd() + }, + }) +} + +func RunDaemonCmd(daemon Daemon) error { + err := common.CreateOrUpdatePIDFile("/var/run/certman.pid") if err != nil { - if errors.Is(err, internal.ErrorPIDInUse) { + if errors.Is(err, common.ErrorPIDInUse) { return fmt.Errorf("daemon process is already running") } return fmt.Errorf("error creating pidfile: %v", err) @@ -38,10 +90,11 @@ func RunDaemonCmd() error { } else if err != nil { return fmt.Errorf("error opening /etc/certman/certman.conf: %v", err) } - err = internal.LoadConfig() + err = LoadConfig() if err != nil { return fmt.Errorf("error loading configuration: %v", err) } + localConfig := Config() // Setup SIGINT and SIGTERM listeners sigChannel := make(chan os.Signal, 1) @@ -56,54 +109,28 @@ func RunDaemonCmd() error { signal.Notify(tickSigChan, syscall.SIGUSR1) defer signal.Stop(tickSigChan) - tickRate := internal.Config().GetInt("App.tick_rate") + tickRate := localConfig.App.TickRate 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() - } + go func() { + daemon.Init() + defer wg.Done() + for { + select { + case <-ctx.Done(): + daemon.Stop() + return + case <-reloadSigChan: + daemon.Reload() + case <-ticker.C: + daemon.Tick() + case <-tickSigChan: + daemon.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 @@ -116,11 +143,11 @@ func RunDaemonCmd() error { func stop() { cancel() - internal.ClearPIDFile() + common.ClearPIDFile() } -func StopDaemonCmd() error { - proc, err := internal.DaemonProcess() +func stopDaemonCmd() error { + proc, err := common.DaemonProcess() if err != nil { return fmt.Errorf("error getting daemon process: %v", err) } @@ -132,8 +159,8 @@ func StopDaemonCmd() error { return nil } -func ReloadDaemonCmd() error { - proc, err := internal.DaemonProcess() +func reloadDaemonCmd() error { + proc, err := common.DaemonProcess() if err != nil { return fmt.Errorf("error getting daemon process: %v", err) } @@ -145,8 +172,8 @@ func ReloadDaemonCmd() error { return nil } -func TickDaemonCmd() error { - proc, err := internal.DaemonProcess() +func tickDaemonCmd() error { + proc, err := common.DaemonProcess() if err != nil { return fmt.Errorf("error getting daemon process: %v", err) } @@ -158,7 +185,7 @@ func TickDaemonCmd() error { return nil } -func DaemonStatusCmd() error { +func daemonStatusCmd() error { fmt.Println("Not implemented :/") return nil } diff --git a/app/shared/install.go b/app/shared/install.go new file mode 100644 index 0000000..a29b5e4 --- /dev/null +++ b/app/shared/install.go @@ -0,0 +1 @@ +package shared diff --git a/app/shared/util.go b/app/shared/util.go new file mode 100644 index 0000000..539b274 --- /dev/null +++ b/app/shared/util.go @@ -0,0 +1,72 @@ +package shared + +import ( + "fmt" + "os" + "path/filepath" + + "git.nevets.tech/Keys/certman/common" + "github.com/spf13/cobra" +) + +func createFile(fileName string, filePermission os.FileMode, data []byte) { + fileInfo, err := os.Stat(fileName) + if err != nil { + if os.IsNotExist(err) { + file, err := os.Create(fileName) + if err != nil { + fmt.Println("Error creating configuration file: ", err) + os.Exit(1) + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + fmt.Println("Error writing to file: ", err) + os.Exit(1) + } + err = file.Chmod(filePermission) + if err != nil { + fmt.Println("Error changing file permission: ", err) + } + } else { + fmt.Println("Error opening configuration file: ", err) + os.Exit(1) + } + } else { + if fileInfo.Size() == 0 { + file, err := os.Create(fileName) + if err != nil { + fmt.Println("Error creating configuration file: ", err) + os.Exit(1) + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + fmt.Println("Error writing to file:", err) + os.Exit(1) + } + } + } +} + +// DomainCertsDirWConf Can return ErrBlankConfigEntry or other errors +func DomainCertsDirWConf(domain string, domainConfig *common.DomainConfig) string { + var effectiveDataRoot string + if domainConfig.Certificates.DataRoot == "" { + effectiveDataRoot = config.Certificates.DataRoot + } else { + effectiveDataRoot = domainConfig.Certificates.DataRoot + } + + return filepath.Join(effectiveDataRoot, "certificates", domain) +} + +func basicCmd(use, short string, commandFunc func(cmd *cobra.Command, args []string)) *cobra.Command { + return &cobra.Command{ + Use: use, + Short: short, + Run: commandFunc, + } +} diff --git a/client/certificates.go b/client/certificates.go new file mode 100644 index 0000000..29a953d --- /dev/null +++ b/client/certificates.go @@ -0,0 +1,109 @@ +package client + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "git.nevets.tech/Keys/certman/common" +) + +func PullCerts(config *common.AppConfig, domainConfig *common.DomainConfig, gitWorkspace *common.GitWorkspace) error { + // Ex: https://git.example.com/Org/Repo-suffix.git + // Clones repo and stores in gitWorkspace, skip if clone fails (doesn't exist?) + repoUrl := config.Git.Server + "/" + config.Git.OrgName + "/" + gitWorkspace.Domain + domainConfig.Repo.RepoSuffix + ".git" + err := common.CloneRepo(repoUrl, gitWorkspace, common.Client, config) + if err != nil { + return fmt.Errorf("Error cloning domain repo %s: %v\n", gitWorkspace.Domain, err) + } + return nil +} + +func DecryptAndWriteCertificates(certsDir string, config *common.AppConfig, domainConfig *common.DomainConfig, gitWorkspace *common.GitWorkspace) error { + // 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", gitWorkspace.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", gitWorkspace.Domain, err) + continue + } + fileBytes, err := io.ReadAll(file) + if err != nil { + fmt.Printf("Error reading file in memFS on domain %s: %v\n", gitWorkspace.Domain, err) + file.Close() + continue + } + err = file.Close() + if err != nil { + fmt.Printf("Error closing file on domain %s: %v\n", gitWorkspace.Domain, err) + continue + } + + err = common.DecryptFileFromBytes(domainConfig.Certificates.CryptoKey, fileBytes, filepath.Join(certsDir, filename), nil) + if err != nil { + fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, gitWorkspace.Domain, err) + continue + } + + headRef, err := gitWorkspace.Repo.Head() + if err != nil { + fmt.Printf("Error getting head reference for domain %s: %v\n", gitWorkspace.Domain, err) + continue + } + + err = common.WriteCommitHash(headRef.Hash().String(), config, domainConfig) + if err != nil { + fmt.Printf("Error writing commit hash: %v\n", err) + continue + } + } + } + return nil +} + +func DecryptCertificates(certPath, cryptoKey string) error { + // Get files in repo + fileInfos, err := os.ReadDir(certPath) + if err != nil { + return fmt.Errorf("error reading directory: %v", 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 := os.OpenFile(fileInfo.Name(), os.O_RDONLY, 0640) + if err != nil { + fmt.Printf("Error opening file: %v\n", err) + continue + } + fileBytes, err := io.ReadAll(file) + if err != nil { + fmt.Printf("Error reading file: %v\n", err) + file.Close() + continue + } + err = file.Close() + if err != nil { + fmt.Printf("Error closing file: %v\n", err) + continue + } + + err = common.DecryptFileFromBytes(cryptoKey, fileBytes, filepath.Join(certPath, filename), nil) + if err != nil { + fmt.Printf("Error decrypting file %s: %v\n", filename, err) + continue + } + } + } + + return nil +} diff --git a/client/git.go b/client/git.go new file mode 100644 index 0000000..98c09e2 --- /dev/null +++ b/client/git.go @@ -0,0 +1,44 @@ +package client + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "git.nevets.tech/Keys/certman/common" +) + +func LocalCommitHash(domain string, certsDir string) (string, error) { + data, err := os.ReadFile(filepath.Join(certsDir, "hash")) + if err != nil { + if !os.IsNotExist(err) { + fmt.Printf("Error reading file for domain %s: %v\n", domain, err) + return "", err + } + } + + return strings.TrimSpace(string(data)), nil +} + +func RemoteCommitHash(domain string, gitSource common.GitSource, config *common.AppConfig, domainConfig *common.DomainConfig) (string, error) { + switch gitSource { + case common.Gitea: + return getRemoteCommitHashGitea(config.Git.OrgName, domain+domainConfig.Repo.RepoSuffix, "master", config) + default: + fmt.Printf("Unimplemented git source %v\n", gitSource) + return "", errors.New("unimplemented git source") + } +} + +func getRemoteCommitHashGitea(org, repo, branchName string, config *common.AppConfig) (string, error) { + giteaClient := common.CreateGiteaClient(config) + branch, _, err := giteaClient.GetRepoBranch(org, repo, branchName) + if err != nil { + fmt.Printf("Error getting repo branch: %v\n", err) + return "", err + } + //TODO catch repo not found as ErrRepoNotInit + return branch.Commit.ID, nil +} diff --git a/cmd/bundle/main.go b/cmd/bundle/main.go new file mode 100644 index 0000000..993879e --- /dev/null +++ b/cmd/bundle/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + + "git.nevets.tech/Keys/certman/app/executor" + "git.nevets.tech/Keys/certman/app/shared" + "github.com/spf13/cobra" +) + +//TODO create logic for gh vs gt repos + +func main() { + rootCmd := &cobra.Command{ + Use: "certman", + Short: "CertMan", + Long: "Certificate Manager", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + rootCmd.AddCommand(shared.VersionCmd) + rootCmd.AddCommand(shared.NewKeyCmd) + rootCmd.AddCommand(shared.DevCmd) + + rootCmd.AddCommand(shared.NewDomainCmd) + rootCmd.AddCommand(shared.InstallCmd) + + rootCmd.AddCommand(shared.CertCmd) + + rootCmd.AddCommand(executor.ExecutorCmd) + + rootCmd.AddCommand(shared.DaemonCmd) + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/client/commands.go b/cmd/client/commands.go new file mode 100644 index 0000000..2880bbe --- /dev/null +++ b/cmd/client/commands.go @@ -0,0 +1,18 @@ +package main + +import ( + "git.nevets.tech/Keys/certman/app/client" + "git.nevets.tech/Keys/certman/app/shared" + "github.com/spf13/cobra" +) + +func init() { + shared.DaemonCmd.AddCommand(&cobra.Command{ + Use: "start", + Short: "Start the daemon", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return shared.RunDaemonCmd(&client.Daemon{}) + }, + }) +} diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..a9e43f5 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "os" + + "git.nevets.tech/Keys/certman/app/shared" + "github.com/spf13/cobra" +) + +func main() { + rootCmd := &cobra.Command{ + Use: "certman", + Short: "CertMan", + Long: "Certificate Manager", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + rootCmd.AddCommand(shared.VersionCmd) + rootCmd.AddCommand(shared.NewKeyCmd) + rootCmd.AddCommand(shared.DevCmd) + + rootCmd.AddCommand(shared.NewDomainCmd) + rootCmd.AddCommand(shared.InstallCmd) + + rootCmd.AddCommand(shared.CertCmd) + + rootCmd.AddCommand(shared.DaemonCmd) + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/executor/main.go b/cmd/executor/main.go new file mode 100644 index 0000000..f22771d --- /dev/null +++ b/cmd/executor/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello Executor") +} diff --git a/cmd/server/commands.go b/cmd/server/commands.go new file mode 100644 index 0000000..d2a0b5e --- /dev/null +++ b/cmd/server/commands.go @@ -0,0 +1,18 @@ +package main + +import ( + "git.nevets.tech/Keys/certman/app/server" + "git.nevets.tech/Keys/certman/app/shared" + "github.com/spf13/cobra" +) + +func init() { + shared.DaemonCmd.AddCommand(&cobra.Command{ + Use: "start", + Short: "Start the daemon", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return shared.RunDaemonCmd(&server.Daemon{}) + }, + }) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..f7d1436 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello server") +} diff --git a/commands/basic.go b/commands/basic.go deleted file mode 100644 index 63c747a..0000000 --- a/commands/basic.go +++ /dev/null @@ -1,37 +0,0 @@ -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) -} diff --git a/commands/certs.go b/commands/certs.go deleted file mode 100644 index 9462352..0000000 --- a/commands/certs.go +++ /dev/null @@ -1,266 +0,0 @@ -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 -} diff --git a/commands/install.go b/commands/install.go deleted file mode 100644 index 7159f18..0000000 --- a/commands/install.go +++ /dev/null @@ -1,116 +0,0 @@ -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" - err := internal.LoadConfig() - if err != nil { - return err - } - - 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) - - err := internal.LoadConfig() - if err != nil { - return err - } - - 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 { - if !strings.Contains(err.Error(), "exit status 9") { - return fmt.Errorf("error creating user: %v: output %s", err, output) - } - } - newGroupCmd := exec.Command("groupadd", "-r", "-U", "certman", "certsock") - if output, err := newGroupCmd.CombinedOutput(); err != nil { - if !strings.Contains(err.Error(), "exit status 9") { - return fmt.Errorf("error creating group: %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 -} diff --git a/internal/buildinfo.go b/common/buildinfo.go similarity index 72% rename from internal/buildinfo.go rename to common/buildinfo.go index cd7b8a0..efa0459 100644 --- a/internal/buildinfo.go +++ b/common/buildinfo.go @@ -1,4 +1,4 @@ -package internal +package common var ( Version = "dev" diff --git a/common/config.go b/common/config.go new file mode 100644 index 0000000..625a59c --- /dev/null +++ b/common/config.go @@ -0,0 +1,71 @@ +package common + +// --------------------------------------------------------------------------- +// Default config templates +// --------------------------------------------------------------------------- + +type AppConfig struct { + App App `mapstructure:"app" toml:"app"` + Certificates Certificates `mapstructure:"certificates" toml:"certificates"` + Cloudflare Cloudflare `mapstructure:"cloudflare" toml:"cloudflare"` + Git Git `mapstructure:"git" toml:"git"` +} + +type App struct { + Mode string `mapstructure:"mode" toml:"mode"` + TickRate int `mapstructure:"tick_rate" toml:"tick_rate"` + UUID string `mapstructure:"uuid" toml:"uuid"` +} + +type Git struct { + Host string `mapstructure:"host" toml:"host"` + Server string `mapstructure:"server" toml:"server"` + Username string `mapstructure:"username" toml:"username"` + APIToken string `mapstructure:"api_token" toml:"api_token"` + OrgName string `mapstructure:"org_name" toml:"org_name"` +} + +type Certificates struct { + DataRoot string `mapstructure:"data_root" toml:"data_root"` + Email string `mapstructure:"email" toml:"email"` + CADirURL string `mapstructure:"ca_dir_url" toml:"ca_dir_url"` +} + +type Cloudflare struct { + CFEmail string `mapstructure:"cf_email" toml:"cf_email"` + CFAPIKey string `mapstructure:"cf_api_key" toml:"cf_api_key"` +} + +type DomainConfig struct { + Domain DomainCfg `mapstructure:"domain" toml:"domain"` + Certificates DomainCerts `mapstructure:"certificates" toml:"certificates"` + Repo Repo `mapstructure:"repo" toml:"repo"` + Internal Internal `mapstructure:"shared" toml:"shared"` +} + +type DomainCfg struct { + DomainName string `mapstructure:"domain_name" toml:"domain_name"` + Enabled bool `mapstructure:"enabled" toml:"enabled"` + DNSServer string `mapstructure:"dns_server" toml:"dns_server"` +} + +type DomainCerts struct { + DataRoot string `mapstructure:"data_root" toml:"data_root"` + RequestMethod string `mapstructure:"request_method" toml:"request_method"` + CryptoKey string `mapstructure:"crypto_key" toml:"crypto_key"` + Expiry int `mapstructure:"expiry" toml:"expiry"` + RenewPeriod int `mapstructure:"renew_period" toml:"renew_period"` + SubDomains []string `mapstructure:"sub_domains" toml:"sub_domains"` + CertSymlinks []string `mapstructure:"cert_symlinks" toml:"cert_symlinks"` + KeySymlinks []string `mapstructure:"key_symlinks" toml:"key_symlinks"` +} + +type Repo struct { + RepoSuffix string `mapstructure:"repo_suffix" toml:"repo_suffix"` +} + +type Internal struct { + LastIssued int64 `mapstructure:"last_issued" toml:"last_issued"` + RepoExists bool `mapstructure:"repo_exists" toml:"repo_exists"` + Status string `mapstructure:"status" toml:"status"` +} diff --git a/internal/crypto.go b/common/crypto.go similarity index 74% rename from internal/crypto.go rename to common/crypto.go index 2f3965a..7014602 100644 --- a/internal/crypto.go +++ b/common/crypto.go @@ -1,4 +1,4 @@ -package internal +package common import ( "crypto/rand" @@ -68,39 +68,6 @@ func EncryptFileXChaCha(keyB64, inPath, outPath string, aad []byte) error { return nil } -func DecryptFileXChaCha(keyB64, inPath, outPath string, aad []byte) error { - key, err := decodeKey(keyB64) - if err != nil { - return err - } - - aead, err := chacha20poly1305.NewX(key) - if err != nil { - return fmt.Errorf("new aead: %w", err) - } - - in, err := os.ReadFile(inPath) - if err != nil { - return fmt.Errorf("read input: %w", err) - } - if len(in) < chacha20poly1305.NonceSizeX { - return errors.New("ciphertext too short") - } - - nonce := in[:chacha20poly1305.NonceSizeX] - ciphertext := in[chacha20poly1305.NonceSizeX:] - - plaintext, err := aead.Open(nil, nonce, ciphertext, aad) - if err != nil { - return fmt.Errorf("decrypt/auth failed: %w", err) - } - - if err := os.WriteFile(outPath, plaintext, 0640); err != nil { - return fmt.Errorf("write output: %w", err) - } - return nil -} - func DecryptFileFromBytes(keyB64 string, inBytes []byte, outPath string, aad []byte) error { key, err := decodeKey(keyB64) if err != nil { diff --git a/internal/git.go b/common/git.go similarity index 64% rename from internal/git.go rename to common/git.go index 5f4a438..280a32e 100644 --- a/internal/git.go +++ b/common/git.go @@ -1,4 +1,4 @@ -package internal +package common import ( "errors" @@ -16,7 +16,6 @@ import ( "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/storage/memory" - "github.com/spf13/viper" ) type CertManMode int @@ -27,6 +26,7 @@ const ( ) type GitWorkspace struct { + Domain string Repo *git.Repository Storage *memory.Storage FS billy.Filesystem @@ -66,8 +66,8 @@ func StrToGitSource(s string) (GitSource, error) { // return github.NewClient(nil).WithAuthToken(config.GetString("Git.api_token")) //} -func CreateGiteaClient() *gitea.Client { - client, err := gitea.NewClient(config.GetString("Git.server"), gitea.SetToken(config.GetString("Git.api_token"))) +func CreateGiteaClient(config *AppConfig) *gitea.Client { + client, err := gitea.NewClient(config.Git.Server, gitea.SetToken(config.Git.APIToken)) if err != nil { fmt.Printf("Error connecting to gitea instance: %v\n", err) return nil @@ -98,14 +98,9 @@ func CreateGiteaClient() *gitea.Client { // return *repo.CloneURL //} -func CreateGiteaRepo(domain string, giteaClient *gitea.Client) string { - domainConfig, exists := domainStore.Get(domain) - if !exists { - fmt.Printf("Domain %s config does not exist\n", domain) - return "" - } +func CreateGiteaRepo(domain string, giteaClient *gitea.Client, config *AppConfig, domainConfig *DomainConfig) string { options := gitea.CreateRepoOption{ - Name: domain + domainConfig.GetString("Repo.repo_suffix"), + Name: domain + domainConfig.Repo.RepoSuffix, Description: "Certificate storage for " + domain, Private: true, IssueLabels: "", @@ -118,7 +113,7 @@ func CreateGiteaRepo(domain string, giteaClient *gitea.Client) string { TrustModel: gitea.TrustModelDefault, } - giteaRepo, _, err := giteaClient.CreateOrgRepo(config.GetString("Git.org_name"), options) + giteaRepo, _, err := giteaClient.CreateOrgRepo(config.Git.OrgName, options) if err != nil { fmt.Printf("Error creating repo: %v\n", err) return "" @@ -152,10 +147,10 @@ func InitRepo(url string, ws *GitWorkspace) error { return nil } -func CloneRepo(url string, ws *GitWorkspace, certmanMode CertManMode) error { +func CloneRepo(url string, ws *GitWorkspace, certmanMode CertManMode, config *AppConfig) error { creds := &http.BasicAuth{ - Username: config.GetString("Git.username"), - Password: config.GetString("Git.api_token"), + Username: config.Git.Username, + Password: config.Git.APIToken, } var err error ws.Repo, err = git.Clone(ws.Storage, ws.FS, &git.CloneOptions{URL: url, Auth: creds}) @@ -172,7 +167,7 @@ func CloneRepo(url string, ws *GitWorkspace, certmanMode CertManMode) error { serverIdFile, err := ws.FS.OpenFile("/SERVER_ID", os.O_RDWR, 0640) if err != nil { if os.IsNotExist(err) { - fmt.Printf("Server ID file not found for %s, adopting domain\n", url) + fmt.Printf("server ID file not found for %s, adopting domain\n", url) return nil } return err @@ -182,30 +177,21 @@ func CloneRepo(url string, ws *GitWorkspace, certmanMode CertManMode) error { return err } serverId := strings.TrimSpace(string(serverIdBytes)) - if serverId != config.GetString("App.uuid") { + if serverId != config.App.UUID { return fmt.Errorf("domain is already managed by server with uuid %s", serverId) } } return nil } -func AddAndPushCerts(domain string, ws *GitWorkspace) error { - domainConfig, exists := domainStore.Get(domain) - if !exists { - fmt.Printf("Domain %s config does not exist\n", domain) - return ErrConfigNotFound +func AddAndPushCerts(domain string, ws *GitWorkspace, config *AppConfig, domainConfig *DomainConfig) error { + var dataRoot string + if domainConfig.Certificates.DataRoot == "" { + dataRoot = config.Certificates.DataRoot + } else { + dataRoot = domainConfig.Certificates.DataRoot } - - certsDir, err := DomainCertsDirWConf(domain, domainConfig) - if err != nil { - if errors.Is(err, ErrConfigNotFound) { - fmt.Printf("Domain %s config not found: %v\n", domain, err) - return err - } - fmt.Printf("Error getting domain %s certs dir: %v\n", domain, err) - } - - certFiles, err := os.ReadDir(certsDir) + certFiles, err := os.ReadDir(dataRoot) if err != nil { fmt.Printf("Error reading from directory: %v\n", err) return err @@ -217,7 +203,7 @@ func AddAndPushCerts(domain string, ws *GitWorkspace) error { fmt.Printf("Error copying file to memfs: %v\n", err) return err } - certFile, err := os.ReadFile(filepath.Join(certsDir, entry.Name())) + certFile, err := os.ReadFile(filepath.Join(dataRoot, entry.Name())) if err != nil { fmt.Printf("Error reading file to memfs: %v\n", err) file.Close() @@ -246,7 +232,7 @@ func AddAndPushCerts(domain string, ws *GitWorkspace) error { fmt.Printf("Error creating file in memfs: %v\n", err) return err } - _, err = file.Write([]byte(config.GetString("App.uuid"))) + _, err = file.Write([]byte(config.App.UUID)) if err != nil { fmt.Printf("Error writing to memfs: %v\n", err) file.Close() @@ -278,7 +264,7 @@ func AddAndPushCerts(domain string, ws *GitWorkspace) error { fmt.Println("Work Tree Status:\n" + status.String()) signature := &object.Signature{ Name: "Cert Manager", - Email: config.GetString("Certificates.email"), + Email: config.Certificates.Email, When: time.Now(), } _, err = ws.WorkTree.Commit("Update "+domain+" @ "+time.Now().Format("Mon Jan _2 2006 15:04:05 MST"), &git.CommitOptions{Author: signature, Committer: signature}) @@ -287,8 +273,8 @@ func AddAndPushCerts(domain string, ws *GitWorkspace) error { return err } creds := &http.BasicAuth{ - Username: config.GetString("Git.username"), - Password: config.GetString("Git.api_token"), + Username: config.Git.Username, + Password: config.Git.APIToken, } err = ws.Repo.Push(&git.PushOptions{ Auth: creds, @@ -303,72 +289,23 @@ func AddAndPushCerts(domain string, ws *GitWorkspace) error { return err } - fmt.Println("Successfully uploaded to " + config.GetString("Git.server") + "/" + config.GetString("Git.org_name") + "/" + domain + domainConfig.GetString("Repo.repo_suffix") + ".git") + fmt.Println("Successfully uploaded to " + config.Git.Server + "/" + config.Git.OrgName + "/" + domain + domainConfig.Repo.RepoSuffix + ".git") return nil } -func WriteCommitHash(hash string, domainConfig *viper.Viper) error { - certsDir, err := DomainCertsDirWOnlyConf(domainConfig) - if err != nil { - if errors.Is(err, ErrConfigNotFound) { - return err - } - return err +func WriteCommitHash(hash string, config *AppConfig, domainConfig *DomainConfig) error { + var dataRoot string + if domainConfig.Certificates.DataRoot == "" { + dataRoot = config.Certificates.DataRoot + } else { + dataRoot = domainConfig.Certificates.DataRoot } - err = os.WriteFile(filepath.Join(certsDir, "hash"), []byte(hash), 0644) + err := os.WriteFile(filepath.Join(dataRoot, "hash"), []byte(hash), 0644) if err != nil { return err } return nil } - -func LocalCommitHash(domain string) (string, error) { - certsDir, err := DomainCertsDir(domain) - if err != nil { - if errors.Is(err, ErrConfigNotFound) { - fmt.Printf("Domain %s config not found: %v\n", domain, err) - return "", err - } - fmt.Printf("Error getting domain %s certs dir: %v\n", domain, err) - } - - data, err := os.ReadFile(filepath.Join(certsDir, "hash")) - if err != nil { - if !os.IsNotExist(err) { - fmt.Printf("Error reading file for domain %s: %v\n", domain, err) - return "", err - } - } - - return strings.TrimSpace(string(data)), nil -} - -func RemoteCommitHash(domain string, gitSource GitSource) (string, error) { - domainConfig, exists := DomainStore().Get(domain) - if !exists { - fmt.Printf("Domain %s config does not exist\n", domain) - return "", ErrConfigNotFound - } - - switch gitSource { - case Gitea: - return getRemoteCommitHashGitea(config.GetString("Git.org_name"), domain+domainConfig.GetString("Repo.repo_suffix"), "master") - default: - fmt.Printf("Unimplemented git source %v\n", gitSource) - return "", errors.New("unimplemented git source") - } -} - -func getRemoteCommitHashGitea(org, repo, branchName string) (string, error) { - giteaClient := CreateGiteaClient() - branch, _, err := giteaClient.GetRepoBranch(org, repo, branchName) - if err != nil { - fmt.Printf("Error getting repo branch: %v\n", err) - return "", err - } - //TODO catch repo not found as ErrRepoNotInit - return branch.Commit.ID, nil -} diff --git a/internal/util.go b/common/util.go similarity index 78% rename from internal/util.go rename to common/util.go index b09e06b..9755675 100644 --- a/internal/util.go +++ b/common/util.go @@ -1,4 +1,4 @@ -package internal +package common import ( "errors" @@ -7,12 +7,12 @@ import ( "os" "os/user" "path/filepath" + "regexp" "strconv" "strings" "syscall" "code.gitea.io/sdk/gitea" - "github.com/spf13/viper" ) var ( @@ -23,7 +23,7 @@ var ( type Domain struct { name *string - config *viper.Viper + config *AppConfig description *string gtClient *gitea.Client } @@ -168,46 +168,6 @@ func DaemonProcess() (*os.Process, error) { return proc, nil } -func createFile(fileName string, filePermission os.FileMode, data []byte) { - fileInfo, err := os.Stat(fileName) - if err != nil { - if os.IsNotExist(err) { - file, err := os.Create(fileName) - if err != nil { - fmt.Println("Error creating configuration file: ", err) - os.Exit(1) - } - - _, err = file.Write(data) - if err != nil { - fmt.Println("Error writing to file: ", err) - os.Exit(1) - } - err = file.Chmod(filePermission) - if err != nil { - fmt.Println("Error changing file permission: ", err) - } - } else { - fmt.Println("Error opening configuration file: ", err) - os.Exit(1) - } - } else { - if fileInfo.Size() == 0 { - file, err := os.Create(fileName) - if err != nil { - fmt.Println("Error creating configuration file: ", err) - os.Exit(1) - } - - _, err = file.Write(data) - if err != nil { - fmt.Println("Error writing to file:", err) - os.Exit(1) - } - } - } -} - func LinkFile(source, target, domain, extension string) error { if target == "" { return ErrBlankCert @@ -234,7 +194,7 @@ func LinkFile(source, target, domain, extension string) error { return nil } -func fileExists(path string) bool { +func FileExists(path string) bool { _, err := os.Stat(path) return err == nil } @@ -262,31 +222,6 @@ func SanitizeDomainKey(s string) string { return r.Replace(s) } -// DomainCertsDir Can return ErrBlankConfigEntry, ErrConfigNotFound, or other errors -func DomainCertsDir(domain string) (string, error) { - domainConfig, exists := domainStore.Get(domain) - if !exists { - return "", ErrConfigNotFound - } - - return DomainCertsDirWConf(domain, domainConfig) -} - -// DomainCertsDirWConf Can return ErrBlankConfigEntry or other errors -func DomainCertsDirWConf(domain string, domainConfig *viper.Viper) (string, error) { - effectiveDataRoot, err := EffectiveString(domainConfig, "Certificates.data_root") - if err != nil { - return "", err - } - - return filepath.Join(effectiveDataRoot, "certificates", domain), nil -} - -func DomainCertsDirWOnlyConf(domainConfig *viper.Viper) (string, error) { - domain := domainConfig.GetString("Domain.domain_name") - return DomainCertsDirWConf(domain, domainConfig) -} - func ChownRecursive(path string, uid, gid int) error { return filepath.WalkDir(path, func(name string, d fs.DirEntry, err error) error { if err != nil { @@ -357,3 +292,30 @@ func MakeCredential(username, groupname string) (*syscall.Credential, error) { return &syscall.Credential{Uid: uid, Gid: gid}, nil } + +func CertsDir(config *AppConfig, domainConfig *DomainConfig) string { + if config == nil { + return "" + } + if domainConfig == nil { + return "" + } + + if domainConfig.Certificates.DataRoot == "" { + if config.Certificates.DataRoot == "" { + workDir, err := os.Getwd() + if err != nil { + return "./" + } + return workDir + } + return config.Certificates.DataRoot + } + return domainConfig.Certificates.DataRoot +} + +var fqdnRegex = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`) + +func IsValidFQDN(domain string) bool { + return len(domain) <= 253 && fqdnRegex.MatchString(domain) +} diff --git a/go.mod b/go.mod index bfbc262..5af133c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.nevets.tech/Keys/CertManager +module git.nevets.tech/Keys/certman go 1.25.0 diff --git a/internal/grpc.go b/internal/grpc.go deleted file mode 100644 index 5bf0569..0000000 --- a/internal/grpc.go +++ /dev/null @@ -1 +0,0 @@ -package internal diff --git a/main.go b/main.go deleted file mode 100644 index a0d499e..0000000 --- a/main.go +++ /dev/null @@ -1,181 +0,0 @@ -package main - -import ( - "fmt" - "os" - "regexp" - - "git.nevets.tech/Keys/CertManager/commands" - "git.nevets.tech/Keys/CertManager/internal" - "github.com/spf13/cobra" -) - -var configFile string - -var fqdnRegex = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`) - -//TODO create logic for gh vs gt repos - -func main() { - rootCmd := &cobra.Command{ - Use: "certman", - Short: "CertMan", - Long: "Certificate Manager", - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, - } - - rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "/etc/certman/certman.conf", "Configuration file") - - rootCmd.AddCommand(basicCmd("version", "Show version", commands.VersionCmd)) - rootCmd.AddCommand(basicCmd("gen-key", "Generates encryption key", commands.NewKeyCmd)) - rootCmd.AddCommand(basicCmd("dev", "Dev Function", commands.DevCmd)) - - var domainCertDir string - newDomainCmd := &cobra.Command{ - Use: "new-domain", - Short: "Create config and directories for new domain", - Args: cobra.ExactArgs(1), - SilenceUsage: true, - SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - dirOverridden := cmd.Flags().Changed("dir") - return commands.NewDomainCmd(args[0], domainCertDir, dirOverridden) - }, - } - newDomainCmd.Flags().StringVar(&domainCertDir, "dir", "/var/local/certman/certificates/", "Alternate directory for certificates") - rootCmd.AddCommand(newDomainCmd) - - var ( - modeFlag string - thinInstallFlag bool - ) - installCmd := &cobra.Command{ - Use: "install", - Short: "Create certman files and directories", - RunE: func(cmd *cobra.Command, args []string) error { - switch modeFlag { - case "server", "client": - return commands.InstallCmd(thinInstallFlag, modeFlag) - default: - return fmt.Errorf("invalid --mode %q (must be server or client)", modeFlag) - } - }, - } - installCmd.Flags().StringVar(&modeFlag, "mode", "client", "CertManager mode [server, client]") - installCmd.Flags().BoolVarP(&thinInstallFlag, "thin", "t", false, "Thin install (skip creating dirs)") - rootCmd.AddCommand(installCmd) - - certCmd := &cobra.Command{ - Use: "cert", - Short: "Certificate management", - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, - } - - var noPush bool - renewCertCmd := &cobra.Command{ - Use: "renew", - Short: "Renews a domains certificate", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - 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]") - 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(&cobra.Command{ - Use: "executor", - Short: "Privileged daemon", - RunE: func(cmd *cobra.Command, args []string) error { - return commands.StartExecutorCmd() - }, - }) - - daemonCmd := &cobra.Command{ - Use: "daemon", - Short: "Daemon management", - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, - } - - daemonCmd.AddCommand(&cobra.Command{ - Use: "start", - Short: "Start the daemon", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return commands.RunDaemonCmd() - }, - }) - - daemonCmd.AddCommand(&cobra.Command{ - Use: "stop", - Short: "Stop the daemon", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return commands.StopDaemonCmd() - }, - }) - - daemonCmd.AddCommand(&cobra.Command{ - Use: "reload", - Short: "Reload daemon configs", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return commands.ReloadDaemonCmd() - }, - }) - - daemonCmd.AddCommand(&cobra.Command{ - Use: "tick", - Short: "Manually triggers daemon tick", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return commands.TickDaemonCmd() - }, - }) - - daemonCmd.AddCommand(&cobra.Command{ - Use: "status", - Short: "Show daemon status", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return commands.DaemonStatusCmd() - }, - }) - - rootCmd.AddCommand(daemonCmd) - - if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func basicCmd(use, short string, commandFunc func(cmd *cobra.Command, args []string)) *cobra.Command { - return &cobra.Command{ - Use: use, - Short: short, - Run: commandFunc, - } -} - -func IsValidFQDN(domain string) bool { - return len(domain) <= 253 && fqdnRegex.MatchString(domain) -} diff --git a/internal/acme_manager.go b/server/acme_manager.go similarity index 89% rename from internal/acme_manager.go rename to server/acme_manager.go index d5c3232..d421961 100644 --- a/internal/acme_manager.go +++ b/server/acme_manager.go @@ -1,4 +1,4 @@ -package internal +package server import ( "crypto" @@ -16,6 +16,7 @@ import ( "sync" "time" + "git.nevets.tech/Keys/certman/common" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/lego" @@ -93,11 +94,11 @@ type StoredCertMeta struct { // - persistent ECDSA P-256 account key // - Let’s Encrypt production by default (from config fallback) // - Cloudflare DNS-01 only -func NewACMEManager() (*ACMEManager, error) { +func NewACMEManager(config *common.AppConfig) (*ACMEManager, error) { // Pull effective (main-only) certificate settings. - email := config.GetString("Certificates.email") - dataRoot := config.GetString("Certificates.data_root") - caDirURL := config.GetString("Certificates.ca_dir_url") + email := config.Certificates.Email + dataRoot := config.Certificates.DataRoot + caDirURL := config.Certificates.CADirURL // Build manager paths mgr := &ACMEManager{ @@ -121,7 +122,7 @@ func NewACMEManager() (*ACMEManager, error) { // Cloudflare provider (DNS-01 only). // lego Cloudflare provider expects env vars (CLOUDFLARE_EMAIL/CLOUDFLARE_API_KEY or tokens). :contentReference[oaicite:2]{index=2} - restoreEnv, err := setCloudflareEnvFromMainConfig() + restoreEnv, err := setCloudflareEnvFromMainConfig(config) if err != nil { return nil, err } @@ -167,8 +168,8 @@ func NewACMEManager() (*ACMEManager, error) { } // ObtainForDomain obtains a new cert for a configured domain and saves it to disk. -func (m *ACMEManager) ObtainForDomain(domainKey string) (*certificate.Resource, error) { - rcfg, err := buildDomainRuntimeConfig(domainKey) +func (m *ACMEManager) ObtainForDomain(domainKey string, config *common.AppConfig, domainConfig *common.DomainConfig) (*certificate.Resource, error) { + rcfg, err := buildDomainRuntimeConfig(config, domainConfig) if err != nil { return nil, err } @@ -225,7 +226,7 @@ func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, e // GetCertPaths returns disk paths for the domain's cert material. func (m *ACMEManager) GetCertPaths(domainKey string) (certPEM, keyPEM string) { - base := SanitizeDomainKey(domainKey) + base := common.SanitizeDomainKey(domainKey) dir := filepath.Join(m.CertsRoot, base) return filepath.Join(dir, base+".crt"), filepath.Join(dir, base+".key") @@ -235,31 +236,28 @@ func (m *ACMEManager) GetCertPaths(domainKey string) (certPEM, keyPEM string) { // Domain runtime config assembly // --------------------------------------------- -func buildDomainRuntimeConfig(domainKey string) (*DomainRuntimeConfig, error) { - domainCfg, exists := domainStore.Get(domainKey) - if !exists { - return nil, fmt.Errorf("domain config not found for %q", domainKey) - } +func buildDomainRuntimeConfig(config *common.AppConfig, domainConfig *common.DomainConfig) (*DomainRuntimeConfig, error) { + domainName := domainConfig.Domain.DomainName - domainName := domainCfg.GetString("Domain.domain_name") - - email := config.GetString("Certificates.email") + email := config.Certificates.Email // domain override data_root can be blank -> main fallback - dataRoot, err := EffectiveString(domainCfg, "Certificates.data_root") - if err != nil { - return nil, err + var dataRoot string + if domainConfig.Certificates.DataRoot == "" { + dataRoot = config.Certificates.DataRoot + } else { + dataRoot = domainConfig.Certificates.DataRoot } - caDirURL := config.GetString("Certificates.ca_dir_url") + caDirURL := config.Certificates.CADirURL - expiry := domainCfg.GetInt("Certificates.expiry") + expiry := domainConfig.Certificates.Expiry - renewPeriod := domainCfg.GetInt("Certificates.renew_period") + renewPeriod := domainConfig.Certificates.RenewPeriod - requestMethod := domainCfg.GetString("Certificates.request_method") + requestMethod := domainConfig.Certificates.RequestMethod - subdomainArray := domainCfg.GetStringSlice("Certificates.subdomains") + subdomainArray := domainConfig.Certificates.SubDomains return &DomainRuntimeConfig{ DomainName: domainName, @@ -273,21 +271,6 @@ func buildDomainRuntimeConfig(domainKey string) (*DomainRuntimeConfig, error) { }, nil } -func parseCSVLines(raw string) []string { - // supports comma-separated and newline-separated lists - fields := strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == '\n' || r == '\r' - }) - out := make([]string, 0, len(fields)) - for _, f := range fields { - s := strings.TrimSpace(f) - if s != "" { - out = append(out, s) - } - } - return out -} - // If subdomains contains ["www","api"], returns ["example.com","www.example.com","api.example.com"]. // If a subdomain entry looks like a full FQDN already, it is used as-is. func buildDomainList(baseDomain string, subs []string) []string { @@ -343,11 +326,11 @@ func buildDomainList(baseDomain string, subs []string) []string { // Cloudflare env setup from main config // --------------------------------------------- -func setCloudflareEnvFromMainConfig() (restore func(), err error) { +func setCloudflareEnvFromMainConfig(config *common.AppConfig) (restore func(), err error) { // Your current defaults show legacy email + API key fields. // Prefer API tokens in the future if you add them. Cloudflare provider supports both styles. :contentReference[oaicite:3]{index=3} - cfEmail := config.GetString("Cloudflare.cf_email") - cfAPIKey := config.GetString("Cloudflare.cf_api_key") + cfEmail := config.Cloudflare.CFEmail + cfAPIKey := config.Cloudflare.CFAPIKey // Save prior env values so we can restore them after provider creation. prevEmail, hadEmail := os.LookupEnv("CLOUDFLARE_EMAIL") @@ -387,8 +370,8 @@ func loadOrCreateACMEUser(accountRoot, email string) (*fileUser, error) { accountJSON := filepath.Join(accountRoot, "account.json") accountKey := filepath.Join(accountRoot, "account.key.pem") - jsonExists := fileExists(accountJSON) - keyExists := fileExists(accountKey) + jsonExists := common.FileExists(accountJSON) + keyExists := common.FileExists(accountKey) switch { case jsonExists && keyExists: @@ -523,7 +506,7 @@ func (m *ACMEManager) saveCertFiles(domainKey string, res *certificate.Resource, return errors.New("nil certificate resource") } - base := SanitizeDomainKey(domainKey) + base := common.SanitizeDomainKey(domainKey) dir := filepath.Join(m.CertsRoot, base) if err := os.MkdirAll(dir, 0o700); err != nil { return err @@ -597,7 +580,7 @@ func (m *ACMEManager) saveCertFiles(domainKey string, res *certificate.Resource, } func (m *ACMEManager) loadStoredResource(domainKey string) (*certificate.Resource, error) { - base := SanitizeDomainKey(domainKey) + base := common.SanitizeDomainKey(domainKey) dir := filepath.Join(m.CertsRoot, base) raw, err := os.ReadFile(filepath.Join(dir, base+".json")) if err != nil { @@ -621,7 +604,7 @@ func (m *ACMEManager) loadStoredResource(domainKey string) (*certificate.Resourc } func (m *ACMEManager) loadMeta(domainKey string) (*StoredCertMeta, error) { - base := SanitizeDomainKey(domainKey) + base := common.SanitizeDomainKey(domainKey) dir := filepath.Join(m.CertsRoot, base) raw, err := os.ReadFile(filepath.Join(dir, base+".meta.json")) if err != nil { diff --git a/server/git.go b/server/git.go new file mode 100644 index 0000000..abb4e43 --- /dev/null +++ b/server/git.go @@ -0,0 +1 @@ +package server diff --git a/server/server.go b/server/server.go deleted file mode 100644 index b24b548..0000000 --- a/server/server.go +++ /dev/null @@ -1,167 +0,0 @@ -package server - -import ( - "fmt" - "log" - "path/filepath" - "sync" - "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 ( - tickMu sync.Mutex - mgr *internal.ACMEManager - mgrMu sync.Mutex -) - -func getACMEManager() (*internal.ACMEManager, error) { - mgrMu.Lock() - defer mgrMu.Unlock() - - if mgr == nil { - var err error - mgr, err = internal.NewACMEManager() - if err != nil { - return nil, err - } - } - return mgr, nil -} - -func Init() { - err := internal.LoadDomainConfigs() - if err != nil { - log.Fatalf("Error loading domain configs: %v", err) - } - - Tick() -} - -func Tick() { - tickMu.Lock() - defer tickMu.Unlock() - fmt.Println("Tick!") - - var err error - mgr, err = getACMEManager() - if err != nil { - fmt.Printf("Error getting acme manager: %v\n", err) - return - } - - now := time.Now().UTC() - - localDomainConfigs := internal.DomainStore().Snapshot() - - for domainStr, domainConfig := range localDomainConfigs { - if !domainConfig.GetBool("Domain.enabled") { - continue - } - renewPeriod := domainConfig.GetInt("Certificates.renew_period") - lastIssued := time.Unix(domainConfig.GetInt64("Internal.last_issued"), 0).UTC() - renewalDue := lastIssued.AddDate(0, 0, renewPeriod) - if now.After(renewalDue) { - //TODO extra check if certificate expiry (create cache?) - _, err = mgr.RenewForDomain(domainStr) - if err != nil { - // if no existing cert, obtain instead - _, err = mgr.ObtainForDomain(domainStr) - if err != nil { - fmt.Printf("Error obtaining domain certificates for domain %s: %v\n", domainStr, err) - continue - } - } - - domainConfig.Set("Internal.last_issued", time.Now().UTC().Unix()) - err = internal.WriteDomainConfig(domainConfig) - if err != nil { - fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) - continue - } - - 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 { - fmt.Printf("Error encrypting domain cert for domain %s: %v\n", domainStr, err) - continue - } - 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 { - fmt.Printf("Error encrypting domain key for domain %s: %v\n", domainStr, err) - continue - } - - giteaClient := internal.CreateGiteaClient() - if giteaClient == nil { - fmt.Printf("Error creating gitea client for domain %s: %v\n", domainStr, err) - continue - } - gitWorkspace := &internal.GitWorkspace{ - Storage: memory.NewStorage(), - FS: memfs.New(), - } - - var repoUrl string - if !domainConfig.GetBool("Internal.repo_exists") { - repoUrl = internal.CreateGiteaRepo(domainStr, giteaClient) - if repoUrl == "" { - fmt.Printf("Error creating Gitea repo for domain %s\n", domainStr) - continue - } - domainConfig.Set("Internal.repo_exists", true) - err = internal.WriteDomainConfig(domainConfig) - if err != nil { - fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) - continue - } - - err = internal.InitRepo(repoUrl, gitWorkspace) - if err != nil { - fmt.Printf("Error initializing repo for domain %s: %v\n", domainStr, err) - continue - } - } else { - repoUrl = internal.Config().GetString("Git.server") + "/" + internal.Config().GetString("Git.org_name") + "/" + domainStr + domainConfig.GetString("Repo.repo_suffix") + ".git" - err = internal.CloneRepo(repoUrl, gitWorkspace, internal.Server) - if err != nil { - fmt.Printf("Error cloning repo for domain %s: %v\n", domainStr, err) - continue - } - } - - err = internal.AddAndPushCerts(domainStr, gitWorkspace) - if err != nil { - fmt.Printf("Error pushing certificates for domain %s: %v\n", domainStr, err) - continue - } - fmt.Printf("Successfully pushed certificates for domain %s\n", domainStr) - } - } - err = internal.SaveDomainConfigs() - if err != nil { - fmt.Printf("Error saving domain configs: %v\n", err) - } -} - -func Reload() { - fmt.Println("Reloading configs...") - err := internal.LoadDomainConfigs() - - if err != nil { - fmt.Printf("Error loading domain configs: %v\n", err) - return - } - - mgrMu.Lock() - mgr = nil - mgrMu.Unlock() - - fmt.Println("Successfully reloaded configs") -} - -func Stop() { - fmt.Println("Shutting down server") -} diff --git a/certman-exec.service b/systemd/certman-exec.service similarity index 100% rename from certman-exec.service rename to systemd/certman-exec.service diff --git a/certman.service b/systemd/certman.service similarity index 100% rename from certman.service rename to systemd/certman.service diff --git a/certman.socket b/systemd/certman.socket similarity index 100% rename from certman.socket rename to systemd/certman.socket