diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..613d2ff --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +build: + @go build -o ./certman . \ No newline at end of file diff --git a/build.bat b/build.bat deleted file mode 100644 index 652e9ae..0000000 --- a/build.bat +++ /dev/null @@ -1,5 +0,0 @@ -@echo off -set GOARCH=amd64 -set GOOS=linux - -go build -o ./certman . \ No newline at end of file diff --git a/certs.go b/certs.go index f5d0829..be116b8 100644 --- a/certs.go +++ b/certs.go @@ -6,10 +6,11 @@ import ( "crypto/elliptic" "crypto/rand" "fmt" - "github.com/go-acme/lego/v4/providers/dns/cloudflare" "log" "os" + "github.com/go-acme/lego/v4/providers/dns/cloudflare" + "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/lego" @@ -25,7 +26,7 @@ type User struct { func (u *User) GetEmail() string { return u.Email } -func (u User) GetRegistration() *registration.Resource { +func (u *User) GetRegistration() *registration.Resource { return u.Registration } func (u *User) GetPrivateKey() crypto.PrivateKey { diff --git a/config.go b/config.go index 7e26f2c..cfb8ddd 100644 --- a/config.go +++ b/config.go @@ -4,10 +4,14 @@ import ( "fmt" "os" "strings" + + "git.nevets.tech/Steven/ezconf" ) +var domainConfCache map[string]*ezconf.Configuration + func makeDirs() { - err := os.MkdirAll("/etc/certman", 0775) + err := os.MkdirAll("/etc/certman", 0644) if err != nil { if !os.IsExist(err) { fmt.Println("Unable to create config directory") @@ -15,7 +19,7 @@ func makeDirs() { } } - err = os.Mkdir("/etc/certman/conf", 0775) + err = os.Mkdir("/etc/certman/domains", 0644) if err != nil { if !os.IsExist(err) { fmt.Println("Unable to create config directory") @@ -23,7 +27,7 @@ func makeDirs() { } } - err = os.Mkdir("/var/local/certman", 0660) + err = os.Mkdir("/var/local/certman", 0640) if err != nil { if !os.IsExist(err) { fmt.Printf("Unable to create certman directory: %v\n", err) @@ -34,11 +38,20 @@ func makeDirs() { func createNewDomainConfig(domain string) { data := []byte(strings.ReplaceAll(defaultDomainConfig, "{domain}", domain)) - createFile("/etc/certman/conf/"+domain+".conf", 0755, data) + createFile("/etc/certman/domains/"+domain+".conf", 0755, data) } -func createNewDomainCertsDir(domain string) { - err := os.Mkdir("/var/local/certman/"+domain, 0660) +func createNewDomainCertsDir(domain string, dir string) { + var err error + if dir == "/opt/certs/example.com" { + err = os.Mkdir("/var/local/certman/"+domain, 0640) + } else { + if strings.HasSuffix(dir, "/") { + err = os.MkdirAll(dir+domain, 0640) + } else { + err = os.Mkdir(dir+"/"+domain, 0640) + } + } if err != nil { if os.IsExist(err) { fmt.Println("Directory already exists...") @@ -48,3 +61,22 @@ func createNewDomainCertsDir(domain string) { } } } + +func getDomainConfig(domain string) *ezconf.Configuration { + if domainConfCache == nil { + domainConfCache = make(map[string]*ezconf.Configuration) + domainConf := ezconf.LoadConfiguration("/etc/certman/domains/" + domain + ".conf") + domainConfCache[domain] = domainConf + return domainConf + } + if domainConfCache[domain] == nil { + domainConf := ezconf.LoadConfiguration("/etc/certman/domains/" + domain + ".conf") + domainConfCache[domain] = domainConf + return domainConf + } + return domainConfCache[domain] +} + +func clearDomainConfCache() { + domainConfCache = nil +} diff --git a/crypto.go b/crypto.go index 81774f1..128262d 100644 --- a/crypto.go +++ b/crypto.go @@ -1,17 +1,15 @@ package main import ( - "bufio" "crypto/rand" "encoding/base64" "errors" "fmt" "io" "os" - "strings" _ "filippo.io/age" - "filippo.io/age/armor" + "golang.org/x/crypto/chacha20poly1305" ) //var cert *x509.Certificate @@ -75,86 +73,80 @@ func GenerateKey() (string, error) { return string(out), nil } -// LoadKeyFromFile reads a key file that contains either a raw base64 string or -// "AGE_SYM_KEY=" (handy for dotenv). Whitespace is trimmed. -func LoadKeyFromFile(path string) (string, error) { - b, err := os.ReadFile(path) +func decodeKey(b64 string) ([]byte, error) { + key, err := base64.StdEncoding.DecodeString(b64) // standard padded if err != nil { - return "", err + return nil, err } - s := strings.TrimSpace(string(b)) - if i := strings.Index(s, "AGE_SYM_KEY="); i >= 0 { - s = strings.TrimSpace(strings.TrimPrefix(s, "AGE_SYM_KEY=")) + if len(key) != chacha20poly1305.KeySize { + return nil, fmt.Errorf("bad key length: got %d, want %d", len(key), chacha20poly1305.KeySize) } - if s == "" { - return "", errors.New("empty symmetric key") - } - // Quick sanity check that it’s base64 and ~32 bytes after decode. - if _, err := base64.StdEncoding.DecodeString(s); err != nil { - return "", fmt.Errorf("invalid base64 key: %w", err) - } - return s, nil + return key, nil } -// Encrypt streams plaintext from r to w using a symmetric passphrase. -// If armorOut is true, output is ASCII-armored (BEGIN AGE ENCRYPTED FILE). -func Encrypt(r io.Reader, w io.Writer, passphrase string, armorOut bool) error { - passphrase = strings.TrimSpace(passphrase) - if passphrase == "" { - return errors.New("missing passphrase") - } - - var out io.WriteCloser - var err error - - if armorOut { - aw := armor.NewWriter(w) - defer aw.Close() - //out, err = age.Encrypt(aw, age.NewScryptRecipient(passphrase)) - } else { - //out, err = age.Encrypt(w, age.NewScryptRecipient(passphrase)) - } +func EncryptFileXChaCha(keyB64, inPath, outPath string, aad []byte) error { + key, err := decodeKey(keyB64) if err != nil { return err } - _, copyErr := io.Copy(out, bufio.NewReader(r)) - closeErr := out.Close() - if copyErr != nil { - return copyErr - } - return closeErr -} - -// Decrypt streams ciphertext from r to w using the same symmetric passphrase. -// It auto-detects armored vs binary ciphertext. -func Decrypt(r io.Reader, w io.Writer, passphrase string) error { - passphrase = strings.TrimSpace(passphrase) - if passphrase == "" { - return errors.New("missing passphrase") + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return fmt.Errorf("new aead: %w", err) } - br := bufio.NewReader(r) - peek, _ := br.Peek(32) - //var in io.Reader = br - if strings.HasPrefix(string(peek), "-----BEGIN AGE ENCRYPTED FILE-----") { - // in = armor.NewReader(br) + plaintext, err := os.ReadFile(inPath) + if err != nil { + return fmt.Errorf("read input: %w", err) } - //dr, err := age.Decrypt(in, age.NewScryptIdentity(passphrase)) - //if err != nil { - // return err - //} - //_, err = io.Copy(w, bufio.NewWriter(wrap0600(w))) - //return err + nonce := make([]byte, chacha20poly1305.NonceSizeX) // 24 bytes + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return fmt.Errorf("nonce: %w", err) + } + + ciphertext := aead.Seal(nil, nonce, plaintext, aad) + + // Write: nonce || ciphertext + out := make([]byte, 0, len(nonce)+len(ciphertext)) + out = append(out, nonce...) + out = append(out, ciphertext...) + + if err := os.WriteFile(outPath, out, 0600); err != nil { + return fmt.Errorf("write output: %w", err) + } return nil } -// wrap0600 ensures that when w is an *os.File newly created by caller, -// its perms are 0600. If it’s not an *os.File, it’s returned unchanged. -func wrap0600(w io.Writer) io.Writer { - if f, ok := w.(*os.File); ok { - _ = f.Chmod(0600) +func DecryptFileXChaCha(keyB64, inPath, outPath string, aad []byte) error { + key, err := decodeKey(keyB64) + if err != nil { + return err } - return w + + 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 } diff --git a/example.config.ini b/example.config.conf similarity index 63% rename from example.config.ini rename to example.config.conf index 0f0f407..369a768 100644 --- a/example.config.ini +++ b/example.config.conf @@ -1,14 +1,17 @@ +[App] +mode = {mode} + [Git] host = gitea server = https://gitea.instance.com username = user -api_token = xxxxxxxxxxxxxxxx org_name = org template_name = template +[Certificates] +email = user@example.com +data_root = /var/local/certman +request_method = dns + [Cloudflare] cf_email = email@example.com -cf_api_token = xxxxxxxxxxxxxxxx - -[Certificates] -data_root = /var/local/certman \ No newline at end of file diff --git a/example.domainconfig.conf b/example.domainconfig.conf new file mode 100644 index 0000000..d172160 --- /dev/null +++ b/example.domainconfig.conf @@ -0,0 +1,19 @@ +[Domain] +domain_name = {domain} +; default (use system dns) or IPv4 Address (1.1.1.1) +dns_server = default +; optionally use /path/to/directory +file_location = default + +[Certificates] +subdomains = +expiry = 90 +cert_symlink = +key_symlink = + +[Repo] +repo_suffix = -certificates + +; Don't change setting below here unless you know what you're doing! +[Internal] +last_issued = never \ No newline at end of file diff --git a/example.domainconfig.ini b/example.domainconfig.ini deleted file mode 100644 index 9b8b648..0000000 --- a/example.domainconfig.ini +++ /dev/null @@ -1,6 +0,0 @@ -[Domain] -domain_name = example.com - -[Certificates] -subdomains = -expiry = 90 \ No newline at end of file diff --git a/fullbuild.bat b/fullbuild.bat deleted file mode 100644 index 3ab35dd..0000000 --- a/fullbuild.bat +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -set GOARCH=amd64 -set GOOS=linux -go install -v -a std - -go build -o ./certman . \ No newline at end of file diff --git a/git.go b/git.go index aa40134..552631d 100644 --- a/git.go +++ b/git.go @@ -1,15 +1,16 @@ package main import ( - "code.gitea.io/sdk/gitea" "context" "fmt" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/google/go-github/v55/github" "os" "strings" "time" + + "code.gitea.io/sdk/gitea" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/google/go-github/v55/github" ) func createGithubClient() *github.Client { @@ -49,13 +50,14 @@ func createGithubRepo(domain *Domain, client *github.Client) string { } func createGiteaRepo() string { + domainConfig := getDomainConfig(domain) options := gitea.CreateRepoFromTemplateOption{ Avatar: true, Description: "Certificates storage for " + domain, GitContent: true, GitHooks: true, Labels: true, - Name: domain + "-certificates", + Name: domain + domainConfig.GetAsString("Repo.repo_suffix"), Owner: config.GetAsString("Git.org_name"), Private: true, Topics: true, @@ -85,19 +87,19 @@ func cloneRepo(url string) (*git.Repository, *git.Worktree) { } func addAndPushCerts() { - certs, err := os.ReadDir(config.GetAsString("Certificates.certs_path") + "/certificates") + certFiles, err := os.ReadDir(config.GetAsString("Certificates.certs_path") + "/certificates") if err != nil { fmt.Printf("Error reading from directory: %v\n", err) os.Exit(1) } - for _, cert := range certs { - if strings.HasPrefix(cert.Name(), domain) { - file, err := fs.Create(cert.Name()) + for _, file := range certFiles { + if strings.HasPrefix(file.Name(), domain) { + file, err := fs.Create(file.Name()) if err != nil { - fmt.Printf("Error copying cert to memfs: %v\n", err) + fmt.Printf("Error copying file to memfs: %v\n", err) os.Exit(1) } - certFile, err := os.ReadFile(config.GetAsString("Certificates.certs_path") + "/certificates/" + cert.Name()) + certFile, err := os.ReadFile(config.GetAsString("Certificates.certs_path") + "/certificates/" + file.Name()) //certFile = encryptBytes(certFile) _, err = file.Write(certFile) err = file.Close() @@ -105,9 +107,9 @@ func addAndPushCerts() { fmt.Printf("Error writing to memfs: %v\n", err) os.Exit(1) } - _, err = workTree.Add(cert.Name()) + _, err = workTree.Add(file.Name()) if err != nil { - fmt.Printf("Error adding certificate %v: %v", cert.Name(), err) + fmt.Printf("Error adding file %v: %v", file.Name(), err) os.Exit(1) } } diff --git a/go.mod b/go.mod index 7335523..9c98254 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ require ( github.com/go-git/go-billy/v5 v5.4.1 github.com/go-git/go-git/v5 v5.7.0 github.com/google/go-github/v55 v55.0.0 + github.com/makifdb/pidfile v0.0.0-20231129022650-50ec86392313 + golang.org/x/crypto v0.42.0 ) require ( @@ -34,7 +36,6 @@ require ( github.com/sergi/go-diff v1.3.1 // indirect github.com/skeema/knownhosts v1.1.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.42.0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/sync v0.17.0 // indirect diff --git a/go.sum b/go.sum index f0a19ae..b72df66 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= +c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= code.gitea.io/gitea-vet v0.2.1/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.15.1 h1:WJreC7YYuxbn0UDaPuWIe/mtiNKTvLN8MLkaw71yx/M= code.gitea.io/sdk/gitea v0.15.1/go.mod h1:klY2LVI3s3NChzIk/MzMn7G1FHrfU7qd63iSMVoHRBA= @@ -13,7 +14,9 @@ github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903/go.mod h1:8TI github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= @@ -24,10 +27,13 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0= +github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/go-acme/lego/v4 v4.26.0 h1:521aEQxNstXvPQcFDDPrJiFfixcCQuvAvm35R4GbyYA= github.com/go-acme/lego/v4 v4.26.0/go.mod h1:BQVAWgcyzW4IT9eIKHY/RxYlVhoyKyOMXOkq7jK1eEQ= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -35,6 +41,7 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmS github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE= github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= @@ -43,6 +50,7 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -63,6 +71,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/makifdb/pidfile v0.0.0-20231129022650-50ec86392313 h1:5/CjuZQWnRALu4hkEDRg4fA5lWDSfjlKg+koRDRuotQ= +github.com/makifdb/pidfile v0.0.0-20231129022650-50ec86392313/go.mod h1:nm72+BE0Z1PcotR9soX+NnGyEs1iQ0b1Ot0IhL2Nwwk= github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= @@ -74,6 +84,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -83,6 +94,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -136,6 +148,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/main.go b/main.go index fea7c8c..3d5a3a0 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,19 @@ package main import ( "bufio" - "code.gitea.io/sdk/gitea" + "context" "flag" "fmt" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "code.gitea.io/sdk/gitea" "git.nevets.tech/Steven/ezconf" "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" @@ -12,12 +22,7 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" "github.com/google/go-github/v55/github" - "io" - "os" - "os/exec" - "os/signal" - "strings" - "syscall" + "github.com/makifdb/pidfile" ) var config *ezconf.Configuration @@ -33,57 +38,175 @@ var creds *http.BasicAuth var repo *git.Repository +var ctx context.Context +var cancel context.CancelFunc +var wg sync.WaitGroup + //TODO create logic for gh vs gt repos func main() { + devFlag := flag.Bool("dev", false, "Developer Mode") + configFile := flag.String("config", "/etc/certman/certman.conf", "Configuration file") + newDomainFlag := flag.String("new-domain", "example.com", "Domain to create new configs and directories for") + newDomainDirFlag := flag.String("new-domain-dir", "/opt/certs/example.com", "Directory that certs will be stored in") + installFlag := flag.Bool("install", false, "Install Certman") + modeFlag := flag.String("mode", "client", "CertManager Mode [server, client]") + thinInstallFlag := flag.Bool("t", false, "Thin Install (skip creating dirs)") + + newKeyFlag := flag.Bool("newkey", false, "Generate new encryption key") + + reloadFlag := flag.Bool("reload", false, "Reload configs") + daemonFlag := flag.Bool("d", false, "Daemon Mode") flag.Parse() if *devFlag { - os.Exit(0) } if *newDomainFlag != "example.com" { fmt.Printf("Creating new domain %s\n", *newDomainFlag) createNewDomainConfig(*newDomainFlag) - createNewDomainCertsDir(*newDomainFlag) + createNewDomainCertsDir(*newDomainFlag, *newDomainDirFlag) + fmt.Println("Successfully created domain entry for " + *newDomainFlag + "\nUpdate config file as needed in /etc/certman/domains/" + *newDomainFlag + ".conf") + os.Exit(0) } if *installFlag { - makeDirs() - config = ezconf.NewConfiguration("/etc/certman/certman.conf", defaultConfig) + if !*thinInstallFlag { + makeDirs() + } + config = ezconf.NewConfiguration(*configFile, strings.ReplaceAll(defaultConfig, "{mode}", *modeFlag)) + os.Exit(0) } + + if *newKeyFlag { + key, err := GenerateKey() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + fmt.Printf(key) + os.Exit(0) + } + + if *reloadFlag { + pidBytes, err := os.ReadFile("/var/run/certman.pid") + if err != nil { + fmt.Printf("Error getting PID from /var/run/certman.pid: %v\n", err) + os.Exit(1) + } + pidStr := strings.TrimSpace(string(pidBytes)) + daemonPid, err := strconv.Atoi(pidStr) + if err != nil { + fmt.Printf("Error converting PID string to int (%s): %v\n", pidStr, err) + os.Exit(1) + } + proc, err := os.FindProcess(daemonPid) + if err != nil { + fmt.Printf("Error finding process with PID %d: %v\n", daemonPid, err) + os.Exit(1) + } + + err = proc.Signal(syscall.SIGHUP) + if err != nil { + fmt.Printf("Error sending SIGHUP to PID %d: %v\n", daemonPid, err) + os.Exit(1) + } + os.Exit(0) + } + if *daemonFlag { + err := pidfile.CreateOrUpdatePIDFile("/var/run/certman.pid") + if err != nil { + fmt.Println("Error creating pidfile") + os.Exit(1) + } + + ctx, cancel = context.WithCancel(context.Background()) + // Check if main config exists - if _, err := os.Stat("/etc/certman/certman.conf"); os.IsNotExist(err) { + if _, err := os.Stat(*configFile); os.IsNotExist(err) { fmt.Println("Main config file not found, please run 'certman --install', then properly configure /etc/certman/certman.conf.") os.Exit(1) } else if err != nil { - fmt.Printf("Error opening /etc/certman/certman.conf: %v\n", err) + fmt.Printf("Error opening %s: %v\n", *configFile, err) } + config = ezconf.LoadConfiguration(*configFile) + // Setup SIGINT and SIGTERM listeners sigChannel := make(chan os.Signal, 1) signal.Notify(sigChannel, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigChannel) - // Task loop - go func() { + reloadSigChan := make(chan os.Signal, 1) + signal.Notify(reloadSigChan, syscall.SIGHUP) + defer signal.Stop(reloadSigChan) - }() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + wg.Add(1) + if config.GetAsString("App.mode") == "server" { + fmt.Println("Starting CertManager in server mode...") + // Server Task loop + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + fmt.Println("Shutting down server") + return + case <-reloadSigChan: + { + fmt.Println("Reloading configs...") + } + case <-ticker.C: + { + fmt.Println("Tick!") + } + } + } + }() + } else if config.GetAsString("App.mode") == "client" { + fmt.Println("Starting CertManager in client mode...") + // Client Task loop + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + fmt.Println("Shutting down client") + return + case <-reloadSigChan: + { + fmt.Println("Reloading configs...") + } + case <-ticker.C: + { + fmt.Println("Tick!") + } + } + } + }() + } else { + fmt.Println("Invalid operating mode \"" + config.GetAsString("App.mode") + "\"") + } // Cleanup on stop sig := <-sigChannel fmt.Printf("Program terminated with %v\n", sig) - close() + stop() + wg.Wait() } } -func close() { - +func stop() { + cancel() } func maindis() { @@ -141,7 +264,6 @@ func maindis() { { url := createGiteaRepo() repo, workTree = cloneRepo(url) - fixUpdateSh() cmd = exec.Command("lego", legoNewSiteArgs...) } case "renew": @@ -161,7 +283,6 @@ func maindis() { { url := createGiteaRepo() repo, workTree = cloneRepo(url) - fixUpdateSh() addAndPushCerts() os.Exit(0) } @@ -202,30 +323,3 @@ func maindis() { } addAndPushCerts() } - -func fixUpdateSh() { - oldUpdateSh, err := fs.Open("update.sh") - if err != nil { - fmt.Printf("Error opening update.sh: %v\n", err) - os.Exit(1) - } - contentBytes, err := io.ReadAll(oldUpdateSh) - if err != nil { - fmt.Printf("Error reading update.sh: %v\n", err) - os.Exit(1) - } - content := string(contentBytes) - strings.ReplaceAll(content, "<>", domain) - updateSh, err := fs.Create("update.sh") - _, err = updateSh.Write([]byte(content)) - err = updateSh.Close() - if err != nil { - fmt.Printf("Error writing update.sh: %v\n", err) - os.Exit(1) - } - _, err = workTree.Add("update.sh") - if err != nil { - fmt.Printf("Error adding update.sh: %v\n", err) - os.Exit(1) - } -} diff --git a/util.go b/util.go index 550e316..e42b8ce 100644 --- a/util.go +++ b/util.go @@ -1,12 +1,13 @@ package main import ( - "code.gitea.io/sdk/gitea" "errors" "fmt" + "os" + + "code.gitea.io/sdk/gitea" "git.nevets.tech/Steven/ezconf" "github.com/google/go-github/v55/github" - "os" ) type Domain struct { @@ -99,7 +100,10 @@ func insert(a []string, index int, value string) []string { return a } -const defaultConfig = `[Git] +const defaultConfig = `[App] +mode = {mode} + +[Git] host = gitea server = https://gitea.instance.com username = user @@ -120,12 +124,20 @@ const defaultDomainConfig = `[Domain] domain_name = {domain} ; default (use system dns) or IPv4 Address (1.1.1.1) dns_server = default +; optionally use /path/to/directory +file_location = default [Certificates] subdomains = expiry = 90 +cert_symlink = +key_symlink = + +[Repo] +repo_suffix = -certificates ; Don't change setting below here unless you know what you're doing! [Internal] last_issued = never + `