4 Commits
Author SHA1 Message Date
abrown38andClaude Opus 5.5 f6c31858a3 Release 0.2.0-rc.3
release / release (push) Successful in 3m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:29:07 +01:00
abrown38andClaude Opus 5.5 1bc9f9f63a Let the Reserve button pick another address of the lease's subnet
The Reserve dialog fills in the lease's address but lets it be changed;
POST /leases/{ip}/reserve takes an optional ip, steward lease reserve
takes --ip. The address must be in the lease's subnet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:29:07 +01:00
abrown38andClaude Opus 5.5 c7a64773a7 Release 0.2.0-rc.2
release / release (push) Successful in 3m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:58:57 +01:00
abrown38andClaude Opus 5.5 1407f7ab62 Log failed logins and refused tokens, address first
release / release (push) Failing after 17s
For Guard's steward 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
13 changed files with 105 additions and 16 deletions

No files matched your search

+10
View File
@@ -8,6 +8,16 @@ All notable changes to Steward are documented here. The format follows
### Added
- **Reserve another address from a lease.** The Reserve dialog on the
Leases page fills in the lease's address but lets you change it, to
any address of the same subnet (`steward lease reserve IP --ip ADDR`,
`"ip"` in the API). The client moves there when it next renews.
- **Failed logins in the journal.** A failed login 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 `steward` preset or a fail2ban filter to ban such addresses
(docs/operations.md).
- **Allow and deny lists per subnet** (docs/subnets.md): a deny-list
subnet (the default) answers everyone not denied; an allow-list subnet
only clients allowed or with a reservation. Entries are MAC addresses,
+4 -3
View File
@@ -111,9 +111,10 @@ Version 0.1.0 is the current release: a DHCPv4 server with subnets per
interface, reservations (by MAC address, client ID or either, with their
own lease time), a web UI, REST API and CLI, Kea import, the web UI's
certificate through Jarl, clients' last-seen times and a choice of address
order per subnet and pool. 0.2.0 is in testing (0.2.0-rc.1):
allow and deny lists per subnet, and every option of a DHCP message in
words in the activity log.
order per subnet and pool. 0.2.0 is in testing (0.2.0-rc.3):
allow and deny lists per subnet, every option of a DHCP message in
words in the activity log, failed logins logged for Guard and
fail2ban, and reserving a lease at another address of its subnet.
## Licence
+1 -1
View File
@@ -1 +1 @@
0.2.0-rc.1
0.2.0-rc.3
+9 -2
View File
@@ -126,7 +126,7 @@ gets a NAK when it next renews, and then asks for a new address.`,
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
var hostname, comment, by string
var hostname, comment, by, ip string
reserve := &cobra.Command{
Use: "reserve IP",
Short: "Make a lease's address its client's reservation",
@@ -136,7 +136,10 @@ then on. The host name the client sent becomes the reservation's, unless
The reservation knows the client by its MAC address and, if it sent one,
its client ID (option 61): either one gets it the address. --by mac or
--by client-id uses just one.`,
--by client-id uses just one.
--ip reserves another address of the same subnet instead; the client
moves there when it next renews.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
@@ -153,6 +156,9 @@ its client ID (option 61): either one gets it the address. --by mac or
if by != "" {
body["by"] = by
}
if ip != "" {
body["ip"] = ip
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
return err
@@ -162,6 +168,7 @@ its client ID (option 61): either one gets it the address. --by mac or
}
reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name")
reserve.Flags().StringVar(&comment, "comment", "", "a comment")
reserve.Flags().StringVar(&ip, "ip", "", "reserve this address of the lease's subnet instead")
reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)")
cmd.AddCommand(reserve)
return cmd
+1 -1
View File
@@ -125,7 +125,7 @@ out. `lease_time` 0 means the settings'.
| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation, and `last_seen` when the client last answered a ping (left out if it never has); `q` searches address, MAC and host name |
| `GET /leases/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment", "by"}`, `by` being `mac`, `client_id` or `both` (the default) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"ip", "hostname", "comment", "by"}`, `ip` another address of the lease's subnet to reserve instead, `by` being `mac`, `client_id` or `both` (the default) |
| `GET /events[?mac=&subnet=&interface=&limit=&details=1]` | the latest DHCP messages, newest first (the last 1000 are kept in memory), each with an `id`, `direction` (`in` from the client, `out` from Steward) and `client_id`. `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface. `details=1` adds `fields` and `options` |
| `GET /events/{id}` | one message with `fields` (header fields that are set) and `options`, each `{"code", "name", "value"}` in words |
+1 -1
View File
@@ -100,7 +100,7 @@ See [subnets.md](subnets.md#access-allow-and-deny-lists).
|---|---|
| `lease list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all |
| `lease show IP` | one lease |
| `lease reserve IP [--hostname H] [--by mac\|client-id\|both]` | make the address its client's reservation, by its MAC address and client ID (either matches) unless `--by` says one |
| `lease reserve IP [--ip ADDR] [--hostname H] [--by mac\|client-id\|both]` | make the address its client's reservation, by its MAC address and client ID (either matches) unless `--by` says one; `--ip` reserves another address of the same subnet instead |
| `lease delete IP [-y]` | free the address at once |
### activity
+16
View File
@@ -69,6 +69,22 @@ clients renewing them get a NAK and ask again.
database when it starts, and an older one refuses a database a newer one
has upgraded: restore a backup to go back.
## Failed logins in the journal
A failed login to the web UI or the API, 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 `steward` 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.
## Checks
`stewardd check` validates the configuration in the database against
+1
View File
@@ -56,6 +56,7 @@ A reservation fixes a client's address:
```sh
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas
steward lease reserve 10.0.10.57 # the address a client has now
steward lease reserve 10.0.10.57 --ip 10.0.10.21 # its client, another address
```
* **The client** is known by its MAC address, its client identifier
+25
View File
@@ -0,0 +1,25 @@
package server
import (
"bytes"
"log/slog"
"strings"
"testing"
"steward/internal/api"
)
// 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 := newEnv(t)
var buf bytes.Buffer
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
e.call("", "POST", "/auth/login", api.Login{Username: "x ip=8.8.8.8", Password: "wrong"}, nil)
e.call("steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, 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
@@ -102,6 +102,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, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
}
@@ -246,6 +248,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(), who, "auth.login_failed", "user "+name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
}
+21 -3
View File
@@ -114,7 +114,8 @@ func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Pri
return nil
}
// reserveLease makes a lease's address the client's reservation.
// reserveLease makes a lease's address (or another in its subnet) the
// client's reservation.
func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
ip, err := leaseIP(r)
if err != nil {
@@ -126,6 +127,8 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
// By is how the reservation knows the client: mac, client_id or
// both (the default when the client sent a client ID).
By string `json:"by,omitempty"`
// IP reserves another address of the lease's subnet instead.
IP string `json:"ip,omitempty"`
}
if err := decodeOptional(r, &in); err != nil {
return err
@@ -161,10 +164,25 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
} else if !validate.ValidHostname(host) {
host = ""
}
want := ip
if in.IP != "" {
if want, err = netip.ParseAddr(strings.TrimSpace(in.IP)); err != nil || !want.Is4() {
return badRequest("ip %q: not an IPv4 address", in.IP)
}
}
var id int64
return s.change(w, r, p, "reserve "+ip.String()+" for "+who, func(st *model.State, newID func() int64) error {
return s.change(w, r, p, "reserve "+want.String()+" for "+who, func(st *model.State, newID func() int64) error {
if want != ip {
if sub := st.SubnetFor(ip); sub == nil || !sub.Prefix.Contains(want) {
where := "the lease's subnet"
if sub != nil {
where = sub.Prefix.String()
}
return badRequest("%s isn't in %s", want, where)
}
}
id = newID()
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: want, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
return nil
}, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil {
+6 -2
View File
@@ -483,12 +483,16 @@ func TestReserveByClientID(t *testing.T) {
if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" {
t.Errorf("both: %+v", rv)
}
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"by": "client_id"}, &mr); code != 201 {
var eb0 api.ErrorBody
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.10.30"}, &eb0); code != 400 {
t.Errorf("reserve outside the subnet: %d %+v", code, eb0)
}
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.20.200", "by": "client_id"}, &mr); code != 201 {
t.Fatalf("reserve by id: %d", code)
}
rv = api.Reservation{}
json.Unmarshal(mr.Object, &rv)
if rv.MAC != "" || rv.ClientID != "ff02" {
if rv.MAC != "" || rv.ClientID != "ff02" || rv.IP.String() != "10.0.20.200" {
t.Errorf("by client id: %+v", rv)
}
var eb api.ErrorBody
+6 -3
View File
@@ -36,18 +36,21 @@ function stateBadge(st) {
}
async function reserve(l, done) {
const ip = input({ value: l.ip, class: "mono" });
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
const by = select([["both", "MAC address or client ID (either one)"], ["mac", "MAC address only"], ["client_id", "client ID only"]], "both");
const err = h("div");
await modal("Reserve " + l.ip, h("div", null,
h("p", null, (l.mac || "The client") + " keeps " + l.ip + " from now on."),
h("div", { class: "form-grid" }, h("label", null, "Host name"), host,
h("p", null, (l.mac || "The client") + " keeps this address from now on."),
h("div", { class: "form-grid" }, h("label", null, "Address"), ip, h("label"),
h("div", { class: "help" }, "Another address of the same subnet moves the client there when it next renews."),
h("label", null, "Host name"), host,
l.client_id && l.mac ? [h("label", null, "Know it by"), by, h("label"),
h("div", { class: "help" }, "It sent the client ID ", h("span", { class: "mono" }, l.client_id), ". With either one, it gets the address whichever system it boots.")] : null), err), [
{ label: "Cancel", value: null },
{ label: "Reserve", kind: "primary", onclick: async () => {
try {
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined });
await change("POST", "/leases/" + l.ip + "/reserve", { ip: ip.value.trim(), hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined });
done();
return true;
} catch (e) {