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
@@ -420,6 +420,9 @@ type UserInput struct {
|
||||
Role *string `json:"role,omitempty"` // a role template name
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// CertLimit is how many certificates the user may own (jarl cert
|
||||
// get); a negative number goes back to certificates.max_per_user.
|
||||
CertLimit *int `json:"cert_limit,omitempty"`
|
||||
}
|
||||
|
||||
// TokenInput creates a token.
|
||||
|
||||
@@ -134,3 +134,23 @@ func TestRoleTemplatesValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateGrants(t *testing.T) {
|
||||
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.adbraun.lol"}) || GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.example.org"}) {
|
||||
t.Error("names filter")
|
||||
}
|
||||
if _, err := ParseGrant("certificates:read types=TXT"); err == nil {
|
||||
t.Error("types on certificates accepted")
|
||||
}
|
||||
if _, err := ParseGrant("certificates:read zones=adbraun.lol"); err == nil {
|
||||
t.Error("zones on certificates accepted")
|
||||
}
|
||||
// "*" never covers certificates: they hand out private keys.
|
||||
if GrantAllows(model.Grant{Resource: ResAll, Access: Write}, Action{Resource: ResCerts, Name: "mail.adbraun.lol"}) {
|
||||
t.Error("* covers certificates")
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -21,11 +21,14 @@ const (
|
||||
ResAudit = "audit" // audit log and everyone's changesets
|
||||
ResStats = "stats" // query statistics
|
||||
ResBlocking = "blocking" // blocklists and block/allow rules
|
||||
ResAll = "*"
|
||||
// ResCerts is certificates for other hosts (jarl cert get), by the
|
||||
// names in them. It is never part of "*": it hands out private keys.
|
||||
ResCerts = "certificates"
|
||||
ResAll = "*"
|
||||
)
|
||||
|
||||
// Resources lists the resources in display order.
|
||||
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
|
||||
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking, ResCerts}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
|
||||
for i, t := range g.Types {
|
||||
g.Types[i] = strings.ToUpper(t)
|
||||
}
|
||||
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
|
||||
return fmt.Errorf("names and types filters only apply to the records resource")
|
||||
if len(g.Types) > 0 && g.Resource != ResRecords {
|
||||
return fmt.Errorf("the types filter only applies to the records resource")
|
||||
}
|
||||
if len(g.Names) > 0 && g.Resource != ResRecords && g.Resource != ResCerts {
|
||||
return fmt.Errorf("the names filter only applies to the records and certificates resources")
|
||||
}
|
||||
if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews {
|
||||
return fmt.Errorf("zone and view filters only apply to the zones, records and views resources")
|
||||
@@ -228,6 +234,9 @@ func GrantAllows(g model.Grant, a Action) bool {
|
||||
if g.Resource != ResAll && g.Resource != a.Resource {
|
||||
return false
|
||||
}
|
||||
if g.Resource == ResAll && a.Resource == ResCerts {
|
||||
return false // only an explicit certificates grant
|
||||
}
|
||||
if a.Write && g.Access != Write {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -51,6 +51,10 @@ type Certificates struct {
|
||||
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||
// "" is <bind.config_dir>/tls.
|
||||
Dir string `yaml:"dir"`
|
||||
// MaxPerUser is how many certificates a user may own through jarl
|
||||
// cert get (certificates for other hosts), unless the user has a
|
||||
// limit of their own (jarl user update --cert-limit). Default 10.
|
||||
MaxPerUser int `yaml:"max_per_user"`
|
||||
}
|
||||
|
||||
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||
@@ -467,6 +471,7 @@ func Default() *Config {
|
||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 24 * time.Hour)},
|
||||
TopN: 100,
|
||||
},
|
||||
Certificates: Certificates{MaxPerUser: 10},
|
||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
||||
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
|
||||
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||
|
||||
@@ -62,11 +62,27 @@ type Certificate struct {
|
||||
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||
LastRoutes []string `json:"last_routes,omitempty"`
|
||||
|
||||
// OwnerID is the user a certificate for another host belongs to
|
||||
// (jarl cert get); 0 for the administrators' own.
|
||||
OwnerID int64 `json:"owner_id,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
// CertificatePull records that someone downloaded a certificate: who
|
||||
// ("token NAME" or "user NAME"), which one (Serial), when and from where.
|
||||
type CertificatePull struct {
|
||||
CertID int64 `json:"-"`
|
||||
Who string `json:"who"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
TokenID int64 `json:"token_id,omitempty"`
|
||||
Serial string `json:"serial"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HasUse reports whether c is used for use.
|
||||
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||
@@ -76,6 +76,9 @@ type User struct {
|
||||
TOTPSecret string `json:"-"`
|
||||
TOTPPending string `json:"-"`
|
||||
TOTPLastStep int64 `json:"-"`
|
||||
// CertLimit is how many certificates the user may own (jarl cert get);
|
||||
// nil: certificates.max_per_user in jarl.yaml.
|
||||
CertLimit *int `json:"cert_limit,omitempty"`
|
||||
}
|
||||
|
||||
// HasTOTP reports whether the user logs in with a TOTP code.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// certUser creates a service user with grants and returns a token.
|
||||
func certUser(t *testing.T, e *apiEnv, name string, grants ...string) (string, *model.User) {
|
||||
t.Helper()
|
||||
gs := []model.Grant{}
|
||||
for _, s := range grants {
|
||||
g, err := auth.ParseGrant(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gs = append(gs, g)
|
||||
}
|
||||
u := &model.User{Username: name, Grants: gs}
|
||||
if err := e.st.CreateUser(context.Background(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, pid, sh := auth.NewToken()
|
||||
if err := e.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: name + "-host", PublicID: pid, SecretHash: sh}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tok, u
|
||||
}
|
||||
|
||||
// obtained gives a certificate a current certificate, as if the CA had
|
||||
// signed it.
|
||||
func obtained(t *testing.T, e *apiEnv, name string, domains ...string) {
|
||||
t.Helper()
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), domains...)
|
||||
cb, _ := os.ReadFile(certFile)
|
||||
kb, _ := os.ReadFile(keyFile)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 50 && e.srv.cfg.Certs.Issuing(name) != (time.Time{}); i++ {
|
||||
time.Sleep(20 * time.Millisecond) // the failing attempt (no CA) ends
|
||||
}
|
||||
c, err := e.st.CertificateByName(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
na, nb := time.Now().Add(30*24*time.Hour), time.Now()
|
||||
c.CertPEM, c.KeyPEM, c.NotAfter, c.NotBefore, c.Serial, c.Failures, c.LastError = string(cb), string(kb), &na, &nb, "s-"+name, 0, ""
|
||||
if err := e.st.UpdateCertificate(ctx, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertGet(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
withCerts(e)
|
||||
mx, mxUser := certUser(t, e, "mx", "certificates:write names=*.adbraun.lol")
|
||||
other, _ := certUser(t, e, "other", "certificates:write names=*.adbraun.lol")
|
||||
wide, _ := certUser(t, e, "wide", "*:write")
|
||||
|
||||
req := func(tok, name string, domains ...string) (map[string]any, int) {
|
||||
b, status := e.req("POST", "/certificates/"+name+"/request", tok, map[string]any{"domains": domains})
|
||||
var out map[string]any
|
||||
_ = jsonUnmarshal(b, &out)
|
||||
return out, status
|
||||
}
|
||||
// "*" doesn't cover certificates.
|
||||
if _, st := req(wide, "mail", "mail.adbraun.lol"); st != 403 {
|
||||
t.Errorf("*:write: %d", st)
|
||||
}
|
||||
// Names outside the grant.
|
||||
if out, st := req(mx, "mail", "mail.example.org"); st != 403 || !strings.Contains(errMsg(out), "mail.example.org") {
|
||||
t.Errorf("other names: %d %v", st, out)
|
||||
}
|
||||
// A new certificate, owned by mx.
|
||||
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 && st != 202 || out["created"] != true {
|
||||
t.Fatalf("create: %d %v", st, out)
|
||||
}
|
||||
c, _ := e.st.CertificateByName(context.Background(), "mail")
|
||||
if c.OwnerID != mxUser.ID || len(c.Uses) != 0 {
|
||||
t.Errorf("owner %d uses %v", c.OwnerID, c.Uses)
|
||||
}
|
||||
// Another user can't have the name, or see it.
|
||||
if out, st := req(other, "mail", "mail.adbraun.lol"); st != 409 || !strings.Contains(errMsg(out), "taken") {
|
||||
t.Errorf("taken: %d %v", st, out)
|
||||
}
|
||||
e.do("GET", "/certificates/mail", other, nil, 404)
|
||||
if l := e.do("GET", "/certificates", other, nil, 200)["certificates"].([]any); len(l) != 0 {
|
||||
t.Errorf("other's list: %v", l)
|
||||
}
|
||||
// Nothing to download until it is obtained.
|
||||
e.do("GET", "/certificates/mail/pem", mx, nil, 400)
|
||||
obtained(t, e, "mail", "mail.adbraun.lol")
|
||||
|
||||
// The same names: the same certificate.
|
||||
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 || out["created"] != false || out["changed"] != false {
|
||||
t.Errorf("again: %d %v", st, out)
|
||||
}
|
||||
if c2, _ := e.st.CertificateByName(context.Background(), "mail"); c2.Serial != "s-mail" {
|
||||
t.Errorf("replaced: %v", c2.Serial)
|
||||
}
|
||||
// Download: recorded, with the token.
|
||||
if b, st := e.req("GET", "/certificates/mail/pem?part=key", mx, nil); st != 200 || !strings.Contains(string(b), "PRIVATE KEY") {
|
||||
t.Errorf("key: %d", st)
|
||||
}
|
||||
got := e.do("GET", "/certificates/mail", e.token, nil, 200)
|
||||
pulls, _ := got["pulled_by"].([]any)
|
||||
if got["owner"] != "mx" || len(pulls) != 1 || !strings.Contains(pulls[0].(map[string]any)["who"].(string), "mx-host") || pulls[0].(map[string]any)["serial"] != "s-mail" {
|
||||
t.Errorf("owner %v pulled_by %v", got["owner"], got["pulled_by"])
|
||||
}
|
||||
// A name added: obtained again.
|
||||
if out, st := req(mx, "mail", "mail.adbraun.lol", "smtp.adbraun.lol"); out["changed"] != true || st != 200 && st != 202 {
|
||||
t.Errorf("SAN added: %d %v", st, out)
|
||||
}
|
||||
// The owner's own list.
|
||||
if l := e.do("GET", "/certificates", mx, nil, 200)["certificates"].([]any); len(l) != 1 {
|
||||
t.Errorf("mx's list: %v", l)
|
||||
}
|
||||
// Jarl's own certificates are the administrators'.
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.adbraun.lol")
|
||||
e.do("POST", "/certificates", e.token, map[string]any{"name": "web", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
|
||||
if _, st := req(mx, "web", "jarl.adbraun.lol"); st != 409 {
|
||||
t.Errorf("Jarl's own: %d", st)
|
||||
}
|
||||
e.do("GET", "/certificates/web/pem", mx, nil, 404)
|
||||
// The limit: one more for mx, then no more.
|
||||
limit := 2
|
||||
mxUser.CertLimit = &limit
|
||||
if err := e.st.UpdateUser(context.Background(), mxUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, st := req(mx, "www", "www.adbraun.lol"); st != 200 && st != 202 {
|
||||
t.Errorf("second: %d %v", st, out)
|
||||
}
|
||||
if out, st := req(mx, "third", "x.adbraun.lol"); st != 400 || !strings.Contains(errMsg(out), "limit") {
|
||||
t.Errorf("over the limit: %d %v", st, out)
|
||||
}
|
||||
// The owner may delete theirs.
|
||||
e.do("DELETE", "/certificates/www", mx, nil, 200)
|
||||
}
|
||||
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// errMsg is an error answer's message.
|
||||
func errMsg(out map[string]any) string {
|
||||
e, _ := out["error"].(map[string]any)
|
||||
m, _ := e["message"].(string)
|
||||
return m
|
||||
}
|
||||
|
||||
// A private key goes back only over HTTPS or to this server itself.
|
||||
func TestKeyTransport(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
for addr, want := range map[string]bool{"127.0.0.1:5000": true, "[::1]:5000": true, "192.0.2.7:5000": false} {
|
||||
r, _ := http.NewRequest("GET", "/x", nil)
|
||||
r.RemoteAddr = addr
|
||||
if ok, why := e.srv.keyTransport(r); ok != want {
|
||||
t.Errorf("%s: %v (%s)", addr, ok, why)
|
||||
}
|
||||
}
|
||||
r, _ := http.NewRequest("GET", "/x", nil)
|
||||
r.RemoteAddr, r.TLS = "192.0.2.7:5000", &tls.ConnectionState{}
|
||||
if ok, _ := e.srv.keyTransport(r); !ok {
|
||||
t.Error("HTTPS refused")
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile st
|
||||
|
||||
func withCerts(env *apiEnv) *certs.Manager {
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
|
||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir", MaxPerUser: 10}, env.st, env.srv.eng, quiet)
|
||||
env.srv.cfg.Certs = m
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
|
||||
if in.Comment != nil {
|
||||
u.Comment = *in.Comment
|
||||
}
|
||||
if in.CertLimit != nil {
|
||||
u.CertLimit = in.CertLimit
|
||||
if *in.CertLimit < 0 {
|
||||
u.CertLimit = nil
|
||||
}
|
||||
}
|
||||
if in.Password != nil {
|
||||
if *in.Password == "" {
|
||||
u.PasswordHash = "" // service account: tokens only
|
||||
|
||||
+194
-6
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
@@ -28,6 +32,10 @@ type certView struct {
|
||||
NamedUse []string `json:"named_use,omitempty"`
|
||||
// IssuingSince is set while Jarl is obtaining it.
|
||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||
// Owner is the user a certificate for another host belongs to; PulledBy
|
||||
// who downloaded it (list and get, administrators only).
|
||||
Owner string `json:"owner,omitempty"`
|
||||
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
|
||||
}
|
||||
|
||||
type certFiles struct {
|
||||
@@ -62,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// certsAccess lets in administrators and users with a certificates grant
|
||||
// (certificates for other hosts: their own only).
|
||||
func (s *Server) certsAccess(p *auth.Principal) error {
|
||||
if s.cfg.Certs == nil {
|
||||
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||
}
|
||||
if p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResCerts}) {
|
||||
return nil
|
||||
}
|
||||
return forbidden("certificates need an administrator, or a certificates grant for their names (certificates:write names=*.example.com)")
|
||||
}
|
||||
|
||||
// certAllowed reports whether p may see (or, with write, change) c: an
|
||||
// administrator any; anyone else their own, while their grants cover all
|
||||
// its names.
|
||||
func certAllowed(p *auth.Principal, c *model.Certificate, write bool) bool {
|
||||
if p.IsAdmin() {
|
||||
return true
|
||||
}
|
||||
if p.User == nil || c.OwnerID == 0 || c.OwnerID != p.User.ID {
|
||||
return false
|
||||
}
|
||||
for _, d := range c.Domains {
|
||||
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: write, Name: d}) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// certFor returns the certificate in the path, if p may see it (else not
|
||||
// found, so names of others' certificates don't leak).
|
||||
func (s *Server) certFor(r *http.Request, p *auth.Principal, write bool) (*model.Certificate, error) {
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return nil, certErr(err)
|
||||
}
|
||||
if !certAllowed(p, c, write) {
|
||||
if write && certAllowed(p, c, false) {
|
||||
return nil, forbidden("you may only download certificate %s", c.Name)
|
||||
}
|
||||
return nil, notFound("no such certificate")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// withOwners adds owners and downloads to views (for administrators).
|
||||
func (s *Server) withOwners(ctx context.Context, views []certView) error {
|
||||
pulls, err := s.store.CertificatePulls(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := s.store.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := map[int64]string{}
|
||||
for _, u := range users {
|
||||
names[u.ID] = u.Username
|
||||
}
|
||||
for i := range views {
|
||||
if id := views[i].OwnerID; id != 0 {
|
||||
views[i].Owner = names[id]
|
||||
if views[i].Owner == "" {
|
||||
views[i].Owner = fmt.Sprintf("deleted user %d", id)
|
||||
}
|
||||
}
|
||||
views[i].PulledBy = pulls[views[i].ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certErr(err error) error {
|
||||
var ie *certs.InputError
|
||||
switch {
|
||||
@@ -71,13 +151,15 @@ func certErr(err error) error {
|
||||
return notFound("no such certificate")
|
||||
case errors.Is(err, certs.ErrConflict):
|
||||
return conflict("a certificate with that name exists")
|
||||
case errors.Is(err, certs.ErrTaken):
|
||||
return conflict("the certificate name is taken by another user's certificate (or one Jarl uses): choose another name")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// certList: GET /certificates.
|
||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := s.cfg.Certs.List(r.Context())
|
||||
@@ -90,22 +172,32 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
||||
}
|
||||
out := []certView{}
|
||||
for _, c := range all {
|
||||
if !certAllowed(p, c, false) {
|
||||
continue
|
||||
}
|
||||
v := s.presentCert(c)
|
||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||
out = append(out, v)
|
||||
}
|
||||
if !p.IsAdmin() {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
|
||||
return nil
|
||||
}
|
||||
if err := s.withOwners(r.Context(), out); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certGet: GET /certificates/{name}.
|
||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
c, err := s.certFor(r, p, false)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
return err
|
||||
}
|
||||
st, err := s.store.LoadState(r.Context())
|
||||
if err != nil {
|
||||
@@ -113,22 +205,80 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
}
|
||||
v := s.presentCert(c)
|
||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||
if p.IsAdmin() {
|
||||
vs := []certView{v}
|
||||
if err := s.withOwners(r.Context(), vs); err != nil {
|
||||
return err
|
||||
}
|
||||
v = vs[0]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certRequest: POST /certificates/{name}/request {"domains": [...],
|
||||
// "key_type": "ec256"}, for jarl cert get: the certificate for another
|
||||
// host, owned by the caller's user. A new one is created and obtained
|
||||
// (202 while that runs; poll GET /certificates/{name} until
|
||||
// issuing_since is gone). An existing one is the caller's, obtained again
|
||||
// only when its names change; otherwise it is returned as it is (200).
|
||||
func (s *Server) certRequest(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.User == nil || p.User.ID == 0 {
|
||||
return forbidden("certificates for other hosts belong to a user: use an API token, not the local socket as root")
|
||||
}
|
||||
var in struct {
|
||||
Domains []string `json:"domains"`
|
||||
KeyType string `json:"key_type"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range in.Domains {
|
||||
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
|
||||
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: true, Name: d}) {
|
||||
return forbidden("you may not get a certificate for %s: that needs certificates:write with a names pattern that matches it", d)
|
||||
}
|
||||
}
|
||||
res, err := s.cfg.Certs.Ensure(r.Context(), r.PathValue("name"), in.Domains, in.KeyType, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
v := s.presentCert(res.Certificate)
|
||||
out := map[string]any{"certificate": v, "created": res.Created, "changed": res.Changed}
|
||||
if res.Ignored != "" {
|
||||
out["warnings"] = []string{res.Ignored}
|
||||
}
|
||||
status := http.StatusOK
|
||||
if v.IssuingSince != nil {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle,
|
||||
// the certificate as PEM for use elsewhere: the chain (default), the
|
||||
// certificate alone, the private key, or the key followed by the chain
|
||||
// (one file, as HAProxy wants). Anything with the key is audited.
|
||||
func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.certFor(r, p, false); err != nil {
|
||||
return err
|
||||
}
|
||||
part := r.URL.Query().Get("part")
|
||||
if part == "" {
|
||||
part = "fullchain"
|
||||
}
|
||||
if part == "key" || part == "bundle" {
|
||||
if ok, why := s.keyTransport(r); !ok {
|
||||
return &httpError{http.StatusForbidden, "https_required", "a private key is only handed out over HTTPS (or to this server itself), and this request came by " + why}
|
||||
}
|
||||
}
|
||||
c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
@@ -155,6 +305,16 @@ func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
if part == "key" || part == "bundle" {
|
||||
s.eng.Audit(r.Context(), p, "certificate.export_key", "certificate", c.ID, c.Name, "success", "exported the private key ("+part+")")
|
||||
}
|
||||
pull := &model.CertificatePull{CertID: c.ID, Who: pullWho(p), Serial: c.Serial, IP: s.clientIP(r), At: time.Now().UTC()}
|
||||
if p.User != nil {
|
||||
pull.UserID = p.User.ID
|
||||
}
|
||||
if p.Token != nil {
|
||||
pull.TokenID = p.Token.ID
|
||||
}
|
||||
if err := s.store.RecordCertificatePull(r.Context(), pull); err != nil {
|
||||
s.log.Warn("recording a certificate download failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -208,7 +368,10 @@ func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
|
||||
// certDelete: DELETE /certificates/{name}.
|
||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.certFor(r, p, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||
@@ -303,3 +466,28 @@ func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keyTransport reports whether a private key may go back over r: HTTPS
|
||||
// (also through a trusted proxy), the local socket, or loopback.
|
||||
func (s *Server) keyTransport(r *http.Request) (bool, string) {
|
||||
https, why := s.transport(r)
|
||||
if https {
|
||||
return true, why
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && a.Unmap().IsLoopback() && !s.trusted(a.Unmap()) {
|
||||
return true, "plain HTTP from this server"
|
||||
}
|
||||
return false, why
|
||||
}
|
||||
|
||||
// pullWho names a downloader: its token (and user), or the user.
|
||||
func pullWho(p *auth.Principal) string {
|
||||
switch {
|
||||
case p.Token != nil:
|
||||
return "token " + p.Token.Name + " (" + p.Name() + ")"
|
||||
case p.User != nil && p.Method != "":
|
||||
return p.Method + " " + p.Name()
|
||||
}
|
||||
return p.Name()
|
||||
}
|
||||
@@ -175,6 +175,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
|
||||
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
|
||||
m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/request", s.authed(s.certRequest))
|
||||
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
|
||||
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const certCols = `id, name, source, domains, cert_file, key_file, uses, challenge, key_type, comment, cert_pem, key_pem, names,
|
||||
not_before, not_after, issuer, serial, renew_at, ari_checked_at, last_attempt, last_success, last_error, failures, last_routes,
|
||||
created_at, updated_at, created_by, updated_by`
|
||||
created_at, updated_at, created_by, updated_by, owner_id`
|
||||
|
||||
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||
c := &model.Certificate{}
|
||||
@@ -20,7 +20,7 @@ func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||
var nb, na, ra, ac, la, ls sql.NullString
|
||||
if err := sc.Scan(&c.ID, &c.Name, &c.Source, &domains, &c.CertFile, &c.KeyFile, &uses, &c.Challenge, &c.KeyType, &c.Comment,
|
||||
&c.CertPEM, &c.KeyPEM, &names, &nb, &na, &c.Issuer, &c.Serial, &ra, &ac, &la, &ls, &c.LastError, &c.Failures, &routes,
|
||||
&ca, &ua, &c.CreatedBy, &c.UpdatedBy); err != nil {
|
||||
&ca, &ua, &c.CreatedBy, &c.UpdatedBy, &c.OwnerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range []struct {
|
||||
@@ -71,7 +71,7 @@ func (s *Store) CertificateByName(ctx context.Context, name string) (*model.Cert
|
||||
func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error {
|
||||
now := time.Now().UTC()
|
||||
c.CreatedAt, c.UpdatedAt = now, now
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(27)+`)`, certArgs(c)...)
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(28)+`)`, certArgs(c)...)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) err
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE certificates SET name=?, source=?, domains=?, cert_file=?, key_file=?, uses=?, challenge=?, key_type=?,
|
||||
comment=?, cert_pem=?, key_pem=?, names=?, not_before=?, not_after=?, issuer=?, serial=?, renew_at=?, ari_checked_at=?, last_attempt=?,
|
||||
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=? WHERE id=?`,
|
||||
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=?, owner_id=? WHERE id=?`,
|
||||
append(certArgs(c), c.ID)...)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
@@ -123,5 +123,35 @@ func certArgs(c *model.Certificate) []any {
|
||||
return []any{c.Name, c.Source, list(c.Domains), c.CertFile, c.KeyFile, list(c.Uses), c.Challenge, c.KeyType, c.Comment,
|
||||
c.CertPEM, c.KeyPEM, list(c.Names), tsPtr(c.NotBefore), tsPtr(c.NotAfter), c.Issuer, c.Serial, tsPtr(c.RenewAt),
|
||||
tsPtr(c.ARICheckedAt), tsPtr(c.LastAttempt), tsPtr(c.LastSuccess), c.LastError, c.Failures, list(c.LastRoutes),
|
||||
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy}
|
||||
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy, c.OwnerID}
|
||||
}
|
||||
|
||||
// RecordCertificatePull notes a download of a certificate, replacing the
|
||||
// same downloader's earlier one.
|
||||
func (s *Store) RecordCertificatePull(ctx context.Context, p *model.CertificatePull) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO certificate_pulls (cert_id, who, user_id, token_id, serial, ip, at) VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT (cert_id, who) DO UPDATE SET user_id = excluded.user_id, token_id = excluded.token_id, serial = excluded.serial, ip = excluded.ip, at = excluded.at`,
|
||||
p.CertID, p.Who, p.UserID, p.TokenID, p.Serial, p.IP, ts(p.At))
|
||||
return err
|
||||
}
|
||||
|
||||
// CertificatePulls returns who downloaded each certificate, newest first,
|
||||
// by certificate ID.
|
||||
func (s *Store) CertificatePulls(ctx context.Context) (map[int64][]model.CertificatePull, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT cert_id, who, user_id, token_id, serial, ip, at FROM certificate_pulls ORDER BY at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[int64][]model.CertificatePull{}
|
||||
for rows.Next() {
|
||||
var p model.CertificatePull
|
||||
var at string
|
||||
if err := rows.Scan(&p.CertID, &p.Who, &p.UserID, &p.TokenID, &p.Serial, &p.IP, &at); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
p.At = parseTS(at)
|
||||
out[p.CertID] = append(out[p.CertID], p)
|
||||
}
|
||||
return out, closeRows(rows)
|
||||
}
|
||||
@@ -384,6 +384,22 @@ ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
|
||||
`,
|
||||
// 14: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts):
|
||||
// an owner, a per-user limit, and who downloaded them.
|
||||
`
|
||||
ALTER TABLE certificates ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN cert_limit INTEGER;
|
||||
CREATE TABLE certificate_pulls (
|
||||
cert_id INTEGER NOT NULL REFERENCES certificates(id) ON DELETE CASCADE,
|
||||
who TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
token_id INTEGER NOT NULL DEFAULT 0,
|
||||
serial TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (cert_id, who)
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -18,16 +18,21 @@ func isUnique(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step`
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step, cert_limit`
|
||||
|
||||
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||
u := &model.User{}
|
||||
var grants, ca, ua string
|
||||
var admin, disabled int
|
||||
var ll, te sql.NullString
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep); err != nil {
|
||||
var limit sql.NullInt64
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep, &limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit.Valid {
|
||||
n := int(limit.Int64)
|
||||
u.CertLimit = &n
|
||||
}
|
||||
u.Admin, u.Disabled = admin != 0, disabled != 0
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
|
||||
@@ -48,8 +53,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
||||
if u.Grants == nil {
|
||||
u.Grants = []model.Grant{}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at, cert_limit) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now), u.CertLimit)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
@@ -64,8 +69,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
||||
// UpdateUser saves a user.
|
||||
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
||||
u.UpdatedAt = time.Now()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ?, cert_limit = ? WHERE id = ?`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.CertLimit, u.ID)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function render(el) {
|
||||
c.uses.includes("web") ? [badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : ""), " "] : null,
|
||||
c.uses.includes("dns") ? namedUse(c) : null,
|
||||
!c.uses.length ? h("span", { class: "muted", title: "Export it to use it in another program" }, "not used by Jarl") : null] },
|
||||
{ label: "Owner", render: (c) => c.owner ? h("span", null, c.owner, pulledBy(c)) : h("span", { class: "muted" }, "Jarl") },
|
||||
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
||||
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
||||
{ label: "Status", render: (c) => {
|
||||
@@ -64,6 +65,15 @@ export async function render(el) {
|
||||
}
|
||||
}
|
||||
|
||||
// pulledBy says who downloaded a certificate for another host, and
|
||||
// whether they have the current one.
|
||||
function pulledBy(c) {
|
||||
const ps = c.pulled_by || [];
|
||||
if (!ps.length) return h("div", { class: "small muted" }, "not downloaded yet");
|
||||
return ps.map((p) => h("div", { class: "small " + (p.serial === c.serial ? "muted" : "warn-text"), title: p.who + " from " + (p.ip || "?") + ", " + fmtTime(p.at) },
|
||||
p.who.replace(/^token /, "") + ", " + fmtTime(p.at) + (p.serial === c.serial ? "" : " (old: not fetched since the renewal)")));
|
||||
}
|
||||
|
||||
// namedUse is what named uses a certificate for: DoT and DoH, with the
|
||||
// addresses in the tooltip (the details list them).
|
||||
function namedUse(c) {
|
||||
|
||||
@@ -54,6 +54,7 @@ async function userDialog(u, grants) {
|
||||
tmpl.value = "";
|
||||
});
|
||||
const comment = input({ value: u ? u.comment : "" });
|
||||
const certLimit = input({ type: "number", min: "0", value: u && u.cert_limit != null ? String(u.cert_limit) : "", placeholder: "certificates.max_per_user" });
|
||||
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Username", name),
|
||||
field("Password", pw),
|
||||
@@ -62,6 +63,7 @@ async function userDialog(u, grants) {
|
||||
u ? h("span") : null, u ? disabled : null,
|
||||
field("Permissions", gr, "One grant per line (ignored for administrators)."),
|
||||
h("span"), tmpl,
|
||||
field("Certificates", certLimit, "How many certificates this user may own through jarl cert get (needs a certificates grant). Empty: the default from jarl.yaml."),
|
||||
field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
|
||||
@@ -69,6 +71,8 @@ async function userDialog(u, grants) {
|
||||
if (service.input.checked) data.password = "";
|
||||
else if (pw.value) data.password = pw.value;
|
||||
if (u) data.disabled = disabled.input.checked;
|
||||
if (certLimit.value.trim() !== "") data.cert_limit = Number(certLimit.value);
|
||||
else if (u && u.cert_limit != null) data.cert_limit = -1;
|
||||
try {
|
||||
if (u) await api("PATCH", "/users/" + u.id, { body: data });
|
||||
else await api("POST", "/users", { body: data });
|
||||
|
||||
Reference in new issue
Block a user