Compare commits
6
Commits
v0.6.1
...
v0.6.4-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fb159cf4d | ||
|
|
71ebed2b73 | ||
|
|
95125e7f50 | ||
|
|
c10ebe0d38 | ||
|
|
3d1911dd38 | ||
|
|
310ed8061e |
No files matched your search
@@ -6,6 +6,36 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Failed logins in the journal.** A failed login (a wrong password, or
|
||||
a wrong two-factor code) and a refused API token are logged as well as
|
||||
audited, with the address first (`msg="login failed" ip=… user=…`,
|
||||
`msg="token refused" ip=…`), for Guard's `jarl` preset or a fail2ban
|
||||
filter to ban such addresses (docs/operations.md).
|
||||
|
||||
## [0.6.3] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- A docs/acme.md section: lego on a host inside a split DNS fails its own
|
||||
propagation check when the challenge is written into the external view
|
||||
only (`JARL_VIEW`, a one-view token, `.../httpreq/view/...`): why, and
|
||||
the ways round it (no view limit, `--dns.propagation.disable-ans` or
|
||||
`--dns.propagation.wait`, Traefik's `propagation.disableANSChecks`).
|
||||
- docs/acme.md and `jarl-lego` show lego 5's commands (`lego run ...`,
|
||||
which also renews; `lego migrate` when upgrading), with lego 4's
|
||||
beside them. Jarl's side is the same for both.
|
||||
|
||||
## [0.6.2] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- A `certificates` role (template): `certificates:write`, for tokens and
|
||||
users that get certificates for other hosts with `jarl cert get`. In
|
||||
the web UI's user and token forms it is in "Insert a template…", to
|
||||
narrow with `names=`; in the CLI, `--role certificates`.
|
||||
|
||||
## [0.6.1] - 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -202,7 +202,11 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.6.1 is the current release. It gets certificates onto your
|
||||
Version 0.6.3 is the current release; 0.6.4 is in testing (0.6.4-rc.1):
|
||||
failed logins and refused tokens are logged, address first, for Guard
|
||||
and fail2ban. 0.6.3 brings the lego docs up to
|
||||
lego 5 and explains lego with split DNS. 0.6.2 added a `certificates` role
|
||||
for tokens that get certificates for other hosts. 0.6.1 gets certificates onto your
|
||||
other hosts (`jarl cert get`: Jarl obtains and renews them, a host downloads them and runs a
|
||||
hook, a daily timer keeps them current), exports certificates, fixes
|
||||
PTR records and overrides in linked zones, and makes one-view tokens
|
||||
|
||||
@@ -392,7 +392,8 @@ ACCESS is read or write, and filters take comma separated glob patterns:
|
||||
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
|
||||
*:read
|
||||
|
||||
Roles are ready-made grant sets: readonly, dns-editor, operator, acme.`
|
||||
Roles are ready-made grant sets: readonly, dns-editor, operator, acme,
|
||||
certificates.`
|
||||
|
||||
func userCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
|
||||
@@ -477,7 +478,7 @@ func userCmd() *cobra.Command {
|
||||
}
|
||||
addUserFlags := func(c *cobra.Command) {
|
||||
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
|
||||
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
|
||||
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator, acme or certificates")
|
||||
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
|
||||
@@ -758,7 +759,7 @@ func tokenCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
|
||||
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme")
|
||||
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator, acme or certificates")
|
||||
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
|
||||
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
|
||||
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
#
|
||||
# Install it, then:
|
||||
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \
|
||||
# lego --email you@example.com --dns exec -d example.com -d '*.example.com' run
|
||||
# lego run --accept-tos --email you@example.com --dns exec -d example.com -d '*.example.com'
|
||||
# (lego 4: lego --accept-tos --email ... --dns exec -d ... run, and "renew" to renew)
|
||||
#
|
||||
# Options (environment):
|
||||
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured)
|
||||
# JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured)
|
||||
# JARL_VIEW Only write into this view (optional; default every view that has the zone)
|
||||
# On a host inside a split DNS, lego's own propagation check then can't
|
||||
# see the record: leave it unset, or run lego with
|
||||
# --dns.propagation.disable-ans (lego 5; --dns.propagation-disable-ans in lego 4)
|
||||
# (docs/acme.md, "Split DNS and lego's propagation check").
|
||||
# JARL_BIN Path of the jarl binary (optional; default "jarl")
|
||||
|
||||
JARL_BIN="${JARL_BIN:-jarl}"
|
||||
|
||||
+87
-5
@@ -98,10 +98,23 @@ sudo install -m 0755 contrib/lego/jarl-lego.sh /usr/local/bin/jarl-lego # or f
|
||||
export EXEC_PATH=/usr/local/bin/jarl-lego
|
||||
export JARL_SERVER=https://ns1.adbraun.lol:8053
|
||||
export JARL_TOKEN=@/etc/jarl/certbot.token
|
||||
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run
|
||||
lego run --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol'
|
||||
```
|
||||
|
||||
Renew with the same variables and `lego ... renew` in place of `run`.
|
||||
That is lego 5. `lego run` also renews: run the same command daily (a
|
||||
cron job or systemd timer), and it renews once a third of the
|
||||
certificate's lifetime is left. **lego 4** takes its options before the
|
||||
command, and renews with `renew`:
|
||||
|
||||
```sh
|
||||
lego --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run # first time
|
||||
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' renew # then daily
|
||||
```
|
||||
|
||||
Upgrading from lego 4 to 5, run `lego migrate` once (with `--path` if you
|
||||
keep lego's data somewhere other than `./.lego`) before anything else:
|
||||
lego 5 stores accounts and certificates differently. The exec and httpreq
|
||||
providers, and so Jarl's side, are the same in both.
|
||||
|
||||
* lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`,
|
||||
and the script passes them to `jarl acme set|unset --exact`. FQDN is the
|
||||
@@ -114,7 +127,66 @@ Renew with the same variables and `lego ... renew` in place of `run`.
|
||||
* Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`.
|
||||
* lego waits until the record is visible on the zone's name servers
|
||||
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only
|
||||
once named serves the record, so this check passes on its first try.
|
||||
once named serves the record, so this check passes on its first try,
|
||||
as long as the view that answers lego's host has the record: with
|
||||
`JARL_VIEW` on a host inside your network it doesn't. See
|
||||
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
|
||||
|
||||
### Split DNS and lego's propagation check
|
||||
|
||||
Before lego asks the CA to look, it checks the record itself, asking the
|
||||
zone's name servers (`ns1.adbraun.lol`) directly. With views, they answer
|
||||
lego's host from the view that matches *it*. On a host inside your
|
||||
network that is the internal view. If the challenge was written into
|
||||
another view only (`JARL_VIEW=external`, `--view external`, a token
|
||||
limited to `views=external`, or the httpreq endpoint
|
||||
`.../httpreq/view/external`), lego never sees it, and gives up after
|
||||
its propagation timeout without the CA having looked:
|
||||
|
||||
```
|
||||
propagation: time limit exceeded: last error: authoritative nameservers:
|
||||
NS ns1.adbraun.lol.:53 returned NXDOMAIN for _acme-challenge.stats.adbraun.lol.
|
||||
```
|
||||
|
||||
A name whose authorization is still valid from an earlier run ("authorization
|
||||
already valid; skipping challenge") isn't checked at all, so one name can
|
||||
work and the next fail with the same settings.
|
||||
|
||||
The ways round it, best first:
|
||||
|
||||
1. **Don't limit the view.** Leave `JARL_VIEW` unset (and give the token
|
||||
no `views=` filter, the httpreq endpoint no `/view/...`): the record
|
||||
goes into every view that has the zone, the internal one too, and
|
||||
lego's check passes at once. The challenge is a random value that is
|
||||
useless once the CA has checked it, and it is removed straight after.
|
||||
2. **Skip lego's check of the name servers.** The record is already
|
||||
served when Jarl answers, so lego's check adds nothing:
|
||||
|
||||
```sh
|
||||
lego run --dns exec --dns.propagation.disable-ans ... # lego 5
|
||||
lego --dns exec --dns.propagation-disable-ans ... run # lego 4.20 to 4.x
|
||||
lego --dns exec --dns.disable-cp ... run # older lego 4
|
||||
```
|
||||
|
||||
or replace the checks with a fixed wait, which also gives secondary
|
||||
name servers time to catch up: `--dns.propagation.wait 30s` (lego 5;
|
||||
`--dns.propagation-wait 30s` in lego 4.20 and later). In lego 5 these
|
||||
can also be set in the environment: `LEGO_DNS_PROPAGATION_DISABLE_ANS=true`,
|
||||
`LEGO_DNS_PROPAGATION_WAIT=30s`.
|
||||
3. **In Traefik**, the same under `dnsChallenge` (Traefik 3.2 and later;
|
||||
older versions have `disablePropagationCheck: true`):
|
||||
|
||||
```yaml
|
||||
dnsChallenge:
|
||||
provider: httpreq
|
||||
propagation:
|
||||
disableANSChecks: true # or delayBeforeChecks: 30s
|
||||
```
|
||||
4. **Run lego where the external view answers it**, such as a host outside
|
||||
your network. Rarely the easy option.
|
||||
|
||||
certbot (section 3) doesn't check by itself, and acme.sh (section 4)
|
||||
checks through public DNS-over-HTTPS resolvers, so neither runs into this.
|
||||
|
||||
## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI
|
||||
|
||||
@@ -299,14 +371,20 @@ of the `acme` role.
|
||||
export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
|
||||
export HTTPREQ_USERNAME=lego
|
||||
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_...
|
||||
lego --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol' run
|
||||
lego run --accept-tos --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol'
|
||||
```
|
||||
|
||||
That is lego 5; in lego 4 the options go before the command (`lego
|
||||
--email ... --dns httpreq -d ... run`, and `renew` to renew). See
|
||||
[section 5](#5-lego).
|
||||
|
||||
Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which
|
||||
jarld refuses with a message saying so). If jarld's certificate doesn't come from a public
|
||||
CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's
|
||||
`LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put
|
||||
it in the endpoint: `.../api/v1/acme/httpreq/view/external`.
|
||||
it in the endpoint: `.../api/v1/acme/httpreq/view/external`; on a host inside
|
||||
your network, read [Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check)
|
||||
first.
|
||||
|
||||
### 4. Traefik
|
||||
|
||||
@@ -412,4 +490,8 @@ the main zone is never modified by automation.
|
||||
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
|
||||
sure the record is in the view that matches the CA (it is by default)
|
||||
and that `allow-query` lets the internet query the zone.
|
||||
* lego says `propagation: time limit exceeded ... returned NXDOMAIN for
|
||||
_acme-challenge...`: the record is in a view that doesn't answer lego's
|
||||
host, usually `JARL_VIEW=external` on a host inside your network. See
|
||||
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
|
||||
* `jarl audit --user certbot` shows every attempt.
|
||||
@@ -537,6 +537,7 @@ jarl token list --all --expiring [60d] # expiring within 14 days
|
||||
jarl token create laptop --expires 365d
|
||||
jarl token create backup-script --grant "*:read"
|
||||
jarl token create acme --user certbot --role acme
|
||||
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host (or --role certificates)
|
||||
jarl token rotate laptop --save # new secret, same token and grants
|
||||
jarl token rotate --current --save --grace 10m # the token this CLI uses
|
||||
jarl token revoke 7
|
||||
|
||||
@@ -360,6 +360,23 @@ cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
|
||||
rndc reconfig
|
||||
```
|
||||
|
||||
## Failed logins in the journal
|
||||
|
||||
A failed login to the web UI or the API (a wrong password, or a wrong
|
||||
two-factor code), and a refused API token, are written to the journal as
|
||||
well as the audit log, with the address first:
|
||||
|
||||
```
|
||||
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
|
||||
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
|
||||
```
|
||||
|
||||
Guard's `jarl` preset, or a fail2ban filter, can ban such addresses at
|
||||
the firewall (with Housecarl's ban lists). The address comes before the
|
||||
user name, which a caller chooses, so a pattern anchored on
|
||||
`msg="login failed" ip=` can't be fooled by a name. With `log.format:
|
||||
json` they are JSON objects with the same fields in the same order.
|
||||
|
||||
## Monitoring
|
||||
|
||||
* `GET /healthz` (no authentication) reports that jarld is up.
|
||||
|
||||
@@ -185,6 +185,7 @@ you can then edit.
|
||||
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
|
||||
| `operator` | `*:write` (everything except user management) |
|
||||
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
|
||||
| `certificates` | `certificates:write`: certificates for other hosts (`jarl cert get`) for any name in Jarl's zones. Narrow it to a host's names, e.g. `certificates:write names=web01.adbraun.lol` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
|
||||
|
||||
```sh
|
||||
jarl user create alice --role dns-editor
|
||||
@@ -210,6 +211,7 @@ The audit log shows the user and the token name.
|
||||
jarl token create laptop # full rights of the user, no expiry
|
||||
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
|
||||
jarl token create certbot --user certbot --role acme # admin: a token for another user
|
||||
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host
|
||||
jarl token list --all
|
||||
jarl token revoke 12
|
||||
```
|
||||
|
||||
@@ -135,6 +135,16 @@ func TestRoleTemplatesValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificatesRole(t *testing.T) {
|
||||
p := &Principal{User: &model.User{Grants: RoleTemplates["certificates"]}}
|
||||
if !p.Can(Action{Resource: ResCerts, Write: true, Name: "web01.adbraun.lol"}) {
|
||||
t.Error("the certificates role can't get a certificate")
|
||||
}
|
||||
if p.Can(Action{Resource: ResRecords, Write: true, Zone: "adbraun.lol."}) || p.Can(Action{Resource: ResZones, Zone: "adbraun.lol."}) {
|
||||
t.Error("the certificates role reaches beyond certificates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateGrants(t *testing.T) {
|
||||
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
|
||||
if err != nil {
|
||||
|
||||
@@ -391,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{
|
||||
},
|
||||
"operator": {{Resource: ResAll, Access: Write}},
|
||||
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
|
||||
// certificates for other hosts (jarl cert get): narrow it with names=
|
||||
// to the host's own.
|
||||
"certificates": {{Resource: ResCerts, Access: Write}},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFailedLoginLogged: a failed login and a refused token are logged
|
||||
// with the address first, for Guard's and fail2ban's patterns.
|
||||
func TestFailedLoginLogged(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
var buf bytes.Buffer
|
||||
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
|
||||
e.req("POST", "/auth/login", "", map[string]string{"username": "x ip=8.8.8.8", "password": "wrong"})
|
||||
e.req("GET", "/zones", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil)
|
||||
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("no %q in\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session,
|
||||
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:" + ip)
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("token refused", "ip", ip, "reason", err.Error())
|
||||
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error())
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
|
||||
}
|
||||
@@ -328,6 +330,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
if err == nil && u.Disabled && ok {
|
||||
reason = "account disabled"
|
||||
}
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason)
|
||||
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,8 @@ func (s *Server) httpreqPrincipal(w http.ResponseWriter, r *http.Request) (*auth
|
||||
}
|
||||
fail := func(reason string) error {
|
||||
s.limiter.fail("ip:" + ip)
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("token refused", "ip", ip, "reason", "httpreq: "+reason)
|
||||
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: user}, IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", "httpreq: "+reason)
|
||||
w.Header().Set("WWW-Authenticate", httpreqRealm)
|
||||
return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason}
|
||||
|
||||
@@ -206,6 +206,8 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:"+ip, userKey)
|
||||
left := s.pendingLogins.fail(in.Challenge)
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("login failed", "ip", ip, "user", u.Username, "reason", "wrong code: "+err.Error())
|
||||
s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error())
|
||||
msg := "wrong code: use the current code from your authenticator app, or a recovery code"
|
||||
if left == 0 {
|
||||
|
||||
@@ -195,7 +195,8 @@ async function rotateDialog(t, redraw) {
|
||||
function coveredBy(user, grant) {
|
||||
if (!user || user.admin) return true;
|
||||
const [res, acc] = grant.split(/\s+/)[0].split(":");
|
||||
return (user.grants || []).some((g) => (g.resource === "*" || g.resource === res) && (g.access === "write" || acc === "read"));
|
||||
// "*" never covers certificates (they hand out private keys).
|
||||
return (user.grants || []).some((g) => ((g.resource === "*" && res !== "certificates") || g.resource === res) && (g.access === "write" || acc === "read"));
|
||||
}
|
||||
|
||||
// newTokenForm makes a token for owner (the user it belongs to). Its
|
||||
|
||||
Reference in new issue
Block a user