Server and Client daemons functional

This commit is contained in:
2026-02-23 00:48:39 +01:00
parent d09c81da5c
commit d737353f2d
12 changed files with 1720 additions and 449 deletions

View File

@@ -1,2 +1,6 @@
build: build:
@go build -o ./certman . @go build -o ./certman .
stage: build
@sudo cp ./certman /srv/vm-passthru/certman
@ssh steven@192.168.122.44 updateCertman.sh

672
acme_manager.go Normal file
View File

@@ -0,0 +1,672 @@
// acme_manager.go
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/registration"
)
// ---------------------------------------------
// Thread safety for your domain config map
// (assumes you already have these globals elsewhere)
// ---------------------------------------------
// var mu sync.RWMutex
// var domainConfigs map[string]*ezconf.Configuration
// var config *ezconf.Configuration
//
// func getDomainConfig(domain string) (*ezconf.Configuration, bool) { ... }
// ---------------------------------------------
// ACME account user (file-backed)
// ---------------------------------------------
type fileUser struct {
Email string `json:"email"`
Registration *registration.Resource `json:"registration,omitempty"`
privateKey crypto.PrivateKey `json:"-"`
}
func (u *fileUser) GetEmail() string { return u.Email }
func (u *fileUser) GetRegistration() *registration.Resource { return u.Registration }
func (u *fileUser) GetPrivateKey() crypto.PrivateKey { return u.privateKey }
// ---------------------------------------------
// Manager config + manager
// ---------------------------------------------
type ACMEManager struct {
mu sync.Mutex // serializes lego client ops + account writes
client *lego.Client
user *fileUser
// root dirs
dataRoot string // e.g. /var/local/certman
accountRoot string // e.g. /var/local/certman/accounts
certsRoot string // e.g. /var/local/certman/certificates
}
// DomainRuntimeConfig has domain-specific runtime settings derived from main+domain config.
type DomainRuntimeConfig struct {
DomainName string
Email string
DataRoot string
CADirURL string
ExpiryDays int
RenewPeriod int
RequestMethod string
Subdomains []string
}
type StoredCertMeta struct {
DomainKey string `json:"domain_key"`
Domains []string `json:"domains"`
CertURL string `json:"cert_url,omitempty"`
CertStableURL string `json:"cert_stable_url,omitempty"`
LastIssued time.Time `json:"last_issued,omitempty"`
LastRenewed time.Time `json:"last_renewed,omitempty"`
}
// ---------------------------------------------
// Public API
// ---------------------------------------------
// NewACMEManager initializes a long-lived lego client using:
// - file-backed account
// - persistent ECDSA P-256 account key
// - Lets Encrypt production by default (from config fallback)
// - Cloudflare DNS-01 only
func NewACMEManager() (*ACMEManager, error) {
// Pull effective (main-only) certificate settings.
email, err := config.GetAsStringErr("Certificates.email")
if err != nil {
return nil, err
}
dataRoot, err := config.GetAsStringErr("Certificates.data_root")
if err != nil {
return nil, err
}
caDirURL, err := config.GetAsStringErr("Certificates.ca_dir_url")
if err != nil {
return nil, err
}
// Build manager paths
mgr := &ACMEManager{
dataRoot: dataRoot,
accountRoot: filepath.Join(dataRoot, "accounts"),
certsRoot: filepath.Join(dataRoot, "certificates"),
}
if err := os.MkdirAll(mgr.accountRoot, 0o700); err != nil {
return nil, fmt.Errorf("create account root: %w", err)
}
if err := os.MkdirAll(mgr.certsRoot, 0o700); err != nil {
return nil, fmt.Errorf("create certs root: %w", err)
}
// Create/load file-backed account user
user, err := loadOrCreateACMEUser(mgr.accountRoot, email)
if err != nil {
return nil, fmt.Errorf("load/create acme user: %w", err)
}
// 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()
if err != nil {
return nil, err
}
defer restoreEnv()
legoCfg := lego.NewConfig(user)
legoCfg.CADirURL = caDirURL
// Secure cert key type for issued certs (ECDSA P-256)
legoCfg.Certificate.KeyType = certcrypto.EC256
client, err := lego.NewClient(legoCfg)
if err != nil {
return nil, fmt.Errorf("lego.NewClient: %w", err)
}
cfProvider, err := cloudflare.NewDNSProvider()
if err != nil {
return nil, fmt.Errorf("cloudflare dns provider: %w", err)
}
if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil {
return nil, fmt.Errorf("set dns-01 provider: %w", err)
}
mgr.client = client
mgr.user = user
// Register account only on first run
if mgr.user.Registration == nil {
reg, err := mgr.client.Registration.Register(registration.RegisterOptions{
TermsOfServiceAgreed: true,
})
if err != nil {
return nil, fmt.Errorf("acme registration: %w", err)
}
mgr.user.Registration = reg
if err := saveACMEUser(mgr.accountRoot, mgr.user); err != nil {
return nil, fmt.Errorf("save acme user registration: %w", err)
}
}
return mgr, nil
}
// 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)
if err != nil {
return nil, err
}
domains := buildDomainList(rcfg.DomainName, rcfg.Subdomains)
if len(domains) == 0 {
return nil, fmt.Errorf("no domains built for %q", domainKey)
}
req := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
m.mu.Lock()
defer m.mu.Unlock()
res, err := m.client.Certificate.Obtain(req)
if err != nil {
return nil, fmt.Errorf("obtain %q: %w", domainKey, err)
}
if err := m.saveCertFiles(domainKey, res, true); err != nil {
return nil, fmt.Errorf("save cert files for %q: %w", domainKey, err)
}
return res, nil
}
// RenewForDomain renews an existing stored cert for a domain key.
func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, error) {
m.mu.Lock()
defer m.mu.Unlock()
existing, err := m.loadStoredResource(domainKey)
if err != nil {
return nil, fmt.Errorf("load stored resource for %q: %w", domainKey, err)
}
// RenewWithOptions is preferred in newer lego versions.
renewed, err := m.client.Certificate.RenewWithOptions(*existing, &certificate.RenewOptions{
Bundle: true,
})
if err != nil {
return nil, fmt.Errorf("renew %q: %w", domainKey, err)
}
if err := m.saveCertFiles(domainKey, renewed, false); err != nil {
return nil, fmt.Errorf("save renewed cert files for %q: %w", domainKey, err)
}
return renewed, nil
}
// GetCertPaths returns disk paths for the domain's cert material.
func (m *ACMEManager) GetCertPaths(domainKey string) (certPEM, keyPEM string) {
base := sanitizeDomainKey(domainKey)
dir := filepath.Join(m.certsRoot, base)
return filepath.Join(dir, base+".crt"),
filepath.Join(dir, base+".key")
}
// ---------------------------------------------
// Domain runtime config assembly
// ---------------------------------------------
func buildDomainRuntimeConfig(domainKey string) (*DomainRuntimeConfig, error) {
domainCfg, exists := getDomainConfig(domainKey)
if !exists {
return nil, fmt.Errorf("domain config not found for %q", domainKey)
}
domainName, err := domainCfg.GetAsStringErr("Domain.domain_name")
if err != nil {
return nil, err
}
email, err := config.GetAsStringErr("Certificates.email")
if err != nil {
return nil, err
}
// domain override data_root can be blank -> main fallback
dataRoot, err := getEffectiveString(domainCfg, "Certificates.data_root")
if err != nil {
return nil, err
}
caDirURL, err := config.GetAsStringErr("Certificates.ca_dir_url")
if err != nil {
return nil, err
}
expiry, err := domainCfg.GetAsIntErr("Certificates.expiry")
if err != nil {
return nil, err
}
renewPeriod, err := domainCfg.GetAsIntErr("Certificates.renew_period")
if err != nil {
return nil, err
}
requestMethod := ""
if k, err := domainCfg.GetKey("Certificates.request_method"); err == nil && k != nil {
requestMethod = strings.TrimSpace(k.String())
}
subdomains := []string{}
if k, err := domainCfg.GetKey("Certificates.subdomains"); err == nil && k != nil {
subdomains = parseCSVLines(k.String())
}
return &DomainRuntimeConfig{
DomainName: domainName,
Email: email,
DataRoot: dataRoot,
CADirURL: caDirURL,
ExpiryDays: expiry,
RenewPeriod: renewPeriod,
RequestMethod: requestMethod,
Subdomains: subdomains,
}, 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 {
seen := map[string]struct{}{}
add := func(d string) {
d = strings.TrimSpace(strings.ToLower(d))
if d == "" {
return
}
if _, ok := seen[d]; ok {
return
}
seen[d] = struct{}{}
}
add(baseDomain)
for _, s := range subs {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if s == "*" {
add("*." + baseDomain)
continue
}
if strings.HasPrefix(s, "*.") && !strings.HasSuffix(s, "."+baseDomain) {
rest := strings.TrimPrefix(s, "*.")
if rest != "" {
add("*." + rest + "." + baseDomain)
continue
}
}
if strings.Contains(s, ".") {
add(s)
continue
}
add(s + "." + baseDomain)
}
out := make([]string, 0, len(seen))
for d := range seen {
out = append(out, d)
}
return out
}
// ---------------------------------------------
// Cloudflare env setup from main config
// ---------------------------------------------
func setCloudflareEnvFromMainConfig() (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, err := config.GetAsStringErr("Cloudflare.cf_email")
if err != nil {
return nil, err
}
cfAPIKey, err := config.GetAsStringErr("Cloudflare.cf_api_key")
if err != nil {
return nil, err
}
// Save prior env values so we can restore them after provider creation.
prevEmail, hadEmail := os.LookupEnv("CLOUDFLARE_EMAIL")
prevKey, hadKey := os.LookupEnv("CLOUDFLARE_API_KEY")
if err := os.Setenv("CLOUDFLARE_EMAIL", cfEmail); err != nil {
return nil, err
}
if err := os.Setenv("CLOUDFLARE_API_KEY", cfAPIKey); err != nil {
return nil, err
}
restore = func() {
if hadEmail {
_ = os.Setenv("CLOUDFLARE_EMAIL", prevEmail)
} else {
_ = os.Unsetenv("CLOUDFLARE_EMAIL")
}
if hadKey {
_ = os.Setenv("CLOUDFLARE_API_KEY", prevKey)
} else {
_ = os.Unsetenv("CLOUDFLARE_API_KEY")
}
}
return restore, nil
}
// ---------------------------------------------
// File-backed ACME account persistence
// ---------------------------------------------
func loadOrCreateACMEUser(accountRoot, email string) (*fileUser, error) {
if err := os.MkdirAll(accountRoot, 0o700); err != nil {
return nil, err
}
accountJSON := filepath.Join(accountRoot, "account.json")
accountKey := filepath.Join(accountRoot, "account.key.pem")
jsonExists := fileExists(accountJSON)
keyExists := fileExists(accountKey)
switch {
case jsonExists && keyExists:
return loadACMEUser(accountRoot)
case !jsonExists && !keyExists:
return createACMEUser(accountRoot, email)
default:
return nil, fmt.Errorf("inconsistent account state (need both %s and %s)", accountJSON, accountKey)
}
}
func createACMEUser(accountRoot, email string) (*fileUser, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate ECDSA P-256 account key: %w", err)
}
u := &fileUser{
Email: email,
privateKey: priv,
}
if err := saveACMEUser(accountRoot, u); err != nil {
return nil, err
}
return u, nil
}
func loadACMEUser(accountRoot string) (*fileUser, error) {
keyPEM, err := os.ReadFile(filepath.Join(accountRoot, "account.key.pem"))
if err != nil {
return nil, err
}
priv, err := parseECPrivateKeyPEM(keyPEM)
if err != nil {
return nil, err
}
raw, err := os.ReadFile(filepath.Join(accountRoot, "account.json"))
if err != nil {
return nil, err
}
var tmp struct {
Email string `json:"email"`
Registration *registration.Resource `json:"registration,omitempty"`
}
if err := json.Unmarshal(raw, &tmp); err != nil {
return nil, err
}
return &fileUser{
Email: tmp.Email,
Registration: tmp.Registration,
privateKey: priv,
}, nil
}
func saveACMEUser(accountRoot string, u *fileUser) error {
if u == nil {
return errors.New("nil user")
}
if err := os.MkdirAll(accountRoot, 0o700); err != nil {
return err
}
keyPEM, err := marshalECPrivateKeyPEM(u.privateKey)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(accountRoot, "account.key.pem"), keyPEM, 0o600); err != nil {
return err
}
tmp := struct {
Email string `json:"email"`
Registration *registration.Resource `json:"registration,omitempty"`
}{
Email: u.Email,
Registration: u.Registration,
}
data, err := json.MarshalIndent(tmp, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(accountRoot, "account.json"), data, 0o600); err != nil {
return err
}
return nil
}
func marshalECPrivateKeyPEM(pk crypto.PrivateKey) ([]byte, error) {
ec, ok := pk.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("expected *ecdsa.PrivateKey, got %T", pk)
}
der, err := x509.MarshalECPrivateKey(ec)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil
}
func parseECPrivateKeyPEM(data []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("invalid PEM")
}
if block.Type != "EC PRIVATE KEY" {
return nil, fmt.Errorf("unexpected PEM type %q", block.Type)
}
return x509.ParseECPrivateKey(block.Bytes)
}
// ---------------------------------------------
// Certificate storage / loading
// ---------------------------------------------
type storedResource struct {
Domain string `json:"domain"`
Domains []string `json:"domains"`
CertURL string `json:"cert_url,omitempty"`
CertStableURL string `json:"cert_stable_url,omitempty"`
Certificate []byte `json:"certificate,omitempty"`
PrivateKey []byte `json:"private_key,omitempty"`
IssuerCert []byte `json:"issuer_certificate,omitempty"`
CSR []byte `json:"csr,omitempty"`
}
func (m *ACMEManager) saveCertFiles(domainKey string, res *certificate.Resource, isNew bool) error {
if res == nil {
return errors.New("nil certificate resource")
}
base := sanitizeDomainKey(domainKey)
dir := filepath.Join(m.certsRoot, base)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if len(res.PrivateKey) > 0 {
if err := os.WriteFile(filepath.Join(dir, base+".key"), res.PrivateKey, 0o600); err != nil {
return err
}
}
if len(res.IssuerCertificate) > 0 {
fullchain := append(append([]byte{}, res.Certificate...), res.IssuerCertificate...)
if err := os.WriteFile(filepath.Join(dir, base+".crt"), fullchain, 0o600); err != nil {
return err
}
} else if len(res.Certificate) > 0 {
// fallback fullchain = cert only
if err := os.WriteFile(filepath.Join(dir, base+".pem"), res.Certificate, 0o600); err != nil {
return err
}
}
sr := storedResource{
Domain: domainKey,
Domains: append([]string(nil), res.Domain),
CertURL: res.CertURL,
CertStableURL: res.CertStableURL,
Certificate: res.Certificate,
PrivateKey: res.PrivateKey,
IssuerCert: res.IssuerCertificate,
CSR: res.CSR,
}
js, err := json.MarshalIndent(sr, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, base+".json"), js, 0o600); err != nil {
return err
}
// metadata
meta := StoredCertMeta{
DomainKey: domainKey,
Domains: append([]string(nil), res.Domain),
CertURL: res.CertURL,
CertStableURL: res.CertStableURL,
}
now := time.Now().UTC()
if old, err := m.loadMeta(domainKey); err == nil && !old.LastIssued.IsZero() {
meta.LastIssued = old.LastIssued
}
if isNew {
if meta.LastIssued.IsZero() {
meta.LastIssued = now
}
} else {
meta.LastRenewed = now
if meta.LastIssued.IsZero() {
meta.LastIssued = now
}
}
metaRaw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, base+".meta.json"), metaRaw, 0o600); err != nil {
return err
}
return nil
}
func (m *ACMEManager) loadStoredResource(domainKey string) (*certificate.Resource, error) {
base := sanitizeDomainKey(domainKey)
dir := filepath.Join(m.certsRoot, base)
raw, err := os.ReadFile(filepath.Join(dir, base+".json"))
if err != nil {
return nil, err
}
var sr storedResource
if err := json.Unmarshal(raw, &sr); err != nil {
return nil, err
}
return &certificate.Resource{
Domain: sr.Domain,
CertURL: sr.CertURL,
CertStableURL: sr.CertStableURL,
Certificate: sr.Certificate,
PrivateKey: sr.PrivateKey,
IssuerCertificate: sr.IssuerCert,
CSR: sr.CSR,
}, nil
}
func (m *ACMEManager) loadMeta(domainKey string) (*StoredCertMeta, error) {
base := sanitizeDomainKey(domainKey)
dir := filepath.Join(m.certsRoot, base)
raw, err := os.ReadFile(filepath.Join(dir, base+".meta.json"))
if err != nil {
return nil, err
}
var meta StoredCertMeta
if err := json.Unmarshal(raw, &meta); err != nil {
return nil, err
}
return &meta, nil
}

View File

@@ -1,97 +0,0 @@
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"fmt"
"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"
"github.com/go-acme/lego/v4/registration"
)
type User struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *User) GetEmail() string {
return u.Email
}
func (u *User) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *User) GetPrivateKey() crypto.PrivateKey {
return u.key
}
func mainexample() {
// Create a user. New accounts need an email and private key to start.
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatal(err)
}
user := User{
Email: config.GetAsString("Certificates.email"),
key: privateKey,
}
configLE := lego.NewConfig(&user)
// This CA URL is configured for a local dev instance of Boulder running in Docker in a VM.
configLE.CADirURL = "http://192.168.99.100:4000/directory"
configLE.Certificate.KeyType = certcrypto.RSA2048
// A client facilitates communication with the CA server.
client, err := lego.NewClient(configLE)
if err != nil {
log.Fatal(err)
}
dnsConfig := cloudflare.NewDefaultConfig()
dnsConfig.AuthEmail = "" //TODO Pull from config
dnsConfig.AuthKey = "" //TODO Pull from config
provider, err := cloudflare.NewDNSProviderConfig(dnsConfig)
if err != nil {
fmt.Printf("Error creating DNS provider: %v\n", err)
os.Exit(1)
}
err = client.Challenge.SetDNS01Provider(provider)
if err != nil {
fmt.Printf("Error setting dns provider: %v\n", err)
os.Exit(1)
}
// New users will need to register
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
log.Fatal(err)
}
user.Registration = reg
request := certificate.ObtainRequest{
Domains: []string{"mydomain.com"},
Bundle: true,
}
certificates, err := client.Certificate.Obtain(request)
if err != nil {
log.Fatal(err)
}
// Each certificate comes back with the cert bytes, the bytes of the client's
// private key, and a certificate URL. SAVE THESE TO DISK.
fmt.Printf("%#v\n", certificates)
// ... all done.
}

103
client.go Normal file
View File

@@ -0,0 +1,103 @@
package main
import (
"fmt"
"io"
"log"
"path/filepath"
"strings"
"git.nevets.tech/Steven/ezconf"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5/storage/memory"
)
func initClient() {
err := loadDomainConfigs()
if err != nil {
log.Fatalf("Error loading domain configs: %v", err)
}
}
func clientTick() {
fmt.Println("Tick!")
mu.RLock()
localDomainConfigs := make(map[string]*ezconf.Configuration, len(domainConfigs))
for k, v := range domainConfigs {
localDomainConfigs[k] = v
}
mu.RUnlock()
for domainStr, domainConfig := range localDomainConfigs {
if !domainConfig.GetAsBoolean("Domain.enabled") {
continue
}
gitWorkspace := &GitWorkspace{
Storage: memory.NewStorage(),
FS: memfs.New(),
}
repoUrl := config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domainStr + domainConfig.GetAsString("Repo.repo_suffix") + ".git"
err := cloneRepo(repoUrl, gitWorkspace)
if err != nil {
fmt.Printf("Error cloning domain repo %s: %v\n", domainStr, err)
continue
}
fileInfos, err := gitWorkspace.FS.ReadDir("/")
if err != nil {
fmt.Printf("Error reading directory in memFS on domain %s: %v\n", domainStr, err)
continue
}
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", domainStr, err)
continue
}
fileBytes, err := io.ReadAll(file)
if err != nil {
fmt.Printf("Error reading file in memFS on domain %s: %v\n", domainStr, err)
file.Close()
continue
}
err = file.Close()
if err != nil {
fmt.Printf("Error closing file on domain %s: %v\n", domainStr, err)
continue
}
dataRoot, err := getEffectiveString(domainConfig, "Certificates.data_root")
if err != nil {
fmt.Printf("Error getting effective data_root for domain %s: %v\n", domainStr, err)
continue
}
err = DecryptFileFromBytes(domainConfig.GetAsString("Certificates.crypto_key"), fileBytes, filepath.Join(dataRoot, "certificates", domainStr, filename), nil)
if err != nil {
fmt.Printf("Error decrypting file %s in domain %s: %v\n", filename, domainStr, err)
continue
}
//TODO write hash locally, compare on tick to determine update
}
}
}
}
func reloadClient() {
fmt.Println("Reloading configs...")
err := loadDomainConfigs()
if err != nil {
fmt.Printf("Error loading domain configs: %v\n", err)
return
}
}
func stopClient() {
fmt.Println("Shutting down client")
}

181
config.go
View File

@@ -1,14 +1,25 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath"
"strings" "strings"
"sync"
"git.nevets.tech/Steven/ezconf" "git.nevets.tech/Steven/ezconf"
"gopkg.in/ini.v1"
) )
var domainConfCache map[string]*ezconf.Configuration var domainConfigs map[string]*ezconf.Configuration
var mu sync.RWMutex
var (
BlankConfigEntry = errors.New("blank config entry")
ConfigNotFound = errors.New("config file not found")
)
func makeDirs() { func makeDirs() {
err := os.MkdirAll("/etc/certman", 0644) err := os.MkdirAll("/etc/certman", 0644)
@@ -37,14 +48,18 @@ func makeDirs() {
} }
func createNewDomainConfig(domain string) { func createNewDomainConfig(domain string) {
data := []byte(strings.ReplaceAll(defaultDomainConfig, "{domain}", domain)) key, err := GenerateKey()
createFile("/etc/certman/domains/"+domain+".conf", 0755, data) if err != nil {
log.Fatalf("Unable to generate key: %v\n", err)
}
data := []byte(strings.ReplaceAll(strings.ReplaceAll(defaultDomainConfig, "{domain}", domain), "{key}", key))
createFile("/etc/certman/domains/"+domain+".conf", 0640, data)
} }
func createNewDomainCertsDir(domain string, dir string) { func createNewDomainCertsDir(domain string, dir string) {
var err error var err error
if dir == "/opt/certs/example.com" { if dir == "/opt/certs/example.com" {
err = os.Mkdir("/var/local/certman/"+domain, 0640) err = os.MkdirAll("/var/local/certman/certificates/"+domain, 0640)
} else { } else {
if strings.HasSuffix(dir, "/") { if strings.HasSuffix(dir, "/") {
err = os.MkdirAll(dir+domain, 0640) err = os.MkdirAll(dir+domain, 0640)
@@ -56,27 +71,155 @@ func createNewDomainCertsDir(domain string, dir string) {
if os.IsExist(err) { if os.IsExist(err) {
fmt.Println("Directory already exists...") fmt.Println("Directory already exists...")
} else { } else {
fmt.Printf("Error creating certificate directory for %s: %v\n", domain, err) log.Fatalf("Error creating certificate directory for %s: %v\n", domain, err)
os.Exit(1)
} }
} }
} }
func getDomainConfig(domain string) *ezconf.Configuration { func loadDomainConfigs() error {
if domainConfCache == nil { tempDomainConfigs := make(map[string]*ezconf.Configuration)
domainConfCache = make(map[string]*ezconf.Configuration) entries, err := os.ReadDir("/etc/certman/domains/")
domainConf := ezconf.LoadConfiguration("/etc/certman/domains/" + domain + ".conf") if err != nil {
domainConfCache[domain] = domainConf return err
return domainConf
} }
if domainConfCache[domain] == nil {
domainConf := ezconf.LoadConfiguration("/etc/certman/domains/" + domain + ".conf") for _, entry := range entries {
domainConfCache[domain] = domainConf if entry.IsDir() || filepath.Ext(entry.Name()) != ".conf" {
return domainConf continue
}
domainConf, err := ezconf.LoadConfiguration("/etc/certman/domains/" + entry.Name())
if err != nil {
return err
}
domain, err := domainConf.GetAsStringErr("Domain.domain_name")
if err != nil {
return err
}
if _, exists := tempDomainConfigs[domain]; exists {
fmt.Printf("Duplicate domain found in %s, skipping...\n", "/etc/certman/domains/"+entry.Name())
continue
}
tempDomainConfigs[domain] = domainConf
} }
return domainConfCache[domain] mu.Lock()
domainConfigs = tempDomainConfigs
mu.Unlock()
return nil
} }
func clearDomainConfCache() { func saveDomainConfigs() {
domainConfCache = nil mu.RLock()
localDomainConfigs := make(map[string]*ezconf.Configuration, len(domainConfigs))
for k, v := range domainConfigs {
localDomainConfigs[k] = v
}
mu.RUnlock()
for domainStr, domainConfig := range localDomainConfigs {
err := domainConfig.Save()
if err != nil {
fmt.Printf("Error saving domain config %s: %v\n", domainStr, err)
continue
}
}
} }
func getDomainConfig(domain string) (*ezconf.Configuration, bool) {
mu.RLock()
defer mu.RUnlock()
funcDomainConfig, exists := domainConfigs[domain]
return funcDomainConfig, exists
}
func getEffectiveKey(domainConfig *ezconf.Configuration, path string) *ini.Key {
key, err := getEffectiveKeyErr(domainConfig, path)
if err != nil {
if errors.Is(err, BlankConfigEntry) {
return &ini.Key{}
}
fmt.Printf("Error getting value for %s: %v\n", path, err)
return nil
}
return key
}
func getEffectiveKeyErr(domainConfig *ezconf.Configuration, path string) (*ini.Key, error) {
if domainConfig != nil {
if key, err := domainConfig.GetKey(path); err == nil && key != nil {
if strings.TrimSpace(key.String()) != "" {
return key, nil
}
}
}
key, err := config.GetKey(path)
if err != nil {
fmt.Printf("Error getting key for %s: %v\n", path, err)
return nil, err
}
if strings.TrimSpace(key.String()) == "" {
return nil, BlankConfigEntry
}
return key, nil
}
func getEffectiveString(domainCfg *ezconf.Configuration, path string) (string, error) {
k, err := getEffectiveKeyErr(domainCfg, path)
if err != nil {
return "", err
}
return strings.TrimSpace(k.String()), nil
}
const defaultConfig = `[App]
mode = {mode}
[Git]
host = gitea
server = https://gitea.instance.com
username = user
api_token = xxxxxxxxxxxxxxxxxxxxxxxxx
org_name = org
[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 defaultDomainConfig = `[Domain]
domain_name = {domain}
enabled = true
; default (use system dns) or IPv4 Address (1.1.1.1)
dns_server = default
[Certificates]
data_root =
expiry = 90
request_method = dns-01
renew_period = 30
subdomains =
cert_symlink =
key_symlink =
crypto_key = {key}
[Repo]
repo_suffix = -certificates
; Don't change setting below here unless you know what you're doing!
[Internal]
last_issued = 0
repo_exists = false
status = clean
`

View File

@@ -12,55 +12,6 @@ import (
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
//var cert *x509.Certificate
//var key *rsa.PrivateKey
//
//func encryptBytes(data []byte) []byte {
// if cert == nil || key == nil {
// loadCerts()
// }
//
// encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, cert.PublicKey.(*rsa.PublicKey), data)
// if err != nil {
// fmt.Println("Error encrypting data,", err)
// os.Exit(1)
// }
// return encrypted
//}
//
//func decryptBytes(data []byte) []byte {
// if cert == nil || key == nil {
// loadCerts()
// }
//
// decrypted, err := rsa.DecryptPKCS1v15(rand.Reader, key, data)
// if err != nil {
// fmt.Println("Error decrypting data,", err)
// os.Exit(1)
// }
// return decrypted
//}
//
//func loadCerts() {
// var err error
// certBytes, err := os.ReadFile(config.GetAsString("Crypto.cert_path"))
// keyBytes, err := os.ReadFile(config.GetAsString("Crypto.key_path"))
// if err != nil {
// fmt.Println("Error reading cert or key,", err)
// os.Exit(1)
// }
//
// cert, err = x509.ParseCertificate(certBytes)
// if err != nil {
// fmt.Println("Error parsing certificate,", err)
// os.Exit(1)
// }
// key, err = x509.ParsePKCS1PrivateKey(keyBytes)
// if err != nil {
// fmt.Println("Error parsing private key,", err)
// }
//}
// GenerateKey returns a base64-encoded 32-byte random key suitable to use as the // GenerateKey returns a base64-encoded 32-byte random key suitable to use as the
// symmetric passphrase for age scrypt mode. Store this securely (never in Git). // symmetric passphrase for age scrypt mode. Store this securely (never in Git).
func GenerateKey() (string, error) { func GenerateKey() (string, error) {
@@ -150,3 +101,32 @@ func DecryptFileXChaCha(keyB64, inPath, outPath string, aad []byte) error {
} }
return nil return nil
} }
func DecryptFileFromBytes(keyB64 string, inBytes []byte, 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)
}
if len(inBytes) < chacha20poly1305.NonceSizeX {
return errors.New("ciphertext too short")
}
nonce := inBytes[:chacha20poly1305.NonceSizeX]
ciphertext := inBytes[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
}

216
git.go
View File

@@ -2,17 +2,30 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
gitconf "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
"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" "github.com/google/go-github/v55/github"
) )
type GitWorkspace struct {
Repo *git.Repository
Storage *memory.Storage
FS billy.Filesystem
WorkTree *git.Worktree
}
func createGithubClient() *github.Client { func createGithubClient() *github.Client {
return github.NewClient(nil).WithAuthToken(config.GetAsString("Git.api_token")) return github.NewClient(nil).WithAuthToken(config.GetAsString("Git.api_token"))
} }
@@ -21,7 +34,7 @@ func createGiteaClient() *gitea.Client {
client, err := gitea.NewClient(config.GetAsString("Git.server"), gitea.SetToken(config.GetAsString("Git.api_token"))) client, err := gitea.NewClient(config.GetAsString("Git.server"), gitea.SetToken(config.GetAsString("Git.api_token")))
if err != nil { if err != nil {
fmt.Printf("Error connecting to gitea instance: %v\n", err) fmt.Printf("Error connecting to gitea instance: %v\n", err)
os.Exit(1) return nil
} }
return client return client
} }
@@ -44,98 +57,205 @@ func createGithubRepo(domain *Domain, client *github.Client) string {
repo, _, err := client.Repositories.CreateFromTemplate(ctx, config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), template) repo, _, err := client.Repositories.CreateFromTemplate(ctx, config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), template)
if err != nil { if err != nil {
fmt.Println("Error creating repository from template,", err) fmt.Println("Error creating repository from template,", err)
os.Exit(1) return ""
} }
return *repo.CloneURL return *repo.CloneURL
} }
func createGiteaRepo() string { func createGiteaRepo(domain string, giteaClient *gitea.Client) string {
domainConfig := getDomainConfig(domain) domainConfig, exists := getDomainConfig(domain)
options := gitea.CreateRepoFromTemplateOption{ if !exists {
Avatar: true, fmt.Printf("Domain %s config does not exist\n", domain)
Description: "Certificates storage for " + domain, return ""
GitContent: true,
GitHooks: true,
Labels: true,
Name: domain + domainConfig.GetAsString("Repo.repo_suffix"),
Owner: config.GetAsString("Git.org_name"),
Private: true,
Topics: true,
Webhooks: true,
} }
giteaRepo, _, err := giteaClient.CreateRepoFromTemplate(config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), options) //options := gitea.CreateRepoFromTemplateOption{
// Avatar: true,
// Description: "Certificates storage for " + domain,
// GitContent: true,
// GitHooks: true,
// Labels: true,
// Name: domain + domainConfig.GetAsString("Repo.repo_suffix"),
// Owner: config.GetAsString("Git.org_name"),
// Private: true,
// Topics: true,
// Webhooks: true,
//}
options := gitea.CreateRepoOption{
Name: domain + domainConfig.GetAsString("Repo.repo_suffix"),
Description: "Certificate storage for " + domain,
Private: true,
IssueLabels: "",
AutoInit: false,
Template: false,
Gitignores: "",
License: "",
Readme: "",
DefaultBranch: "master",
TrustModel: gitea.TrustModelDefault,
}
giteaRepo, _, err := giteaClient.CreateOrgRepo(config.GetAsString("Git.org_name"), options)
//giteaRepo, _, err := giteaClient.CreateRepoFromTemplate(config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), options)
if err != nil { if err != nil {
fmt.Printf("Error creating repo: %v\n", err) fmt.Printf("Error creating repo: %v\n", err)
os.Exit(1) return ""
} }
return giteaRepo.CloneURL return giteaRepo.CloneURL
} }
func cloneRepo(url string) (*git.Repository, *git.Worktree) { func initRepo(url string, ws *GitWorkspace) error {
repository, err := git.Clone(storage, fs, &git.CloneOptions{URL: url, Auth: creds}) var err error
ws.Repo, err = git.Init(ws.Storage, ws.FS)
if err != nil { if err != nil {
fmt.Printf("Error clone git repo: %v\n", err) fmt.Printf("Error initializing local repo: %v\n", err)
os.Exit(1) return err
} }
workingTree, err := repo.Worktree() _, err = ws.Repo.CreateRemote(&gitconf.RemoteConfig{
if err != nil { Name: "origin",
fmt.Printf("Error getting worktree from repo: %v\n", err) URLs: []string{url},
os.Exit(1) })
if err != nil && !errors.Is(err, git.ErrRemoteExists) {
fmt.Printf("Error creating remote origin repo: %v\n", err)
return err
} }
return repository, workingTree
ws.WorkTree, err = ws.Repo.Worktree()
if err != nil {
fmt.Printf("Error getting worktree from local repo: %v\n", err)
return err
}
return nil
} }
func addAndPushCerts() { func cloneRepo(url string, ws *GitWorkspace) error {
certFiles, err := os.ReadDir(config.GetAsString("Certificates.certs_path") + "/certificates") creds := &http.BasicAuth{
Username: config.GetAsString("Git.username"),
Password: config.GetAsString("Git.api_token"),
}
var err error
ws.Repo, err = git.Clone(ws.Storage, ws.FS, &git.CloneOptions{URL: url, Auth: creds})
if err != nil {
fmt.Printf("Error cloning repo: %v\n", err)
}
ws.WorkTree, err = ws.Repo.Worktree()
if err != nil {
fmt.Printf("Error getting worktree from cloned repo: %v\n", err)
return err
}
return nil
}
func addAndPushCerts(domain string, ws *GitWorkspace) error {
domainConfig, exists := getDomainConfig(domain)
if !exists {
fmt.Printf("Domain %s config does not exist\n", domain)
return ConfigNotFound
}
effectiveDataRoot, err := getEffectiveString(domainConfig, "Certificates.data_root")
if err != nil {
fmt.Printf("Error getting effective data root for domain %s: %v\n", domain, err)
return err
}
certFiles, err := os.ReadDir(filepath.Join(effectiveDataRoot, "certificates", domain))
if err != nil { if err != nil {
fmt.Printf("Error reading from directory: %v\n", err) fmt.Printf("Error reading from directory: %v\n", err)
os.Exit(1) return err
} }
for _, file := range certFiles { for _, entry := range certFiles {
if strings.HasPrefix(file.Name(), domain) { if strings.HasSuffix(entry.Name(), ".crpt") {
file, err := fs.Create(file.Name()) file, err := ws.FS.Create(entry.Name())
if err != nil { if err != nil {
fmt.Printf("Error copying file to memfs: %v\n", err) fmt.Printf("Error copying file to memfs: %v\n", err)
os.Exit(1) return err
}
certFile, err := os.ReadFile(filepath.Join(effectiveDataRoot, "certificates", domain, file.Name()))
if err != nil {
fmt.Printf("Error reading file to memfs: %v\n", err)
file.Close()
return err
} }
certFile, err := os.ReadFile(config.GetAsString("Certificates.certs_path") + "/certificates/" + file.Name())
//certFile = encryptBytes(certFile)
_, err = file.Write(certFile) _, err = file.Write(certFile)
err = file.Close()
if err != nil { if err != nil {
fmt.Printf("Error writing to memfs: %v\n", err) fmt.Printf("Error writing to memfs: %v\n", err)
os.Exit(1) file.Close()
return err
} }
_, err = workTree.Add(file.Name()) _, err = ws.WorkTree.Add(file.Name())
if err != nil { if err != nil {
fmt.Printf("Error adding file %v: %v", file.Name(), err) fmt.Printf("Error adding file %v: %v\n", file.Name(), err)
os.Exit(1) file.Close()
return err
}
err = file.Close()
if err != nil {
fmt.Printf("Error closing file: %v\n", err)
} }
} }
} }
status, err := workTree.Status() status, err := ws.WorkTree.Status()
if err != nil { if err != nil {
fmt.Printf("Error getting repo status: %v\n", err) fmt.Printf("Error getting repo status: %v\n", err)
os.Exit(1) return err
} }
if status.IsClean() {
fmt.Printf("Repository is clean, skipping commit...\n")
return nil
}
fmt.Println("Work Tree Status:\n" + status.String()) fmt.Println("Work Tree Status:\n" + status.String())
signature := &object.Signature{ signature := &object.Signature{
Name: "Cert Manager", Name: "Cert Manager",
Email: config.GetAsString("Git.email"), Email: config.GetAsString("Certificates.email"),
When: time.Now(), When: time.Now(),
} }
_, err = workTree.Commit("Update "+domain+" @ "+time.Now().Format("Mon Jan _2 2006 15:04:05 MST"), &git.CommitOptions{Author: signature, Committer: signature}) _, err = ws.WorkTree.Commit("Update "+domain+" @ "+time.Now().Format("Mon Jan _2 2006 15:04:05 MST"), &git.CommitOptions{Author: signature, Committer: signature})
if err != nil { if err != nil {
fmt.Printf("Error committing certs: %v\n", err) fmt.Printf("Error committing certs: %v\n", err)
os.Exit(1) return err
} }
err = repo.Push(&git.PushOptions{Auth: creds, Force: true, RemoteName: "origin"}) creds := &http.BasicAuth{
Username: config.GetAsString("Git.username"),
Password: config.GetAsString("Git.api_token"),
}
err = ws.Repo.Push(&git.PushOptions{
Auth: creds,
Force: true,
RemoteName: "origin",
RefSpecs: []gitconf.RefSpec{
"refs/heads/master:refs/heads/master",
},
})
if err != nil { if err != nil {
fmt.Printf("Error pushing to origin: %v\n", err) fmt.Printf("Error pushing to origin: %v\n", err)
os.Exit(1) return err
} }
fmt.Println("Successfully uploaded to " + config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + "-certificates.git") fmt.Println("Successfully uploaded to " + config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + domainConfig.GetAsString("Repo.repo_suffix") + ".git")
return nil
}
func getLocalCommitHash(domain string) (string, error) {
return "", nil
}
func writeCommitHash(domain string, ws *GitWorkspace) error {
//ref, err := ws.Repo.Head()
//if err != nil {
// fmt.Printf("Error getting HEAD: %v\n", err)
// return err
//}
//hash := ref.Hash()
return nil
}
func getRemoteCommitHash(domain string) (string, error) {
return "", nil
} }

2
go.mod
View File

@@ -7,7 +7,7 @@ toolchain go1.24.7
require ( require (
code.gitea.io/sdk/gitea v0.15.1 code.gitea.io/sdk/gitea v0.15.1
filippo.io/age v1.2.1 filippo.io/age v1.2.1
git.nevets.tech/Steven/ezconf v0.1.1 git.nevets.tech/Steven/ezconf v0.1.4
github.com/go-acme/lego/v4 v4.26.0 github.com/go-acme/lego/v4 v4.26.0
github.com/go-git/go-billy/v5 v5.4.1 github.com/go-git/go-billy/v5 v5.4.1
github.com/go-git/go-git/v5 v5.7.0 github.com/go-git/go-git/v5 v5.7.0

6
go.sum
View File

@@ -7,6 +7,12 @@ filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
git.nevets.tech/Steven/ezconf v0.1.1 h1:dEqV9Q0zVKX9UkPg5UTchGLd0J0WhiuV4dVg0o3blnY= git.nevets.tech/Steven/ezconf v0.1.1 h1:dEqV9Q0zVKX9UkPg5UTchGLd0J0WhiuV4dVg0o3blnY=
git.nevets.tech/Steven/ezconf v0.1.1/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI= git.nevets.tech/Steven/ezconf v0.1.1/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI=
git.nevets.tech/Steven/ezconf v0.1.2 h1:KD47Av0swRPHKLxmDtJwahZd+x0k902CgNqBVQcxf2w=
git.nevets.tech/Steven/ezconf v0.1.2/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI=
git.nevets.tech/Steven/ezconf v0.1.3 h1:l9yG5SwYx/Jg4HzkikOsJ5FTPS9BTLGDBxTPgVOovLI=
git.nevets.tech/Steven/ezconf v0.1.3/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI=
git.nevets.tech/Steven/ezconf v0.1.4 h1:W9AHcnWQfmkc1PAlrRj54u3zPq1BXeX3u37X/+Y746g=
git.nevets.tech/Steven/ezconf v0.1.4/go.mod h1:O8svyJLWVPYdxPeZeiTkfmwz77BM0Wq2ZhDrHtdRhvI=
github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2nVAQTx5S5jQvMeDqWtD1By5mOoyY/xJek= github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2nVAQTx5S5jQvMeDqWtD1By5mOoyY/xJek=

418
main.go
View File

@@ -1,42 +1,25 @@
package main package main
import ( import (
"bufio"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"log"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
"code.gitea.io/sdk/gitea"
"git.nevets.tech/Steven/ezconf" "git.nevets.tech/Steven/ezconf"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"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"
"github.com/makifdb/pidfile"
) )
var version = "1.1.0-beta"
var build = "1"
var config *ezconf.Configuration var config *ezconf.Configuration
var githubClient *github.Client
var giteaClient *gitea.Client
var domain string
var legoBaseArgs []string
var storage *memory.Storage
var fs billy.Filesystem
var workTree *git.Worktree
var creds *http.BasicAuth
var repo *git.Repository
var ctx context.Context var ctx context.Context
var cancel context.CancelFunc var cancel context.CancelFunc
@@ -48,10 +31,14 @@ func main() {
devFlag := flag.Bool("dev", false, "Developer Mode") devFlag := flag.Bool("dev", false, "Developer Mode")
versionFlag := flag.Bool("version", false, "Show version")
helpFlag := flag.Bool("help", false, "Show help")
configFile := flag.String("config", "/etc/certman/certman.conf", "Configuration file") 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") 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") newDomainDirFlag := flag.String("new-domain-dir", "/opt/certs/example.com", "Directory that certs will be stored in")
localOnlyFlag := flag.Bool("local-only", false, "Local only")
installFlag := flag.Bool("install", false, "Install Certman") installFlag := flag.Bool("install", false, "Install Certman")
modeFlag := flag.String("mode", "client", "CertManager Mode [server, client]") modeFlag := flag.String("mode", "client", "CertManager Mode [server, client]")
@@ -60,82 +47,143 @@ func main() {
newKeyFlag := flag.Bool("newkey", false, "Generate new encryption key") newKeyFlag := flag.Bool("newkey", false, "Generate new encryption key")
reloadFlag := flag.Bool("reload", false, "Reload configs") reloadFlag := flag.Bool("reload", false, "Reload configs")
stopFlag := flag.Bool("stop", false, "Stop certman")
daemonFlag := flag.Bool("d", false, "Daemon Mode") daemonFlag := flag.Bool("d", false, "Daemon Mode")
flag.Parse() flag.Parse()
if *devFlag { if *devFlag {
testDomain := "lunamc.org"
var err error
config, err = ezconf.LoadConfiguration("/etc/certman/certman.conf")
if err != nil {
log.Fatalf("Error loading configuration: %v\n", err)
}
err = loadDomainConfigs()
if err != nil {
log.Fatalf("Error loading configs: %v\n", err)
}
fmt.Println(testDomain)
os.Exit(0) os.Exit(0)
} }
if *versionFlag {
fmt.Println("CertManager (certman) - Steven Tracey\nVersion: " + version + " build-" + build)
os.Exit(0)
}
if *helpFlag {
fmt.Printf(`CertManager (certman) - Steven Tracey
Version: %s build-%s
Subcommands: certman -subcommand
- version Shows the current version and build
- help Displays this help message
- newkey Creates a new random 256 bit base64 key
Daemon Controls: certman -command
- d Start in daemon mode
- reload Reload configs
- stop Stop Daemon
Installation: certman -install -mode (mode) [-t] [-config /path/to/file]
- install
- mode [mode] Uses the specified config file [server, client]
- t Thin install (skip creating directories)
- config /path/to/file Create config file at the specified path
New Domain Options: certman -new-domain example.com [-new-domain-dir /path/to/certs]
- new-domain Creates a new domain config
- new-domain-dir Specifies directory for new domain certificates to be stored
- local-only Don't create git repo
`, version, build)
os.Exit(0)
}
if *newDomainFlag != "example.com" { if *newDomainFlag != "example.com" {
fmt.Printf("Creating new domain %s\n", *newDomainFlag) fmt.Printf("Creating new domain %s\n", *newDomainFlag)
createNewDomainConfig(*newDomainFlag) createNewDomainConfig(*newDomainFlag)
createNewDomainCertsDir(*newDomainFlag, *newDomainDirFlag) createNewDomainCertsDir(*newDomainFlag, *newDomainDirFlag)
if !*localOnlyFlag {
//TODO create git repo
}
fmt.Println("Successfully created domain entry for " + *newDomainFlag + "\nUpdate config file as needed in /etc/certman/domains/" + *newDomainFlag + ".conf") fmt.Println("Successfully created domain entry for " + *newDomainFlag + "\nUpdate config file as needed in /etc/certman/domains/" + *newDomainFlag + ".conf")
os.Exit(0) os.Exit(0)
} }
if *installFlag { if *installFlag {
if !*thinInstallFlag { if !*thinInstallFlag {
makeDirs() makeDirs()
} }
config = ezconf.NewConfiguration(*configFile, strings.ReplaceAll(defaultConfig, "{mode}", *modeFlag)) var err error
config, err = ezconf.NewConfiguration(*configFile, strings.ReplaceAll(defaultConfig, "{mode}", *modeFlag))
if err != nil {
log.Fatalf("Error creating config: %s\n", err)
}
os.Exit(0) os.Exit(0)
} }
if *newKeyFlag { if *newKeyFlag {
key, err := GenerateKey() key, err := GenerateKey()
if err != nil { if err != nil {
fmt.Println(err) log.Fatalf("%v", err)
os.Exit(1)
} }
fmt.Printf(key) fmt.Printf(key)
os.Exit(0) os.Exit(0)
} }
if *reloadFlag { if *reloadFlag {
pidBytes, err := os.ReadFile("/var/run/certman.pid") proc, err := getDaemonProcess()
if err != nil { if err != nil {
fmt.Printf("Error getting PID from /var/run/certman.pid: %v\n", err) log.Fatalf("Error getting daemon process: %v", 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) err = proc.Signal(syscall.SIGHUP)
if err != nil { if err != nil {
fmt.Printf("Error sending SIGHUP to PID %d: %v\n", daemonPid, err) log.Fatalf("Error sending SIGHUP to daemon PID: %v\n", err)
os.Exit(1) }
os.Exit(0)
}
if *stopFlag {
proc, err := getDaemonProcess()
if err != nil {
log.Fatalf("Error getting daemon process: %v", err)
}
err = proc.Signal(syscall.SIGTERM)
if err != nil {
log.Fatalf("Error sending SIGTERM to daemon PID: %v\n", err)
} }
os.Exit(0) os.Exit(0)
} }
if *daemonFlag { if *daemonFlag {
err := pidfile.CreateOrUpdatePIDFile("/var/run/certman.pid") err := createOrUpdatePIDFile("/var/run/certman.pid")
if err != nil { if err != nil {
fmt.Println("Error creating pidfile") if errors.Is(err, ErrorPIDInUse) {
os.Exit(1) log.Fatalf("Deemon process is already running\n")
}
log.Fatalf("Error creating pidfile: %v\n", err)
} }
ctx, cancel = context.WithCancel(context.Background()) ctx, cancel = context.WithCancel(context.Background())
// Check if main config exists // Check if main config exists
if _, err := os.Stat(*configFile); 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.") log.Fatalf("Main config file not found, please run 'certman --install', then properly configure /etc/certman/certman.conf.")
os.Exit(1)
} else if err != nil { } else if err != nil {
fmt.Printf("Error opening %s: %v\n", *configFile, err) fmt.Printf("Error opening %s: %v\n", *configFile, err)
} }
config = ezconf.LoadConfiguration(*configFile) config, err = ezconf.LoadConfiguration(*configFile)
if err != nil {
log.Fatalf("Error loading configuration: %v\n", err)
}
// Setup SIGINT and SIGTERM listeners // Setup SIGINT and SIGTERM listeners
sigChannel := make(chan os.Signal, 1) sigChannel := make(chan os.Signal, 1)
@@ -146,7 +194,7 @@ func main() {
signal.Notify(reloadSigChan, syscall.SIGHUP) signal.Notify(reloadSigChan, syscall.SIGHUP)
defer signal.Stop(reloadSigChan) defer signal.Stop(reloadSigChan)
ticker := time.NewTicker(5 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
wg.Add(1) wg.Add(1)
@@ -154,20 +202,17 @@ func main() {
fmt.Println("Starting CertManager in server mode...") fmt.Println("Starting CertManager in server mode...")
// Server Task loop // Server Task loop
go func() { go func() {
initServer()
defer wg.Done() defer wg.Done()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
fmt.Println("Shutting down server") stopServer()
return return
case <-reloadSigChan: case <-reloadSigChan:
{ reloadServer()
fmt.Println("Reloading configs...")
}
case <-ticker.C: case <-ticker.C:
{ serverTick()
fmt.Println("Tick!")
}
} }
} }
}() }()
@@ -175,20 +220,17 @@ func main() {
fmt.Println("Starting CertManager in client mode...") fmt.Println("Starting CertManager in client mode...")
// Client Task loop // Client Task loop
go func() { go func() {
initClient()
defer wg.Done() defer wg.Done()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
fmt.Println("Shutting down client") stopClient()
return return
case <-reloadSigChan: case <-reloadSigChan:
{ reloadClient()
fmt.Println("Reloading configs...")
}
case <-ticker.C: case <-ticker.C:
{ clientTick()
fmt.Println("Tick!")
}
} }
} }
}() }()
@@ -207,119 +249,143 @@ func main() {
func stop() { func stop() {
cancel() cancel()
clearPIDFile()
} }
func maindis() { //var legoBaseArgs []string
config = ezconf.NewConfiguration("/etc/certman/certman.conf", "") //
//func maindis() {
var err error // config, err := ezconf.NewConfiguration("/etc/certman/certman.conf", "")
args := os.Args // var domain string
// if err != nil {
// -d // log.Fatalf("Error loading configuration: %v\n", err)
hasDomain, domainIndex := contains(args, "-d") // }
if hasDomain { //
domain = args[domainIndex+1] // args := os.Args
} else { //
fmt.Printf("Error, no domain passed. Please add '-d domain.tld' to the command\n") // // -d
os.Exit(1) // hasDomain, domainIndex := contains(args, "-d")
} // if hasDomain {
// domain = args[domainIndex+1]
hasDns, dnsIndex := contains(args, "--dns") // } else {
// log.Fatalf("Error, no domain passed. Please add '-d domain.tld' to the command\n")
legoBaseArgs = []string{ // }
"-a", //
"--dns", // hasDns, dnsIndex := contains(args, "--dns")
"cloudflare", //
"--email=" + config.GetAsString("Cloudflare.cf_email"), // legoBaseArgs = []string{
"--domains=" + domain, // "-a",
"--domains=*." + domain, // "--dns",
"--path=" + config.GetAsString("Certificates.certs_path"), // "cloudflare",
} // "--email=" + config.GetAsString("Cloudflare.cf_email"),
legoNewSiteArgs := append(legoBaseArgs, "run") // "--domains=" + domain,
legoRenewSiteArgs := append(legoBaseArgs, "renew", "--days", "90") // "--domains=*." + domain,
// "--path=" + config.GetAsString("Certificates.certs_path"),
subdomains := config.GetAsStrings("Certificates.subdomains") // }
if subdomains != nil { // legoNewSiteArgs := append(legoBaseArgs, "run")
for i, subdomain := range subdomains { // legoRenewSiteArgs := append(legoBaseArgs, "renew", "--days", "90")
legoBaseArgs = insert(legoBaseArgs, 5+i, "--domains=*."+subdomain+"."+domain) //
} // subdomains := config.GetAsStrings("Certificates.subdomains")
} // if subdomains != nil {
// for i, subdomain := range subdomains {
if hasDns { // legoBaseArgs = insert(legoBaseArgs, 5+i, "--domains=*."+subdomain+"."+domain)
legoBaseArgs = insert(legoBaseArgs, 3, "--dns.resolvers="+args[dnsIndex+1]) // }
} // }
//
creds = &http.BasicAuth{ // if hasDns {
Username: config.GetAsString("Git.username"), // legoBaseArgs = insert(legoBaseArgs, 3, "--dns.resolvers="+args[dnsIndex+1])
Password: config.GetAsString("Git.api_token"), // }
} //
giteaClient = createGiteaClient() // giteaClient = createGiteaClient()
// gitWorkspace := &GitWorkspace{
storage = memory.NewStorage() // Storage: memory.NewStorage(),
fs = memfs.New() // FS: memfs.New(),
// }
var cmd *exec.Cmd //
switch args[len(args)-1] { // var cmd *exec.Cmd
case "gen": // switch args[len(args)-1] {
{ // case "gen":
url := createGiteaRepo() // {
repo, workTree = cloneRepo(url) // url := createGiteaRepo(domain)
cmd = exec.Command("lego", legoNewSiteArgs...) // if url == "" {
} // return
case "renew": // }
{ // gitWorkspace.Repo, gitWorkspace.WorkTree = cloneRepo(url, gitWorkspace)
repo, workTree = cloneRepo(config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + "-certificates.git") // if gitWorkspace.Repo == nil {
cmd = exec.Command("lego", legoRenewSiteArgs...) // return
} // }
case "gen-cert-only": // cmd = exec.Command("lego", legoNewSiteArgs...)
{ // }
cmd = exec.Command("lego", legoNewSiteArgs...) // case "renew":
} // {
case "renew-cert-only": // gitWorkspace.Repo, gitWorkspace.WorkTree = cloneRepo(config.GetAsString("Git.server")+"/"+config.GetAsString("Git.org_name")+"/"+domain+"-certificates.git", gitWorkspace)
{ // if gitWorkspace.Repo == nil {
cmd = exec.Command("lego", legoRenewSiteArgs...) // return
} // }
case "git": // cmd = exec.Command("lego", legoRenewSiteArgs...)
{ // }
url := createGiteaRepo() // case "gen-cert-only":
repo, workTree = cloneRepo(url) // {
addAndPushCerts() // cmd = exec.Command("lego", legoNewSiteArgs...)
os.Exit(0) // }
} // case "renew-cert-only":
default: // {
{ // cmd = exec.Command("lego", legoRenewSiteArgs...)
fmt.Println("Missing arguments: conclude command with 'gen' or 'renew'") // }
os.Exit(1) // case "git":
} // {
} // url := createGiteaRepo(domain)
cmd.Env = append(cmd.Environ(), // if url == "" {
"CLOUDFLARE_DNS_API_TOKEN="+config.GetAsString("Cloudflare.cf_api_token"), // return
"CLOUDFLARE_ZONE_API_TOKEN"+config.GetAsString("Cloudflare.cf_api_token"), // }
"CLOUDFLARE_EMAIL="+config.GetAsString("Cloudflare.cf_email"), // gitWorkspace.Repo, gitWorkspace.WorkTree = cloneRepo(url, gitWorkspace)
) // if gitWorkspace.Repo == nil {
stdout, err := cmd.StdoutPipe() // return
if err != nil { // }
fmt.Printf("Error getting stdout from lego process: %v\n", err) // err := addAndPushCerts(domain, gitWorkspace)
os.Exit(1) // if err != nil {
} // return
err = cmd.Start() // }
if err != nil { // os.Exit(0)
fmt.Printf("Error creating certs with lego: %v\n", err) // }
os.Exit(1) // default:
} // {
scanner := bufio.NewScanner(stdout) // fmt.Println("Missing arguments: conclude command with 'gen' or 'renew'")
go func() { // os.Exit(1)
for scanner.Scan() { // }
fmt.Println(scanner.Text()) // }
} // cmd.Env = append(cmd.Environ(),
if err := scanner.Err(); err != nil { // "CLOUDFLARE_DNS_API_TOKEN="+config.GetAsString("Cloudflare.cf_api_token"),
fmt.Fprintln(os.Stderr, "reading standard input:", err) // "CLOUDFLARE_ZONE_API_TOKEN"+config.GetAsString("Cloudflare.cf_api_token"),
} // "CLOUDFLARE_EMAIL="+config.GetAsString("Cloudflare.cf_email"),
}() // )
err = cmd.Wait() // stdout, err := cmd.StdoutPipe()
if err != nil { // if err != nil {
fmt.Printf("Error waiting for lego command to finish: %v\n", err) // fmt.Printf("Error getting stdout from lego process: %v\n", err)
os.Exit(1) // os.Exit(1)
} // }
addAndPushCerts() // err = cmd.Start()
} // if err != nil {
// fmt.Printf("Error creating certs with lego: %v\n", err)
// os.Exit(1)
// }
// scanner := bufio.NewScanner(stdout)
// go func() {
// for scanner.Scan() {
// fmt.Println(scanner.Text())
// }
// if err := scanner.Err(); err != nil {
// fmt.Fprintln(os.Stderr, "reading standard input:", err)
// }
// }()
// err = cmd.Wait()
// if err != nil {
// fmt.Printf("Error waiting for lego command to finish: %v\n", err)
// os.Exit(1)
// }
// err = addAndPushCerts(domain, gitWorkspace)
// if err != nil {
// fmt.Printf("Error adding and pushing certs: %v\n", err)
// return
// }
//}

173
server.go Normal file
View File

@@ -0,0 +1,173 @@
package main
import (
"fmt"
"log"
"path/filepath"
"strconv"
"sync"
"time"
"git.nevets.tech/Steven/ezconf"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5/storage/memory"
)
var (
tickMu sync.Mutex
mgr *ACMEManager
mgrMu sync.Mutex
)
func getACMEManager() (*ACMEManager, error) {
mgrMu.Lock()
defer mgrMu.Unlock()
if mgr == nil {
var err error
mgr, err = NewACMEManager()
if err != nil {
return nil, err
}
}
return mgr, nil
}
func initServer() {
err := loadDomainConfigs()
if err != nil {
log.Fatalf("Error loading domain configs: %v", err)
}
}
func serverTick() {
tickMu.Lock()
defer tickMu.Unlock()
fmt.Println("Tick!")
mgr, err := getACMEManager()
if err != nil {
fmt.Printf("Error getting acme manager: %v\n", err)
return
}
now := time.Now().UTC()
mu.RLock()
localDomainConfigs := make(map[string]*ezconf.Configuration, len(domainConfigs))
for k, v := range domainConfigs {
localDomainConfigs[k] = v
}
mu.RUnlock()
for domainStr, domainConfig := range localDomainConfigs {
if !domainConfig.GetAsBoolean("Domain.enabled") {
continue
}
renewPeriod := domainConfig.GetAsInt("Certificates.renew_period")
lastIssued := time.Unix(domainConfig.GetAsInt64("Internal.last_issued"), 0).UTC()
renewalDue := lastIssued.AddDate(0, 0, renewPeriod)
if now.After(renewalDue) {
_, 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
}
}
err = domainConfig.SetValueErr("Internal.last_issued", strconv.FormatInt(time.Now().UTC().Unix(), 10))
if err != nil {
fmt.Printf("Error updating last_issued config for domain %s: %v\n", domainStr, err)
continue
}
err = domainConfig.Save()
if err != nil {
fmt.Printf("Error saving domain config %s: %v\n", domainStr, err)
continue
}
err = EncryptFileXChaCha(domainConfig.GetAsString("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 = EncryptFileXChaCha(domainConfig.GetAsString("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 := createGiteaClient()
if giteaClient == nil {
fmt.Printf("Error creating gitea client for domain %s: %v\n", domainStr, err)
continue
}
gitWorkspace := &GitWorkspace{
Storage: memory.NewStorage(),
FS: memfs.New(),
}
var repoUrl string
if !domainConfig.GetAsBoolean("Internal.repo_exists") {
repoUrl = createGiteaRepo(domainStr, giteaClient)
if repoUrl == "" {
fmt.Printf("Error creating Gitea repo for domain %s\n", domainStr)
continue
}
err = domainConfig.SetValueErr("Internal.repo_exists", "true")
if err != nil {
fmt.Printf("Error updating repo_exists config for domain %s: %v\n", domainStr, err)
continue
}
err = domainConfig.Save()
if err != nil {
fmt.Printf("Error saving domain config %s: %v\n", domainStr, err)
continue
}
err = initRepo(repoUrl, gitWorkspace)
if err != nil {
fmt.Printf("Error initializing repo for domain %s: %v\n", domainStr, err)
continue
}
} else {
repoUrl = config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domainStr + domainConfig.GetAsString("Repo.repo_suffix") + ".git"
err = cloneRepo(repoUrl, gitWorkspace)
if err != nil {
fmt.Printf("Error cloning repo for domain %s: %v\n", domainStr, err)
continue
}
}
err = 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)
}
}
saveDomainConfigs()
}
func reloadServer() {
fmt.Println("Reloading configs...")
err := 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 stopServer() {
fmt.Println("Shutting down server")
}

217
util.go
View File

@@ -4,12 +4,20 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv"
"strings"
"syscall"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"git.nevets.tech/Steven/ezconf" "git.nevets.tech/Steven/ezconf"
"github.com/google/go-github/v55/github" "github.com/google/go-github/v55/github"
) )
var (
ErrorPIDInUse = errors.New("daemon is already running")
ErrLockFailed = errors.New("failed to acquire a lock on the PID file")
)
type Domain struct { type Domain struct {
name *string name *string
config *ezconf.Configuration config *ezconf.Configuration
@@ -18,17 +26,144 @@ type Domain struct {
gtClient *gitea.Client gtClient *gitea.Client
} }
type GlobalConfig struct { // 0x01
Git struct { func createPIDFile() {
Host string file, err := os.Create("/var/run/certman.pid")
Endpoint string if err != nil {
Username string fmt.Printf("0x01: Error creating PID file: %v\n", err)
Password string return
ApiToken string }
err = file.Close()
if err != nil {
fmt.Printf("0x01: Error closing PID file: %v\n", err)
return
} }
} }
type DomainConfig struct { // 0x02
func clearPIDFile() {
file, err := os.OpenFile("/var/run/certman.pid", os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
fmt.Printf("0x02: Error opening PID file: %v\n", err)
return
}
defer file.Close()
err = file.Truncate(0)
if err != nil {
fmt.Printf("0x02: Error writing PID file: %v\n", err)
return
}
}
// 0x03
func createOrUpdatePIDFile(filename string) error {
pidBytes, err := os.ReadFile(filename)
if err != nil {
fmt.Printf("0x03: Error reading PID file: %v\n", err)
return err
}
pidStr := strings.TrimSpace(string(pidBytes))
isPidFileEmpty := pidStr == ""
if !isPidFileEmpty {
pid, err := strconv.Atoi(pidStr)
if err != nil {
fmt.Printf("0x03: Error parsing PID file: %v\n", err)
return err
}
isProcActive, err := isProcessActive(pid)
if err != nil {
fmt.Printf("0x03: Error checking if process is active: %v\n", err)
return err
}
if isProcActive {
return ErrorPIDInUse
}
}
pidFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
if os.IsNotExist(err) {
createPIDFile()
} else {
fmt.Printf("0x03: Error opening PID file: %v\n", err)
return err
}
}
defer pidFile.Close()
if err := syscall.Flock(int(pidFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
if errors.Is(err, syscall.EWOULDBLOCK) {
return ErrLockFailed
}
return fmt.Errorf("error locking PID file: %w", err)
}
curPid := os.Getpid()
if _, err := pidFile.Write([]byte(strconv.Itoa(curPid))); err != nil {
return fmt.Errorf("error writing pid to PID file: %w", err)
}
return nil
}
// 0x04
// isProcessActive checks whether the process with the provided PID is running.
func isProcessActive(pid int) (bool, error) {
if pid <= 0 {
return false, errors.New("invalid process ID")
}
process, err := os.FindProcess(pid)
if err != nil {
// On Unix systems, os.FindProcess always succeeds and returns a process with the given pid, irrespective of whether the process exists.
return false, nil
}
err = process.Signal(syscall.Signal(0))
if err != nil {
if errors.Is(err, syscall.ESRCH) {
// The process does not exist
return false, nil
} else if errors.Is(err, os.ErrProcessDone) {
return false, nil
}
// Some other unexpected error
return false, err
}
// The process exists and is active
return true, nil
}
// 0x05
func getDaemonProcess() (*os.Process, error) {
pidBytes, err := os.ReadFile("/var/run/certman.pid")
if err != nil {
fmt.Printf("0x05: Error getting PID from /var/run/certman.pid: %v\n", err)
return nil, err
}
pidStr := strings.TrimSpace(string(pidBytes))
daemonPid, err := strconv.Atoi(pidStr)
if err != nil {
fmt.Printf("0x05: Error converting PID string to int (%s): %v\n", pidStr, err)
return nil, err
}
isProcActive, err := isProcessActive(daemonPid)
if err != nil {
fmt.Printf("0x05: Error checking if process is active: %v\n", err)
}
if !isProcActive {
return nil, errors.New("process is not active")
}
proc, err := os.FindProcess(daemonPid)
if err != nil {
fmt.Printf("0x05: Error finding process with PID %d: %v\n", daemonPid, err)
return nil, err
}
return proc, nil
} }
func createFile(fileName string, filePermission os.FileMode, data []byte) { func createFile(fileName string, filePermission os.FileMode, data []byte) {
@@ -71,16 +206,18 @@ func createFile(fileName string, filePermission os.FileMode, data []byte) {
} }
} }
func fileExists(filePath string) bool { func linkFile(source, target string) error {
if _, err := os.Stat(filePath); err == nil { err := os.Symlink(source, target)
return true if err != nil {
} else if errors.Is(err, os.ErrNotExist) { fmt.Println("Error creating symlink:", err)
return false return err
} else {
fmt.Println("Error checking file existence: ", err)
os.Exit(1)
return false
} }
return nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
} }
func contains(slice []string, value string) (sliceHas bool, index int) { func contains(slice []string, value string) (sliceHas bool, index int) {
@@ -100,44 +237,8 @@ func insert(a []string, index int, value string) []string {
return a return a
} }
const defaultConfig = `[App] func sanitizeDomainKey(s string) string {
mode = {mode} s = strings.TrimSpace(strings.ToLower(s))
r := strings.NewReplacer("/", "_", "\\", "_", " ", "_", ":", "_")
[Git] return r.Replace(s)
host = gitea }
server = https://gitea.instance.com
username = user
org_name = org
template_name = template
[Certificates]
email = user@example.com
data_root = /var/local/certman
request_method = dns
[Cloudflare]
cf_email = email@example.com
`
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
`