From 58e426a3f31ae04cc50f2bd5203686cb41fd2e6c Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Mon, 3 Jul 2023 16:27:24 +0100 Subject: [PATCH] Partial work --- cmd/orchid/conf.go | 8 +++ cmd/orchid/main.go | 16 +++++ cmd/orchid/serve.go | 63 ++++++++++++++++++ cmd/orchid/setup.go | 53 +++++++++++++++ go.mod | 9 ++- go.sum | 39 +++++++++++ http-acme/http-acme-provider.go | 98 +++++++++++++++++++++++----- http-acme/http-acme-provider_test.go | 30 +++++---- renewal/account.go | 2 + renewal/service.go | 92 ++++++++++++++++++++++++-- renewal/service_test.go | 5 +- 11 files changed, 377 insertions(+), 38 deletions(-) create mode 100644 cmd/orchid/conf.go create mode 100644 cmd/orchid/serve.go create mode 100644 cmd/orchid/setup.go diff --git a/cmd/orchid/conf.go b/cmd/orchid/conf.go new file mode 100644 index 0000000..45e088b --- /dev/null +++ b/cmd/orchid/conf.go @@ -0,0 +1,8 @@ +package main + +type startUpConfig struct { + Database string `json:"db"` + PrivKey string `json:"priv_key"` + PubKey string `json:"pub_key"` + Listen string `json:"listen"` +} diff --git a/cmd/orchid/main.go b/cmd/orchid/main.go index da29a2c..1a149dc 100644 --- a/cmd/orchid/main.go +++ b/cmd/orchid/main.go @@ -1,4 +1,20 @@ package main +import ( + "context" + "flag" + "github.com/google/subcommands" + "os" +) + func main() { + subcommands.Register(subcommands.HelpCommand(), "") + subcommands.Register(subcommands.FlagsCommand(), "") + subcommands.Register(subcommands.CommandsCommand(), "") + subcommands.Register(&serveCmd{}, "") + subcommands.Register(&setupCmd{}, "") + + flag.Parse() + ctx := context.Background() + os.Exit(int(subcommands.Execute(ctx))) } diff --git a/cmd/orchid/serve.go b/cmd/orchid/serve.go new file mode 100644 index 0000000..e735484 --- /dev/null +++ b/cmd/orchid/serve.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "crypto/x509" + "encoding/json" + "flag" + "github.com/MrMelon54/mjwt" + "github.com/google/subcommands" + "log" + "os" +) + +type serveCmd struct{ configPath string } + +func (s *serveCmd) Name() string { return "serve" } +func (s *serveCmd) Synopsis() string { return "Serve certificate renewal service" } +func (s *serveCmd) SetFlags(f *flag.FlagSet) { + f.StringVar(&s.configPath, "conf", "", "/path/to/config.json : path to the config file") +} +func (s *serveCmd) Usage() string { + return `serve [-conf ] + Serve certificate renewal service using information from config file +` +} + +func (s *serveCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { + log.Println("[Orchid] Starting...") + + if s.configPath == "" { + log.Println("[Orchid] Error: config flag is missing") + return subcommands.ExitUsageError + } + + openConf, err := os.Open(s.configPath) + if err != nil { + if os.IsNotExist(err) { + log.Println("[Orchid] Error: missing config file") + } else { + log.Println("[Orchid] Error: open config file: ", err) + } + return subcommands.ExitFailure + } + + var conf startUpConfig + err = json.NewDecoder(openConf).Decode(&conf) + if err != nil { + log.Println("[Orchid] Error: invalid config file: ", err) + return subcommands.ExitFailure + } + + normalLoad(conf) + return subcommands.ExitSuccess +} + +func normalLoad(conf startUpConfig) { + os.ReadFile() + x509.ParsePKCS1PrivateKey() + mjwtVerify, err := mjwt.NewMJwtVerifierFromFile(conf.PubKey) + if err != nil { + + } +} diff --git a/cmd/orchid/setup.go b/cmd/orchid/setup.go new file mode 100644 index 0000000..07b50f6 --- /dev/null +++ b/cmd/orchid/setup.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/google/subcommands" + "path/filepath" +) + +type setupCmd struct{ wdPath string } + +func (s *setupCmd) Name() string { return "setup" } +func (s *setupCmd) Synopsis() string { return "Setup certificate renewal service" } +func (s *setupCmd) SetFlags(f *flag.FlagSet) { + f.StringVar(&s.wdPath, "wd", ".", "Path to the directory to create config files in (defaults to the working directory)") +} +func (s *setupCmd) Usage() string { + return `setup [-wd ] + Setup Orchid automatically by answering questions. +` +} + +func (s *setupCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { + // get absolute path to specify files + wdAbs, err := filepath.Abs(s.wdPath) + if err != nil { + fmt.Println("[Orchid] Failed to get full directory path: ", err) + return subcommands.ExitFailure + } + + // ask about running the setup steps + createFile := false + err = survey.AskOne(&survey.Confirm{Message: fmt.Sprintf("Create Orchid config files in this directory: '%s'?", wdAbs)}, &createFile) + if err != nil { + fmt.Println("[Orchid] Error: ", err) + return subcommands.ExitFailure + } + if !createFile { + fmt.Println("[Orchid] Goodbye") + return subcommands.ExitSuccess + } + + var answers struct { + ApiListen string + FirstDomains []string + } + _ = answers + + // ask main questions + return subcommands.ExitUsageError +} diff --git a/go.mod b/go.mod index 985c24c..e99b2fc 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/MrMelon54/orchid go 1.20 require ( + github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MrMelon54/certgen v0.0.1 - github.com/MrMelon54/mjwt v0.1.0 + github.com/MrMelon54/mjwt v0.1.1 github.com/go-acme/lego/v4 v4.12.3 + github.com/google/subcommands v1.2.0 github.com/google/uuid v1.3.0 github.com/mattn/go-sqlite3 v1.14.17 github.com/miekg/dns v1.1.50 @@ -18,6 +20,10 @@ require ( 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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // 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 @@ -25,6 +31,7 @@ require ( 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/term 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 c800aa7..aff72a1 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,17 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/MrMelon54/certgen v0.0.1 h1:ycWdZ2RlxQ5qSuejeBVv4aXjGo5hdqqL4j4EjrXnFMk= github.com/MrMelon54/certgen v0.0.1/go.mod h1:GHflVlSbtFLJZLpN1oWyUvDBRrR8qCWiwZLXCCnS2Gc= github.com/MrMelon54/mjwt v0.1.0 h1:x1wBrh9l2CowRekHecxcZaH2zy9Hvqwlp4ppmW1P1OA= github.com/MrMelon54/mjwt v0.1.0/go.mod h1:oYrDBWK09Hju98xb+bRQ0wy+RuAzacxYvKYOZchR2Tk= +github.com/MrMelon54/mjwt v0.1.1 h1:m+aTpxbhQCrOPKHN170DQMFR5r938LkviU38unob5Jw= +github.com/MrMelon54/mjwt v0.1.1/go.mod h1:oYrDBWK09Hju98xb+bRQ0wy+RuAzacxYvKYOZchR2Tk= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 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/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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= @@ -19,10 +27,23 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ 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/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 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= @@ -32,46 +53,64 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 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.0.0-20220722155255-886fb9371eb4/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-20190222072716-a9d3bda3a223/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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 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= diff --git a/http-acme/http-acme-provider.go b/http-acme/http-acme-provider.go index 46b63a4..8549e41 100644 --- a/http-acme/http-acme-provider.go +++ b/http-acme/http-acme-provider.go @@ -1,6 +1,7 @@ package http_acme import ( + "encoding/json" "fmt" "github.com/go-acme/lego/v4/challenge" "net/http" @@ -9,26 +10,26 @@ import ( var _ challenge.Provider = &HttpAcmeProvider{} +// HttpAcmeProvider sends HTTP requests to an API updating the outputted +// `.wellknown/acme-challenges` data type HttpAcmeProvider struct { accessToken, refreshToken string apiUrlPresent, apiUrlCleanUp string + apiUrlRefreshToken string trip http.RoundTripper } -func NewCustomHTTPProvider(accessToken, refreshToken, apiUrlPresent, apiUrlCleanUp string) *HttpAcmeProvider { - return &HttpAcmeProvider{accessToken, refreshToken, apiUrlPresent, apiUrlCleanUp, http.DefaultTransport} +// NewHttpAcmeProvider creates a new HttpAcmeProvider using http.DefaultTransport +// as the transport +func NewHttpAcmeProvider(accessToken, refreshToken, apiUrlPresent, apiUrlCleanUp, apiUrlRefreshToken string) *HttpAcmeProvider { + return &HttpAcmeProvider{accessToken, refreshToken, apiUrlPresent, apiUrlCleanUp, apiUrlRefreshToken, http.DefaultTransport} } +// Present implements challenge.Provider and sends a put request to the specified +// path along with a bearer token to authenticate func (h *HttpAcmeProvider) Present(domain, token, keyAuth string) error { - v := strings.NewReplacer("%domain%", domain, "%token%", token, "%content%", keyAuth).Replace(h.apiUrlPresent) - req, err := http.NewRequest(http.MethodPut, v, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+h.accessToken) - // round trip - trip, err := h.trip.RoundTrip(req) + trip, err := h.authCheckRequest(http.MethodPut, h.apiUrlPresent, domain, token, keyAuth) if err != nil { return err } @@ -38,16 +39,11 @@ func (h *HttpAcmeProvider) Present(domain, token, keyAuth string) error { return nil } +// CleanUp implements challenge.Provider and sends a delete request to the +// specified path along with a bearer token to authenticate func (h *HttpAcmeProvider) CleanUp(domain, token, keyAuth string) error { - v := strings.NewReplacer("%domain%", domain, "%token%", token, "%content%", keyAuth).Replace(h.apiUrlCleanUp) - req, err := http.NewRequest(http.MethodPut, v, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+h.accessToken) - // round trip - trip, err := h.trip.RoundTrip(req) + trip, err := h.authCheckRequest(http.MethodDelete, h.apiUrlCleanUp, domain, token, keyAuth) if err != nil { return err } @@ -56,3 +52,69 @@ func (h *HttpAcmeProvider) CleanUp(domain, token, keyAuth string) error { } return nil } + +// authCheckRequest call internalRequest and renews the access token if it is +// outdated and calls internalRequest again +func (h *HttpAcmeProvider) authCheckRequest(method, url, domain, token, keyAuth string) (*http.Response, error) { + // call internal request and check the status code + resp, err := h.internalRequest(method, url, domain, token, keyAuth) + if err != nil { + return nil, err + } + switch resp.StatusCode { + case http.StatusOK: + // just return + return resp, nil + case http.StatusForbidden: + // send request to get renewed access and refresh tokens + req, err := http.NewRequest(http.MethodPost, h.apiUrlRefreshToken, nil) + if err != nil { + return nil, fmt.Errorf("refresh token request failed: %w", err) + } + req.Header.Set("Authorization", "Bearer "+h.refreshToken) + + // round trip and status check + trip, err := h.trip.RoundTrip(req) + if err != nil { + return nil, fmt.Errorf("refresh token request failed: %w", err) + } + if trip.StatusCode != http.StatusOK { + return nil, fmt.Errorf("refresh token request failed: due to invalid status code, expected 200 got %d", trip.StatusCode) + } + + // parse tokens from response body + var tokens struct { + Access string `json:"access"` + Refresh string `json:"refresh"` + } + if json.NewDecoder(trip.Body).Decode(&tokens) != nil { + return nil, fmt.Errorf("refresh token parsing failed: %w", err) + } + h.accessToken = tokens.Access + h.refreshToken = tokens.Refresh + + // call internal request again + resp, err = h.internalRequest(method, url, domain, token, keyAuth) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusOK { + // just return + return resp, nil + } + return nil, fmt.Errorf("invalid status code, expected 200 got %d", resp.StatusCode) + } + // first request had an invalid status code + return nil, fmt.Errorf("invalid status code, expected 200/403 got %d", resp.StatusCode) +} + +// internalRequest sends a request to the acme challenge hosting api +func (h *HttpAcmeProvider) internalRequest(method, url, domain, token, keyAuth string) (*http.Response, error) { + v := strings.NewReplacer("%domain%", domain, "%token%", token, "%content%", keyAuth).Replace(url) + req, err := http.NewRequest(method, v, nil) + if err != nil { + return nil, nil + } + req.Header.Set("Authorization", "Bearer "+h.accessToken) + return h.trip.RoundTrip(req) +} diff --git a/http-acme/http-acme-provider_test.go b/http-acme/http-acme-provider_test.go index 17c7710..b954d3e 100644 --- a/http-acme/http-acme-provider_test.go +++ b/http-acme/http-acme-provider_test.go @@ -16,6 +16,19 @@ import ( "time" ) +func makeQuickHttpProv(accessToken string, ft http.RoundTripper) *HttpAcmeProvider { + return &HttpAcmeProvider{ + accessToken, + "", + "https://api.example.com/acme/present/%domain%/%token%/%content%", + "https://api.example.com/acme/clean/%domain%/%token%", + "https://api.example.com/acme/token", + ft, + } +} + +// fakeTransport captures any requests and responds with a successful answer if +// applicable type fakeTransport struct { verify mjwt.Verifier req *http.Request @@ -23,6 +36,7 @@ type fakeTransport struct { } func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // check bearer token and extract claims bearer := req.Header.Get("Authorization") if !strings.HasPrefix(bearer, "Bearer ") { return nil, fmt.Errorf("invalid bearer token") @@ -59,13 +73,7 @@ func TestHttpAcmeProvider_Present(t *testing.T) { assert.NoError(t, err) ft := &fakeTransport{verify: signer} - prov := &HttpAcmeProvider{ - accessToken, - "", - "https://api.example.com/acme/present/%domain%/%token%/%content%", - "https://api.example.com/acme/clean/%domain%/%token%", - ft, - } + prov := makeQuickHttpProv(accessToken, ft) assert.NoError(t, prov.Present("example.com", "1234", "1234abcd")) assert.Equal(t, *ft.req.URL, url.URL{ Scheme: "https", @@ -88,13 +96,7 @@ func TestHttpAcmeProvider_CleanUp(t *testing.T) { assert.NoError(t, err) ft := &fakeTransport{verify: signer, clean: true} - prov := &HttpAcmeProvider{ - accessToken, - "", - "https://api.example.com/acme/present/%domain%/%token%/%content%", - "https://api.example.com/acme/clean/%domain%/%token%", - ft, - } + prov := makeQuickHttpProv(accessToken, ft) assert.NoError(t, prov.CleanUp("example.com", "1234", "1234abcd")) assert.Equal(t, *ft.req.URL, url.URL{ Scheme: "https", diff --git a/renewal/account.go b/renewal/account.go index 5f62e29..5a54269 100644 --- a/renewal/account.go +++ b/renewal/account.go @@ -5,6 +5,8 @@ import ( "github.com/go-acme/lego/v4/registration" ) +// Account stores the information required for the lego library to use the +// LetsEncrypt account details. type Account struct { email string reg *registration.Resource diff --git a/renewal/service.go b/renewal/service.go index fd8a22d..94aef1c 100644 --- a/renewal/service.go +++ b/renewal/service.go @@ -35,11 +35,20 @@ var ( createTableCertificates string ) +// overrides only used in testing var testDnsOptions interface { challenge.Provider GetDnsAddrs() []string } +// Service manages the scheduled renewal of certificates stored in the database +// and outputs the latest certificates to the certDir folder. If the certificate +// does not have a key already defined in keyDir then a new key is generated. +// +// The service makes use of an HTTP ACME challenge provider, and a DNS ACME +// challenge provider. These ensure the `.wellknown/acme-challenges` files and +// `_acme-challenges` TXT records are updated to validate the ownership of the +// specified domains. type Service struct { db *sql.DB httpAcme challenge.Provider @@ -55,7 +64,8 @@ type Service struct { insecure bool } -func NewRenewalService(wg *sync.WaitGroup, db *sql.DB, httpAcme challenge.Provider, leConfig LetsEncryptConfig, certDir, keyDir string) (*Service, error) { +// NewService creates a new certificate renewal service. +func NewService(wg *sync.WaitGroup, db *sql.DB, httpAcme challenge.Provider, leConfig LetsEncryptConfig, certDir, keyDir string) (*Service, error) { r := &Service{ db: db, httpAcme: httpAcme, @@ -99,16 +109,20 @@ func NewRenewalService(wg *sync.WaitGroup, db *sql.DB, httpAcme challenge.Provid return nil, fmt.Errorf("failed to resolve CA certificate: %w", err) } + // start the background routine wg.Add(1) go r.renewalRoutine(wg) return r, nil } +// Shutdown the renewal service. func (s *Service) Shutdown() { log.Println("[Renewal] Shutting down certificate renewal service") close(s.certDone) } +// resolveLEPrivKey resolves the private key for the LetsEncrypt account. +// If the string is a path to a file then the contents of the file is read. func (s *Service) resolveLEPrivKey(a string) error { key, err := x509.ParsePKCS1PrivateKey([]byte(a)) if err != nil { @@ -122,6 +136,14 @@ func (s *Service) resolveLEPrivKey(a string) error { return err } +// resolveCADirectory resolves the certificate authority directory URL. +// +// If "production" or "prod" then the LetsEncrypt production directory is used. +// +// If "staging" then the LetsEncrypt staging directory is used. +// +// Otherwise, the string is assumed to be a value directory URL (used for testing +// with pebble). func (s *Service) resolveCADirectory(dir string) { switch dir { case "production", "prod": @@ -133,6 +155,12 @@ func (s *Service) resolveCADirectory(dir string) { } } +// resolveCACertificate resolves the certificate authority root certificate. +// +// If "default" is used then the internal library lego defaults to the +// LetsEncrypt production root certificate. +// +// If "pebble" is used then the pebble certificate is used. func (s *Service) resolveCACertificate(cert string) error { switch cert { case "default": @@ -164,27 +192,37 @@ func (s *Service) resolveCACertificate(cert string) error { var ErrAlreadyRenewing = errors.New("already renewing") +// renewalRoutine is the main loop which makes used of certTicker to constantly +// check if the existing certificates are up-to-date. func (s *Service) renewalRoutine(wg *sync.WaitGroup) { + // Upon leaving the function stop the ticker and clear the WaitGroup. defer func() { s.certTicker.Stop() log.Println("[Renewal] Stopped certificate renewal service") wg.Done() }() + // Do an initial check and refuse to start if any errors occur. 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...") + // Logging or something + log.Println("[Renewal] Initial check complete, continually checking every 10 minutes...") + + // Main loop for { select { case <-s.certDone: + // Exit if certDone has closed return case <-s.certTicker.C: + // Start a new check in a separate routine go func() { + // run a renewal check and log errors, but ignore ErrAlreadyRenewing err := s.renewalCheck() if err != nil && err != ErrAlreadyRenewing { log.Println("[Renewal] Certificate check, an error occurred: ", err) @@ -194,12 +232,15 @@ func (s *Service) renewalRoutine(wg *sync.WaitGroup) { } } +// renewalCheck runs a locked renewal check, this only returns and unlocks once a +// renewal finishes or if no certificate needs to renew. func (s *Service) renewalCheck() error { if !s.renewLock.TryLock() { return ErrAlreadyRenewing } defer s.renewLock.Unlock() + // check for running out certificates in the database localData, err := s.findNextCertificateToRenew() if err != nil { return fmt.Errorf("failed to find a certificate to renew: %w", err) @@ -210,19 +251,35 @@ func (s *Service) renewalCheck() error { return nil } + // renew the certificate from the collected data err = s.renewCert(localData) if err != nil { return err } + + // renew succeeded log.Printf("[Renewal] Updated certificate %d successfully\n", localData.id) return nil } +// findNextCertificateToRenew finds a certificate to update func (s *Service) findNextCertificateToRenew() (*localCertData, error) { d := &localCertData{} - row := s.db.QueryRow(findNextCertSql) - err := row.Scan(&d.id, &d.notAfter, &d.dns.name, &d.dns.token) + // sql or something, the query is in `find-next-cert.sql` + row, err := s.db.Query(findNextCertSql) + if err != nil { + return nil, fmt.Errorf("failed to run query: %w", err) + } + defer row.Close() + + // if next fails no rows were found + if !row.Next() { + return nil, nil + } + + // scan the first row + err = row.Scan(&d.id, &d.notAfter, &d.dns.name, &d.dns.token) switch err { case nil: // no nothing @@ -238,11 +295,13 @@ func (s *Service) findNextCertificateToRenew() (*localCertData, error) { } func (s *Service) fetchDomains(localData *localCertData) ([]string, error) { + // more sql: this one just grabs all the domains for a certificate 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) } + // convert query responses to a string slice domains := make([]string, 0) for query.Next() { var domain string @@ -252,6 +311,7 @@ func (s *Service) fetchDomains(localData *localCertData) ([]string, error) { } domains = append(domains, domain) } + // if no domains were found then the renewal will fail if len(domains) == 0 { return nil, fmt.Errorf("no domains registered for certificate: %d", localData.id) } @@ -259,12 +319,15 @@ func (s *Service) fetchDomains(localData *localCertData) ([]string, error) { } func (s *Service) setupLegoClient(localData *localCertData) (*lego.Client, error) { + // create lego config and change the certificate authority directory URL and the + // http.Client transport if an alternative is provided config := lego.NewConfig(s.leAccount) config.CADirURL = s.caAddr if s.transport != nil { config.HTTPClient.Transport = s.transport } + // create lego client from the config client, err := lego.NewClient(config) if err != nil { return nil, fmt.Errorf("failed to generate client: %w", err) @@ -275,6 +338,8 @@ func (s *Service) setupLegoClient(localData *localCertData) (*lego.Client, error // if testDnsOptions is defined then set up the test provider if testDnsOptions != nil { + // set up the dns provider used during tests and disable propagation as no dns + // will validate these tests dnsAddrs := testDnsOptions.GetDnsAddrs() log.Printf("Using testDnsOptions with DNS server: %v\n", dnsAddrs) _ = client.Challenge.SetDNS01Provider(testDnsOptions, dns01.AddRecursiveNameservers(dnsAddrs), dns01.DisableCompletePropagationRequirement()) @@ -288,15 +353,19 @@ func (s *Service) setupLegoClient(localData *localCertData) (*lego.Client, error _ = client.Challenge.SetDNS01Provider(dnsProv) } + // make sure the LetsEncrypt account is registered register, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) if err != nil { return nil, fmt.Errorf("failed to update account registration: %w", err) } + // return and use the client s.leAccount.reg = register return client, nil } +// getDnsProvider loads a DNS challenge provider using the provided name and +// token func (s *Service) getDnsProvider(name, token string) (challenge.Provider, error) { switch name { case "duckdns": @@ -312,6 +381,7 @@ func (s *Service) getDnsProvider(name, token string) (challenge.Provider, error) } } +// getPrivateKey reads the private key for the specified certificate id 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 { @@ -320,20 +390,27 @@ func (s *Service) getPrivateKey(id uint64) (*rsa.PrivateKey, error) { return x509.ParsePKCS1PrivateKey(privKeyBytes) } +// renewCert sets the renewing state in the database, calls renewCertInternal, +// updates the NotAfter/NotBefore columns in the database and writes the +// certificate to the certDir directory. func (s *Service) renewCert(localData *localCertData) error { + // database synchronous state s.setRenewing(localData.id, true, false) + // run internal renewal code and log errors cert, certBytes, err := s.renewCertInternal(localData) if err != nil { s.setRenewing(localData.id, false, true) return fmt.Errorf("failed to renew cert %d: %w", localData.id, err) } + // set the NotAfter/NotBefore in the database _, 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 { return fmt.Errorf("failed to update cert %d in database: %w", localData.id, err) } + // write out the certificate file err = s.writeCertFile(localData.id, certBytes) if err != nil { return fmt.Errorf("failed to write cert file: %w", err) @@ -342,6 +419,9 @@ func (s *Service) renewCert(localData *localCertData) error { return nil } +// renewCertInternal handles each stage of fetching the certificate private key, +// fetching the domains slice, setting up the lego client, obtaining a renewed +// certificate, decoding and parsing the certificate. func (s *Service) renewCertInternal(localData *localCertData) (*x509.Certificate, []byte, error) { // read private key file privKey, err := s.getPrivateKey(localData.id) @@ -387,6 +467,8 @@ func (s *Service) renewCertInternal(localData *localCertData) (*x509.Certificate return parseCert, obtain.Certificate, nil } +// setRenewing sets the renewing and failed states in the database for a +// specified certifcate id. 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 { @@ -394,6 +476,8 @@ func (s *Service) setRenewing(id uint64, renewing, failed bool) { } } +// writeCertFile writes the output certificate file and renames the current one +// to include `-old` in the name. func (s *Service) writeCertFile(id uint64, certBytes []byte) error { oldPath := filepath.Join(s.certDir, fmt.Sprintf("%d-old.cert.pem", id)) newPath := filepath.Join(s.certDir, fmt.Sprintf("%d.cert.pem", id)) diff --git a/renewal/service_test.go b/renewal/service_test.go index cc69a60..abdcaff 100644 --- a/renewal/service_test.go +++ b/renewal/service_test.go @@ -121,7 +121,7 @@ func setupPebbleTest(t *testing.T, serverTls *certgen.CertGen) *Service { assert.NoError(t, err) acmeProv := test.MakeFakeAcmeProv(serverTls.GetCertPem()) - service, err := NewRenewalService(wg, db, acmeProv, LetsEncryptConfig{ + service, err := NewService(wg, db, acmeProv, LetsEncryptConfig{ Account: struct { Email string `yaml:"email"` PrivateKey string `yaml:"privateKey"` @@ -144,6 +144,9 @@ func setupPebbleTest(t *testing.T, serverTls *certgen.CertGen) *Service { } func TestPebbleRenewal(t *testing.T) { + if testing.Short() { + t.Skip("Skipping renewal tests in short mode") + } serverTls, cancel := setupPebbleSuite(t) t.Cleanup(cancel)