diff --git a/Makefile b/Makefile index 5a84d12..48e729f 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,13 @@ -VERSION := 1.1.6-beta +VERSION := 1.1.7-beta BUILD := $(shell git rev-parse --short HEAD) +UPDATE_SRC := "https://git.nevets.tech/api/v1/repos/Steven/certman/releases" GO := go BUILD_FLAGS := -buildmode=pie -trimpath -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) +LDFLAGS := -linkmode=external -extldflags="-Wl,-z,relro,-z,now" -X git.nevets.tech/Steven/certman/common.Version=$(VERSION) -X git.nevets.tech/Steven/certman/common.Build=$(BUILD) -X git.nevets.tech/Steven/certman/app.SourceCertmanRepo=$(UPDATE_SRC) -.PHONY: proto bundle client server executor build debug stage help +.PHONY: proto bundle client server executor cryptr build debug stage help proto: @protoc --go_out=./proto --go-grpc_out=./proto proto/hook.proto @@ -28,7 +29,11 @@ executor: proto @echo "Building Certman Executor" $(GO) build $(BUILD_FLAGS) -ldflags="-s -w $(LDFLAGS)" -o ./bin/certman-executor-$(VERSION)-amd64 ./app/executor -build: proto bundle client server executor +cryptr: + @echo "Building Cryptr" + $(GO) build $(BUILD_FLAGS) -ldflags="-s -w -linkmode=external -extldflags="-Wl,-z,relro,-z,now"" -o ./bin/cryptr ./app/cryptr + +build: proto bundle client server cryptr @echo "All binaries successfully built" debug: proto diff --git a/app/client/certs.go b/app/client/certs.go index ace4482..4d67c71 100644 --- a/app/client/certs.go +++ b/app/client/certs.go @@ -48,7 +48,7 @@ func init() { func renewCert(domain string) error { config := app.Config() - domainConfig, exists := app.DomainStore().Get(domain) + domainConfig, exists := app.ClientDomainStore().Get(domain) if !exists { return app.ErrConfigNotFound } @@ -62,17 +62,17 @@ func renewCert(domain string) error { if err := client.PullCerts(config, gitWorkspace); err != nil { return err } - certsDir := common.EffectiveDataRoot(config, domainConfig) + certsDir := common.EffectiveDataRoot(config, domainConfig.Certificates.DataRoot) return client.DecryptAndWriteCertificates(certsDir, config, domainConfig, gitWorkspace) } func updateLinks(domain string) error { - domainConfig, exists := app.DomainStore().Get(domain) + domainConfig, exists := app.ClientDomainStore().Get(domain) if !exists { return fmt.Errorf("domain %s does not exist", domain) } - effectiveDataRoot := common.EffectiveDataRoot(app.Config(), domainConfig) + effectiveDataRoot := common.EffectiveDataRoot(app.Config(), domainConfig.Certificates.DataRoot) certsDir := filepath.Join(effectiveDataRoot, "certificates", domain) certLinks := domainConfig.Certificates.CertSymlinks diff --git a/app/client/daemon.go b/app/client/daemon.go index 086926f..2d6728c 100644 --- a/app/client/daemon.go +++ b/app/client/daemon.go @@ -18,7 +18,7 @@ type Daemon struct{} func (d *Daemon) Init() { fmt.Println("Starting CertManager in client mode...") - err := app.LoadDomainConfigs() + err := app.LoadClientDomainConfigs() if err != nil { log.Fatalf("Error loading domain configs: %v", err) } @@ -31,7 +31,7 @@ func (d *Daemon) Tick() { // Get local copy of configs config := app.Config() - localDomainConfigs := app.DomainStore().Snapshot() + localDomainConfigs := app.ClientDomainStore().Snapshot() // Loop over all domain configs (domains) for domainStr, domainConfig := range localDomainConfigs { @@ -40,32 +40,6 @@ func (d *Daemon) Tick() { 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.Internal.RepoExists - if repoExists { - dataRoot := common.EffectiveDataRoot(config, domainConfig) - localHash, err := client.LocalCommitHash(domainStr, dataRoot) - if err != nil { - fmt.Printf("No local commit hash found for domain %s\n", domainStr) - } - gitSource, err := common.StrToGitSource(app.Config().Git.Host) - if err != nil { - fmt.Printf("Error getting git source for domain %s: %v\n", domainStr, err) - continue - } - 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) - } - // If both hashes are blank (errored), break - // If localHash equals remoteHash (local is up-to-date), skip - if !(localHash == "" && remoteHash == "") && localHash == remoteHash { - fmt.Printf("Domain %s is up to date. Skipping...\n", domainStr) - continue - } - } - gitWorkspace := &common.GitWorkspace{ Domain: domainStr, URL: app.Config().Git.Server + "/" + config.Git.OrgName + "/" + domainStr + domainConfig.Repo.RepoSuffix + ".git", @@ -80,7 +54,7 @@ func (d *Daemon) Tick() { continue } - effectiveDataRoot := common.EffectiveDataRoot(config, domainConfig) + effectiveDataRoot := common.EffectiveDataRoot(config, domainConfig.Certificates.DataRoot) certsDir := filepath.Join(effectiveDataRoot, "certificates", domainStr) // Get files in repo @@ -122,7 +96,7 @@ func (d *Daemon) Tick() { continue } - dataRoot := common.EffectiveDataRoot(config, domainConfig) + dataRoot := common.EffectiveDataRoot(config, domainConfig.Certificates.DataRoot) err = client.WriteCommitHash(headRef.Hash().String(), dataRoot) if err != nil { fmt.Printf("Error writing commit hash: %v\n", err) @@ -154,7 +128,7 @@ func (d *Daemon) Tick() { func (d *Daemon) Reload() { fmt.Println("Reloading configs...") - err := app.LoadDomainConfigs() + err := app.LoadClientDomainConfigs() if err != nil { fmt.Printf("Error loading domain configs: %v\n", err) return diff --git a/app/client/grpc.go b/app/client/grpc.go index 4137beb..19c2e5f 100644 --- a/app/client/grpc.go +++ b/app/client/grpc.go @@ -3,36 +3,13 @@ package main import ( "context" "fmt" - "log" "time" - "git.nevets.tech/Steven/certman/app" pb "git.nevets.tech/Steven/certman/proto/v1" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" ) -func SendHook(domain string) { - conn, err := grpc.NewClient( - "unix:///run/certman.sock", - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - if err != nil { - log.Fatalf("fail to dial: %v", err) - } - defer conn.Close() - client := pb.NewHookServiceClient(conn) - - hooks, err := app.PostPullHooks(domain) - if err != nil { - fmt.Printf("Error getting hooks: %v\n", err) - return - } - - for _, hook := range hooks { - sendHook(client, hook) - } -} +// SendHook is currently a no-op pending hook config modeling on ClientDomainConfig. +func SendHook(domain string) {} func sendHook(client pb.HookServiceClient, hook *pb.Hook) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/app/client/main.go b/app/client/main.go index 32686c0..4427aef 100644 --- a/app/client/main.go +++ b/app/client/main.go @@ -20,6 +20,7 @@ func main() { rootCmd.AddCommand(app.VersionCmd) rootCmd.AddCommand(app.NewKeyCmd) + rootCmd.AddCommand(app.UpdateCmd) rootCmd.AddCommand(app.DevCmd) rootCmd.AddCommand(app.NewDomainCmd) diff --git a/app/commands.go b/app/commands.go index 8464c7d..457618d 100644 --- a/app/commands.go +++ b/app/commands.go @@ -1,8 +1,11 @@ package app import ( + "encoding/json" "fmt" + "io" "log" + "net/http" "os" "os/exec" "os/user" @@ -15,7 +18,8 @@ import ( var ( VersionCmd = basicCmd("version", "Show version", versionCmd) - NewKeyCmd = basicCmd("gen-key", "Generates encryption key", newKeyCmd) + NewKeyCmd = basicCmd("gen-key", "Generates encryption key", GenKeyCmd) + UpdateCmd = basicCmd("update", "Updates if new version is found", updateCmd) DevCmd = basicCmd("dev", "Dev Function", devCmd) domainCertDir string @@ -74,7 +78,7 @@ func versionCmd(cmd *cobra.Command, args []string) { ) } -func newKeyCmd(cmd *cobra.Command, args []string) { +func GenKeyCmd(cmd *cobra.Command, args []string) { key, err := common.GenerateKey() if err != nil { log.Fatalf("%v", err) @@ -82,6 +86,41 @@ func newKeyCmd(cmd *cobra.Command, args []string) { fmt.Printf(key) } +func updateCmd(cmd *cobra.Command, args []string) { + if os.Geteuid() != 0 { + log.Fatal(fmt.Errorf("installation must be run as root")) + } + + err := LoadConfig() + if err != nil { + log.Fatalf("%v", err) + } + resp, err := http.Get(SourceCertmanRepo + "/latest") + if err != nil { + log.Fatalf("%v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + + var release GiteaRelease + err = json.Unmarshal(body, &release) + if err != nil { + log.Fatalf("%v", err) + } + + for _, asset := range release.Assets { + if strings.Contains(asset.Name, Config().App.Mode) { + if !strings.Contains(asset.Name, common.Version) { + fmt.Printf("Found a newer version: %s\n", asset.Name) + err = downloadFile("/usr/local/bin/certman", SourceCertmanRepo+"/"+strconv.Itoa(release.Id)+"/assets/"+strconv.Itoa(asset.Id)) + if err != nil { + log.Fatalf("%v", err) + } + } + } + } +} + func newDomainCmd(domain, domainDir string, dirOverridden bool) error { //TODO add config option for "overridden dir" if !common.IsValidFQDN(domain) { @@ -160,15 +199,8 @@ func installCmd(isThin bool, mode string) error { 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)) + //TODO chmod after chown + uid, gid, err := GetUserIds("certman") if err != nil { return err } @@ -187,5 +219,12 @@ func installCmd(isThin bool, mode string) error { } else { CreateConfig(mode) } + + //TODO move executable to /usr/bin + //execPath, err := os.Executable() + //if err != nil { + // return err + //} + return nil } diff --git a/app/config.go b/app/config.go index 9b45569..586e5dc 100644 --- a/app/config.go +++ b/app/config.go @@ -10,7 +10,6 @@ import ( "sync" "git.nevets.tech/Steven/certman/common" - pb "git.nevets.tech/Steven/certman/proto/v1" "github.com/google/uuid" "github.com/pelletier/go-toml/v2" "github.com/spf13/viper" @@ -21,42 +20,46 @@ var ( ErrConfigNotFound = errors.New("config file not found") ) -type DomainConfigStore struct { +// --------------------------------------------------------------------------- +// Generic domain config store +// --------------------------------------------------------------------------- + +type DomainConfigStore[T any] struct { mu sync.RWMutex - configs map[string]*common.DomainConfig + configs map[string]*T } -func NewDomainConfigStore() *DomainConfigStore { - return &DomainConfigStore{ - configs: make(map[string]*common.DomainConfig), +func NewDomainConfigStore[T any]() *DomainConfigStore[T] { + return &DomainConfigStore[T]{ + configs: make(map[string]*T), } } -func (s *DomainConfigStore) Get(domain string) (*common.DomainConfig, bool) { +func (s *DomainConfigStore[T]) Get(domain string) (*T, bool) { s.mu.RLock() defer s.mu.RUnlock() v, ok := s.configs[domain] return v, ok } -func (s *DomainConfigStore) Set(domain string, v *common.DomainConfig) { +func (s *DomainConfigStore[T]) Set(domain string, v *T) { 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]*common.DomainConfig) { +func (s *DomainConfigStore[T]) Swap(newConfigs map[string]*T) { 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]*common.DomainConfig { +func (s *DomainConfigStore[T]) Snapshot() map[string]*T { s.mu.RLock() defer s.mu.RUnlock() - snap := make(map[string]*common.DomainConfig, len(s.configs)) + snap := make(map[string]*T, len(s.configs)) for k, v := range s.configs { snap[k] = v } @@ -68,9 +71,10 @@ func (s *DomainConfigStore) Snapshot() map[string]*common.DomainConfig { // --------------------------------------------------------------------------- var ( - config *common.AppConfig - configMu sync.RWMutex - domainStore = NewDomainConfigStore() + config *common.AppConfig + configMu sync.RWMutex + serverDomainStore = NewDomainConfigStore[common.ServerDomainConfig]() + clientDomainStore = NewDomainConfigStore[common.ClientDomainConfig]() ) func Config() *common.AppConfig { @@ -79,8 +83,12 @@ func Config() *common.AppConfig { return config } -func DomainStore() *DomainConfigStore { - return domainStore +func ServerDomainStore() *DomainConfigStore[common.ServerDomainConfig] { + return serverDomainStore +} + +func ClientDomainStore() *DomainConfigStore[common.ClientDomainConfig] { + return clientDomainStore } // --------------------------------------------------------------------------- @@ -110,15 +118,16 @@ func LoadConfig() error { return nil } -// LoadDomainConfigs reads every .conf file in the domains directory. -func LoadDomainConfigs() error { +// loadDomainConfigs walks /etc/certman/domains and unmarshals every .conf file +// into T, keyed by domain.domain_name. +func loadDomainConfigs[T any]() (map[string]*T, error) { dir := "/etc/certman/domains/" entries, err := os.ReadDir(dir) if err != nil { - return fmt.Errorf("reading domain config dir: %w", err) + return nil, fmt.Errorf("reading domain config dir: %w", err) } - temp := make(map[string]*common.DomainConfig) + out := make(map[string]*T) for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".conf" { @@ -131,26 +140,47 @@ func LoadDomainConfigs() error { v.SetConfigType("toml") if err := v.ReadInConfig(); err != nil { - return fmt.Errorf("loading %s: %w", path, err) + return nil, fmt.Errorf("loading %s: %w", path, err) } domain := v.GetString("domain.domain_name") if domain == "" { - return fmt.Errorf("%s: missing domain.domain_name", path) + return nil, fmt.Errorf("%s: missing domain.domain_name", path) } - if _, exists := temp[domain]; exists { + if _, exists := out[domain]; exists { fmt.Printf("Duplicate domain in %s, skipping...\n", path) continue } - cfg := &common.DomainConfig{} + cfg := new(T) if err = v.Unmarshal(cfg); err != nil { - return fmt.Errorf("unmarshaling %s: %w", path, err) + return nil, fmt.Errorf("unmarshaling %s: %w", path, err) } - temp[domain] = cfg + out[domain] = cfg } - domainStore.Swap(temp) + return out, nil +} + +// LoadServerDomainConfigs reads every .conf file in the domains directory into +// the server-typed store. +func LoadServerDomainConfigs() error { + configs, err := loadDomainConfigs[common.ServerDomainConfig]() + if err != nil { + return err + } + serverDomainStore.Swap(configs) + return nil +} + +// LoadClientDomainConfigs reads every .conf file in the domains directory into +// the client-typed store. +func LoadClientDomainConfigs() error { + configs, err := loadDomainConfigs[common.ClientDomainConfig]() + if err != nil { + return err + } + clientDomainStore.Swap(configs) return nil } @@ -170,40 +200,44 @@ func WriteConfig(filePath string, config *common.AppConfig) error { return nil } -func WriteDomainConfig(config *common.DomainConfig) error { - buf, err := toml.Marshal(config) +func writeDomainConfigFile(domainName string, v any) error { + buf, err := toml.Marshal(v) if err != nil { return fmt.Errorf("marshaling domain config: %w", err) } - configPath := filepath.Join("/etc/certman/domains", config.Domain.DomainName+".conf") + configPath := filepath.Join("/etc/certman/domains", 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 _, v := range domainStore.Snapshot() { - err := WriteDomainConfig(v) - if err != nil { +func WriteServerDomainConfig(config *common.ServerDomainConfig) error { + return writeDomainConfigFile(config.Domain.DomainName, config) +} + +func WriteClientDomainConfig(config *common.ClientDomainConfig) error { + return writeDomainConfigFile(config.Domain.DomainName, config) +} + +// SaveServerDomainConfigs writes every loaded server domain config back to disk. +func SaveServerDomainConfigs() error { + for _, v := range serverDomainStore.Snapshot() { + if err := WriteServerDomainConfig(v); err != nil { return err } } return nil } -// --------------------------------------------------------------------------- -// Domain Specific Lookups -// --------------------------------------------------------------------------- - -func PostPullHooks(domain string) ([]*pb.Hook, error) { - var hooks []*pb.Hook - if err := viper.UnmarshalKey("Hooks.PostPull", hooks); err != nil { - return nil, err +// SaveClientDomainConfigs writes every loaded client domain config back to disk. +func SaveClientDomainConfigs() error { + for _, v := range clientDomainStore.Snapshot() { + if err := WriteClientDomainConfig(v); err != nil { + return err + } } - return hooks, nil + return nil } // --------------------------------------------------------------------------- diff --git a/app/cryptr/main.go b/app/cryptr/main.go new file mode 100644 index 0000000..d3517ff --- /dev/null +++ b/app/cryptr/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "os" + + "git.nevets.tech/Steven/certman/app" + "git.nevets.tech/Steven/certman/common" + "github.com/spf13/cobra" +) + +func main() { + rootCmd := &cobra.Command{ + Use: "cryptr", + Short: "cryptr is a tool for encrypting and decrypting files", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + rootCmd.AddCommand(&cobra.Command{ + Use: "key-gen", + Short: "Generates encryption key", + Run: app.GenKeyCmd, + }) + + var ( + keyUTF8 bool + keyFile string + ) + encryptCmd := &cobra.Command{ + Use: "encrypt", + Short: "encrypt a file", + RunE: func(cmd *cobra.Command, args []string) error { + key, err := getKey(keyFile, keyUTF8) + if err != nil { + return err + } + fmt.Println(key) + + inFilePath := args[0] + outFilePath := args[1] + return common.EncryptFileXChaCha(key, inFilePath, outFilePath, nil) + }, + } + encryptCmd.Flags().BoolVar(&keyUTF8, "utf8", false, "Use UTF-8 encoded key") + encryptCmd.Flags().StringVar(&keyFile, "key", "", "File containing key") + rootCmd.AddCommand(encryptCmd) + + decryptCmd := &cobra.Command{ + Use: "decrypt", + Short: "decrypt a file", + RunE: func(cmd *cobra.Command, args []string) error { + key, err := getKey(keyFile, keyUTF8) + if err != nil { + return err + } + + inFilePath := args[0] + outFilePath := args[1] + inFile, err := os.ReadFile(inFilePath) + if err != nil { + return err + } + return common.DecryptFileFromBytes(key, inFile, outFilePath, nil) + }, + } + decryptCmd.Flags().BoolVar(&keyUTF8, "utf8", false, "Use UTF-8 encoded key") + decryptCmd.Flags().StringVar(&keyFile, "key", "", "File containing key") + rootCmd.AddCommand(decryptCmd) + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func normalizeKey(b []byte) []byte { + out := make([]byte, 32) + copy(out, b) + return out +} + +func getKey(keyFile string, keyUTF8 bool) (string, error) { + var raw []byte + if keyFile != "" { + b, err := os.ReadFile(keyFile) + if err != nil { + return "", err + } + raw = b + } else { + scanner := bufio.NewScanner(os.Stdin) + fmt.Print("Enter Key: ") + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", err + } + } + raw = scanner.Bytes() + } + + var keyBytes []byte + if keyUTF8 { + keyBytes = normalizeKey(bytes.TrimSpace(raw)) + } else { + decoded, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(raw))) + if err != nil { + return "", fmt.Errorf("key is not valid base64: %w", err) + } + keyBytes = normalizeKey(decoded) + } + + return base64.StdEncoding.EncodeToString(keyBytes), nil +} diff --git a/app/server/certs.go b/app/server/certs.go index 69a9a7f..01865c6 100644 --- a/app/server/certs.go +++ b/app/server/certs.go @@ -34,7 +34,7 @@ func renewCertCmd(domain string, noPush bool) error { if err := app.LoadConfig(); err != nil { return err } - if err := app.LoadDomainConfigs(); err != nil { + if err := app.LoadServerDomainConfigs(); err != nil { return err } mgr, err := server.NewACMEManager(app.Config()) @@ -51,22 +51,22 @@ func renewCertCmd(domain string, noPush bool) error { func renewCerts(domain string, noPush bool, mgr *server.ACMEManager) error { config := app.Config() - domainConfig, exists := app.DomainStore().Get(domain) + domainConfig, exists := app.ServerDomainStore().Get(domain) if !exists { return fmt.Errorf("domain %s does not exist", domain) } - _, err := mgr.RenewForDomain(domain) + _, err := mgr.RenewForDomain(domainConfig) if err != nil { // if no existing cert, obtain instead - _, err = mgr.ObtainForDomain(domain, config, domainConfig) + _, err = mgr.ObtainForDomain(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 = app.WriteDomainConfig(domainConfig) + err = app.WriteServerDomainConfig(domainConfig) if err != nil { return fmt.Errorf("error saving domain config %s: %v", domain, err) } @@ -96,7 +96,7 @@ func renewCerts(domain string, noPush bool, mgr *server.ACMEManager) error { return fmt.Errorf("error creating Gitea repo for domain %s", domain) } domainConfig.Internal.RepoExists = true - err = app.WriteDomainConfig(domainConfig) + err = app.WriteServerDomainConfig(domainConfig) if err != nil { return fmt.Errorf("error saving domain config %s: %v", domain, err) } @@ -113,7 +113,7 @@ func renewCerts(domain string, noPush bool, mgr *server.ACMEManager) error { } } - dataRoot := common.EffectiveDataRoot(config, domainConfig) + dataRoot := common.EffectiveDataRoot(config, domainConfig.Certificates.DataRoot) err = server.AddAndPushCerts(gitWorkspace, dataRoot, domainConfig.Repo.RepoSuffix, config) if err != nil { return fmt.Errorf("error pushing certificates for domain %s: %v", domain, err) diff --git a/app/server/daemon.go b/app/server/daemon.go index 34fc1e3..57a6704 100644 --- a/app/server/daemon.go +++ b/app/server/daemon.go @@ -38,7 +38,7 @@ func (d *Daemon) loadACMEManager() error { func (d *Daemon) Init() { fmt.Println("Starting CertManager in server mode...") - err := appShared.LoadDomainConfigs() + err := appShared.LoadServerDomainConfigs() if err != nil { log.Fatalf("Error loading domain configs: %v", err) } @@ -59,7 +59,7 @@ func (d *Daemon) Tick() { now := time.Now().UTC() config := appShared.Config() - localDomainConfigs := appShared.DomainStore().Snapshot() + localDomainConfigs := appShared.ServerDomainStore().Snapshot() for domainStr, domainConfig := range localDomainConfigs { if !domainConfig.Domain.Enabled { @@ -71,11 +71,11 @@ func (d *Daemon) Tick() { renewalDue := lastIssued.AddDate(0, 0, renewPeriod) if now.After(renewalDue) { //TODO extra check if certificate expiry (create cache?) - _, err := d.ACMEManager.RenewForDomain(domainStr) + _, err := d.ACMEManager.RenewForDomain(domainConfig) if err != nil { if errors.Is(err, os.ErrNotExist) { // if no existing cert, obtain instead - _, err = d.ACMEManager.ObtainForDomain(domainStr, appShared.Config(), domainConfig) + _, err = d.ACMEManager.ObtainForDomain(appShared.Config(), domainConfig) if err != nil { fmt.Printf("Error obtaining domain certificates for domain %s: %v\n", domainStr, err) continue @@ -86,7 +86,7 @@ func (d *Daemon) Tick() { } domainConfig.Internal.LastIssued = time.Now().UTC().Unix() - err = appShared.WriteDomainConfig(domainConfig) + err = appShared.WriteServerDomainConfig(domainConfig) if err != nil { fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) continue @@ -120,7 +120,7 @@ func (d *Daemon) Tick() { continue } domainConfig.Internal.RepoExists = true - err = appShared.WriteDomainConfig(domainConfig) + err = appShared.WriteServerDomainConfig(domainConfig) if err != nil { fmt.Printf("Error saving domain config %s: %v\n", domainStr, err) continue @@ -140,7 +140,7 @@ func (d *Daemon) Tick() { } } - dataRoot := common.EffectiveDataRoot(config, domainConfig) + dataRoot := common.EffectiveDataRoot(config, domainConfig.Certificates.DataRoot) err = server.AddAndPushCerts(gitWorkspace, dataRoot, domainConfig.Repo.RepoSuffix, config) if err != nil { fmt.Printf("Error pushing certificates for domain %s: %v\n", domainStr, err) @@ -149,14 +149,14 @@ func (d *Daemon) Tick() { fmt.Printf("Successfully pushed certificates for domain %s\n", domainStr) } } - if err := appShared.SaveDomainConfigs(); err != nil { + if err := appShared.SaveServerDomainConfigs(); err != nil { fmt.Printf("Error saving domain configs: %v\n", err) } } func (d *Daemon) Reload() { fmt.Println("Reloading configs...") - err := appShared.LoadDomainConfigs() + err := appShared.LoadServerDomainConfigs() if err != nil { fmt.Printf("Error loading domain configs: %v\n", err) diff --git a/app/server/main.go b/app/server/main.go index 6e4d8d2..696d061 100644 --- a/app/server/main.go +++ b/app/server/main.go @@ -20,6 +20,7 @@ func main() { rootCmd.AddCommand(app.VersionCmd) rootCmd.AddCommand(app.NewKeyCmd) + rootCmd.AddCommand(app.UpdateCmd) rootCmd.AddCommand(app.DevCmd) rootCmd.AddCommand(app.NewDomainCmd) diff --git a/app/util.go b/app/util.go index d101f41..588fc7e 100644 --- a/app/util.go +++ b/app/util.go @@ -2,11 +2,44 @@ package app import ( "fmt" + "io" + "net/http" "os" + "os/user" + "strconv" + "strings" "github.com/spf13/cobra" ) +var SourceCertmanRepo = "http://127.0.0.1" + +type GiteaRelease struct { + Id int `json:"id"` + Assets []Asset `json:"assets"` +} +type Asset struct { + Id int `json:"id"` + Name string `json:"name"` +} + +func GetUserIds(username string) (int, int, error) { + userEntry, err := user.Lookup(username) + if err != nil { + return -1, -1, fmt.Errorf("error getting user %s: %v", username, err) + } + uid, err := strconv.Atoi(strings.TrimSpace(userEntry.Uid)) + if err != nil { + return -1, -1, err + } + gid, err := strconv.Atoi(strings.TrimSpace(userEntry.Gid)) + if err != nil { + return -1, -1, err + } + + return uid, gid, nil +} + func createFile(fileName string, filePermission os.FileMode, data []byte) { fileInfo, err := os.Stat(fileName) if err != nil { @@ -49,6 +82,45 @@ func createFile(fileName string, filePermission os.FileMode, data []byte) { } } +// downloadFile streams data from a URL and saves it directly to a local file path +func downloadFile(filepath string, url string) error { + // 1. Send the HTTP GET request + resp, err := http.Get(url) + if err != nil { + return err + } + // Always close the response body to prevent resource leaks + defer resp.Body.Close() + + // 2. Check for a valid HTTP 200 OK status code + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + + // 3. Create the local destination file + out, err := os.Create(filepath) + if err != nil { + return err + } + defer out.Close() + uid, gid, err := GetUserIds("certman") + if err != nil { + return err + } + err = out.Chown(uid, gid) + if err != nil { + return err + } + err = out.Chmod(775) + if err != nil { + return err + } + + // 4. Stream the server response body directly into the local file + _, err = io.Copy(out, resp.Body) + return err +} + func basicCmd(use, short string, commandFunc func(cmd *cobra.Command, args []string)) *cobra.Command { return &cobra.Command{ Use: use, diff --git a/client/certificates.go b/client/certificates.go index 96b14b7..b2cfe98 100644 --- a/client/certificates.go +++ b/client/certificates.go @@ -20,7 +20,7 @@ func PullCerts(config *common.AppConfig, gitWorkspace *common.GitWorkspace) erro return nil } -func DecryptAndWriteCertificates(certsDir string, config *common.AppConfig, domainConfig *common.DomainConfig, gitWorkspace *common.GitWorkspace) error { +func DecryptAndWriteCertificates(certsDir string, config *common.AppConfig, domainConfig *common.ClientDomainConfig, gitWorkspace *common.GitWorkspace) error { // Get files in repo fileInfos, err := gitWorkspace.FS.ReadDir("/") if err != nil { @@ -59,7 +59,7 @@ func DecryptAndWriteCertificates(certsDir string, config *common.AppConfig, doma continue } - dataRoot := common.EffectiveDataRoot(config, domainConfig) + dataRoot := common.EffectiveDataRoot(config, domainConfig.Certificates.DataRoot) err = WriteCommitHash(headRef.Hash().String(), dataRoot) if err != nil { fmt.Printf("Error writing commit hash: %v\n", err) diff --git a/client/git.go b/client/git.go index ef39cae..56d34f7 100644 --- a/client/git.go +++ b/client/git.go @@ -39,7 +39,7 @@ func LocalCommitHash(domain string, certsDir string) (string, error) { return strings.TrimSpace(string(data)), nil } -func RemoteCommitHash(domain string, gitSource common.GitSource, config *common.AppConfig, domainConfig *common.DomainConfig) (string, error) { +func RemoteCommitHash(domain string, gitSource common.GitSource, config *common.AppConfig, domainConfig *common.ClientDomainConfig) (string, error) { switch gitSource { case common.Gitea: return getRemoteCommitHashGitea(config.Git.OrgName, domain+domainConfig.Repo.RepoSuffix, "master", config) diff --git a/common/config.go b/common/config.go index 625a59c..2ebb3cf 100644 --- a/common/config.go +++ b/common/config.go @@ -36,30 +36,6 @@ type Cloudflare struct { 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"` } @@ -69,3 +45,51 @@ type Internal struct { RepoExists bool `mapstructure:"repo_exists" toml:"repo_exists"` Status string `mapstructure:"status" toml:"status"` } + +// --------------------------------------------------------------------------- +// Server domain config +// --------------------------------------------------------------------------- + +type ServerDomainConfig struct { + Domain ServerDomainCfg `mapstructure:"domain" toml:"domain"` + Certificates ServerDomainCerts `mapstructure:"certificates" toml:"certificates"` + Repo Repo `mapstructure:"repo" toml:"repo"` + Internal Internal `mapstructure:"internal" toml:"internal"` +} + +type ServerDomainCfg struct { + DomainName string `mapstructure:"domain_name" toml:"domain_name"` + Enabled bool `mapstructure:"enabled" toml:"enabled"` + DNSServer string `mapstructure:"dns_server" toml:"dns_server"` +} + +type ServerDomainCerts struct { + DataRoot string `mapstructure:"data_root" toml:"data_root"` + CryptoKey string `mapstructure:"crypto_key" toml:"crypto_key"` + RequestMethod string `mapstructure:"request_method" toml:"request_method"` + Expiry int `mapstructure:"expiry" toml:"expiry"` + RenewPeriod int `mapstructure:"renew_period" toml:"renew_period"` + SubDomains []string `mapstructure:"sub_domains" toml:"sub_domains"` +} + +// --------------------------------------------------------------------------- +// Client domain config +// --------------------------------------------------------------------------- + +type ClientDomainConfig struct { + Domain ClientDomainCfg `mapstructure:"domain" toml:"domain"` + Certificates ClientDomainCerts `mapstructure:"certificates" toml:"certificates"` + Repo Repo `mapstructure:"repo" toml:"repo"` +} + +type ClientDomainCfg struct { + DomainName string `mapstructure:"domain_name" toml:"domain_name"` + Enabled bool `mapstructure:"enabled" toml:"enabled"` +} + +type ClientDomainCerts struct { + DataRoot string `mapstructure:"data_root" toml:"data_root"` + CryptoKey string `mapstructure:"crypto_key" toml:"crypto_key"` + CertSymlinks []string `mapstructure:"cert_symlinks" toml:"cert_symlinks"` + KeySymlinks []string `mapstructure:"key_symlinks" toml:"key_symlinks"` +} diff --git a/common/git.go b/common/git.go index e2ea943..e5bf7b8 100644 --- a/common/git.go +++ b/common/git.go @@ -96,7 +96,7 @@ func CreateGiteaClient(config *AppConfig) *gitea.Client { // return *repo.CloneURL //} -func CreateGiteaRepo(domain string, giteaClient *gitea.Client, config *AppConfig, domainConfig *DomainConfig) string { +func CreateGiteaRepo(domain string, giteaClient *gitea.Client, config *AppConfig, domainConfig *ServerDomainConfig) string { options := gitea.CreateRepoOption{ Name: domain + domainConfig.Repo.RepoSuffix, Description: "Certificate storage for " + domain, diff --git a/common/util.go b/common/util.go index e0a798f..781f2ff 100644 --- a/common/util.go +++ b/common/util.go @@ -293,15 +293,15 @@ func MakeCredential(username, groupname string) (*syscall.Credential, error) { return &syscall.Credential{Uid: uid, Gid: gid}, nil } -func EffectiveDataRoot(config *AppConfig, domainConfig *DomainConfig) string { +// EffectiveDataRoot resolves the data root for a domain. The per-domain +// override takes precedence; otherwise the app-level data root is used; if +// both are empty, the current working directory is used. +func EffectiveDataRoot(config *AppConfig, domainDataRoot string) string { if config == nil { return "" } - if domainConfig == nil { - return "" - } - if domainConfig.Certificates.DataRoot == "" { + if domainDataRoot == "" { if config.Certificates.DataRoot == "" { workDir, err := os.Getwd() if err != nil { @@ -311,7 +311,7 @@ func EffectiveDataRoot(config *AppConfig, domainConfig *DomainConfig) string { } return config.Certificates.DataRoot } - return domainConfig.Certificates.DataRoot + return domainDataRoot } var fqdnRegex = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`) diff --git a/server/acme_manager.go b/server/acme_manager.go index 755a026..536c59a 100644 --- a/server/acme_manager.go +++ b/server/acme_manager.go @@ -19,6 +19,7 @@ import ( "git.nevets.tech/Steven/certman/common" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/providers/dns/cloudflare" "github.com/go-acme/lego/v4/registration" @@ -58,6 +59,11 @@ type ACMEManager struct { Client *lego.Client User *fileUser + // cached DNS-01 provider so we can re-bind it with per-domain recursive nameservers + cfProvider *cloudflare.DNSProvider + // snapshot of /etc/resolv.conf nameservers taken at manager init + defaultNameservers []string + // root dirs dataRoot string // e.g. /var/local/certman accountRoot string // e.g. /var/local/certman/accounts @@ -143,7 +149,10 @@ func NewACMEManager(config *common.AppConfig) (*ACMEManager, error) { return nil, fmt.Errorf("cloudflare dns provider: %w", err) } - if err = client.Challenge.SetDNS01Provider(cfProvider); err != nil { + mgr.cfProvider = cfProvider + mgr.defaultNameservers = dns01.ParseNameservers(readResolvConfNameservers("/etc/resolv.conf")) + + if err = client.Challenge.SetDNS01Provider(cfProvider, dns01.AddRecursiveNameservers(mgr.defaultNameservers)); err != nil { return nil, fmt.Errorf("set dns-01 provider: %w", err) } @@ -168,7 +177,9 @@ func NewACMEManager(config *common.AppConfig) (*ACMEManager, error) { } // ObtainForDomain obtains a new cert for a configured domain and saves it to disk. -func (m *ACMEManager) ObtainForDomain(domainKey string, config *common.AppConfig, domainConfig *common.DomainConfig) (*certificate.Resource, error) { +func (m *ACMEManager) ObtainForDomain(config *common.AppConfig, domainConfig *common.ServerDomainConfig) (*certificate.Resource, error) { + domainKey := domainConfig.Domain.DomainName + rcfg, err := buildDomainRuntimeConfig(config, domainConfig) if err != nil { return nil, err @@ -187,6 +198,10 @@ func (m *ACMEManager) ObtainForDomain(domainKey string, config *common.AppConfig m.MU.Lock() defer m.MU.Unlock() + if err := m.applyDNS01ForDomain(domainConfig); err != nil { + return nil, fmt.Errorf("apply dns-01 nameservers for %q: %w", domainKey, err) + } + res, err := m.Client.Certificate.Obtain(req) if err != nil { return nil, fmt.Errorf("obtain %q: %w", domainKey, err) @@ -200,7 +215,9 @@ func (m *ACMEManager) ObtainForDomain(domainKey string, config *common.AppConfig } // RenewForDomain renews an existing stored cert for a domain key. -func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, error) { +func (m *ACMEManager) RenewForDomain(domainConfig *common.ServerDomainConfig) (*certificate.Resource, error) { + domainKey := domainConfig.Domain.DomainName + m.MU.Lock() defer m.MU.Unlock() @@ -209,6 +226,10 @@ func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, e return nil, fmt.Errorf("load stored resource for %q: %w", domainKey, err) } + if err := m.applyDNS01ForDomain(domainConfig); err != nil { + return nil, fmt.Errorf("apply dns-01 nameservers for %q: %w", domainKey, err) + } + // RenewWithOptions is preferred in newer lego versions. renewed, err := m.Client.Certificate.RenewWithOptions(*existing, &certificate.RenewOptions{ Bundle: true, @@ -232,17 +253,55 @@ func (m *ACMEManager) GetCertPaths(domainKey string) (certPEM, keyPEM string) { filepath.Join(dir, base+".key") } +// applyDNS01ForDomain re-binds the lego DNS-01 provider with recursive nameservers +// appropriate for this domain. When ServerDomainCfg.DNSServer is empty or "default", the +// resolv.conf snapshot taken at NewACMEManager time is used; otherwise the configured +// server is used. Caller must hold m.MU. +func (m *ACMEManager) applyDNS01ForDomain(domainConfig *common.ServerDomainConfig) error { + ns := m.defaultNameservers + cfgDNS := strings.TrimSpace(domainConfig.Domain.DNSServer) + if cfgDNS != "" && !strings.EqualFold(cfgDNS, "default") { + ns = dns01.ParseNameservers([]string{cfgDNS}) + } + return m.Client.Challenge.SetDNS01Provider(m.cfProvider, dns01.AddRecursiveNameservers(ns)) +} + +// readResolvConfNameservers parses `nameserver` lines from a resolv.conf-style file. +// Returns nil on any error so the caller falls through to lego's own defaults. +func readResolvConfNameservers(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var servers []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + rest, ok := strings.CutPrefix(line, "nameserver") + if !ok { + continue + } + ns := strings.TrimSpace(rest) + if ns != "" { + servers = append(servers, ns) + } + } + return servers +} + // --------------------------------------------- // Domain runtime config assembly // --------------------------------------------- -func buildDomainRuntimeConfig(config *common.AppConfig, domainConfig *common.DomainConfig) (*DomainRuntimeConfig, error) { +func buildDomainRuntimeConfig(config *common.AppConfig, domainConfig *common.ServerDomainConfig) (*DomainRuntimeConfig, error) { domainName := domainConfig.Domain.DomainName email := config.Certificates.Email // domain override data_root can be blank -> main fallback - dataRoot := common.EffectiveDataRoot(config, domainConfig) + dataRoot := common.EffectiveDataRoot(config, domainConfig.Certificates.DataRoot) caDirURL := config.Certificates.CADirURL