Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • ai3/tools/acmeserver
  • godog/acmeserver
  • svp-bot/acmeserver
3 results
Show changes
Commits on Source (105)
include:
- "https://git.autistici.org/pipelines/debian/raw/master/common.yml"
- "https://git.autistici.org/pipelines/images/test/golang/raw/master/ci.yml"
...@@ -13,7 +13,6 @@ import ( ...@@ -13,7 +13,6 @@ import (
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
"path/filepath"
"golang.org/x/crypto/acme" "golang.org/x/crypto/acme"
) )
...@@ -46,6 +45,9 @@ func NewACME(config *Config) (*ACME, error) { ...@@ -46,6 +45,9 @@ func NewACME(config *Config) (*ACME, error) {
if config.Email == "" { if config.Email == "" {
return nil, errors.New("configuration parameter 'email' is unset") return nil, errors.New("configuration parameter 'email' is unset")
} }
if config.AccountKeyPath == "" {
return nil, errors.New("configuration parameter 'account_key_path' is unset")
}
var h http.Handler var h http.Handler
v := make(map[string]validator) v := make(map[string]validator)
...@@ -77,7 +79,7 @@ func NewACME(config *Config) (*ACME, error) { ...@@ -77,7 +79,7 @@ func NewACME(config *Config) (*ACME, error) {
return &ACME{ return &ACME{
email: config.Email, email: config.Email,
accountKeyPath: filepath.Join(config.Dir, "account.key"), accountKeyPath: config.AccountKeyPath,
directoryURL: directoryURL, directoryURL: directoryURL,
handler: h, handler: h,
validators: v, validators: v,
...@@ -131,10 +133,16 @@ func (a *ACME) acmeClient(ctx context.Context) (*acme.Client, error) { ...@@ -131,10 +133,16 @@ func (a *ACME) acmeClient(ctx context.Context) (*acme.Client, error) {
// account is already registered we get a StatusConflict, // account is already registered we get a StatusConflict,
// which we can ignore. // which we can ignore.
_, err = client.Register(ctx, ac, func(_ string) bool { return true }) _, err = client.Register(ctx, ac, func(_ string) bool { return true })
if ae, ok := err.(*acme.Error); err == nil || ok && ae.StatusCode == http.StatusConflict { if ae, ok := err.(*acme.Error); err == nil || err == acme.ErrAccountAlreadyExists || (ok && ae.StatusCode == http.StatusConflict) {
a.client = client a.client = client
err = nil err = nil
} }
// Fetch account info and display it.
if acct, err := client.GetReg(ctx, ""); err == nil {
log.Printf("ACME account %s", acct.URI)
}
return a.client, err return a.client, err
} }
...@@ -147,7 +155,8 @@ func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, c *certCon ...@@ -147,7 +155,8 @@ func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, c *certCon
return nil, nil, err return nil, nil, err
} }
if err = a.verifyAll(ctx, client, c); err != nil { o, err := a.verifyAll(ctx, client, c)
if err != nil {
return nil, nil, err return nil, nil, err
} }
...@@ -155,7 +164,7 @@ func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, c *certCon ...@@ -155,7 +164,7 @@ func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, c *certCon
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
der, _, err = client.CreateCert(ctx, csr, 0, true) der, _, err = client.CreateOrderCert(ctx, o.FinalizeURL, csr, true)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
...@@ -166,56 +175,67 @@ func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, c *certCon ...@@ -166,56 +175,67 @@ func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, c *certCon
return der, leaf, nil return der, leaf, nil
} }
func (a *ACME) verifyAll(ctx context.Context, client *acme.Client, c *certConfig) error { func (a *ACME) verifyAll(ctx context.Context, client *acme.Client, c *certConfig) (*acme.Order, error) {
for _, domain := range c.Names {
if err := a.verify(ctx, client, c, domain); err != nil {
return err
}
}
return nil
}
func (a *ACME) verify(ctx context.Context, client *acme.Client, c *certConfig, domain string) error {
// Make an authorization request to the ACME server, and // Make an authorization request to the ACME server, and
// verify that it returns a valid response with challenges. // verify that it returns a valid response with challenges.
authz, err := client.Authorize(ctx, domain) o, err := client.AuthorizeOrder(ctx, acme.DomainIDs(c.Names...))
if err != nil { if err != nil {
return err return nil, fmt.Errorf("AuthorizeOrder failed: %v", err)
}
switch authz.Status {
case acme.StatusValid:
return nil // already authorized
case acme.StatusInvalid:
return fmt.Errorf("invalid authorization %q", authz.URI)
} }
// Pick a challenge that matches our preferences and the switch o.Status {
// available validators. The validator fulfills the challenge, case acme.StatusReady:
// and returns a cleanup function that we're going to call return o, nil // already authorized
// before we return. All steps are sequential and idempotent. case acme.StatusPending:
chal := a.pickChallenge(authz.Challenges, c) default:
if chal == nil { return nil, fmt.Errorf("invalid new order status %q", o.Status)
return fmt.Errorf("unable to authorize %q", domain)
}
v, ok := a.validators[chal.Type]
if !ok {
return fmt.Errorf("challenge type '%s' is not available", chal.Type)
} }
cleanup, err := v.Fulfill(ctx, client, domain, chal)
if err != nil { for _, zurl := range o.AuthzURLs {
return err z, err := client.GetAuthorization(ctx, zurl)
if err != nil {
return nil, fmt.Errorf("GetAuthorization(%s) failed: %v", zurl, err)
}
if z.Status != acme.StatusPending {
continue
}
// Pick a challenge that matches our preferences and the
// available validators. The validator fulfills the challenge,
// and returns a cleanup function that we're going to call
// before we return. All steps are sequential and idempotent.
chal := a.pickChallenge(z.Challenges, c)
if chal == nil {
return nil, fmt.Errorf("unable to authorize %q", c.Names)
}
v, ok := a.validators[chal.Type]
if !ok {
return nil, fmt.Errorf("challenge type '%s' is not available", chal.Type)
}
log.Printf("attempting fulfillment for %q (identifier: %+v)", c.Names, z.Identifier)
for _, domain := range c.Names {
cleanup, err := v.Fulfill(ctx, client, domain, chal)
if err != nil {
return nil, fmt.Errorf("fulfillment failed: %v", err)
}
defer cleanup()
}
if _, err := client.Accept(ctx, chal); err != nil {
return nil, fmt.Errorf("challenge accept failed: %v", err)
}
if _, err := client.WaitAuthorization(ctx, z.URI); err != nil {
return nil, fmt.Errorf("WaitAuthorization(%s) failed: %v", z.URI, err)
}
} }
defer cleanup()
// Tell the ACME server that we've accepted the challenge, and // Authorizations are satisfied, wait for the CA
// then wait, possibly for some time, until there is an // to update the order status.
// authorization response (either successful or not) from the if _, err = client.WaitOrder(ctx, o.URI); err != nil {
// server. return nil, err
if _, err = client.Accept(ctx, chal); err != nil {
return err
} }
_, err = client.WaitAuthorization(ctx, authz.URI) return o, nil
return err
} }
// Pick a challenge with the right type from the Challenge response // Pick a challenge with the right type from the Challenge response
......
...@@ -3,38 +3,38 @@ package main ...@@ -3,38 +3,38 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"git.autistici.org/ai3/acmeserver"
"git.autistici.org/ai3/go-common/serverutil" "git.autistici.org/ai3/go-common/serverutil"
"gopkg.in/yaml.v2" "git.autistici.org/ai3/tools/acmeserver"
"gopkg.in/yaml.v3"
) )
var ( var (
addr = flag.String("addr", ":2780", "tcp `address` to listen on") addr = flag.String("addr", ":2780", "tcp `address` to listen on")
configFile = flag.String("config", "/etc/acmeserver/config.yml", "configuration `file`") configFile = flag.String("config", "/etc/acme/config.yml", "configuration `file`")
) )
// Config ties together the acmeserver Config and the standard // Config ties together the acmeserver Config and the standard
// serverutil HTTP server configuration. // serverutil HTTP server configuration.
type Config struct { type Config struct {
ACME *acmeserver.Config `yaml:",inline"` ACME acmeserver.Config `yaml:",inline"`
Server *serverutil.ServerConfig `yaml:"http_server"` Server *serverutil.ServerConfig `yaml:"http_server"`
} }
func loadConfig(path string) (*Config, error) { func loadConfig(path string) (*Config, error) {
// Read YAML config. // Read YAML config.
data, err := ioutil.ReadFile(path) // nolint: gosec f, err := os.Open(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer f.Close()
var config Config var config Config
if err := yaml.Unmarshal(data, &config); err != nil { if err := yaml.NewDecoder(f).Decode(&config); err != nil {
return nil, err return nil, err
} }
return &config, nil return &config, nil
...@@ -52,7 +52,7 @@ func main() { ...@@ -52,7 +52,7 @@ func main() {
var h http.Handler var h http.Handler
var cg acmeserver.CertGenerator var cg acmeserver.CertGenerator
if !config.ACME.Testing { if !config.ACME.Testing {
acme, err := acmeserver.NewACME(config.ACME) // nolint: vetshadow acme, err := acmeserver.NewACME(&config.ACME) // nolint: vetshadow
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
...@@ -69,7 +69,7 @@ func main() { ...@@ -69,7 +69,7 @@ func main() {
}) })
} }
m, err := acmeserver.NewManager(config.ACME, cg) m, err := acmeserver.NewManager(&config.ACME, cg)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
......
...@@ -3,30 +3,39 @@ package acmeserver ...@@ -3,30 +3,39 @@ package acmeserver
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"log" "log"
"os"
"path/filepath" "path/filepath"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v3"
"git.autistici.org/ai3/go-common/clientutil" "git.autistici.org/ai3/go-common/clientutil"
) )
// Config holds the configuration for an acmeserver instance. // Config holds the configuration for an acmeserver instance.
// //
// The reason for supporting multiple config_dirs is to allow
// integration with third-party automation systems: in some cases,
// automation tools need control of an entire directory in order to
// safely delete entries that no longer exist.
//
// nolint: maligned // nolint: maligned
type Config struct { type Config struct {
Addr string `yaml:"addr"`
Testing bool `yaml:"testing"` Testing bool `yaml:"testing"`
DirectoryURL string `yaml:"directory_url"` DirectoryURL string `yaml:"directory_url"`
DefaultChallengeType string `yaml:"default_challenge"` DefaultChallengeType string `yaml:"default_challenge"`
UseRSA bool `yaml:"use_rsa"` UseRSA bool `yaml:"use_rsa"`
RenewalDays int `yaml:"renewal_days"` RenewalDays int `yaml:"renewal_days"`
Email string `yaml:"email"`
Dir string `yaml:"cert_dir"` AccountKeyPath string `yaml:"account_key_path"`
ReplDS *clientutil.BackendConfig `yaml:"replds"` Email string `yaml:"email"`
Dirs []string `yaml:"config_dirs"`
Output struct {
Path string `yaml:"path"`
ReplDS *clientutil.BackendConfig `yaml:"replds"`
} `yaml:"output"`
HTTP struct { HTTP struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
...@@ -68,23 +77,23 @@ func (c *certConfig) check() error { ...@@ -68,23 +77,23 @@ func (c *certConfig) check() error {
} }
func readCertConfigs(path string) ([]*certConfig, error) { func readCertConfigs(path string) ([]*certConfig, error) {
data, err := ioutil.ReadFile(path) // nolint: gosec f, err := os.Open(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer f.Close()
var cc []*certConfig var cc []*certConfig
if err := yaml.Unmarshal(data, &cc); err != nil { if err := yaml.NewDecoder(f).Decode(&cc); err != nil {
return nil, err return nil, err
} }
return cc, nil return cc, nil
} }
func readCertConfigsFromDir(dir string) ([]*certConfig, error) { func readCertConfigsFromDir(dir string, certs []*certConfig) ([]*certConfig, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.yml")) files, err := filepath.Glob(filepath.Join(dir, "*.yml"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
var out []*certConfig
for _, f := range files { for _, f := range files {
cc, err := readCertConfigs(f) cc, err := readCertConfigs(f)
if err != nil { if err != nil {
...@@ -97,8 +106,20 @@ func readCertConfigsFromDir(dir string) ([]*certConfig, error) { ...@@ -97,8 +106,20 @@ func readCertConfigsFromDir(dir string) ([]*certConfig, error) {
log.Printf("configuration error in %s: %v", f, err) log.Printf("configuration error in %s: %v", f, err)
continue continue
} }
out = append(out, c) certs = append(certs, c)
}
}
return certs, nil
}
func readCertConfigsFromDirs(dirs []string) ([]*certConfig, error) {
var out []*certConfig
for _, dir := range dirs {
certs, err := readCertConfigsFromDir(dir, out)
if err != nil {
return nil, err
} }
out = certs
} }
return out, nil return out, nil
} }
...@@ -7,6 +7,7 @@ User=acmeserver ...@@ -7,6 +7,7 @@ User=acmeserver
Group=acmeserver Group=acmeserver
EnvironmentFile=-/etc/default/acmeserver EnvironmentFile=-/etc/default/acmeserver
ExecStart=/usr/bin/acmeserver --addr $ADDR ExecStart=/usr/bin/acmeserver --addr $ADDR
ExecReload=/bin/kill -HUP $MAINPID
Restart=always Restart=always
# Hardening # Hardening
...@@ -16,6 +17,7 @@ PrivateDevices=yes ...@@ -16,6 +17,7 @@ PrivateDevices=yes
ProtectHome=yes ProtectHome=yes
ProtectSystem=full ProtectSystem=full
ReadOnlyDirectories=/ ReadOnlyDirectories=/
ReadWriteDirectories=/var/lib/acme
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install] [Install]
......
...@@ -2,4 +2,4 @@ acmeserver (2.0) unstable; urgency=medium ...@@ -2,4 +2,4 @@ acmeserver (2.0) unstable; urgency=medium
* Initial Release. * Initial Release.
-- Autistici/Inventati <debian@autistici.org> Sat, 15 Jun 2018 09:23:40 +0000 -- Autistici/Inventati <debian@autistici.org> Fri, 15 Jun 2018 09:23:40 +0000
10
...@@ -2,12 +2,12 @@ Source: acmeserver ...@@ -2,12 +2,12 @@ Source: acmeserver
Section: admin Section: admin
Priority: optional Priority: optional
Maintainer: Autistici/Inventati <debian@autistici.org> Maintainer: Autistici/Inventati <debian@autistici.org>
Build-Depends: debhelper (>=9), golang-go, dh-systemd, dh-golang Build-Depends: debhelper-compat (= 13), golang-any (>= 1.11), dh-golang
Standards-Version: 3.9.6 Standards-Version: 3.9.6
Package: acmeserver Package: acmeserver
Architecture: any Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends} Depends: ${shlibs:Depends}, ${misc:Depends}
Built-Using: ${misc:Built-Using}
Description: ACME server Description: ACME server
Automatically manages and renews public SSL certificates. Automatically manages and renews public SSL certificates.
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: acmeserver Upstream-Name: acmeserver
Source: <https://git.autistici.org/ai3/acmeserver> Source: <https://git.autistici.org/ai3/tools/acmeserver>
Files: * Files: *
Copyright: 2018 Autistici/Inventati <info@autistici.org> Copyright: 2018 Autistici/Inventati <info@autistici.org>
......
#!/usr/bin/make -f #!/usr/bin/make -f
export DH_GOPKG = git.autistici.org/ai3/acmeserver export DH_GOPKG = git.autistici.org/ai3/tools/acmeserver
export DH_GOLANG_EXCLUDES = vendor export DH_GOLANG_EXCLUDES = vendor
export DH_GOLANG_INSTALL_ALL := 1
%: %:
dh $@ --with systemd --with golang --buildsystem golang dh $@ --with golang --buildsystem golang
override_dh_install: override_dh_auto_install:
rm -fr $(CURDIR)/debian/acmeserver/usr/share/gocode dh_auto_install -- --no-source
dh_install
override_dh_systemd_enable: override_dh_installsystemd:
dh_systemd_enable --no-enable dh_installsystemd --no-enable
override_dh_systemd_start:
dh_systemd_start --no-start
override_dh_installsystemd:
dh_installsystemd --no-start
...@@ -10,6 +10,7 @@ import ( ...@@ -10,6 +10,7 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
"golang.org/x/crypto/acme" "golang.org/x/crypto/acme"
"golang.org/x/net/publicsuffix"
) )
const ( const (
...@@ -87,8 +88,16 @@ func (d *dnsValidator) client() *dns.Client { ...@@ -87,8 +88,16 @@ func (d *dnsValidator) client() *dns.Client {
} }
func (d *dnsValidator) Fulfill(ctx context.Context, client *acme.Client, domain string, chal *acme.Challenge) (func(), error) { func (d *dnsValidator) Fulfill(ctx context.Context, client *acme.Client, domain string, chal *acme.Challenge) (func(), error) {
zone := domain[strings.Index(domain, ".")+1:] domain = strings.TrimPrefix(domain, "*.")
fqdn := dns.Fqdn(domain)
zone, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
return nil, fmt.Errorf("could not determine effective tld: %w", err)
}
zone = dns.Fqdn(zone)
fqdn := dns.Fqdn("_acme-challenge." + domain)
value, err := client.DNS01ChallengeRecord(chal.Token) value, err := client.DNS01ChallengeRecord(chal.Token)
if err != nil { if err != nil {
return nil, err return nil, err
...@@ -109,6 +118,7 @@ func (d *dnsValidator) Fulfill(ctx context.Context, client *acme.Client, domain ...@@ -109,6 +118,7 @@ func (d *dnsValidator) Fulfill(ctx context.Context, client *acme.Client, domain
} }
func (d *dnsValidator) updateNS(ctx context.Context, ns, zone, fqdn, value string, remove bool) error { func (d *dnsValidator) updateNS(ctx context.Context, ns, zone, fqdn, value string, remove bool) error {
log.Printf("updateNS(%s, %s, %s, %s, %v)", ns, zone, fqdn, value, remove)
rrs := d.makeRR(fqdn, value, rfc2136Timeout) rrs := d.makeRR(fqdn, value, rfc2136Timeout)
m := d.makeMsg(zone, rrs, remove) m := d.makeMsg(zone, rrs, remove)
c := d.client() c := d.client()
......
module git.autistici.org/ai3/tools/acmeserver
go 1.19
require (
git.autistici.org/ai3/go-common v0.0.0-20230816213645-b3aa3fb514d6
git.autistici.org/ai3/tools/replds v0.0.0-20230923170339-b6e6e3cc032b
github.com/miekg/dns v1.1.50
github.com/prometheus/client_golang v1.12.2
golang.org/x/crypto v0.24.0
golang.org/x/net v0.26.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/openzipkin/zipkin-go v0.4.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.34.0 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.9.0 // indirect
go.opentelemetry.io/otel v1.10.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.9.0 // indirect
go.opentelemetry.io/otel/metric v0.31.0 // indirect
go.opentelemetry.io/otel/sdk v1.10.0 // indirect
go.opentelemetry.io/otel/trace v1.10.0 // indirect
golang.org/x/mod v0.4.2 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/protobuf v1.27.1 // indirect
)
This diff is collapsed.
...@@ -13,14 +13,18 @@ import ( ...@@ -13,14 +13,18 @@ import (
"errors" "errors"
"io" "io"
"log" "log"
"path/filepath"
"sync" "sync"
"time" "time"
"git.autistici.org/ai3/replds" "git.autistici.org/ai3/tools/replds"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
) )
var (
defaultRenewalDays = 21
updateInterval = 1 * time.Minute
)
// certInfo represents what we know about the state of the certificate // certInfo represents what we know about the state of the certificate
// at runtime. // at runtime.
type certInfo struct { type certInfo struct {
...@@ -72,44 +76,48 @@ type CertGenerator interface { ...@@ -72,44 +76,48 @@ type CertGenerator interface {
GetCertificate(context.Context, crypto.Signer, *certConfig) ([][]byte, *x509.Certificate, error) GetCertificate(context.Context, crypto.Signer, *certConfig) ([][]byte, *x509.Certificate, error)
} }
// Manager periodically renews certificates before they expire, and // Manager periodically renews certificates before they expire.
// responds to http-01 validation requests.
type Manager struct { type Manager struct {
configDir string configDirs []string
useRSA bool useRSA bool
storage certStorage storage certStorage
certs []*certInfo
certGen CertGenerator certGen CertGenerator
renewalDays int renewalDays int
configCh chan []*certConfig configCh chan []*certConfig
doneCh chan bool doneCh chan bool
mx sync.Mutex
certs []*certInfo
} }
// NewManager creates a new Manager with the given configuration. // NewManager creates a new Manager with the given configuration.
func NewManager(config *Config, certGen CertGenerator) (*Manager, error) { func NewManager(config *Config, certGen CertGenerator) (*Manager, error) {
// Validate the configuration. // Validate the configuration.
if config.Dir == "" { if len(config.Dirs) == 0 {
return nil, errors.New("configuration parameter 'cert_dir' is unset") return nil, errors.New("configuration parameter 'config_dirs' is unset")
}
if config.Output.Path == "" {
return nil, errors.New("'output.path' is unset")
}
if config.RenewalDays <= 0 {
config.RenewalDays = defaultRenewalDays
} }
m := &Manager{ m := &Manager{
useRSA: config.UseRSA, useRSA: config.UseRSA,
configDir: filepath.Join(config.Dir, "config"), configDirs: config.Dirs,
doneCh: make(chan bool), doneCh: make(chan bool),
configCh: make(chan []*certConfig, 1), configCh: make(chan []*certConfig, 1),
certGen: certGen, certGen: certGen,
renewalDays: config.RenewalDays, renewalDays: config.RenewalDays,
} }
if m.renewalDays <= 0 {
m.renewalDays = 15
}
ds := &dirStorage{root: filepath.Join(config.Dir, "certs")} ds := &dirStorage{root: config.Output.Path}
if config.ReplDS == nil { if config.Output.ReplDS == nil {
m.storage = ds m.storage = ds
} else { } else {
r, err := replds.NewPublicClient(config.ReplDS) r, err := replds.NewPublicClient(config.Output.ReplDS)
if err != nil { if err != nil {
return nil, err return nil, err
} }
...@@ -126,7 +134,7 @@ func NewManager(config *Config, certGen CertGenerator) (*Manager, error) { ...@@ -126,7 +134,7 @@ func NewManager(config *Config, certGen CertGenerator) (*Manager, error) {
// cause background processing to stop, interrupting all running // cause background processing to stop, interrupting all running
// updates. // updates.
func (m *Manager) Start(ctx context.Context) error { func (m *Manager) Start(ctx context.Context) error {
domains, err := readCertConfigsFromDir(m.configDir) domains, err := readCertConfigsFromDirs(m.configDirs)
if err != nil { if err != nil {
return err return err
} }
...@@ -145,7 +153,7 @@ func (m *Manager) Wait() { ...@@ -145,7 +153,7 @@ func (m *Manager) Wait() {
// Reload configuration. // Reload configuration.
func (m *Manager) Reload() { func (m *Manager) Reload() {
domains, err := readCertConfigsFromDir(m.configDir) domains, err := readCertConfigsFromDirs(m.configDirs)
if err != nil { if err != nil {
log.Printf("error reading config: %v", err) log.Printf("error reading config: %v", err)
return return
...@@ -156,7 +164,7 @@ func (m *Manager) Reload() { ...@@ -156,7 +164,7 @@ func (m *Manager) Reload() {
var ( var (
renewalTimeout = 10 * time.Minute renewalTimeout = 10 * time.Minute
errorRetryTimeout = 10 * time.Minute errorRetryTimeout = 6 * time.Hour
) )
func (m *Manager) updateAllCerts(ctx context.Context, certs []*certInfo) { func (m *Manager) updateAllCerts(ctx context.Context, certs []*certInfo) {
...@@ -239,55 +247,41 @@ func (m *Manager) loadConfig(certs []*certConfig) []*certInfo { ...@@ -239,55 +247,41 @@ func (m *Manager) loadConfig(certs []*certConfig) []*certInfo {
return out return out
} }
func (m *Manager) getCerts() []*certInfo {
m.mx.Lock()
defer m.mx.Unlock()
return m.certs
}
func (m *Manager) setCerts(certs []*certInfo) {
m.mx.Lock()
m.certs = certs
m.mx.Unlock()
}
// This channel is used by the testing code to trigger an update, // This channel is used by the testing code to trigger an update,
// without having to wait for the timer to tick. // without having to wait for the timer to tick.
var testUpdateCh = make(chan bool) var testUpdateCh = make(chan bool)
func (m *Manager) loop(ctx context.Context) { func (m *Manager) loop(ctx context.Context) {
// Updates are long-term jobs, so they should be reloadCh := make(chan interface{}, 1)
// interruptible. We run updates in a separate goroutine, and go func() {
// cancel them when the configuration is reloaded or on exit. for config := range m.configCh {
var upCancel context.CancelFunc certs := m.loadConfig(config)
var wg sync.WaitGroup m.setCerts(certs)
reloadCh <- certs
startUpdate := func(certs []*certInfo) context.CancelFunc {
// Ensure the previous update has finished.
wg.Wait()
upCtx, cancel := context.WithCancel(ctx)
wg.Add(1)
go func() {
m.updateAllCerts(upCtx, certs)
wg.Done()
}()
return cancel
}
// Cancel the running update, if any. Called on config
// updates, when exiting.
cancelUpdate := func() {
if upCancel != nil {
upCancel()
} }
wg.Wait() }()
}
defer cancelUpdate()
tick := time.NewTicker(5 * time.Minute) runWithUpdates(
defer tick.Stop() ctx,
for { func(ctx context.Context, value interface{}) {
select { certs := value.([]*certInfo)
case <-tick.C: m.updateAllCerts(ctx, certs)
upCancel = startUpdate(m.certs) },
case <-testUpdateCh: reloadCh,
upCancel = startUpdate(m.certs) updateInterval,
case certDomains := <-m.configCh: )
cancelUpdate()
m.certs = m.loadConfig(certDomains)
case <-ctx.Done():
return
}
}
} }
func concatDER(der [][]byte) []byte { func concatDER(der [][]byte) []byte {
...@@ -306,10 +300,8 @@ func concatDER(der [][]byte) []byte { ...@@ -306,10 +300,8 @@ func concatDER(der [][]byte) []byte {
func certRequest(key crypto.Signer, domains []string) ([]byte, error) { func certRequest(key crypto.Signer, domains []string) ([]byte, error) {
req := &x509.CertificateRequest{ req := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: domains[0]}, Subject: pkix.Name{CommonName: domains[0]},
} DNSNames: domains,
if len(domains) > 1 {
req.DNSNames = domains[1:]
} }
return x509.CreateCertificateRequest(rand.Reader, req, key) return x509.CreateCertificateRequest(rand.Reader, req, key)
} }
......
...@@ -26,6 +26,16 @@ func (s *slowACME) GetCertificate(ctx context.Context, _ crypto.Signer, _ *certC ...@@ -26,6 +26,16 @@ func (s *slowACME) GetCertificate(ctx context.Context, _ crypto.Signer, _ *certC
} }
} }
func newTestConfig(dir string) *Config {
c := Config{
Dirs: []string{filepath.Join(dir, "config")},
AccountKeyPath: filepath.Join(dir, "account.key"),
Email: "test@example.com",
}
c.Output.Path = filepath.Join(dir, "certs")
return &c
}
// Create a new test function. // Create a new test function.
// The first function returned is a cleanup callback. // The first function returned is a cleanup callback.
// The second function returned is the cancel callback. // The second function returned is the cancel callback.
...@@ -42,10 +52,7 @@ func newTestManager(t testing.TB, g CertGenerator) (func(), context.CancelFunc, ...@@ -42,10 +52,7 @@ func newTestManager(t testing.TB, g CertGenerator) (func(), context.CancelFunc,
0644, 0644,
) )
m, err := NewManager(&Config{ m, err := NewManager(newTestConfig(dir), g)
Dir: dir,
Email: "test@example.com",
}, g)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
...@@ -70,36 +77,44 @@ func TestManager_Reload(t *testing.T) { ...@@ -70,36 +77,44 @@ func TestManager_Reload(t *testing.T) {
defer cleanup() defer cleanup()
// Data race: we read data owned by another goroutine! // Data race: we read data owned by another goroutine!
if len(m.certs) < 1 { certs := m.getCerts()
if len(certs) < 1 {
t.Fatal("configuration not loaded?") t.Fatal("configuration not loaded?")
} }
if m.certs[0].cn() != "example.com" { if certs[0].cn() != "example.com" {
t.Fatalf("certs[0].cn() is %s, expected example.com", m.certs[0].cn()) t.Fatalf("certs[0].cn() is %s, expected example.com", certs[0].cn())
} }
// Try a reload, catch obvious errors. // Try a reload, catch obvious errors.
m.Reload() m.Reload()
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
certs = m.getCerts()
if len(m.certs) != 1 { if len(certs) != 1 {
t.Fatalf("certs count is %d, expected 1", len(m.certs)) t.Fatalf("certs count is %d, expected 1", len(m.certs))
} }
if m.certs[0].cn() != "example.com" { if certs[0].cn() != "example.com" {
t.Fatalf("certs[0].cn() is %s, expected example.com", m.certs[0].cn()) t.Fatalf("certs[0].cn() is %s, expected example.com", certs[0].cn())
} }
} }
func TestManager_NewCert(t *testing.T) { func TestManager_NewCert(t *testing.T) {
var oldUpdateInterval time.Duration
oldUpdateInterval, updateInterval = updateInterval, 50*time.Millisecond
defer func() {
updateInterval = oldUpdateInterval
}()
cleanup, _, m := newTestManager(t, NewSelfSignedCertGenerator()) cleanup, _, m := newTestManager(t, NewSelfSignedCertGenerator())
defer cleanup() defer cleanup()
now := time.Now() now := time.Now()
ci := m.certs[0] certs := m.getCerts()
ci := certs[0]
if ci.retryDeadline.After(now) { if ci.retryDeadline.After(now) {
t.Fatalf("retry deadline is in the future: %v", ci.retryDeadline) t.Fatalf("retry deadline is in the future: %v", ci.retryDeadline)
} }
testUpdateCh <- true
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
// Verify that the retry/renewal timestamp is in the future. // Verify that the retry/renewal timestamp is in the future.
...@@ -114,11 +129,11 @@ func TestManager_NewCert(t *testing.T) { ...@@ -114,11 +129,11 @@ func TestManager_NewCert(t *testing.T) {
// Verify that the credentials have successfully been written // Verify that the credentials have successfully been written
// to storage. // to storage.
p := filepath.Join(m.configDir, "../certs/example.com/cert.pem") p := filepath.Join(m.configDirs[0], "../certs/example.com/cert.pem")
if _, err := os.Stat(p); err != nil { if _, err := os.Stat(p); err != nil {
t.Fatalf("file not created: %v", err) t.Fatalf("file not created: %v", err)
} }
p = filepath.Join(m.configDir, "../certs/example.com/private_key.pem") p = filepath.Join(m.configDirs[0], "../certs/example.com/privkey.pem")
if _, err := os.Stat(p); err != nil { if _, err := os.Stat(p); err != nil {
t.Fatalf("file not created: %v", err) t.Fatalf("file not created: %v", err)
} }
...@@ -128,7 +143,7 @@ func TestManager_NewCert(t *testing.T) { ...@@ -128,7 +143,7 @@ func TestManager_NewCert(t *testing.T) {
m.Reload() m.Reload()
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
ci = m.certs[0] ci = m.getCerts()[0]
if !ci.valid { if !ci.valid {
t.Fatal("certificate is invalid after a reload") t.Fatal("certificate is invalid after a reload")
} }
......
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
]
}
...@@ -16,7 +16,7 @@ import ( ...@@ -16,7 +16,7 @@ import (
"strings" "strings"
"time" "time"
"git.autistici.org/ai3/replds" "git.autistici.org/ai3/tools/replds"
) )
type dirStorage struct { type dirStorage struct {
...@@ -25,7 +25,7 @@ type dirStorage struct { ...@@ -25,7 +25,7 @@ type dirStorage struct {
func (d *dirStorage) GetCert(cn string) ([][]byte, crypto.Signer, error) { func (d *dirStorage) GetCert(cn string) ([][]byte, crypto.Signer, error) {
certPath := filepath.Join(d.root, cn, "fullchain.pem") certPath := filepath.Join(d.root, cn, "fullchain.pem")
keyPath := filepath.Join(d.root, cn, "private_key.pem") keyPath := filepath.Join(d.root, cn, "privkey.pem")
der, err := parseCertsFromFile(certPath) der, err := parseCertsFromFile(certPath)
if err != nil { if err != nil {
...@@ -53,7 +53,7 @@ func (d *dirStorage) PutCert(cn string, der [][]byte, key crypto.Signer) error { ...@@ -53,7 +53,7 @@ func (d *dirStorage) PutCert(cn string, der [][]byte, key crypto.Signer) error {
for path, data := range filemap { for path, data := range filemap {
var mode os.FileMode = 0644 var mode os.FileMode = 0644
if strings.HasSuffix(path, "private_key.pem") { if strings.HasSuffix(path, "privkey.pem") {
mode = 0400 mode = 0400
} }
log.Printf("writing %s (%03o)", path, mode) log.Printf("writing %s (%03o)", path, mode)
...@@ -79,11 +79,19 @@ func dumpCertsAndKey(cn string, der [][]byte, key crypto.Signer) (map[string][]b ...@@ -79,11 +79,19 @@ func dumpCertsAndKey(cn string, der [][]byte, key crypto.Signer) (map[string][]b
} }
m[filepath.Join(cn, "cert.pem")] = data m[filepath.Join(cn, "cert.pem")] = data
if len(der) > 1 {
data, err = encodeCerts(der[1:])
if err != nil {
return nil, err
}
m[filepath.Join(cn, "chain.pem")] = data
}
data, err = encodePrivateKey(key) data, err = encodePrivateKey(key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
m[filepath.Join(cn, "private_key.pem")] = data m[filepath.Join(cn, "privkey.pem")] = data
return m, nil return m, nil
} }
...@@ -104,7 +112,7 @@ func (d *replStorage) PutCert(cn string, der [][]byte, key crypto.Signer) error ...@@ -104,7 +112,7 @@ func (d *replStorage) PutCert(cn string, der [][]byte, key crypto.Signer) error
now := time.Now() now := time.Now()
var req replds.SetNodesRequest var req replds.SetNodesRequest
for path, data := range filemap { for path, data := range filemap {
req.Nodes = append(req.Nodes, replds.Node{ req.Nodes = append(req.Nodes, &replds.Node{
Path: path, Path: path,
Value: data, Value: data,
Timestamp: now, Timestamp: now,
...@@ -171,7 +179,7 @@ func encodePrivateKey(key crypto.Signer) ([]byte, error) { ...@@ -171,7 +179,7 @@ func encodePrivateKey(key crypto.Signer) ([]byte, error) {
switch priv := key.(type) { switch priv := key.(type) {
case *rsa.PrivateKey: case *rsa.PrivateKey:
pb = &pem.Block{ pb = &pem.Block{
Type: "PRIVATE KEY", Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(priv), Bytes: x509.MarshalPKCS1PrivateKey(priv),
} }
case *ecdsa.PrivateKey: case *ecdsa.PrivateKey:
......
package acmeserver
import (
"context"
"sync"
"time"
)
// Updates are long-term jobs, so they should be interruptible. We run
// updates in a separate goroutine, and cancel them when the
// configuration is reloaded or on exit. A semaphore ensures that only
// one update goroutine will be running at any given time (without
// other ones piling up).
func runWithUpdates(ctx context.Context, fn func(context.Context, interface{}), reloadCh <-chan interface{}, updateInterval time.Duration) {
// Function to cancel the current update, and the associated
// WaitGroup to wait for its termination.
var upCancel context.CancelFunc
var wg sync.WaitGroup
sem := make(chan struct{}, 1)
startUpdate := func(value interface{}) context.CancelFunc {
// Acquire the semaphore, return if we fail to.
// Equivalent to a 'try-lock' construct.
select {
case sem <- struct{}{}:
default:
return nil
}
defer func() {
<-sem
}()
ctx, cancel := context.WithCancel(ctx)
wg.Add(1)
go func() {
fn(ctx, value)
wg.Done()
}()
return cancel
}
// Cancel the running update, if any. Called on config
// updates, when exiting.
cancelUpdate := func() {
if upCancel != nil {
upCancel()
upCancel = nil
}
wg.Wait()
}
defer cancelUpdate()
var cur interface{}
tick := time.NewTicker(updateInterval)
defer tick.Stop()
for {
select {
case <-tick.C:
// Do not cancel running update when running the ticker.
if cancel := startUpdate(cur); cancel != nil {
upCancel = cancel
}
case value := <-reloadCh:
// Cancel the running update when configuration is reloaded.
cancelUpdate()
cur = value
case <-ctx.Done():
return
}
}
}
stages:
- test
run_tests:
stage: test
image: registry.git.autistici.org/ai3/docker/test/golang:master
script:
- run-go-test ./...
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: cover.xml
junit: report.xml
ai3/go-common
===
Common code for ai3 services and tools.
A quick overview of the contents:
* [client](clientutil/) and [server](serverutil/) HTTP-based
"RPC" implementation, just JSON POST requests but with retries,
backoff, timeouts, tracing, etc.
* [server implementation of a generic line-based protocol over a UNIX
socket](unix/).
* a [LDAP connection pool](ldap/).
* utilities to [serialize composite data types](ldap/compositetypes/)
used in our LDAP database.
* a [password hashing library](pwhash/) that uses fancy advanced
crypto by default but is also backwards compatible with old
libc crypto.
* utilities to [manage encryption keys](userenckey/), themselves
encrypted with a password and a KDF.