Compare commits
4
Commits
v0.6.2
...
v0.6.4-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fb159cf4d | ||
|
|
71ebed2b73 | ||
|
|
95125e7f50 | ||
|
|
c10ebe0d38 |
No files matched your search
@@ -6,6 +6,27 @@ 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
|
||||
|
||||
@@ -202,7 +202,10 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.6.2 is the current release. It adds a `certificates` role
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user