Certificates for other hosts: jarl cert get
jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4bb99b482
commit
9db76a113c
29 files changed
+1568
-40
No files matched your search
@@ -52,6 +52,9 @@ type Config struct {
|
||||
PropagationTimeout time.Duration
|
||||
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||
SkipPropagationCheck bool
|
||||
// MaxPerUser is how many certificates a user may own (Ensure), unless
|
||||
// the user has a limit of their own.
|
||||
MaxPerUser int
|
||||
}
|
||||
|
||||
// Manager keeps certificates current and serves jarld's own.
|
||||
@@ -132,6 +135,8 @@ type Input struct {
|
||||
Challenge *string `json:"challenge"`
|
||||
KeyType *string `json:"key_type"`
|
||||
Comment *string `json:"comment"`
|
||||
// Owner sets the owning user (Ensure only).
|
||||
Owner *int64 `json:"-"`
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
@@ -210,6 +215,9 @@ func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
|
||||
if in.Comment != nil {
|
||||
c.Comment = *in.Comment
|
||||
}
|
||||
if in.Owner != nil {
|
||||
c.OwnerID = *in.Owner
|
||||
}
|
||||
switch c.Source {
|
||||
case model.CertSourceACME:
|
||||
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
||||
@@ -320,6 +328,103 @@ func (m *Manager) PEM(ctx context.Context, name string) (*model.Certificate, []b
|
||||
return c, []byte(c.CertPEM), []byte(c.KeyPEM), nil
|
||||
}
|
||||
|
||||
// ErrTaken is Ensure's answer for a name another user's certificate (or
|
||||
// the administrators') has.
|
||||
var ErrTaken = errors.New("certificate name taken")
|
||||
|
||||
// LimitFor is how many certificates u may own.
|
||||
func (m *Manager) LimitFor(u *model.User) int {
|
||||
if u != nil && u.CertLimit != nil {
|
||||
return *u.CertLimit
|
||||
}
|
||||
return m.cfg.MaxPerUser
|
||||
}
|
||||
|
||||
// EnsureResult is what Ensure did.
|
||||
type EnsureResult struct {
|
||||
Certificate *model.Certificate
|
||||
Created bool // a new certificate
|
||||
Changed bool // its names changed, so it is obtained again
|
||||
// Ignored says what of the request an existing certificate keeps as
|
||||
// it is (a different key type).
|
||||
Ignored string
|
||||
}
|
||||
|
||||
// Ensure is jarl cert get: the certificate name, owned by p's user, for
|
||||
// domains. A new one is created (within the user's limit) and obtained
|
||||
// at once. An existing one must be the user's: it is obtained again only
|
||||
// when its names change (a name added or removed); otherwise it is
|
||||
// returned as it is, renewed by the loop as usual. The caller checks that
|
||||
// p may have these names.
|
||||
func (m *Manager) Ensure(ctx context.Context, name string, domains []string, keyType string, p *auth.Principal) (*EnsureResult, error) {
|
||||
owner := p.User
|
||||
want, err := normDomains(domains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := m.Get(ctx, name)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
all, err := m.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := 0
|
||||
for _, o := range all {
|
||||
if o.OwnerID == owner.ID {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if limit := m.LimitFor(owner); n >= limit {
|
||||
return nil, inputErr("user %s has %d certificates, its limit (an administrator can raise it: jarl user update %s --cert-limit N, or certificates.max_per_user in jarl.yaml)", owner.Username, n, owner.Username)
|
||||
}
|
||||
in := Input{Name: &name, Domains: &want, Owner: &owner.ID, Comment: model.Ptr("for " + owner.Username + " (jarl cert get)")}
|
||||
if keyType != "" {
|
||||
in.KeyType = &keyType
|
||||
}
|
||||
c, err = m.Create(ctx, in, p)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
return nil, ErrTaken // created meanwhile
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err = m.Get(ctx, name)
|
||||
return &EnsureResult{Certificate: c, Created: true}, err
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
if c.OwnerID != owner.ID && !(p.IsAdmin() && c.OwnerID == 0) {
|
||||
return nil, ErrTaken
|
||||
}
|
||||
if c.Source != model.CertSourceACME {
|
||||
return nil, inputErr("certificate %s is read from files on the server, so Jarl can't obtain it for you", name)
|
||||
}
|
||||
res := &EnsureResult{Certificate: c}
|
||||
if keyType != "" && keyType != c.KeyType {
|
||||
res.Ignored = "it keeps its key type " + c.KeyType + " (changing it means a new certificate: delete this one, or ask an administrator)"
|
||||
}
|
||||
have := slices.Clone(c.Domains)
|
||||
slices.Sort(have)
|
||||
sorted := slices.Clone(want)
|
||||
slices.Sort(sorted)
|
||||
if !slices.Equal(have, sorted) {
|
||||
if c, err = m.Update(ctx, name, Input{Domains: &want}, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Certificate, res.Changed = c, true
|
||||
}
|
||||
if res.Changed || c.CertPEM == "" && m.Issuing(name).IsZero() && c.Failures == 0 {
|
||||
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Create adds a certificate. A file-source certificate is read at once;
|
||||
// one Jarl obtains is left for Issue (or the background loop).
|
||||
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||
|
||||
Reference in new issue
Block a user