Compare commits

...
6 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 3fb159cf4d Release 0.6.4-rc.1
release / release (push) Successful in 4m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:58:56 +01:00
adamandClaude Opus 5.5 71ebed2b73 Log failed logins and refused tokens, address first
release / release (push) Failing after 18s
For Guard's jarl preset and fail2ban: msg="login failed" ip=... user=...
and msg="token refused" ip=..., beside the audit records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00
adamandClaude Opus 5.5 95125e7f50 Release 0.6.3
Documentation: lego 5's commands beside lego 4's, and lego on a host
inside a split DNS (its propagation check and the ways round it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:44:01 +01:00
adamandClaude Opus 5.5 c10ebe0d38 Docs: lego 5, and split DNS with lego's propagation check
docs/acme.md and jarl-lego show lego 5's commands (lego run, which also
renews; lego migrate when upgrading) beside lego 4's. A new section
explains why lego on a host inside a split DNS fails its own propagation
check when the challenge goes into the external view only (JARL_VIEW, a
one-view token, httpreq's /view/...), and the ways round it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:44:00 +01:00
adamandClaude Opus 5.5 3d1911dd38 Release 0.6.2
release / release (push) Successful in 4m45s
A certificates role (template) for tokens and users that get
certificates for other hosts with jarl cert get.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:08:54 +01:00
adamandClaude Opus 5.5 310ed8061e Permissions: a certificates role
A template for tokens and users that get certificates for other hosts
(jarl cert get): certificates:write, to narrow with names= in the web
UI's "Insert a template…", or --role certificates in the CLI. The token
form's template filter no longer counts "*" as covering certificates,
as the server doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:07:49 +01:00
16 changed files with 199 additions and 12 deletions

No files matched your search

+30
View File
@@ -6,6 +6,36 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [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 ## [0.6.1] - 2026-10-20
### Added ### Added
+5 -1
View File
@@ -202,7 +202,11 @@ review step.
## Status ## 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 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 hook, a daily timer keeps them current), exports certificates, fixes
PTR records and overrides in linked zones, and makes one-view tokens PTR records and overrides in linked zones, and makes one-view tokens
+1 -1
View File
@@ -1 +1 @@
0.6.1 0.6.4-rc.1
+4 -3
View File
@@ -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 records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
*:read *: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 { func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp} 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) { addUserFlags := func(c *cobra.Command) {
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)") 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().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
c.Flags().StringVar(&comment, "comment", "", "comment") 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)") 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(&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().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().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") create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
+6 -1
View File
@@ -7,12 +7,17 @@
# #
# Install it, then: # Install it, then:
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \ # 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): # Options (environment):
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured) # 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_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) # 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 Path of the jarl binary (optional; default "jarl")
JARL_BIN="${JARL_BIN:-jarl}" JARL_BIN="${JARL_BIN:-jarl}"
+87 -5
View File
@@ -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 EXEC_PATH=/usr/local/bin/jarl-lego
export JARL_SERVER=https://ns1.adbraun.lol:8053 export JARL_SERVER=https://ns1.adbraun.lol:8053
export JARL_TOKEN=@/etc/jarl/certbot.token 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`, * 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 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`. * 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 * lego waits until the record is visible on the zone's name servers
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only (`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 ## 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_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
export HTTPREQ_USERNAME=lego export HTTPREQ_USERNAME=lego
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_... 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 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 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 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 `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 ### 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 `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) 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. 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. * `jarl audit --user certbot` shows every attempt.
+1
View File
@@ -537,6 +537,7 @@ jarl token list --all --expiring [60d] # expiring within 14 days
jarl token create laptop --expires 365d jarl token create laptop --expires 365d
jarl token create backup-script --grant "*:read" jarl token create backup-script --grant "*:read"
jarl token create acme --user certbot --role acme 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 laptop --save # new secret, same token and grants
jarl token rotate --current --save --grace 10m # the token this CLI uses jarl token rotate --current --save --grace 10m # the token this CLI uses
jarl token revoke 7 jarl token revoke 7
+17
View File
@@ -360,6 +360,23 @@ cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
rndc reconfig 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 ## Monitoring
* `GET /healthz` (no authentication) reports that jarld is up. * `GET /healthz` (no authentication) reports that jarld is up.
+2
View File
@@ -185,6 +185,7 @@ you can then edit.
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` | | `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
| `operator` | `*:write` (everything except user management) | | `operator` | `*:write` (everything except user management) |
| `acme` | `records:write names=_acme-challenge.* types=TXT` | | `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 ```sh
jarl user create alice --role dns-editor 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 laptop # full rights of the user, no expiry
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d 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 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 list --all
jarl token revoke 12 jarl token revoke 12
``` ```
+10
View File
@@ -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) { func TestCertificateGrants(t *testing.T) {
g, err := ParseGrant("certificates:write names=*.adbraun.lol") g, err := ParseGrant("certificates:write names=*.adbraun.lol")
if err != nil { if err != nil {
+3
View File
@@ -391,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{
}, },
"operator": {{Resource: ResAll, Access: Write}}, "operator": {{Resource: ResAll, Access: Write}},
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}, "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}},
} }
+23
View File
@@ -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())
}
}
}
+4
View File
@@ -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()) p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
if err != nil { if err != nil {
s.limiter.fail("ip:" + ip) 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()) 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()} 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 { if err == nil && u.Disabled && ok {
reason = "account disabled" 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) 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"} return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
} }
+2
View File
@@ -198,6 +198,8 @@ func (s *Server) httpreqPrincipal(w http.ResponseWriter, r *http.Request) (*auth
} }
fail := func(reason string) error { fail := func(reason string) error {
s.limiter.fail("ip:" + ip) 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) 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) w.Header().Set("WWW-Authenticate", httpreqRealm)
return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason} return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason}
+2
View File
@@ -206,6 +206,8 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
s.limiter.fail("ip:"+ip, userKey) s.limiter.fail("ip:"+ip, userKey)
left := s.pendingLogins.fail(in.Challenge) 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()) 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" msg := "wrong code: use the current code from your authenticator app, or a recovery code"
if left == 0 { if left == 0 {
+2 -1
View File
@@ -195,7 +195,8 @@ async function rotateDialog(t, redraw) {
function coveredBy(user, grant) { function coveredBy(user, grant) {
if (!user || user.admin) return true; if (!user || user.admin) return true;
const [res, acc] = grant.split(/\s+/)[0].split(":"); 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 // newTokenForm makes a token for owner (the user it belongs to). Its