From 43905a3dc3be402751e3d16b13a9d4d94f9ad42f Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Fri, 7 Jul 2023 15:47:38 +0100 Subject: [PATCH] Use yaml and fix up setup code --- .gitignore | 1 + cmd/orchid/conf.go | 19 ++-- cmd/orchid/serve.go | 9 +- cmd/orchid/setup.go | 162 ++++++++++++++++++++++++++- http-acme/http-acme-provider.go | 45 +++++++- http-acme/http-acme-provider_test.go | 5 +- renewal/config.go | 14 ++- renewal/service.go | 15 ++- renewal/service_test.go | 5 +- 9 files changed, 234 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index ebf6758..a3843d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.sqlite *.local +.data/ diff --git a/cmd/orchid/conf.go b/cmd/orchid/conf.go index 4c8ae4f..3660fc4 100644 --- a/cmd/orchid/conf.go +++ b/cmd/orchid/conf.go @@ -3,19 +3,14 @@ package main import "github.com/MrMelon54/orchid/renewal" type startUpConfig struct { - Database string `json:"db"` - PrivKey string `json:"priv_key"` - PubKey string `json:"pub_key"` - Listen string `json:"listen"` - Acme acmeConfig `json:"acme"` - LE renewal.LetsEncryptConfig `json:"lets_encrypt"` - Domains []string `json:"domains"` + Listen string `yaml:"listen"` + Acme acmeConfig `yaml:"acme"` + LE renewal.LetsEncryptConfig `yaml:"letsEncrypt"` + Domains []string `yaml:"domains"` } type acmeConfig struct { - Access string `json:"access"` - Refresh string `json:"refresh"` - PresentUrl string `json:"present_url"` - CleanUpUrl string `json:"clean_up_url"` - RefreshUrl string `json:"refresh_url"` + PresentUrl string `yaml:"presentUrl"` + CleanUpUrl string `yaml:"cleanUpUrl"` + RefreshUrl string `yaml:"refreshUrl"` } diff --git a/cmd/orchid/serve.go b/cmd/orchid/serve.go index d2a81e8..a928556 100644 --- a/cmd/orchid/serve.go +++ b/cmd/orchid/serve.go @@ -3,7 +3,6 @@ package main import ( "context" "database/sql" - "encoding/json" "flag" "fmt" "github.com/MrMelon54/mjwt" @@ -12,6 +11,8 @@ import ( "github.com/MrMelon54/orchid/servers" "github.com/MrMelon54/violet/utils" "github.com/google/subcommands" + _ "github.com/mattn/go-sqlite3" + "gopkg.in/yaml.v3" "log" "os" "os/signal" @@ -53,7 +54,7 @@ func (s *serveCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interfa } var conf startUpConfig - err = json.NewDecoder(openConf).Decode(&conf) + err = yaml.NewDecoder(openConf).Decode(&conf) if err != nil { log.Println("[Orchid] Error: invalid config file: ", err) return subcommands.ExitFailure @@ -74,14 +75,14 @@ func normalLoad(conf startUpConfig, wd string) { // open sqlite database db, err := sql.Open("sqlite3", filepath.Join(wd, "orchid.db.sqlite")) if err != nil { - log.Fatal("[Orchid] Failed to open database") + log.Fatal("[Orchid] Failed to open database:", err) } certDir := filepath.Join(wd, "certs") keyDir := filepath.Join(wd, "keys") wg := &sync.WaitGroup{} - acmeProv := httpAcme.NewHttpAcmeProvider(conf.Acme.Access, conf.Acme.Refresh, conf.Acme.PresentUrl, conf.Acme.CleanUpUrl, conf.Acme.RefreshUrl) + acmeProv, _ := httpAcme.NewHttpAcmeProvider(filepath.Join(wd, "tokens.json"), conf.Acme.PresentUrl, conf.Acme.CleanUpUrl, conf.Acme.RefreshUrl) renewalService, err := renewal.NewService(wg, db, acmeProv, conf.LE, certDir, keyDir) if err != nil { log.Fatal("[Orchid] Error:", err) diff --git a/cmd/orchid/setup.go b/cmd/orchid/setup.go index 07b50f6..5410bdd 100644 --- a/cmd/orchid/setup.go +++ b/cmd/orchid/setup.go @@ -1,12 +1,25 @@ package main import ( + "bytes" "context" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "flag" "fmt" "github.com/AlecAivazis/survey/v2" + httpAcme "github.com/MrMelon54/orchid/http-acme" + "github.com/MrMelon54/orchid/renewal" "github.com/google/subcommands" + "gopkg.in/yaml.v3" + "math/rand" + "net" + "net/url" + "os" "path/filepath" + "strings" + "time" ) type setupCmd struct{ wdPath string } @@ -43,11 +56,154 @@ func (s *setupCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interfa } var answers struct { - ApiListen string - FirstDomains []string + ApiListen string + FirstDomains string + AcmeRefresh string + AcmePresentUrl string + AcmeCleanUpUrl string + AcmeRefreshUrl string + LEEmail string } _ = answers // ask main questions - return subcommands.ExitUsageError + err = survey.Ask([]*survey.Question{ + { + Name: "ApiListen", + Prompt: &survey.Input{Message: "API listen address", Default: "127.0.0.1:8080"}, + Validate: listenAddressValidator, + }, + { + Name: "ApiDomains", + Prompt: &survey.Input{Message: "API Domains", Help: "Comma separated list of domains which can be edited by the API"}, + }, + { + Name: "LEEmail", + Prompt: &survey.Input{Message: "Lets Encrypt account email", Help: "Creates an account if it doesn't exist"}, + Validate: survey.Required, + }, + { + Name: "AcmeRefresh", + Prompt: &survey.Input{Message: "ACME API Refresh Token"}, + }, + }, &answers) + if err != nil { + fmt.Println("[Orchid] Error: ", err) + return subcommands.ExitFailure + } + + if answers.AcmeRefresh != "" { + err = survey.Ask([]*survey.Question{ + { + Name: "AcmePresentUrl", + Prompt: &survey.Input{Message: "ACME API Present URL"}, + Validate: urlValidator, + }, + { + Name: "AcmeCleanUpUrl", + Prompt: &survey.Input{Message: "ACME API Clean Up URL"}, + Validate: urlValidator, + }, + { + Name: "AcmeRefreshUrl", + Prompt: &survey.Input{Message: "ACME API Refresh URL"}, + Validate: urlValidator, + }, + }, &answers) + if err != nil { + fmt.Println("[Orchid] Error: ", err) + return subcommands.ExitFailure + } + } + + key, err := rsa.GenerateKey(rand.New(rand.NewSource(time.Now().UnixNano())), 4096) + if err != nil { + fmt.Println("[Orchid] Error: ", err) + return subcommands.ExitFailure + } + keyBytes := x509.MarshalPKCS1PrivateKey(key) + keyBuf := new(bytes.Buffer) + err = pem.Encode(keyBuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}) + if err != nil { + fmt.Println("[Orchid] Error: ", err) + return subcommands.ExitFailure + } + + // write config file + confFile := filepath.Join(wdAbs, "config.yml") + createConf, err := os.Create(confFile) + if err != nil { + fmt.Println("[Orchid] Failed to create config file: ", err) + return subcommands.ExitFailure + } + + confEncode := yaml.NewEncoder(createConf) + confEncode.SetIndent(2) + err = confEncode.Encode(startUpConfig{ + Listen: answers.ApiListen, + Acme: acmeConfig{ + PresentUrl: answers.AcmePresentUrl, + CleanUpUrl: answers.AcmeCleanUpUrl, + RefreshUrl: answers.AcmeRefreshUrl, + }, + LE: renewal.LetsEncryptConfig{ + Account: renewal.LetsEncryptAccount{ + Email: answers.LEEmail, + PrivateKey: keyBuf.String(), + }, + Directory: "production", + Certificate: "default", + }, + Domains: strings.Split(answers.FirstDomains, ","), + }) + if err != nil { + fmt.Println("[Orchid] Failed to write config file: ", err) + return subcommands.ExitFailure + } + + // write token file + tokenFile := filepath.Join(wdAbs, "tokens.yml") + createTokens, err := os.Create(tokenFile) + if err != nil { + fmt.Println("[Orchid] Failed to create tokens file: ", err) + return subcommands.ExitFailure + } + + confEncode = yaml.NewEncoder(createTokens) + confEncode.SetIndent(2) + err = confEncode.Encode(httpAcme.AcmeLogin{ + Access: "", + Refresh: answers.AcmeRefresh, + }) + if err != nil { + fmt.Println("[Orchid] Failed to write tokens file: ", err) + return subcommands.ExitFailure + } + + fmt.Println("[Orchid] Setup complete") + fmt.Printf("[Orchid] Run the renewal service with `orchid serve -conf %s`\n", confFile) + + return subcommands.ExitSuccess +} + +func listenAddressValidator(ans interface{}) error { + if ansStr, ok := ans.(string); ok { + // empty string means disable + if ansStr == "" { + return nil + } + + // use ResolveTCPAddr to validate the input + _, err := net.ResolveTCPAddr("tcp", ansStr) + return err + } + return nil +} + +func urlValidator(ans interface{}) error { + if ansStr, ok := ans.(string); ok { + _, err := url.Parse(ansStr) + return err + } + return nil } diff --git a/http-acme/http-acme-provider.go b/http-acme/http-acme-provider.go index 8549e41..4b6e09f 100644 --- a/http-acme/http-acme-provider.go +++ b/http-acme/http-acme-provider.go @@ -4,7 +4,10 @@ import ( "encoding/json" "fmt" "github.com/go-acme/lego/v4/challenge" + "gopkg.in/yaml.v3" + "log" "net/http" + "os" "strings" ) @@ -13,16 +16,34 @@ var _ challenge.Provider = &HttpAcmeProvider{} // HttpAcmeProvider sends HTTP requests to an API updating the outputted // `.wellknown/acme-challenges` data type HttpAcmeProvider struct { + tokenFile string accessToken, refreshToken string apiUrlPresent, apiUrlCleanUp string apiUrlRefreshToken string trip http.RoundTripper } +type AcmeLogin struct { + Access string `yaml:"access"` + Refresh string `yaml:"refresh"` +} + // 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} +func NewHttpAcmeProvider(tokenFile, apiUrlPresent, apiUrlCleanUp, apiUrlRefreshToken string) (*HttpAcmeProvider, error) { + // acme login token + openTokens, err := os.Open(tokenFile) + if err != nil { + return nil, fmt.Errorf("failed to load acme tokens: %w", err) + } + + var acmeLogins AcmeLogin + err = yaml.NewDecoder(openTokens).Decode(&acmeLogins) + if err != nil { + return nil, fmt.Errorf("failed to load acme tokens: %w", err) + } + + return &HttpAcmeProvider{tokenFile, acmeLogins.Access, acmeLogins.Refresh, apiUrlPresent, apiUrlCleanUp, apiUrlRefreshToken, http.DefaultTransport}, nil } // Present implements challenge.Provider and sends a put request to the specified @@ -93,6 +114,8 @@ func (h *HttpAcmeProvider) authCheckRequest(method, url, domain, token, keyAuth h.accessToken = tokens.Access h.refreshToken = tokens.Refresh + go h.saveLoginTokens() + // call internal request again resp, err = h.internalRequest(method, url, domain, token, keyAuth) if err != nil { @@ -110,11 +133,25 @@ func (h *HttpAcmeProvider) authCheckRequest(method, url, domain, token, keyAuth // 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) + v := strings.NewReplacer("$domain", domain, "$token", token, "$content", keyAuth).Replace(url) req, err := http.NewRequest(method, v, nil) if err != nil { - return nil, nil + return nil, err } req.Header.Set("Authorization", "Bearer "+h.accessToken) return h.trip.RoundTrip(req) } + +func (h *HttpAcmeProvider) saveLoginTokens() { + // acme login token + openTokens, err := os.Create(h.tokenFile) + if err != nil { + log.Println("[Orchid] Failed to open token file:", err) + } + defer openTokens.Close() + + err = yaml.NewEncoder(openTokens).Encode(AcmeLogin{Access: h.accessToken, Refresh: h.refreshToken}) + if err != nil { + log.Println("[Orchid] Failed to write tokens file:", err) + } +} diff --git a/http-acme/http-acme-provider_test.go b/http-acme/http-acme-provider_test.go index b954d3e..e2ff581 100644 --- a/http-acme/http-acme-provider_test.go +++ b/http-acme/http-acme-provider_test.go @@ -18,10 +18,11 @@ import ( 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/present/$domain/$token/$content", + "https://api.example.com/acme/clean/$domain/$token", "https://api.example.com/acme/token", ft, } diff --git a/renewal/config.go b/renewal/config.go index 27359ad..ffdd213 100644 --- a/renewal/config.go +++ b/renewal/config.go @@ -1,11 +1,13 @@ 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"` + Account LetsEncryptAccount `yaml:"account"` + Directory string `yaml:"directory"` + Certificate string `yaml:"certificate"` insecure bool } + +type LetsEncryptAccount struct { + Email string `yaml:"email"` + PrivateKey string `yaml:"key"` +} diff --git a/renewal/service.go b/renewal/service.go index a6af077..996ad30 100644 --- a/renewal/service.go +++ b/renewal/service.go @@ -124,13 +124,16 @@ func (s *Service) Shutdown() { // 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)) + p, _ := pem.Decode([]byte(a)) + if p == nil { + return fmt.Errorf("failed to parse pem encoding") + } + if p.Type != "RSA PRIVATE KEY" { + return fmt.Errorf("invalid key types: %s", p.Type) + } + key, err := x509.ParsePKCS1PrivateKey(p.Bytes) if err != nil { - raw, err := os.ReadFile(a) - if err != nil { - return err - } - key, err = x509.ParsePKCS1PrivateKey(raw) + return fmt.Errorf("failed to parse key: %w", err) } s.leAccount.key = key return err diff --git a/renewal/service_test.go b/renewal/service_test.go index abdcaff..996cd3e 100644 --- a/renewal/service_test.go +++ b/renewal/service_test.go @@ -122,10 +122,7 @@ func setupPebbleTest(t *testing.T, serverTls *certgen.CertGen) *Service { acmeProv := test.MakeFakeAcmeProv(serverTls.GetCertPem()) service, err := NewService(wg, db, acmeProv, LetsEncryptConfig{ - Account: struct { - Email string `yaml:"email"` - PrivateKey string `yaml:"privateKey"` - }{ + Account: LetsEncryptAccount{ Email: "webmaster@example.test", PrivateKey: string(x509.MarshalPKCS1PrivateKey(lePrivKey)), },