diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..b122d45
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ sqlite.xerial
+ true
+ org.sqlite.JDBC
+ jdbc:sqlite:identifier.sqlite
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
new file mode 100644
index 0000000..e188d9a
--- /dev/null
+++ b/.idea/sqldialects.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/go.mod b/go.mod
index f7cc6c3..6213037 100644
--- a/go.mod
+++ b/go.mod
@@ -9,9 +9,20 @@ require (
)
require (
+ github.com/cenkalti/backoff/v4 v4.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/miekg/dns v1.1.50 // indirect
+ github.com/nrdcg/namesilo v0.2.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ golang.org/x/crypto v0.7.0 // indirect
+ golang.org/x/mod v0.8.0 // indirect
+ golang.org/x/net v0.8.0 // indirect
+ golang.org/x/sys v0.6.0 // indirect
+ golang.org/x/text v0.8.0 // indirect
+ golang.org/x/tools v0.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index cf66b32..9b857d0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,18 +1,79 @@
github.com/MrMelon54/mjwt v0.1.0 h1:x1wBrh9l2CowRekHecxcZaH2zy9Hvqwlp4ppmW1P1OA=
github.com/MrMelon54/mjwt v0.1.0/go.mod h1:oYrDBWK09Hju98xb+bRQ0wy+RuAzacxYvKYOZchR2Tk=
+github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4=
+github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-acme/lego/v4 v4.12.3 h1:aWPYhBopAZXWBASPgvi1LnWGrr5YiXOsrpVaFaVJipo=
github.com/go-acme/lego/v4 v4.12.3/go.mod h1:UZoOlhVmUYP/N0z4tEbfUjoCNHRZNObzqWZtT76DIsc=
+github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
+github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
+github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
+github.com/nrdcg/namesilo v0.2.1 h1:kLjCjsufdW/IlC+iSfAqj0iQGgKjlbUUeDJio5Y6eMg=
+github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw=
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+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=
+golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/renewal/http-acme-provider.go b/http-acme/http-acme-provider.go
similarity index 98%
rename from renewal/http-acme-provider.go
rename to http-acme/http-acme-provider.go
index 9e595ed..46b63a4 100644
--- a/renewal/http-acme-provider.go
+++ b/http-acme/http-acme-provider.go
@@ -1,4 +1,4 @@
-package renewal
+package http_acme
import (
"fmt"
diff --git a/renewal/http-acme-provider_test.go b/http-acme/http-acme-provider_test.go
similarity index 99%
rename from renewal/http-acme-provider_test.go
rename to http-acme/http-acme-provider_test.go
index b433b93..17c7710 100644
--- a/renewal/http-acme-provider_test.go
+++ b/http-acme/http-acme-provider_test.go
@@ -1,4 +1,4 @@
-package renewal
+package http_acme
import (
"crypto/rand"
diff --git a/pebble-dev/debug.go b/pebble-dev/debug.go
new file mode 100644
index 0000000..2c21c76
--- /dev/null
+++ b/pebble-dev/debug.go
@@ -0,0 +1,27 @@
+//go:build DEBUG
+
+package pebble_dev
+
+func GetPebbleCert() []byte {
+ return []byte(`
+-----BEGIN CERTIFICATE-----
+MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
+AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx
+MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ
+alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn
+Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu
+9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0
+toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3
+Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB
+AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
+BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v
+d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF
+WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll
+xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix
+Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82
+2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF
+p9BI7gVKtWSZYegicA==
+-----END CERTIFICATE-----
+`)
+}
diff --git a/pebble-dev/normal.go b/pebble-dev/normal.go
new file mode 100644
index 0000000..20196b7
--- /dev/null
+++ b/pebble-dev/normal.go
@@ -0,0 +1,10 @@
+//go:build !DEBUG
+
+package pebble_dev
+
+import "log"
+
+func GetPebbleCert() []byte {
+ log.Fatalln("[Renewal] Pebble is selected as the certificate source but this binary was not compiled in debug mode")
+ return nil
+}
diff --git a/renewal/account.go b/renewal/account.go
new file mode 100644
index 0000000..5f62e29
--- /dev/null
+++ b/renewal/account.go
@@ -0,0 +1,16 @@
+package renewal
+
+import (
+ "crypto"
+ "github.com/go-acme/lego/v4/registration"
+)
+
+type Account struct {
+ email string
+ reg *registration.Resource
+ key crypto.PrivateKey
+}
+
+func (a *Account) GetEmail() string { return a.email }
+func (a *Account) GetRegistration() *registration.Resource { return a.reg }
+func (a *Account) GetPrivateKey() crypto.PrivateKey { return a.key }
diff --git a/renewal/config.go b/renewal/config.go
new file mode 100644
index 0000000..ed94376
--- /dev/null
+++ b/renewal/config.go
@@ -0,0 +1,10 @@
+package renewal
+
+type LetsEncryptConfig struct {
+ Account struct {
+ Email string `yaml:"email"`
+ PrivateKey string `yaml:"privateKey"`
+ } `yaml:"account"`
+ Directory string `yaml:"directory"`
+ Certificate string `yaml:"certificate"`
+}
diff --git a/renewal/create-tables.sql b/renewal/create-tables.sql
new file mode 100644
index 0000000..7a1016d
--- /dev/null
+++ b/renewal/create-tables.sql
@@ -0,0 +1,29 @@
+CREATE TABLE IF NOT EXISTS certificates
+(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ owner INTEGER,
+ dns INTEGER,
+ auto_renew INTEGER DEFAULT 0,
+ active INTEGER DEFAULT 0,
+ renewing INTEGER DEFAULT 0,
+ renew_failed INTEGER DEFAULT 0,
+ not_after DATETIME,
+ updated_at DATETIME,
+ FOREIGN KEY (dns) REFERENCES dns (id)
+);
+
+CREATE TABLE IF NOT EXISTS certificate_domains
+(
+ domain_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ cert_id INTEGER,
+ domain VARCHAR,
+ FOREIGN KEY (cert_id) REFERENCES certificates (id)
+);
+
+CREATE TABLE IF NOT EXISTS dns
+(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type VARCHAR,
+ email VARCHAR,
+ token VARCHAR
+);
diff --git a/renewal/find-next-cert.sql b/renewal/find-next-cert.sql
new file mode 100644
index 0000000..385eaa2
--- /dev/null
+++ b/renewal/find-next-cert.sql
@@ -0,0 +1,11 @@
+select cert.id, certdata.data_id, certdata.not_after, dns.type, dns.token
+from certificates as cert
+ left outer join certificate_data as certdata on cert.id = certdata.meta_id
+ left outer join dns on cert.dns = dns.id
+where cert.active = 1
+ and cert.auto_renew = 1
+ and cert.renewing = 0
+ and cert.renew_failed = 0
+ and (certdata.ready IS NULL or certdata.ready = 1)
+ and (certdata.not_after IS NULL or DATETIME(certdata.not_after, 'utc', '-30 days') < DATETIME())
+order by certdata.not_after DESC NULLS FIRST
diff --git a/renewal/local.go b/renewal/local.go
new file mode 100644
index 0000000..4d85809
--- /dev/null
+++ b/renewal/local.go
@@ -0,0 +1,17 @@
+package renewal
+
+import "time"
+
+// Contains local types for the renewal service
+type localCertData struct {
+ id uint64
+ dns struct {
+ name string
+ token string
+ }
+ cert struct {
+ current uint64
+ notAfter time.Time
+ }
+ domains []string
+}
diff --git a/renewal/renewal_test.go b/renewal/renewal_test.go
deleted file mode 100644
index 629f895..0000000
--- a/renewal/renewal_test.go
+++ /dev/null
@@ -1 +0,0 @@
-package renewal
diff --git a/renewal/service.go b/renewal/service.go
new file mode 100644
index 0000000..1fd68c4
--- /dev/null
+++ b/renewal/service.go
@@ -0,0 +1,345 @@
+package renewal
+
+import (
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "database/sql"
+ _ "embed"
+ "errors"
+ "fmt"
+ "github.com/MrMelon54/orchid/http-acme"
+ "github.com/MrMelon54/orchid/pebble-dev"
+ "github.com/go-acme/lego/v4/certificate"
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/lego"
+ "github.com/go-acme/lego/v4/providers/dns/namesilo"
+ "github.com/go-acme/lego/v4/registration"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+var (
+ ErrUnsupportedDNSProvider = errors.New("unsupported DNS provider")
+ //go:embed find-next-cert.sql
+ findNextCertSql string
+ //go:embed create-tables.sql
+ createTableCertificates string
+)
+
+type Service struct {
+ db *sql.DB
+ httpAcme *http_acme.HttpAcmeProvider
+ certTicker *time.Ticker
+ certDone chan struct{}
+ caAddr string
+ caCert []byte
+ transport *http.Transport
+ renewLock *sync.Mutex
+ leAccount *Account
+ certDir string
+ keyDir string
+
+ //notify
+}
+
+func NewRenewalService(wg *sync.WaitGroup, db *sql.DB, httpAcme *http_acme.HttpAcmeProvider, leConfig LetsEncryptConfig) (*Service, error) {
+ r := &Service{
+ db: db,
+ httpAcme: httpAcme,
+ certTicker: time.NewTicker(time.Minute * 10),
+ certDone: make(chan struct{}),
+ renewLock: &sync.Mutex{},
+ leAccount: &Account{
+ email: leConfig.Account.Email,
+ key: leConfig.Account.PrivateKey,
+ },
+ }
+
+ // init domains table
+ _, err := r.db.Exec(createTableCertificates)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create certificates table: %w", err)
+ }
+
+ // resolve CA information
+ r.resolveCADirectory(leConfig)
+ err = r.resolveCACertificate(leConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve CA certificate: %w", err)
+ }
+
+ wg.Add(1)
+ go r.renewalRoutine(wg)
+ return r, nil
+}
+
+func (s *Service) Shutdown() {
+ log.Println("[Renewal] Shutting down certificate renewal service")
+ close(s.certDone)
+}
+
+func (s *Service) resolveCADirectory(conf LetsEncryptConfig) {
+ switch conf.Directory {
+ case "production", "prod":
+ s.caAddr = lego.LEDirectoryProduction
+ case "staging":
+ s.caAddr = lego.LEDirectoryStaging
+ default:
+ s.caAddr = conf.Directory
+ }
+}
+
+func (s *Service) resolveCACertificate(conf LetsEncryptConfig) error {
+ switch conf.Certificate {
+ case "default":
+ // no nothing
+ case "pebble":
+ s.caCert = pebble_dev.GetPebbleCert()
+ default:
+ caGet, err := http.Get(conf.Certificate)
+ if err != nil {
+ return fmt.Errorf("failed to download CA certificate: %w", err)
+ }
+ s.caCert, err = io.ReadAll(caGet.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read CA certificate: %w", err)
+ }
+ }
+ if s.caCert != nil {
+ caPool := x509.NewCertPool()
+ if !caPool.AppendCertsFromPEM(s.caCert) {
+ return fmt.Errorf("failed to add certificate to CA cert pool")
+ }
+ t := http.DefaultTransport.(*http.Transport).Clone()
+ t.TLSClientConfig = &tls.Config{RootCAs: caPool}
+ s.transport = t
+ }
+ return nil
+}
+
+var ErrAlreadyRenewing = errors.New("already renewing")
+
+func (s *Service) renewalRoutine(wg *sync.WaitGroup) {
+ defer func() {
+ s.certTicker.Stop()
+ log.Println("[Renewal] Stopped certificate renewal service")
+ wg.Done()
+ }()
+
+ log.Println("[Renewal] Doing quick certificate check before starting...")
+ err := s.renewalCheck()
+ if err != nil {
+ log.Println("[Renewal] Certificate check, should not error first try: ", err)
+ return
+ }
+ log.Println("[Renewal] Initial check complete, continually checking every 4 hours...")
+
+ for {
+ select {
+ case <-s.certDone:
+ return
+ case <-s.certTicker.C:
+ go func() {
+ err := s.renewalCheck()
+ if err != nil && err != ErrAlreadyRenewing {
+ log.Println("[Renewal] Certificate check, an error occurred: ", err)
+ }
+ }()
+ }
+ }
+}
+
+func (s *Service) renewalCheck() error {
+ if !s.renewLock.TryLock() {
+ return ErrAlreadyRenewing
+ }
+ defer s.renewLock.Unlock()
+
+ localData, err := s.findNextCertificateToRenew()
+ if err != nil {
+ return fmt.Errorf("failed to find a certificate to renew: %w", err)
+ }
+
+ // no certificates to update
+ if localData == nil {
+ return nil
+ }
+
+ s.renewCert(localData)
+}
+
+func (s *Service) findNextCertificateToRenew() (*localCertData, error) {
+ d := &localCertData{}
+
+ row := s.db.QueryRow(findNextCertSql)
+ err := row.Scan(&d.id, &d.cert.current, &d.cert.notAfter, &d.dns.name, &d.dns.token)
+ switch err {
+ case nil:
+ // no nothing
+ break
+ case io.EOF:
+ // no certificate to update
+ return nil, nil
+ default:
+ return nil, fmt.Errorf("failed to scan table row: %w", err)
+ }
+
+ return d, nil
+}
+
+func (s *Service) fetchDomains(localData *localCertData) ([]string, error) {
+ query, err := s.db.Query(`SELECT domain FROM certificate_domains WHERE cert_id = ?`, localData.id)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch domains for certificate: %d: %w", localData.id, err)
+ }
+
+ domains := make([]string, 0)
+ for query.Next() {
+ var domain string
+ err := query.Scan(&domain)
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan row from domains table: %d: %w", localData.id, err)
+ }
+ domains = append(domains, domain)
+ }
+ if len(domains) == 0 {
+ return nil, fmt.Errorf("no domains registered for certificate: %d", localData.id)
+ }
+ return domains, nil
+}
+
+func (s *Service) setupLegoClient(localData *localCertData) (*lego.Client, error) {
+ config := lego.NewConfig(s.leAccount)
+ config.CADirURL = s.caAddr
+ if s.transport != nil {
+ config.HTTPClient.Transport = s.transport
+ }
+ dnsProv, err := s.getDnsProvider(localData.dns.name, localData.dns.token)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve dns provider: %w", err)
+ }
+
+ client, err := lego.NewClient(config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate client: %w", err)
+ }
+
+ // set providers - always returns nil so ignore the error
+ _ = client.Challenge.SetHTTP01Provider(s.httpAcme)
+ _ = client.Challenge.SetDNS01Provider(dnsProv)
+
+ register, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
+ if err != nil {
+ return nil, fmt.Errorf("failed to update account registration: %w", err)
+ }
+
+ s.leAccount.reg = register
+ return client, nil
+}
+
+func (s *Service) getDnsProvider(name, token string) (challenge.Provider, error) {
+ switch name {
+ case "namesilo":
+ return namesilo.NewDNSProviderConfig(&namesilo.Config{APIKey: token})
+ default:
+ return nil, ErrUnsupportedDNSProvider
+ }
+}
+
+func (s *Service) getPrivateKey(id uint64) (*rsa.PrivateKey, error) {
+ privKeyBytes, err := os.ReadFile(filepath.Join(s.keyDir, fmt.Sprintf("%d.key.pem", id)))
+ if err != nil {
+ return nil, err
+ }
+ return x509.ParsePKCS1PrivateKey(privKeyBytes)
+}
+
+func (s *Service) renewCert(localData *localCertData) {
+ s.setRenewing(localData.id, true, false)
+
+ cert, certBytes, err := s.renewCertInternal(localData)
+ if err != nil {
+ log.Printf("[Renewal Failed to renew cert %d: %s\n", localData.id, err)
+ s.setRenewing(localData.id, false, true)
+ return
+ }
+
+ _, err = s.db.Exec(`UPDATE certificates SET renewing = 0, renew_failed = 0, not_after = ?, updated_at = ? WHERE id = ?`, cert.NotAfter, cert.NotBefore, localData.id)
+ if err != nil {
+ log.Printf("[Renewal] Failed to update certificate %d in database: %s\n", localData.id, err)
+ return
+ }
+
+ oldPath := filepath.Join(s.certDir, fmt.Sprintf("%d-old.cert.pem", localData.id))
+ newPath := filepath.Join(s.certDir, fmt.Sprintf("%d.cert.pem", localData.id))
+
+ err = os.Rename(newPath, oldPath)
+ if err != nil {
+ log.Printf("[Renewal] Failed to rename certificate file '%s' => '%s': %s\n", newPath, oldPath, err)
+ return
+ }
+
+ openCertFile, err := os.Create(newPath)
+ if err != nil {
+ log.Printf("[Renewal] Failed to create certificate file '%s': %s\n", newPath, err)
+ return
+ }
+ defer openCertFile.Close()
+
+ _, err = openCertFile.Write(certBytes)
+ if err != nil {
+ log.Printf("[Renewal] Failed to write certificate file '%s': %s\n", newPath, err)
+ return
+ }
+
+ log.Printf("[Renewal] Updated certificate %d successfully\n", localData.id)
+}
+
+func (s *Service) renewCertInternal(localData *localCertData) (*x509.Certificate, []byte, error) {
+ // read private key file
+ privKey, err := s.getPrivateKey(localData.id)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to open private key: %w", err)
+ }
+
+ // fetch domains for this certificate
+ domains, err := s.fetchDomains(localData)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to update cert: %w", err)
+ }
+
+ // setup client for requesting a new certificate
+ client, err := s.setupLegoClient(localData)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to generate a client: %w", err)
+ }
+
+ obtain, err := client.Certificate.Obtain(certificate.ObtainRequest{
+ Domains: domains,
+ PrivateKey: privKey,
+ Bundle: true,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to obtain replacement certificate: %w", err)
+ }
+
+ parseCert, err := x509.ParseCertificate(obtain.Certificate)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to parse new certificate: %w", err)
+ }
+
+ return parseCert, obtain.Certificate, nil
+}
+
+func (s *Service) setRenewing(id uint64, renewing, failed bool) {
+ _, err := s.db.Exec("UPDATE certificates SET renewing = ?, renew_failed = ? WHERE id = ?", renewing, failed, id)
+ if err != nil {
+ log.Printf("[Renewal] Failed to set renewing/failed mode in database %d: %s\n", id, err)
+ }
+}
diff --git a/renewal/renewal.go b/renewal/service_test.go
similarity index 100%
rename from renewal/renewal.go
rename to renewal/service_test.go