initial commit
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
// Package cli implements the command line interface.
|
||||
//
|
||||
// `serve` is the default when no subcommand is given, so running the binary
|
||||
// with no arguments starts the server, which is the overwhelmingly common case.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/owen/vibedns/internal/config"
|
||||
"github.com/owen/vibedns/internal/version"
|
||||
)
|
||||
|
||||
// Command is one subcommand.
|
||||
type Command struct {
|
||||
Name string
|
||||
Summary string
|
||||
Usage string
|
||||
Run func(ctx context.Context, env *Env, args []string) error
|
||||
}
|
||||
|
||||
// Env carries what every command needs.
|
||||
type Env struct {
|
||||
Boot config.Bootstrap
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// ExitError carries a specific process exit status.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string { return e.Err.Error() }
|
||||
func (e *ExitError) Unwrap() error { return e.Err }
|
||||
|
||||
// Exit wraps an error with an exit code.
|
||||
func Exit(code int, format string, args ...any) error {
|
||||
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
|
||||
}
|
||||
|
||||
// commands is the full command table.
|
||||
func commands() []*Command {
|
||||
return []*Command{
|
||||
serveCommand(),
|
||||
versionCommand(),
|
||||
configCommand(),
|
||||
adminCommand(),
|
||||
databaseCommand(),
|
||||
}
|
||||
}
|
||||
|
||||
// Main parses arguments and runs the selected command.
|
||||
func Main(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
env := &Env{Boot: config.DefaultBootstrap(), Stdout: stdout, Stderr: stderr}
|
||||
|
||||
// Find the subcommand: the first argument that is not a flag. This lets
|
||||
// both `vibedns --db x serve` and `vibedns serve --db x` work.
|
||||
name := ""
|
||||
rest := args
|
||||
for i, a := range args {
|
||||
if !strings.HasPrefix(a, "-") {
|
||||
name = a
|
||||
rest = append(append([]string{}, args[:i]...), args[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "help", "-h", "--help", "":
|
||||
if name == "" && !wantsHelp(args) {
|
||||
// No subcommand: serve.
|
||||
return run(ctx, env, serveCommand(), args)
|
||||
}
|
||||
usage(stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
for _, c := range commands() {
|
||||
if c.Name == name {
|
||||
return run(ctx, env, c, rest)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "unknown command %q\n\n", name)
|
||||
usage(stderr)
|
||||
return 2
|
||||
}
|
||||
|
||||
func wantsHelp(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" || a == "help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func run(ctx context.Context, env *Env, c *Command, args []string) int {
|
||||
err := c.Run(ctx, env, args)
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
var ee *ExitError
|
||||
if errors.As(err, &ee) {
|
||||
fmt.Fprintf(env.Stderr, "%s: %v\n", c.Name, ee.Err)
|
||||
return ee.Code
|
||||
}
|
||||
fmt.Fprintf(env.Stderr, "%s: %v\n", c.Name, err)
|
||||
return 1
|
||||
}
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprintf(w, `%s %s — authoritative DNS server, recursive resolver and filtering appliance
|
||||
|
||||
Usage:
|
||||
vibedns [command] [flags]
|
||||
|
||||
Commands:
|
||||
`, version.Name, version.Version)
|
||||
|
||||
for _, c := range commands() {
|
||||
fmt.Fprintf(w, " %-20s %s\n", c.Name, c.Summary)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, `
|
||||
Running with no command starts the server.
|
||||
|
||||
Common flags:
|
||||
--db PATH SQLite database file (default %s)
|
||||
--http ADDR management interface address (default %s)
|
||||
--dns ADDR DNS listen address for UDP and TCP (default %s)
|
||||
--log-level LEVEL debug, info, warn or error
|
||||
--log-format FORMAT text or json
|
||||
|
||||
Environment variables:
|
||||
%-22s database file
|
||||
%-22s management interface address
|
||||
%-22s DNS listen address
|
||||
%-22s initial administrator username
|
||||
%-22s initial administrator password
|
||||
%-22s log level
|
||||
%-22s log format
|
||||
|
||||
Examples:
|
||||
vibedns
|
||||
vibedns serve --db /var/lib/vibedns/dns.db --http 127.0.0.1:8080
|
||||
vibedns config check
|
||||
vibedns admin reset-password
|
||||
vibedns database backup --output /var/backups
|
||||
vibedns version
|
||||
|
||||
`,
|
||||
config.DefaultDBPath, config.DefaultHTTPAddr, config.DefaultDNSAddr,
|
||||
config.EnvDBPath, config.EnvHTTPAddr, config.EnvDNSAddr,
|
||||
config.EnvAdminUsername, config.EnvAdminPassword,
|
||||
config.EnvLogLevel, config.EnvLogFormat)
|
||||
}
|
||||
|
||||
// newFlagSet builds a flag set that prints its own usage on error.
|
||||
func newFlagSet(env *Env, c *Command) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
|
||||
fs.SetOutput(env.Stderr)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(env.Stderr, "%s\n\n", c.Usage)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// versionCommand prints build information.
|
||||
func versionCommand() *Command {
|
||||
return &Command{
|
||||
Name: "version",
|
||||
Summary: "print version and build information",
|
||||
Usage: "Usage: vibedns version",
|
||||
Run: func(ctx context.Context, env *Env, args []string) error {
|
||||
fmt.Fprint(env.Stdout, version.Long())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// confirm asks for an interactive yes/no answer. It returns false when stdin
|
||||
// is not a terminal, so a piped invocation can never be silently destructive.
|
||||
func confirm(env *Env, prompt string) bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 {
|
||||
fmt.Fprintf(env.Stderr,
|
||||
"%s\nRefusing to continue without an interactive confirmation; pass --yes to proceed.\n", prompt)
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(env.Stdout, "%s [y/N]: ", prompt)
|
||||
var answer string
|
||||
_, _ = fmt.Fscanln(os.Stdin, &answer)
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
Reference in New Issue
Block a user