Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3c0808591f
111 files changed
+17851
No files matched your search
@@ -0,0 +1,700 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
func statusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status", Short: "Show what Steward serves, and any problems", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var st api.Status
|
||||
if err := c.Get(ctx(), "/status", nil, &st); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(st, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Steward:\t%s\n", st.Build.String())
|
||||
fmt.Fprintf(w, "Running since:\t%s\n", st.Started.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Fprintln(w, "Interfaces:")
|
||||
if len(st.Interfaces) == 0 {
|
||||
fmt.Fprintln(w, " none: add a subnet ('steward subnet add PREFIX --interface IFACE')")
|
||||
}
|
||||
for _, i := range st.Interfaces {
|
||||
state := "serving DHCP"
|
||||
if !i.Listening {
|
||||
state = "NOT serving: " + i.Error
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\n", i.Name, strings.Join(i.Addresses, ", "), state)
|
||||
}
|
||||
fmt.Fprintln(w, "Subnets:")
|
||||
for _, s := range st.Subnets {
|
||||
d := ""
|
||||
if s.Disabled {
|
||||
d = " (disabled)"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s leased, %d reserved%s\n", s.Label(), s.Interface, usageText(s.Usage), s.Usage.Reservations, d)
|
||||
}
|
||||
var states []string
|
||||
for k, v := range st.Leases {
|
||||
states = append(states, fmt.Sprintf("%d %s", v, k))
|
||||
}
|
||||
sort.Strings(states)
|
||||
fmt.Fprintf(w, "Leases:\t%s\n", orDash(strings.Join(states, ", ")))
|
||||
var msgs []string
|
||||
for _, t := range []string{"DHCPDISCOVER", "DHCPOFFER", "DHCPREQUEST", "DHCPACK", "DHCPNAK", "DHCPRELEASE", "DHCPDECLINE", "DHCPINFORM"} {
|
||||
if n := st.Messages[t]; n > 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("%d %s", n, strings.TrimPrefix(t, "DHCP")))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "Messages:\t%s\n", orDash(strings.Join(msgs, ", ")))
|
||||
if st.PingProblem != "" {
|
||||
fmt.Fprintf(w, "Ping check:\t%s\n", st.PingProblem)
|
||||
}
|
||||
if len(st.Problems) > 0 {
|
||||
fmt.Fprintln(w, "Problems:")
|
||||
for _, p := range st.Problems {
|
||||
fmt.Fprintf(w, " %s: %s: %s\n", p.Severity, p.Object, p.Message)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func historyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "history", Short: "The history of configuration changes, and undo"}
|
||||
var limit int
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List recent changes", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var hs []model.HistoryEntry
|
||||
if err := c.Get(ctx(), "/history", q("limit", strconv.Itoa(limit)), &hs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(hs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tWHEN\tBY\tVIA\tCHANGES\tDESCRIPTION")
|
||||
for _, h := range hs {
|
||||
desc := h.Description
|
||||
if h.RevertedBy != 0 {
|
||||
desc += fmt.Sprintf(" (undone by %d)", h.RevertedBy)
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04"), h.Username, h.Via, len(h.Changes), desc)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().IntVar(&limit, "limit", 30, "how many")
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show ID", Short: "Show a change: each object before and after", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var h model.HistoryEntry
|
||||
if err := c.Get(ctx(), "/history/"+args[0], nil, &h); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(h, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d\t%s by %s (%s): %s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04:05"), h.Username, h.Via, h.Description)
|
||||
for _, ch := range h.Changes {
|
||||
fmt.Fprintf(w, "\n%s %s %s\n", ch.Action(), ch.Kind, ch.Label)
|
||||
for _, line := range diffJSON(ch.Before, ch.After) {
|
||||
fmt.Fprintln(w, " "+line)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "undo ID",
|
||||
Short: "Undo a change",
|
||||
Long: `Undoes a change by applying it backwards. It is refused when an object the
|
||||
change touched has changed since; undo the later changes first.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/history/"+args[0]+"/revert", nil, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// diffJSON lists the fields that differ between two versions of an
|
||||
// object: "name: office -> lab".
|
||||
func diffJSON(before, after json.RawMessage) []string {
|
||||
var a, b map[string]any
|
||||
json.Unmarshal(before, &a)
|
||||
json.Unmarshal(after, &b)
|
||||
keys := map[string]bool{}
|
||||
for k := range a {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range b {
|
||||
keys[k] = true
|
||||
}
|
||||
var names []string
|
||||
for k := range keys {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []string
|
||||
show := func(v any, ok bool) string {
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
x, _ := json.Marshal(v)
|
||||
return string(x)
|
||||
}
|
||||
for _, k := range names {
|
||||
va, oka := a[k]
|
||||
vb, okb := b[k]
|
||||
sa, sb := show(va, oka), show(vb, okb)
|
||||
if sa == sb {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case before == nil:
|
||||
out = append(out, k+": "+sb)
|
||||
case after == nil:
|
||||
out = append(out, k+": "+sa)
|
||||
default:
|
||||
out = append(out, k+": "+sa+" -> "+sb)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func whoamiCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var me api.Whoami
|
||||
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(me, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "User:\t%s (%s)\n", me.User.Username, me.Method)
|
||||
fmt.Fprintf(w, "Administrator:\t%v\n", me.Admin)
|
||||
fmt.Fprintf(w, "May:\t%s\n", strings.Join(me.Permissions, ", "))
|
||||
fmt.Fprintf(w, "Server:\tSteward %s\n", me.Build.String())
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readSecret(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return strings.TrimSpace(string(b)), err
|
||||
}
|
||||
var s string
|
||||
_, err := fmt.Fscanln(os.Stdin, &s)
|
||||
return strings.TrimSpace(s), err
|
||||
}
|
||||
|
||||
func userID(name string) (int64, error) {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var us []api.User
|
||||
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, u := range us {
|
||||
if strings.EqualFold(u.Username, name) || strconv.FormatInt(u.ID, 10) == name {
|
||||
return u.ID, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no user %s", name)
|
||||
}
|
||||
|
||||
func userCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Users (administrators only)"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List users", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var us []api.User
|
||||
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(us, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tADMIN\tGRANTS\tPASSWORD\tDISABLED\tLAST LOGIN")
|
||||
for _, u := range us {
|
||||
login := "-"
|
||||
if u.LastLoginAt != nil {
|
||||
login = fmtWhen(*u.LastLoginAt)
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%v\t%s\t%v\t%v\t%s\n", u.ID, u.Username, u.Admin, orDash(strings.Join(u.GrantStrings, " ")), u.HasPassword, u.Disabled, login)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
var admin, noPassword bool
|
||||
var role, comment string
|
||||
var grants []string
|
||||
add := &cobra.Command{
|
||||
Use: "add USER",
|
||||
Short: "Add a user (asks for the password)",
|
||||
Long: `Adds a user. Without --admin, give what they may do: a role (viewer,
|
||||
operator, leases) or grants such as --grant config:read --grant leases:write.
|
||||
--no-password makes a service account that only uses API tokens.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.UserInput{Username: &args[0], Admin: &admin}
|
||||
if !noPassword {
|
||||
pw, err := readSecret("Password for " + args[0] + ": ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Password = &pw
|
||||
}
|
||||
if role != "" {
|
||||
in.Role = &role
|
||||
}
|
||||
if grants != nil {
|
||||
in.Grants = &grants
|
||||
}
|
||||
if comment != "" {
|
||||
in.Comment = &comment
|
||||
}
|
||||
var u api.User
|
||||
if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("added user %s (%d)\n", u.Username, u.ID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := add.Flags()
|
||||
f.BoolVar(&admin, "admin", false, "an administrator (may do everything)")
|
||||
f.StringVar(&role, "role", "", "a role: viewer, operator or leases")
|
||||
f.StringArrayVar(&grants, "grant", nil, "a grant, resource:access (config, leases, audit; read or write)")
|
||||
f.BoolVar(&noPassword, "no-password", false, "no password: API tokens only")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
|
||||
var setPassword, disable, enable bool
|
||||
update := &cobra.Command{
|
||||
Use: "update USER", Short: "Change a user", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := userID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, _ := newClient()
|
||||
in := api.UserInput{}
|
||||
fl := cmd.Flags()
|
||||
if fl.Changed("admin") {
|
||||
in.Admin = &admin
|
||||
}
|
||||
if fl.Changed("role") {
|
||||
in.Role = &role
|
||||
}
|
||||
if fl.Changed("grant") {
|
||||
in.Grants = &grants
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if disable || enable {
|
||||
in.Disabled = &disable
|
||||
}
|
||||
if setPassword {
|
||||
pw, err := readSecret("New password for " + args[0] + ": ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Password = &pw
|
||||
}
|
||||
var u api.User
|
||||
if err := c.Do(ctx(), "PATCH", "/users/"+strconv.FormatInt(id, 10), nil, in, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("updated user %s\n", u.Username)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f = update.Flags()
|
||||
f.BoolVar(&admin, "admin", false, "make (or with --admin=false, unmake) an administrator")
|
||||
f.StringVar(&role, "role", "", "replace the grants with a role's")
|
||||
f.StringArrayVar(&grants, "grant", nil, "replace the grants (repeat for several)")
|
||||
f.BoolVar(&setPassword, "password", false, "set a new password (asks for it)")
|
||||
f.BoolVar(&disable, "disable", false, "disable the user (logs them out)")
|
||||
f.BoolVar(&enable, "enable", false, "enable the user again")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(update)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := userID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, _ := newClient()
|
||||
if err := c.Do(ctx(), "DELETE", "/users/"+strconv.FormatInt(id, 10), nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("deleted user", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func tokenCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "API tokens"}
|
||||
var all bool
|
||||
var user string
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List your tokens (--all: everyone's, for administrators)", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/me/tokens"
|
||||
if all {
|
||||
path = "/tokens"
|
||||
}
|
||||
var ts []api.Token
|
||||
if err := c.Get(ctx(), path, nil, &ts); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(ts, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tNAME\tGRANTS\tEXPIRES\tLAST USED")
|
||||
for _, t := range ts {
|
||||
exp, used := "never", "never"
|
||||
if t.ExpiresAt != nil {
|
||||
exp = fmtWhen(*t.ExpiresAt)
|
||||
}
|
||||
if t.LastUsedAt != nil {
|
||||
used = fmtWhen(*t.LastUsedAt) + " from " + t.LastUsedIP
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, strings.Join(t.GrantStrings, " "), exp, used)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().BoolVar(&all, "all", false, "everyone's tokens")
|
||||
cmd.AddCommand(list)
|
||||
var role, expires string
|
||||
var grants []string
|
||||
var save bool
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
Short: "Make an API token (the secret is shown once)",
|
||||
Long: `Makes an API token. It has all of its user's permissions unless --role or
|
||||
--grant narrows it. On the server as root, --user names whose token it is.
|
||||
--save puts it in the CLI's configuration.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/me/tokens"
|
||||
if user != "" {
|
||||
id, err := userID(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = "/users/" + strconv.FormatInt(id, 10) + "/tokens"
|
||||
}
|
||||
in := api.TokenInput{Name: args[0], Role: role, Grants: grants, ExpiresIn: expires}
|
||||
var t api.Token
|
||||
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
if save {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = t.Secret
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(t, nil)
|
||||
}
|
||||
fmt.Println(t.Secret)
|
||||
if !flagQuiet {
|
||||
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; keep it safe")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := create.Flags()
|
||||
f.StringVar(&role, "role", "", "narrow it to a role: viewer, operator or leases")
|
||||
f.StringArrayVar(&grants, "grant", nil, "narrow it to grants (resource:access)")
|
||||
f.StringVar(&expires, "expires", "", "expire after this long (90d, 720h)")
|
||||
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
|
||||
f.BoolVar(&save, "save", false, "save it as this CLI's token")
|
||||
cmd.AddCommand(create)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("revoked token", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func passwordCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "password", Short: "Change your own password", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, err := readSecret("Current password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := readSecret("New password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/me/password", nil, api.PasswordChange{Current: cur, Password: pw}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("password changed")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func auditCmd() *cobra.Command {
|
||||
var user, action, search string
|
||||
var limit int
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit", Short: "Show the audit log: logins, users, tokens, changes, lease deletions", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var evs []model.AuditEvent
|
||||
if err := c.Get(ctx(), "/audit", q("user", user, "action", action, "q", search, "limit", strconv.Itoa(limit)), &evs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(evs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "WHEN\tUSER\tFROM\tACTION\tOBJECT\tRESULT\tDETAIL")
|
||||
for _, e := range evs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), orDash(e.Username), orDash(e.IP), e.Action, orDash(e.Object), e.Result, e.Detail)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&user, "user", "", "only this user's")
|
||||
f.StringVar(&action, "action", "", "only actions starting with this (auth., lease.delete)")
|
||||
f.StringVar(&search, "search", "", "only events whose object or detail contains this")
|
||||
f.IntVar(&limit, "limit", 100, "how many")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "config", Short: "This CLI's settings: the server and token"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show", Short: "Show the CLI's configuration", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok := "-"
|
||||
if cfg.Token != "" {
|
||||
tok = cfg.Token[:min(len(cfg.Token), 24)] + "…"
|
||||
}
|
||||
fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca_cert: %s\n", flagConfig, orDash(cfg.Server)+" (empty: the local socket)", tok, cfg.Insecure, orDash(cfg.CACert))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "set KEY [VALUE]",
|
||||
Short: "Set server, token (asks when no value), insecure or ca_cert",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
val := ""
|
||||
if len(args) == 2 {
|
||||
val = args[1]
|
||||
} else if args[0] == "token" {
|
||||
if val, err = readSecret("API token: "); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch args[0] {
|
||||
case "server":
|
||||
cfg.Server = val
|
||||
case "token":
|
||||
cfg.Token = val
|
||||
case "insecure":
|
||||
cfg.Insecure = val == "true" || val == "yes" || val == "on"
|
||||
case "ca_cert", "ca-cert":
|
||||
cfg.CACert = val
|
||||
default:
|
||||
return fmt.Errorf("unknown setting %q (use server, token, insecure or ca_cert)", args[0])
|
||||
}
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func importCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "import", Short: "Import another DHCP server's configuration"}
|
||||
var leases, ifaces []string
|
||||
var replace bool
|
||||
kea := &cobra.Command{
|
||||
Use: "kea CONFIG",
|
||||
Short: "Import a Kea configuration and its leases",
|
||||
Long: `Imports a kea-dhcp4.conf: settings, subnets (with pools and options),
|
||||
shared networks and reservations; and with --leases, Kea's current leases,
|
||||
so clients keep their addresses. What Steward doesn't support is listed.
|
||||
|
||||
Every subnet needs its interface. Kea's own setting is used, else the
|
||||
interface on this server with an address in the subnet, else the only
|
||||
interface Kea listened on; give others with --interface PREFIX=IFACE.
|
||||
|
||||
steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv -n
|
||||
steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv.2 \
|
||||
--leases /var/lib/kea/kea-leases4.csv
|
||||
|
||||
Stop Kea before Steward starts serving: they can't share port 67.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{"config": string(b), "replace": replace}
|
||||
var texts []string
|
||||
for _, l := range leases {
|
||||
lb, err := os.ReadFile(l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
texts = append(texts, string(lb))
|
||||
}
|
||||
if len(texts) > 0 {
|
||||
body["leases"] = texts
|
||||
}
|
||||
if len(ifaces) > 0 {
|
||||
m := map[string]string{}
|
||||
for _, i := range ifaces {
|
||||
p, name, ok := strings.Cut(i, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("--interface %q: write it as PREFIX=IFACE, e.g. 10.0.20.0/24=eth0.20", i)
|
||||
}
|
||||
m[strings.TrimSpace(p)] = strings.TrimSpace(name)
|
||||
}
|
||||
body["interfaces"] = m
|
||||
}
|
||||
var res api.ImportResult
|
||||
if err := c.Do(ctx(), "POST", "/import/kea", nil, body, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(res, nil)
|
||||
}
|
||||
for _, n := range res.Notes {
|
||||
fmt.Println("note:", n)
|
||||
}
|
||||
for _, s := range res.Skipped {
|
||||
fmt.Println("not imported:", s)
|
||||
}
|
||||
if err := report(&res.MutationResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case res.DryRun:
|
||||
fmt.Printf("would import %d leases\n", res.Leases)
|
||||
case res.Applied:
|
||||
fmt.Printf("imported %d leases\n", res.Leases)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := kea.Flags()
|
||||
f.StringArrayVar(&leases, "leases", nil, "a Kea lease file (kea-leases4.csv); give older ones first")
|
||||
f.StringArrayVar(&ifaces, "interface", nil, "a subnet's interface: PREFIX=IFACE")
|
||||
f.BoolVar(&replace, "replace", false, "replace Steward's configuration if it has one")
|
||||
cmd.AddCommand(kea)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
func leaseHost(l api.Lease) string {
|
||||
switch {
|
||||
case l.ReservedHostname != "" && l.Hostname != "" && l.ReservedHostname != l.Hostname:
|
||||
return l.ReservedHostname + " (says " + l.Hostname + ")"
|
||||
case l.ReservedHostname != "":
|
||||
return l.ReservedHostname
|
||||
}
|
||||
return orDash(l.Hostname)
|
||||
}
|
||||
|
||||
func leaseCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "lease", Aliases: []string{"leases"}, Short: "Leases: the addresses handed out"}
|
||||
var subnet, state, iface, search string
|
||||
var all bool
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List leases (active and declined ones; --all for every one)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ls []api.Lease
|
||||
if err := c.Get(ctx(), "/leases", q("subnet", subnet, "state", state, "interface", iface, "q", search), &ls); err != nil {
|
||||
return err
|
||||
}
|
||||
if !all && state == "" {
|
||||
keep := ls[:0]
|
||||
for _, l := range ls {
|
||||
if l.State == model.LeaseActive || l.State == model.LeaseDeclined {
|
||||
keep = append(keep, l)
|
||||
}
|
||||
}
|
||||
ls = keep
|
||||
}
|
||||
return output(ls, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tINTERFACE\tSUBNET")
|
||||
for _, l := range ls {
|
||||
st := l.State
|
||||
if l.Reservation != 0 {
|
||||
st += ", reserved"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), l.Interface, orDash(l.SubnetName))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
f := list.Flags()
|
||||
f.StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix)")
|
||||
f.StringVar(&state, "state", "", "only leases in this state: active, expired, released or declined")
|
||||
f.StringVar(&iface, "interface", "", "only leases of clients on this interface")
|
||||
f.StringVar(&search, "search", "", "only leases whose address, MAC address or host name contains this")
|
||||
f.BoolVarP(&all, "all", "a", false, "also expired and released leases (kept a while so returning clients get their old address)")
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show IP", Short: "Show the lease of an address", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var l api.Lease
|
||||
if err := c.Get(ctx(), "/leases/"+args[0], nil, &l); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(l, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Address:\t%s (subnet %s)\n", l.IP, orDash(l.SubnetName))
|
||||
fmt.Fprintf(w, "State:\t%s\n", l.State)
|
||||
fmt.Fprintf(w, "MAC address:\t%s\n", orDash(l.MAC))
|
||||
if l.ClientID != "" {
|
||||
fmt.Fprintf(w, "Client ID:\t%s\n", l.ClientID)
|
||||
}
|
||||
fmt.Fprintf(w, "Host name:\t%s\n", leaseHost(l))
|
||||
fmt.Fprintf(w, "Since:\t%s\n", l.Start.Local().Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(w, "Ends:\t%s (%s)\n", l.End.Local().Format("2006-01-02 15:04:05"), fmtWhen(l.End))
|
||||
fmt.Fprintf(w, "Interface:\t%s\n", l.Interface)
|
||||
if l.Reservation != 0 {
|
||||
fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
var yes bool
|
||||
del := &cobra.Command{
|
||||
Use: "delete IP",
|
||||
Short: "Forget a lease, freeing the address",
|
||||
Long: `Forgets a lease: the address is free at once. A client still using it
|
||||
gets a NAK when it next renews, and then asks for a new address.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm("Delete the lease of "+args[0]+"?", yes) {
|
||||
return fmt.Errorf("not deleted")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "DELETE", "/leases/"+args[0], nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("deleted the lease of", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
cmd.AddCommand(del)
|
||||
var hostname, comment string
|
||||
reserve := &cobra.Command{
|
||||
Use: "reserve IP",
|
||||
Short: "Make a lease's address its client's reservation",
|
||||
Long: `Makes the address a client has now its reservation: it keeps it from
|
||||
then on. The host name the client sent becomes the reservation's, unless
|
||||
--hostname gives another.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{}
|
||||
if cmd.Flags().Changed("hostname") {
|
||||
body["hostname"] = hostname
|
||||
}
|
||||
if comment != "" {
|
||||
body["comment"] = comment
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
}
|
||||
reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name")
|
||||
reserve.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(reserve)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func activityCmd() *cobra.Command {
|
||||
var mac, iface string
|
||||
var limit int
|
||||
var follow bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "activity",
|
||||
Short: "Show recent DHCP messages (requests and answers)",
|
||||
Long: `Shows the latest DHCP messages Steward received and sent, newest last.
|
||||
-f keeps watching. Handy to see why a device doesn't get an address:
|
||||
|
||||
steward activity --mac aa:bb:cc:dd:ee:01 -f`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var last time.Time
|
||||
tw := func(evs []api.Event) {
|
||||
sort.Slice(evs, func(i, j int) bool { return evs[i].Time.Before(evs[j].Time) })
|
||||
for _, e := range evs {
|
||||
if !e.Time.After(last) {
|
||||
continue
|
||||
}
|
||||
last = e.Time
|
||||
detail := ""
|
||||
if e.Hostname != "" {
|
||||
detail = " " + e.Hostname
|
||||
}
|
||||
if e.Detail != "" {
|
||||
detail += " (" + e.Detail + ")"
|
||||
}
|
||||
fmt.Printf("%s %-8s %-13s %-17s %-15s%s\n", e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
|
||||
}
|
||||
}
|
||||
for {
|
||||
var evs []api.Event
|
||||
if err := c.Get(ctx(), "/events", q("mac", mac, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(evs, nil)
|
||||
}
|
||||
tw(evs)
|
||||
if !follow {
|
||||
if len(evs) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "no DHCP messages yet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&mac, "mac", "", "only this client's")
|
||||
f.StringVar(&iface, "interface", "", "only this interface's")
|
||||
f.IntVar(&limit, "limit", 50, "at most this many")
|
||||
f.BoolVarP(&follow, "follow", "f", false, "keep watching")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func settingsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show", Short: "Show the settings", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var s model.Settings
|
||||
if err := c.Get(ctx(), "/settings", nil, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(s, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Lease time:\t%s\n", fmtSeconds(s.EffectiveLeaseTime()))
|
||||
fmt.Fprintln(w, "Options:\t"+optionsText(s.Options))
|
||||
ping := "off"
|
||||
if s.PingCheck {
|
||||
ping = "on, waiting " + s.EffectivePingTimeout().String()
|
||||
}
|
||||
fmt.Fprintf(w, "Ping check:\t%s\n", ping)
|
||||
fmt.Fprintf(w, "Decline hold:\t%s\n", fmtSeconds(int(s.EffectiveDeclineHold()/time.Second)))
|
||||
auth := "yes (requests for addresses that don't belong on the interface get a NAK)"
|
||||
if s.NotAuthoritative {
|
||||
auth = "no (such requests get no answer)"
|
||||
}
|
||||
fmt.Fprintf(w, "Authoritative:\t%s\n", auth)
|
||||
})
|
||||
},
|
||||
})
|
||||
var leaseTime, ping, pingTimeout, hold, authoritative string
|
||||
var opts, unsetOpts []string
|
||||
set := &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "Change settings (only what is given changes)",
|
||||
Long: `Changes the settings. Options set here go to every subnet; a subnet's or a
|
||||
reservation's own option of the same name wins.
|
||||
|
||||
steward settings set --option domain-name-servers=10.0.10.2 --option domain-name=lan
|
||||
steward settings set --lease-time 1d --ping-check off`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.SettingsInput{}
|
||||
fl := cmd.Flags()
|
||||
if fl.Changed("lease-time") {
|
||||
n, err := parseSeconds(leaseTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.LeaseTime = &n
|
||||
}
|
||||
onOff := func(name, v string) (*bool, error) {
|
||||
switch strings.ToLower(v) {
|
||||
case "on", "yes", "true":
|
||||
t := true
|
||||
return &t, nil
|
||||
case "off", "no", "false":
|
||||
f := false
|
||||
return &f, nil
|
||||
}
|
||||
return nil, fmt.Errorf("--%s: use on or off", name)
|
||||
}
|
||||
if fl.Changed("ping-check") {
|
||||
if in.PingCheck, err = onOff("ping-check", ping); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if fl.Changed("ping-timeout") {
|
||||
d, err := time.ParseDuration(pingTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--ping-timeout: %v (use e.g. 500ms)", err)
|
||||
}
|
||||
ms := int(d / time.Millisecond)
|
||||
in.PingTimeout = &ms
|
||||
}
|
||||
if fl.Changed("decline-hold") {
|
||||
n, err := parseSeconds(hold)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.DeclineHold = &n
|
||||
}
|
||||
if fl.Changed("authoritative") {
|
||||
a, err := onOff("authoritative", authoritative)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
not := !*a
|
||||
in.NotAuthoritative = ¬
|
||||
}
|
||||
if len(opts) > 0 || len(unsetOpts) > 0 {
|
||||
var cur model.Settings
|
||||
if err := c.Get(ctx(), "/settings", nil, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := optionsArg(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, err := mergeOptions(cur.Options, s, unsetOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Options = &merged
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "PATCH", "/settings", nil, in, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
}
|
||||
f := set.Flags()
|
||||
f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)")
|
||||
f.StringVar(&ping, "ping-check", "", "ping addresses before offering them: on or off")
|
||||
f.StringVar(&pingTimeout, "ping-timeout", "", "how long to wait for a ping's answer (500ms)")
|
||||
f.StringVar(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)")
|
||||
f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off")
|
||||
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE for every subnet")
|
||||
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
|
||||
cmd.AddCommand(set)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func optionCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "option", Aliases: []string{"options"}, Short: "The DHCP options Steward knows by name"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the options and their value types", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cat api.Catalog
|
||||
if err := c.Get(ctx(), "/catalog", nil, &cat); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(cat, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "CODE\tNAME\tTYPE\tWHAT")
|
||||
for _, o := range cat.Options {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", o.Code, o.Name, o.Type, o.Help)
|
||||
}
|
||||
fmt.Fprintf(w, "\nAny other option by number: --option 224=01:02 (hex), or with a type: --option 224:string=hello.\nTypes: %s.\n", strings.Join(cat.Types, ", "))
|
||||
})
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func interfaceCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "interface", Aliases: []string{"interfaces"}, Short: "The server's network interfaces"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the interfaces, their addresses and subnets", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ifs []api.HostInterface
|
||||
if err := c.Get(ctx(), "/interfaces", nil, &ifs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(ifs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "INTERFACE\tADDRESSES\tSUBNETS")
|
||||
for _, i := range ifs {
|
||||
var subs []string
|
||||
for _, s := range i.Subnets {
|
||||
subs = append(subs, fmt.Sprint(s))
|
||||
}
|
||||
addrs := strings.Join(i.Addresses, ", ")
|
||||
if len(i.Addresses) == 0 {
|
||||
addrs = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", i.Name, addrs, orDash(strings.Join(subs, ", ")))
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// Command steward is the command line client for Steward. It talks to
|
||||
// stewardd's REST API: on the server itself through the local socket, or
|
||||
// anywhere with the server's URL and an API token.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/client"
|
||||
"steward/internal/model"
|
||||
"steward/internal/validate"
|
||||
"steward/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
printError(err)
|
||||
os.Exit(exitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Global flags.
|
||||
var (
|
||||
flagServer string
|
||||
flagToken string
|
||||
flagConfig string
|
||||
flagDryRun bool
|
||||
flagOutput string
|
||||
flagInsecure bool
|
||||
flagCACert string
|
||||
flagQuiet bool
|
||||
)
|
||||
|
||||
// DefaultSocket is stewardd's local socket.
|
||||
const DefaultSocket = "/run/steward/steward.sock"
|
||||
|
||||
// cliConfig is the CLI's configuration file.
|
||||
type cliConfig struct {
|
||||
Server string `yaml:"server,omitempty"`
|
||||
Token string `yaml:"token,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
CACert string `yaml:"ca_cert,omitempty"`
|
||||
}
|
||||
|
||||
func defaultConfigPath() string {
|
||||
if p := os.Getenv("STEWARD_CLI_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(dir, "steward", "config.yaml")
|
||||
}
|
||||
|
||||
func loadCLIConfig() (*cliConfig, error) {
|
||||
c := &cliConfig{}
|
||||
b, err := os.ReadFile(flagConfig)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return c, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, yaml.Unmarshal(b, c)
|
||||
}
|
||||
|
||||
func saveCLIConfig(c *cliConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(flagConfig, b, 0o600)
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newClient connects to the server: --server, STEWARD_SERVER, the config
|
||||
// file, or else the local socket.
|
||||
func newClient() (*client.Client, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
|
||||
}
|
||||
server := firstNonEmpty(flagServer, os.Getenv("STEWARD_SERVER"), cfg.Server)
|
||||
token := firstNonEmpty(flagToken, os.Getenv("STEWARD_TOKEN"), cfg.Token)
|
||||
if server == "" || server == "local" {
|
||||
sock := firstNonEmpty(os.Getenv("STEWARD_SOCKET"), DefaultSocket)
|
||||
if _, err := os.Stat(sock); err != nil {
|
||||
return nil, fmt.Errorf("no server configured, and there is no local socket %s: use --server URL, STEWARD_SERVER or 'steward config set server https://...'; on the server itself, check that stewardd runs", sock)
|
||||
}
|
||||
server = "unix://" + sock
|
||||
}
|
||||
if strings.HasPrefix(token, "@") {
|
||||
b, err := os.ReadFile(token[1:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the token file: %w", err)
|
||||
}
|
||||
token = strings.TrimSpace(string(b))
|
||||
}
|
||||
if _, isSocket := client.SocketPath(server); !isSocket && token == "" {
|
||||
return nil, errors.New("no API token: use --token, STEWARD_TOKEN or 'steward config set token'")
|
||||
}
|
||||
c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.DryRun = flagDryRun
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "steward",
|
||||
Short: "Command line client for Steward, the DHCP server",
|
||||
Long: `steward manages a Steward DHCP server through its API.
|
||||
|
||||
Every change is checked as a whole before it is saved and used, and is
|
||||
recorded in the history, where 'steward history undo ID' takes it back.
|
||||
--dry-run shows what a change would do.
|
||||
|
||||
On the server, root needs no setup: the CLI uses stewardd's local socket.
|
||||
From elsewhere, set the server and an API token once:
|
||||
|
||||
steward config set server https://dhcp.example.com:8067
|
||||
steward config set token (paste the token at the prompt)
|
||||
|
||||
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
5 conflict, 6 invalid or rejected by validation, 7 server error.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version.String(),
|
||||
}
|
||||
pf := root.PersistentFlags()
|
||||
pf.StringVarP(&flagServer, "server", "s", "", "server URL, or \"local\" for the local socket (env STEWARD_SERVER)")
|
||||
pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env STEWARD_TOKEN)")
|
||||
pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file")
|
||||
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "check and show a change without making it")
|
||||
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml or id")
|
||||
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
|
||||
root.AddGroup(&cobra.Group{ID: "dhcp", Title: "DHCP:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
||||
add := func(group string, cmds ...*cobra.Command) {
|
||||
for _, c := range cmds {
|
||||
c.GroupID = group
|
||||
root.AddCommand(c)
|
||||
}
|
||||
}
|
||||
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
|
||||
add("changes", historyCmd(), importCmd())
|
||||
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), auditCmd(), configCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
func ctx() context.Context { return context.Background() }
|
||||
|
||||
// ---- output
|
||||
|
||||
func output(v any, table func(w io.Writer)) error {
|
||||
switch flagOutput {
|
||||
case "json":
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
case "yaml":
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var x any
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
return yaml.NewEncoder(os.Stdout).Encode(x)
|
||||
case "id":
|
||||
return outputIDs(v)
|
||||
case "table", "":
|
||||
if table == nil {
|
||||
flagOutput = "yaml"
|
||||
return output(v, nil)
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
table(tw)
|
||||
return tw.Flush()
|
||||
}
|
||||
return fmt.Errorf("unknown output format %q (use table, json, yaml or id)", flagOutput)
|
||||
}
|
||||
|
||||
// outputIDs prints the id (or for leases the ip) of each object.
|
||||
func outputIDs(v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.UseNumber()
|
||||
var x any
|
||||
if err := dec.Decode(&x); err != nil {
|
||||
return err
|
||||
}
|
||||
list, ok := x.([]any)
|
||||
if !ok {
|
||||
list = []any{x}
|
||||
}
|
||||
for _, it := range list {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
if id, ok := m["id"]; ok {
|
||||
fmt.Println(id)
|
||||
} else if ip, ok := m["ip"]; ok {
|
||||
fmt.Println(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printProblems(w io.Writer, ps []validate.Problem) {
|
||||
for _, p := range ps {
|
||||
obj := ""
|
||||
if p.Object != "" {
|
||||
obj = p.Object + ": "
|
||||
}
|
||||
fmt.Fprintf(w, "%s: %s%s [%s]\n", p.Severity, obj, p.Message, p.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func printError(err error) {
|
||||
var ce *client.Error
|
||||
if errors.As(err, &ce) {
|
||||
ps := ce.Info.Problems
|
||||
if len(ps) == 1 {
|
||||
fmt.Fprintf(os.Stderr, "error: %s [%s]\n", ce.Info.Message, ps[0].Code)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "error:", ce.Info.Message)
|
||||
if len(ps) > 1 {
|
||||
printProblems(os.Stderr, ps)
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
}
|
||||
|
||||
// exitCode maps errors to exit codes: 1 other errors, 3 not authenticated
|
||||
// or not allowed, 4 not found, 5 conflict, 6 invalid request or rejected
|
||||
// by validation, 7 server error.
|
||||
func exitCode(err error) int {
|
||||
var ce *client.Error
|
||||
if errors.As(err, &ce) {
|
||||
switch {
|
||||
case ce.Status == 401 || ce.Status == 403:
|
||||
return 3
|
||||
case ce.Status == 404:
|
||||
return 4
|
||||
case ce.Status == 409:
|
||||
return 5
|
||||
case ce.Status == 400 || ce.Status == 422:
|
||||
return 6
|
||||
case ce.Status >= 500:
|
||||
return 7
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// report prints the outcome of a change.
|
||||
func report(resp *api.MutationResponse) error {
|
||||
switch flagOutput {
|
||||
case "json", "yaml":
|
||||
return output(resp, nil)
|
||||
case "id":
|
||||
if len(resp.Object) > 0 {
|
||||
return outputIDs(resp.Object)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
w := os.Stdout
|
||||
verb := func(c model.Change) string {
|
||||
if resp.DryRun {
|
||||
return "would " + c.Action()
|
||||
}
|
||||
return c.Action() + "d"
|
||||
}
|
||||
if !flagQuiet || resp.DryRun {
|
||||
for _, c := range resp.Changes {
|
||||
if c.Kind == model.KindSettings {
|
||||
fmt.Fprintf(w, "%s settings\n", verb(c))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s %s\n", verb(c), c.Kind, c.Label)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case resp.DryRun:
|
||||
fmt.Fprintln(w, "dry run: nothing was changed")
|
||||
case resp.Applied && !flagQuiet:
|
||||
fmt.Fprintf(w, "saved (history %d; undo with 'steward history undo %d')\n", resp.HistoryID, resp.HistoryID)
|
||||
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
|
||||
fmt.Fprintln(w, "nothing to change")
|
||||
}
|
||||
if flagQuiet && len(resp.Object) > 0 && resp.Applied {
|
||||
outputIDs(resp.Object)
|
||||
}
|
||||
printProblems(os.Stderr, resp.Warnings)
|
||||
return nil
|
||||
}
|
||||
|
||||
func q(kv ...string) url.Values {
|
||||
v := url.Values{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
if kv[i+1] != "" {
|
||||
v.Set(kv[i], kv[i+1])
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func confirm(prompt string, yes bool) bool {
|
||||
if yes || flagDryRun {
|
||||
return true
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
|
||||
var answer string
|
||||
fmt.Scanln(&answer)
|
||||
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes")
|
||||
}
|
||||
|
||||
// ---- durations
|
||||
|
||||
// parseSeconds reads a lease time: "12h", "30m", "7d", "1d12h" or plain
|
||||
// seconds.
|
||||
func parseSeconds(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
days := 0
|
||||
if i := strings.Index(s, "d"); i > 0 {
|
||||
n, err := strconv.Atoi(s[:i])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
|
||||
}
|
||||
days, s = n, s[i+1:]
|
||||
}
|
||||
var d time.Duration
|
||||
if s != "" {
|
||||
var err error
|
||||
if d, err = time.ParseDuration(s); err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
|
||||
}
|
||||
}
|
||||
return days*86400 + int(d/time.Second), nil
|
||||
}
|
||||
|
||||
// fmtSeconds writes seconds the short way: 43200 is "12h", 90 is "1m30s".
|
||||
func fmtSeconds(n int) string {
|
||||
if n <= 0 {
|
||||
return "-"
|
||||
}
|
||||
var parts []string
|
||||
for _, u := range []struct {
|
||||
s int
|
||||
name string
|
||||
}{{86400, "d"}, {3600, "h"}, {60, "m"}, {1, "s"}} {
|
||||
if n >= u.s {
|
||||
parts = append(parts, strconv.Itoa(n/u.s)+u.name)
|
||||
n %= u.s
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// fmtWhen writes a time relative to now: "in 3h", "2d ago".
|
||||
func fmtWhen(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
d := time.Until(t)
|
||||
ago := d < 0
|
||||
if ago {
|
||||
d = -d
|
||||
}
|
||||
var s string
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "now"
|
||||
case d < time.Hour:
|
||||
s = fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
s = fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
default:
|
||||
s = fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
if ago {
|
||||
return s + " ago"
|
||||
}
|
||||
return "in " + s
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// optionsArg turns --option NAME=VALUE flags into option inputs.
|
||||
func optionsArg(flags []string) ([]api.OptionInput, error) {
|
||||
var out []api.OptionInput
|
||||
for _, f := range flags {
|
||||
name, value, ok := strings.Cut(f, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--option %q: write it as NAME=VALUE, e.g. routers=10.0.10.1", f)
|
||||
}
|
||||
in := api.OptionInput{Option: strings.TrimSpace(name), Value: strings.TrimSpace(value)}
|
||||
// A custom option's type: 224:string=hello.
|
||||
if n, typ, ok := strings.Cut(in.Option, ":"); ok {
|
||||
in.Option, in.Type = n, typ
|
||||
}
|
||||
out = append(out, in)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeOptions sets and unsets options on a current list (by code).
|
||||
func mergeOptions(cur []model.OptionValue, set []api.OptionInput, unset []string) ([]api.OptionInput, error) {
|
||||
out := []api.OptionInput{}
|
||||
drop := map[string]bool{}
|
||||
for _, u := range unset {
|
||||
drop[strings.ToLower(strings.TrimSpace(u))] = true
|
||||
}
|
||||
setNames := map[string]bool{}
|
||||
for _, s := range set {
|
||||
setNames[strings.ToLower(s.Option)] = true
|
||||
}
|
||||
for _, o := range cur {
|
||||
code := strconv.Itoa(o.Code)
|
||||
if drop[o.Name] || drop[code] || setNames[o.Name] || setNames[code] {
|
||||
continue
|
||||
}
|
||||
out = append(out, api.OptionInput{Option: code, Value: o.Value, Type: o.Type})
|
||||
}
|
||||
for name := range drop {
|
||||
found := false
|
||||
for _, o := range cur {
|
||||
if o.Name == name || strconv.Itoa(o.Code) == name {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("option %s isn't set here", name)
|
||||
}
|
||||
}
|
||||
return append(out, set...), nil
|
||||
}
|
||||
|
||||
func optionsText(opts []model.OptionValue) string {
|
||||
var parts []string
|
||||
for _, o := range opts {
|
||||
parts = append(parts, o.Name+"="+o.Value)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
func TestDurations(t *testing.T) {
|
||||
for in, want := range map[string]int{"3600": 3600, "12h": 43200, "30m": 1800, "1d": 86400, "1d12h": 129600, "1h30m": 5400} {
|
||||
if got, err := parseSeconds(in); err != nil || got != want {
|
||||
t.Errorf("parseSeconds(%q) = %d, %v; want %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"12x", "d", "1d2x"} {
|
||||
if _, err := parseSeconds(bad); err == nil {
|
||||
t.Errorf("parseSeconds(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
for n, want := range map[int]string{43200: "12h", 5400: "1h30m", 90061: "1d1h1m1s", 0: "-"} {
|
||||
if got := fmtSeconds(n); got != want {
|
||||
t.Errorf("fmtSeconds(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
in, err := optionsArg([]string{"routers=10.0.10.1", "224:string=hi", " ntp-servers = 10.0.10.2 "})
|
||||
if err != nil || len(in) != 3 || in[1].Option != "224" || in[1].Type != "string" || in[2].Option != "ntp-servers" || in[2].Value != "10.0.10.2" {
|
||||
t.Fatalf("optionsArg: %+v %v", in, err)
|
||||
}
|
||||
if _, err := optionsArg([]string{"routers"}); err == nil {
|
||||
t.Error("an option without a value accepted")
|
||||
}
|
||||
cur := []model.OptionValue{{Code: 3, Name: "routers", Value: "10.0.10.1"}, {Code: 6, Name: "domain-name-servers", Value: "10.0.10.2"}}
|
||||
got, err := mergeOptions(cur, []api.OptionInput{{Option: "routers", Value: "10.0.10.254"}}, []string{"domain-name-servers"})
|
||||
if err != nil || len(got) != 1 || got[0].Option != "routers" || got[0].Value != "10.0.10.254" {
|
||||
t.Errorf("merge: %+v %v", got, err)
|
||||
}
|
||||
if _, err := mergeOptions(cur, nil, []string{"ntp-servers"}); err == nil {
|
||||
t.Error("unsetting an option that isn't set accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// subnetPath is the API path of a subnet given by ID, name or prefix.
|
||||
func subnetPath(ref string) string {
|
||||
return "/subnets/" + url.PathEscape(strings.Replace(ref, "/", "_", 1))
|
||||
}
|
||||
|
||||
func poolsText(ps []model.Pool) string {
|
||||
var out []string
|
||||
for _, p := range ps {
|
||||
out = append(out, p.String())
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func usageText(u api.Usage) string {
|
||||
if u.PoolSize == 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%d/%d (%d%%)", u.Leased, u.PoolSize, u.Leased*100/u.PoolSize)
|
||||
}
|
||||
|
||||
func subnetCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "subnet", Aliases: []string{"subnets"}, Short: "Subnets: the networks Steward serves, each on its interface"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the subnets with their interface, pools and use", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var subs []api.Subnet
|
||||
if err := c.Get(ctx(), "/subnets", nil, &subs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(subs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tNAME\tINTERFACE\tPREFIX\tPOOLS\tLEASED\tRESERVED\tLEASE TIME\tSTATE")
|
||||
for _, s := range subs {
|
||||
state := "on"
|
||||
if s.Disabled {
|
||||
state = "disabled"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n", s.ID, orDash(s.Name), s.Interface, s.Prefix, poolsText(s.Pools), usageText(s.Usage),
|
||||
s.Usage.Reservations, fmtSeconds(s.LeaseTime), state)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show SUBNET", Short: "Show a subnet (by ID, name or prefix)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var s api.Subnet
|
||||
if err := c.Get(ctx(), subnetPath(args[0]), nil, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(s, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "ID:\t%d\n", s.ID)
|
||||
fmt.Fprintf(w, "Name:\t%s\n", orDash(s.Name))
|
||||
fmt.Fprintf(w, "Interface:\t%s\n", s.Interface)
|
||||
fmt.Fprintf(w, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix))
|
||||
fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools))
|
||||
fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined)
|
||||
fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
|
||||
lt := "the default"
|
||||
if s.LeaseTime > 0 {
|
||||
lt = fmtSeconds(s.LeaseTime)
|
||||
}
|
||||
fmt.Fprintf(w, "Lease time:\t%s\n", lt)
|
||||
fmt.Fprintln(w, "Options:\t"+optionsText(s.Options))
|
||||
if s.Disabled {
|
||||
fmt.Fprintln(w, "State:\tdisabled (no answers)")
|
||||
}
|
||||
if s.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", s.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
var name, iface, leaseTime, comment, prefix string
|
||||
var pools, opts, addPools, removePools, unsetOpts []string
|
||||
add := &cobra.Command{
|
||||
Use: "add PREFIX --interface IFACE",
|
||||
Short: "Add a subnet",
|
||||
Long: `Adds a subnet, served on one interface: only clients on that interface
|
||||
get its addresses.
|
||||
|
||||
steward subnet add 10.0.10.0/24 --interface eth0 --name office \
|
||||
--pool 10.0.10.100-10.0.10.199 \
|
||||
--option routers=10.0.10.1 --option domain-name-servers=10.0.10.2
|
||||
|
||||
Pools are FIRST-LAST or a prefix; give --pool once per pool. Options are
|
||||
NAME=VALUE ('steward option list' has the names); a number works for any
|
||||
other option, with its type for ones Steward doesn't know: 224:string=hi.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.SubnetInput{Prefix: &args[0], Interface: &iface, Pools: &pools}
|
||||
if pools == nil {
|
||||
in.Pools = &[]string{}
|
||||
}
|
||||
if name != "" {
|
||||
in.Name = &name
|
||||
}
|
||||
if comment != "" {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if leaseTime != "" {
|
||||
n, err := parseSeconds(leaseTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.LeaseTime = &n
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
o, err := optionsArg(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Options = &o
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/subnets", nil, in, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
}
|
||||
f := add.Flags()
|
||||
f.StringVar(&iface, "interface", "", "the network interface it is served on (required)")
|
||||
f.StringVar(&name, "name", "", "a name for it")
|
||||
f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix (repeat for several)")
|
||||
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d; default: the settings')")
|
||||
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
add.MarkFlagRequired("interface")
|
||||
cmd.AddCommand(add)
|
||||
|
||||
update := &cobra.Command{
|
||||
Use: "update SUBNET",
|
||||
Short: "Change a subnet",
|
||||
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools;
|
||||
--add-pool and --remove-pool change one. --option sets an option (keeping
|
||||
the others), --unset-option removes one.
|
||||
|
||||
steward subnet update office --add-pool 10.0.10.200-10.0.10.220
|
||||
steward subnet update office --option ntp-servers=10.0.10.2 --unset-option time-offset`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cur api.Subnet
|
||||
if err := c.Get(ctx(), subnetPath(args[0]), nil, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.SubnetInput{}
|
||||
fl := cmd.Flags()
|
||||
if fl.Changed("name") {
|
||||
in.Name = &name
|
||||
}
|
||||
if fl.Changed("interface") {
|
||||
in.Interface = &iface
|
||||
}
|
||||
if fl.Changed("prefix") {
|
||||
in.Prefix = &prefix
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if fl.Changed("lease-time") {
|
||||
n := 0
|
||||
if leaseTime != "default" {
|
||||
if n, err = parseSeconds(leaseTime); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
in.LeaseTime = &n
|
||||
}
|
||||
if fl.Changed("pool") || len(addPools) > 0 || len(removePools) > 0 {
|
||||
list := []string{}
|
||||
if !fl.Changed("pool") {
|
||||
for _, p := range cur.Pools {
|
||||
list = append(list, p.String())
|
||||
}
|
||||
} else {
|
||||
list = append(list, pools...)
|
||||
}
|
||||
for _, r := range removePools {
|
||||
rp, err := model.ParsePool(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for i, p := range list {
|
||||
if pp, _ := model.ParsePool(p); pp == rp {
|
||||
list = append(list[:i], list[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("the subnet has no pool %s (it has %s)", rp, poolsText(cur.Pools))
|
||||
}
|
||||
}
|
||||
list = append(list, addPools...)
|
||||
in.Pools = &list
|
||||
}
|
||||
if len(opts) > 0 || len(unsetOpts) > 0 {
|
||||
set, err := optionsArg(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, err := mergeOptions(cur.Options, set, unsetOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Options = &merged
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "PATCH", subnetPath(args[0]), nil, in, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
}
|
||||
f = update.Flags()
|
||||
f.StringVar(&name, "name", "", "a new name (\"\" for none)")
|
||||
f.StringVar(&iface, "interface", "", "a new interface")
|
||||
f.StringVar(&prefix, "prefix", "", "a new prefix")
|
||||
f.StringArrayVar(&pools, "pool", nil, "replace the pools (repeat for several)")
|
||||
f.StringArrayVar(&addPools, "add-pool", nil, "add a pool")
|
||||
f.StringArrayVar(&removePools, "remove-pool", nil, "remove a pool")
|
||||
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")")
|
||||
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
|
||||
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option (by name or number)")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(update)
|
||||
|
||||
for _, on := range []bool{true, false} {
|
||||
on := on
|
||||
verb := "disable"
|
||||
short := "Stop answering a subnet's clients (its configuration and leases stay)"
|
||||
if on {
|
||||
verb, short = "enable", "Answer a disabled subnet's clients again"
|
||||
}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: verb + " SUBNET", Short: short, Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
disabled := !on
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "PATCH", subnetPath(args[0]), nil, api.SubnetInput{Disabled: &disabled}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var yes bool
|
||||
del := &cobra.Command{
|
||||
Use: "delete SUBNET", Short: "Delete a subnet and its reservations", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cur api.Subnet
|
||||
if err := c.Get(ctx(), subnetPath(args[0]), nil, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := fmt.Sprintf("Delete subnet %s", cur.Label())
|
||||
if cur.Usage.Reservations > 0 {
|
||||
msg += fmt.Sprintf(" and its %d reservations", cur.Usage.Reservations)
|
||||
}
|
||||
if !confirm(msg+"?", yes) {
|
||||
return fmt.Errorf("not deleted")
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "DELETE", subnetPath(args[0]), nil, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
}
|
||||
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
cmd.AddCommand(del)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func reservationCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "reservation", Aliases: []string{"reservations", "res"}, Short: "Reservations: fixed addresses for clients"}
|
||||
var subnet string
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List reservations", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var rs []api.Reservation
|
||||
if err := c.Get(ctx(), "/reservations", q("subnet", subnet), &rs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(rs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tCOMMENT")
|
||||
for _, r := range rs {
|
||||
who := r.MAC
|
||||
if r.ClientID != "" {
|
||||
who = "id " + r.ClientID
|
||||
}
|
||||
lease := "-"
|
||||
if r.Lease != nil {
|
||||
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, r.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix)")
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show RESERVATION", Short: "Show a reservation (by ID, address or MAC address)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r api.Reservation
|
||||
if err := c.Get(ctx(), "/reservations/"+url.PathEscape(args[0]), nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "ID:\t%d\n", r.ID)
|
||||
fmt.Fprintf(w, "Address:\t%s (subnet %s)\n", r.IP, orDash(r.SubnetName))
|
||||
if r.MAC != "" {
|
||||
fmt.Fprintf(w, "MAC address:\t%s\n", r.MAC)
|
||||
}
|
||||
if r.ClientID != "" {
|
||||
fmt.Fprintf(w, "Client ID:\t%s\n", r.ClientID)
|
||||
}
|
||||
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
|
||||
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
|
||||
if r.Lease != nil {
|
||||
fmt.Fprintf(w, "Lease:\tuntil %s, last seen on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface)
|
||||
} else {
|
||||
fmt.Fprintln(w, "Lease:\tnone now")
|
||||
}
|
||||
if r.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
var hostname, clientID, comment, ip, mac string
|
||||
var opts, unsetOpts []string
|
||||
add := &cobra.Command{
|
||||
Use: "add IP MAC",
|
||||
Short: "Reserve an address for a client",
|
||||
Long: `Reserves an address for a client, known by its MAC address (or by its
|
||||
client identifier: give "-" for the MAC and --client-id). The address picks
|
||||
the subnet. An address inside a pool is fine: it is never handed out to
|
||||
anyone else.
|
||||
|
||||
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.ReservationInput{IP: &args[0]}
|
||||
if args[1] != "-" {
|
||||
in.MAC = &args[1]
|
||||
}
|
||||
if clientID != "" {
|
||||
in.ClientID = &clientID
|
||||
}
|
||||
if hostname != "" {
|
||||
in.Hostname = &hostname
|
||||
}
|
||||
if comment != "" {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
o, err := optionsArg(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Options = &o
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/reservations", nil, in, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
}
|
||||
f := add.Flags()
|
||||
f.StringVar(&hostname, "hostname", "", "the host name to send it")
|
||||
f.StringVar(&clientID, "client-id", "", "match the client identifier (hex) instead of the MAC address")
|
||||
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE for this client only")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
|
||||
update := &cobra.Command{
|
||||
Use: "update RESERVATION", Short: "Change a reservation (only what is given changes)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/reservations/" + url.PathEscape(args[0])
|
||||
var cur api.Reservation
|
||||
if err := c.Get(ctx(), path, nil, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.ReservationInput{}
|
||||
fl := cmd.Flags()
|
||||
if fl.Changed("ip") {
|
||||
in.IP = &ip
|
||||
}
|
||||
if fl.Changed("mac") {
|
||||
in.MAC = &mac
|
||||
}
|
||||
if fl.Changed("client-id") {
|
||||
in.ClientID = &clientID
|
||||
}
|
||||
if fl.Changed("hostname") {
|
||||
in.Hostname = &hostname
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if len(opts) > 0 || len(unsetOpts) > 0 {
|
||||
set, err := optionsArg(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, err := mergeOptions(cur.Options, set, unsetOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Options = &merged
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "PATCH", path, nil, in, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
}
|
||||
f = update.Flags()
|
||||
f.StringVar(&ip, "ip", "", "a new address")
|
||||
f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)")
|
||||
f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)")
|
||||
f.StringVar(&hostname, "hostname", "", "a new host name")
|
||||
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
|
||||
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(update)
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "delete RESERVATION", Short: "Delete a reservation", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "DELETE", "/reservations/"+url.PathEscape(args[0]), nil, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// errInUse means another process holds the database lock.
|
||||
var errInUse = errors.New("the database is in use by a running stewardd or by 'stewardd restore'")
|
||||
|
||||
// lockDatabase takes the exclusive lock (on DATABASE.lock) that marks the
|
||||
// database as in use by a running stewardd or by 'stewardd restore', so a
|
||||
// restore can never replace the database under a running stewardd, and two
|
||||
// stewardds can't serve the same database. Other commands (backup, user, ...)
|
||||
// don't take it: they work alongside a running stewardd.
|
||||
func lockDatabase(db string) (release func(), err error) {
|
||||
if err := os.MkdirAll(filepath.Dir(db), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := db + ".lock"
|
||||
// Read-only is enough for flock, and works whoever created the file.
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
return nil, errInUse
|
||||
}
|
||||
return nil, fmt.Errorf("locking %s: %w", path, err)
|
||||
}
|
||||
return func() {
|
||||
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
f.Close()
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// Command stewardd is Steward's daemon: the DHCP server, its REST API and
|
||||
// the web UI. It also has the commands that work on the database directly
|
||||
// (init, user, backup, restore, check).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"steward/internal/auth"
|
||||
"steward/internal/config"
|
||||
"steward/internal/dhcpd"
|
||||
"steward/internal/engine"
|
||||
"steward/internal/model"
|
||||
"steward/internal/server"
|
||||
"steward/internal/store"
|
||||
"steward/internal/validate"
|
||||
"steward/internal/version"
|
||||
"steward/internal/web"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "stewardd",
|
||||
Short: "Steward's DHCP server, API and web UI",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version.String(),
|
||||
}
|
||||
root.PersistentFlags().StringVarP(&configPath, "config", "c", config.DefaultPath, "configuration file")
|
||||
root.AddCommand(serveCmd(), initCmd(), userCmd(), checkCmd(), backupCmd(), restoreCmd(), configCmd(), versionCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
func newLogger(cfg *config.Config, w io.Writer) *slog.Logger {
|
||||
var level slog.Level
|
||||
switch cfg.Log.Level {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
if cfg.Log.Format == "json" {
|
||||
return slog.New(slog.NewJSONHandler(w, opts))
|
||||
}
|
||||
// The journal adds its own timestamps.
|
||||
if os.Getenv("JOURNAL_STREAM") != "" {
|
||||
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey && len(groups) == 0 {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
}
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(w, opts))
|
||||
}
|
||||
|
||||
type app struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func openApp(allowMissing bool) (*app, error) {
|
||||
cfg, err := config.Load(configPath, allowMissing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading the configuration: %w", err)
|
||||
}
|
||||
log := newLogger(cfg, os.Stderr)
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.Database), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := store.Open(cfg.Database)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening the database %s: %w", cfg.Database, err)
|
||||
}
|
||||
return &app{cfg: cfg, log: log, store: st}, nil
|
||||
}
|
||||
|
||||
// hostEnv describes this machine's interfaces for validation.
|
||||
func hostEnv(h *dhcpd.HostInterfaces) func() *validate.Env {
|
||||
return func() *validate.Env {
|
||||
all, err := h.All()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &validate.Env{Interfaces: all}
|
||||
}
|
||||
}
|
||||
|
||||
func serveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Serve DHCP, the API and the web UI",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
release, err := lockDatabase(a.cfg.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
a.log.Info("starting", "version", version.String(), "database", a.cfg.Database)
|
||||
if users, err := a.store.ListUsers(ctx); err == nil && len(users) == 0 {
|
||||
a.log.Warn("there are no users yet; create an administrator with 'stewardd init' (root can use 'steward' on the local socket meanwhile)")
|
||||
}
|
||||
eng, err := engine.New(ctx, a.store, a.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host := &dhcpd.HostInterfaces{}
|
||||
eng.Env = hostEnv(host)
|
||||
pinger := &dhcpd.ICMPPinger{Log: a.log}
|
||||
d := dhcpd.New(a.log, a.store, host, pinger)
|
||||
if err := d.LoadLeases(ctx); err != nil {
|
||||
return fmt.Errorf("reading the leases: %w", err)
|
||||
}
|
||||
d.SetConfig(eng.State())
|
||||
eng.OnChange = d.SetConfig
|
||||
for _, p := range eng.Problems() {
|
||||
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
|
||||
}
|
||||
srv := server.New(server.Config{
|
||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
|
||||
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
|
||||
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem,
|
||||
}, eng, d, host, a.log, web.FS())
|
||||
go d.Run(ctx)
|
||||
hup := make(chan os.Signal, 1)
|
||||
signal.Notify(hup, syscall.SIGHUP)
|
||||
go func() {
|
||||
for range hup {
|
||||
sdNotify("RELOADING=1")
|
||||
srv.ReloadTLS()
|
||||
sdNotify("READY=1")
|
||||
}
|
||||
}()
|
||||
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
|
||||
sdNotify("STOPPING=1")
|
||||
a.log.Info("stopped")
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sdNotify(state string) {
|
||||
sock := os.Getenv("NOTIFY_SOCKET")
|
||||
if sock == "" {
|
||||
return
|
||||
}
|
||||
addr := &net.UnixAddr{Name: sock, Net: "unixgram"}
|
||||
if strings.HasPrefix(sock, "@") {
|
||||
addr.Name = "\x00" + sock[1:]
|
||||
}
|
||||
c, err := net.DialUnix("unixgram", nil, addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
c.Write([]byte(state))
|
||||
}
|
||||
|
||||
// ---- init and users
|
||||
|
||||
func readPassword(prompt string, fromStdin bool) (string, error) {
|
||||
if fromStdin || !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprint(os.Stderr, "Repeat: ")
|
||||
b2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if string(b) != string(b2) {
|
||||
return "", errors.New("the passwords don't match")
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func localPrincipal(u *model.User) *auth.Principal {
|
||||
return &auth.Principal{User: u, Method: "local", IP: "local"}
|
||||
}
|
||||
|
||||
func audit(ctx context.Context, st *store.Store, p *auth.Principal, action, object string) {
|
||||
ev := &model.AuditEvent{Action: action, Object: object, Result: "success", Auth: p.Method, Username: p.Name()}
|
||||
if p.User != nil {
|
||||
ev.UserID = p.User.ID
|
||||
}
|
||||
st.AddAudit(ctx, ev)
|
||||
}
|
||||
|
||||
func createToken(ctx context.Context, st *store.Store, u *model.User, name string) (string, error) {
|
||||
secret, pid, hash := auth.NewToken()
|
||||
if err := st.CreateToken(ctx, &model.Token{UserID: u.ID, Name: name, PublicID: pid, SecretHash: hash}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func initCmd() *cobra.Command {
|
||||
var username string
|
||||
var stdin, withToken bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Create the database and the first administrator",
|
||||
Long: `Creates Steward's database with a first administrator.
|
||||
|
||||
Steward starts with no subnets: add them with 'steward subnet add', in the
|
||||
web UI, or import a Kea configuration with 'steward import kea'.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
ctx := context.Background()
|
||||
users, err := a.store.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) > 0 {
|
||||
return fmt.Errorf("Steward is already set up (%d users); add users with 'stewardd user add' or 'steward user add'", len(users))
|
||||
}
|
||||
if err := server.ValidUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := readPassword("Password for "+username+": ", stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := &model.User{Username: username, PasswordHash: hash, Admin: true, Comment: "created by stewardd init"}
|
||||
if err := a.store.CreateUser(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
audit(ctx, a.store, localPrincipal(u), "system.init", "user "+u.Username)
|
||||
fmt.Printf("Created the administrator %q in %s.\n", u.Username, a.cfg.Database)
|
||||
if withToken {
|
||||
tok, err := createToken(ctx, a.store, u, "cli")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("\nAPI token for the CLI (shown only once):\n\n %s\n", tok)
|
||||
}
|
||||
fmt.Println("\nNext steps:")
|
||||
fmt.Println(" 1. Start the daemon: sudo systemctl enable --now steward")
|
||||
fmt.Println(" 2. Add a subnet: sudo steward subnet add 10.0.10.0/24 --interface eth0 --pool 10.0.10.100-10.0.10.199 --option routers=10.0.10.1")
|
||||
fmt.Println(" or import Kea's setup: sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv")
|
||||
fmt.Printf(" 3. Open the web UI: http://%s/ (see listen in %s)\n", a.cfg.Listen[0], configPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account)")
|
||||
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
|
||||
cmd.Flags().BoolVar(&withToken, "token", false, "also make an API token for the CLI")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func userCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "user", Short: "Manage users directly in the database (when the API can't be used)"}
|
||||
var admin, stdin bool
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME", Short: "Add a user", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
if err := server.ValidUsername(args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := readPassword("Password for "+args[0]+": ", stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := &model.User{Username: args[0], PasswordHash: hash, Admin: admin}
|
||||
if err := a.store.CreateUser(context.Background(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return fmt.Errorf("user %s already exists", args[0])
|
||||
}
|
||||
return err
|
||||
}
|
||||
audit(context.Background(), a.store, localPrincipal(&model.User{Username: "root"}), "user.create", "user "+u.Username)
|
||||
fmt.Printf("added user %s\n", u.Username)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
add.Flags().BoolVar(&admin, "admin", false, "an administrator")
|
||||
add.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
|
||||
cmd.AddCommand(add)
|
||||
pw := &cobra.Command{
|
||||
Use: "password NAME", Short: "Set a user's password (and log them out)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
ctx := context.Background()
|
||||
u, err := a.store.GetUserByName(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("no user %s", args[0])
|
||||
}
|
||||
p, err := readPassword("New password for "+u.Username+": ", stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.PasswordHash, err = auth.HashPassword(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u.Disabled = false
|
||||
if err := a.store.UpdateUser(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
a.store.DeleteUserSessions(ctx, u.ID, "")
|
||||
audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "user.password", "user "+u.Username)
|
||||
fmt.Printf("set the password of %s\n", u.Username)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
pw.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
|
||||
cmd.AddCommand(pw)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "token NAME TOKEN-NAME", Short: "Make an API token for a user (printed once)", Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
ctx := context.Background()
|
||||
u, err := a.store.GetUserByName(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("no user %s", args[0])
|
||||
}
|
||||
tok, err := createToken(ctx, a.store, u, args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "token.create", "token "+u.Username+"/"+args[1])
|
||||
fmt.Println(tok)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func checkCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check the configuration in the database against this machine",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
eng, err := engine.New(context.Background(), a.store, a.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eng.Env = hostEnv(&dhcpd.HostInterfaces{})
|
||||
ps := eng.Problems()
|
||||
for _, p := range ps {
|
||||
fmt.Printf("%s: %s: %s [%s]\n", p.Severity, p.Object, p.Message, p.Code)
|
||||
}
|
||||
if validate.HasErrors(ps) {
|
||||
return errors.New("the configuration has errors")
|
||||
}
|
||||
if len(ps) == 0 {
|
||||
fmt.Println("ok: no problems")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- backups
|
||||
|
||||
// backupName is a backup's file name; the time in it orders and prunes
|
||||
// backups.
|
||||
const backupPrefix, backupSuffix = "steward-", ".db"
|
||||
|
||||
func backupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "backup [FILE]",
|
||||
Short: "Write a backup of the database (and delete old ones)",
|
||||
Long: `Writes a consistent copy of the database, also while stewardd runs. Without
|
||||
FILE it goes to backup.dir as steward-<time>.db, and backups older than
|
||||
backup.retention are deleted (keeping at least backup.keep_at_least).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(configPath, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(cfg.Database); err != nil {
|
||||
return fmt.Errorf("no database at %s yet (run 'stewardd init')", cfg.Database)
|
||||
}
|
||||
st, err := store.OpenExisting(cfg.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
dest := ""
|
||||
if len(args) == 1 {
|
||||
dest = args[0]
|
||||
} else {
|
||||
if err := os.MkdirAll(cfg.Backup.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
dest = filepath.Join(cfg.Backup.Dir, backupPrefix+time.Now().UTC().Format("20060102T150405Z")+backupSuffix)
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
os.Remove(tmp)
|
||||
if err := st.Backup(context.Background(), tmp); err != nil {
|
||||
return fmt.Errorf("writing the backup: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(dest)
|
||||
if len(args) == 0 {
|
||||
return prune(cfg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func prune(cfg *config.Config) error {
|
||||
entries, err := os.ReadDir(cfg.Backup.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type bk struct {
|
||||
name string
|
||||
t time.Time
|
||||
}
|
||||
var list []bk
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if !strings.HasPrefix(n, backupPrefix) || !strings.HasSuffix(n, backupSuffix) {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse("20060102T150405Z", strings.TrimSuffix(strings.TrimPrefix(n, backupPrefix), backupSuffix))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, bk{n, t})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].t.After(list[j].t) })
|
||||
cut := time.Now().Add(-time.Duration(cfg.Backup.Retention))
|
||||
for i, b := range list {
|
||||
if i < cfg.Backup.KeepAtLeast || b.t.After(cut) {
|
||||
continue
|
||||
}
|
||||
os.Remove(filepath.Join(cfg.Backup.Dir, b.name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreCmd() *cobra.Command {
|
||||
var yes bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore FILE",
|
||||
Short: "Replace the database with a backup (stewardd must be stopped)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(configPath, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release, err := lockDatabase(cfg.Database)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w; stop it first: systemctl stop steward", err)
|
||||
}
|
||||
defer release()
|
||||
src, err := store.OpenExisting(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s isn't a database: %w", args[0], err)
|
||||
}
|
||||
v, err := src.Version(context.Background())
|
||||
if err != nil {
|
||||
src.Close()
|
||||
return fmt.Errorf("%s isn't a Steward database: %w", args[0], err)
|
||||
}
|
||||
var ok string
|
||||
if err := src.DB().QueryRow(`PRAGMA integrity_check`).Scan(&ok); err != nil || ok != "ok" {
|
||||
src.Close()
|
||||
return fmt.Errorf("%s fails SQLite's integrity check: %s %v", args[0], ok, err)
|
||||
}
|
||||
src.Close()
|
||||
if v > store.SchemaVersion() {
|
||||
return fmt.Errorf("the backup is from a newer Steward (schema %d, this one has %d)", v, store.SchemaVersion())
|
||||
}
|
||||
if !yes {
|
||||
fmt.Fprintf(os.Stderr, "Replace %s with %s? [y/N] ", cfg.Database, args[0])
|
||||
var a string
|
||||
fmt.Scanln(&a)
|
||||
if a != "y" && a != "yes" {
|
||||
return errors.New("not restored")
|
||||
}
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102T150405Z")
|
||||
for _, ext := range []string{"", "-wal", "-shm"} {
|
||||
if _, err := os.Stat(cfg.Database + ext); err == nil {
|
||||
if err := os.Rename(cfg.Database+ext, cfg.Database+".before-restore-"+stamp+ext); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
b, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(cfg.Database, b, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := store.Open(cfg.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
audit(context.Background(), st, localPrincipal(&model.User{Username: "root"}), "system.restore", args[0])
|
||||
fmt.Printf("restored %s; the old database is %s.before-restore-%s\n", args[0], cfg.Database, stamp)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configCmd() *cobra.Command {
|
||||
var example bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "config", Short: "Show the configuration in use (--example: a commented example)", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if example {
|
||||
fmt.Print(config.Example)
|
||||
return nil
|
||||
}
|
||||
cfg, err := config.Load(configPath, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("listen: %s\nsocket: %s\ndatabase: %s\nbackups: %s\n", strings.Join(cfg.Listen, ", "), cfg.Socket, cfg.Database, cfg.Backup.Dir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) { fmt.Println("stewardd", version.String()) },
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user