Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c31858a3 | ||
|
|
1bc9f9f63a | ||
|
|
c7a64773a7 | ||
|
|
1407f7ab62 |
No files matched your search
@@ -8,6 +8,16 @@ All notable changes to Steward are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### 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
|
- **Allow and deny lists per subnet** (docs/subnets.md): a deny-list
|
||||||
subnet (the default) answers everyone not denied; an allow-list subnet
|
subnet (the default) answers everyone not denied; an allow-list subnet
|
||||||
only clients allowed or with a reservation. Entries are MAC addresses,
|
only clients allowed or with a reservation. Entries are MAC addresses,
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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):
|
order per subnet and pool. 0.2.0 is in testing (0.2.0-rc.3):
|
||||||
allow and deny lists per subnet, and every option of a DHCP message in
|
allow and deny lists per subnet, every option of a DHCP message in
|
||||||
words in the activity log.
|
words in the activity log, failed logins logged for Guard and
|
||||||
|
fail2ban, and reserving a lease at another address of its subnet.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||||
cmd.AddCommand(del)
|
cmd.AddCommand(del)
|
||||||
var hostname, comment, by string
|
var hostname, comment, by, ip string
|
||||||
reserve := &cobra.Command{
|
reserve := &cobra.Command{
|
||||||
Use: "reserve IP",
|
Use: "reserve IP",
|
||||||
Short: "Make a lease's address its client's reservation",
|
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,
|
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
|
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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
@@ -153,6 +156,9 @@ its client ID (option 61): either one gets it the address. --by mac or
|
|||||||
if by != "" {
|
if by != "" {
|
||||||
body["by"] = by
|
body["by"] = by
|
||||||
}
|
}
|
||||||
|
if ip != "" {
|
||||||
|
body["ip"] = ip
|
||||||
|
}
|
||||||
var resp api.MutationResponse
|
var resp api.MutationResponse
|
||||||
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
|
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
|
||||||
return err
|
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(&hostname, "hostname", "", "the reservation's host name")
|
||||||
reserve.Flags().StringVar(&comment, "comment", "", "a comment")
|
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)")
|
reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)")
|
||||||
cmd.AddCommand(reserve)
|
cmd.AddCommand(reserve)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
+1
-1
@@ -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[?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 |
|
| `GET /leases/{ip}` | one |
|
||||||
| `DELETE /leases/{ip}` | forget it (audited) |
|
| `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[?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 |
|
| `GET /events/{id}` | one message with `fields` (header fields that are set) and `options`, each `{"code", "name", "value"}` in words |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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 list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all |
|
||||||
| `lease show IP` | one lease |
|
| `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 |
|
| `lease delete IP [-y]` | free the address at once |
|
||||||
|
|
||||||
### activity
|
### activity
|
||||||
|
|||||||
@@ -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
|
database when it starts, and an older one refuses a database a newer one
|
||||||
has upgraded: restore a backup to go back.
|
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
|
## Checks
|
||||||
|
|
||||||
`stewardd check` validates the configuration in the database against
|
`stewardd check` validates the configuration in the database against
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ A reservation fixes a client's address:
|
|||||||
```sh
|
```sh
|
||||||
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas
|
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 # 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
|
* **The client** is known by its MAC address, its client identifier
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
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, Method: "token"}, "auth.token_failed", "", "failure", 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()}
|
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 {
|
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(), who, "auth.login_failed", "user "+name, "failure", reason)
|
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
|
||||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
return nil
|
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 {
|
func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
ip, err := leaseIP(r)
|
ip, err := leaseIP(r)
|
||||||
if err != nil {
|
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
|
// By is how the reservation knows the client: mac, client_id or
|
||||||
// both (the default when the client sent a client ID).
|
// both (the default when the client sent a client ID).
|
||||||
By string `json:"by,omitempty"`
|
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 {
|
if err := decodeOptional(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -161,10 +164,25 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
} else if !validate.ValidHostname(host) {
|
} else if !validate.ValidHostname(host) {
|
||||||
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
|
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()
|
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
|
return nil
|
||||||
}, func(st *model.State) any {
|
}, func(st *model.State) any {
|
||||||
if res := st.ReservationByID(id); res != nil {
|
if res := st.ReservationByID(id); res != nil {
|
||||||
|
|||||||
@@ -483,12 +483,16 @@ func TestReserveByClientID(t *testing.T) {
|
|||||||
if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" {
|
if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" {
|
||||||
t.Errorf("both: %+v", rv)
|
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)
|
t.Fatalf("reserve by id: %d", code)
|
||||||
}
|
}
|
||||||
rv = api.Reservation{}
|
rv = api.Reservation{}
|
||||||
json.Unmarshal(mr.Object, &rv)
|
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)
|
t.Errorf("by client id: %+v", rv)
|
||||||
}
|
}
|
||||||
var eb api.ErrorBody
|
var eb api.ErrorBody
|
||||||
|
|||||||
@@ -36,18 +36,21 @@ function stateBadge(st) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reserve(l, done) {
|
async function reserve(l, done) {
|
||||||
|
const ip = input({ value: l.ip, class: "mono" });
|
||||||
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
|
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 by = select([["both", "MAC address or client ID (either one)"], ["mac", "MAC address only"], ["client_id", "client ID only"]], "both");
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
await modal("Reserve " + l.ip, h("div", null,
|
await modal("Reserve " + l.ip, h("div", null,
|
||||||
h("p", null, (l.mac || "The client") + " keeps " + l.ip + " from now on."),
|
h("p", null, (l.mac || "The client") + " keeps this address from now on."),
|
||||||
h("div", { class: "form-grid" }, h("label", null, "Host name"), host,
|
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"),
|
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), [
|
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: "Cancel", value: null },
|
||||||
{ label: "Reserve", kind: "primary", onclick: async () => {
|
{ label: "Reserve", kind: "primary", onclick: async () => {
|
||||||
try {
|
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();
|
done();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in new issue
Block a user