v0.23.0 color, presets, certificates made at startup, cli.go, sink flush
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-04 03:44:12 -04:00
1 parent 15745682bc
commit bf410e1225
50 files changed
+2649 -426

No files matched your search

+2 -80
View File
@@ -28,15 +28,7 @@ import (
const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
// authCommand is one `lw auth` subcommand: define registers its flags
// and returns the command, which runs once they are parsed and checked.
type authCommand struct {
name, synopsis, summary string
required []string
define func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error
}
var authCommands = []authCommand{
var authCommands = []subcommand{
{"add-user", "-credentials FILE -user NAME [-password-file FILE] [-generate]",
"Add a user to a credentials file, or replace its password",
[]string{"credentials", "user"}, defineAddUser},
@@ -51,77 +43,6 @@ var authCommands = []authCommand{
[]string{"addr", "user", "password-file"}, defineStream},
}
// usageError is a command-line mistake: exit status 2 rather than 1
type usageError string
func (e usageError) Error() string { return string(e) }
// runAuth runs `lw auth` and returns the exit status: 0 success or
// help, 1 failure, 2 usage error.
func runAuth(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 || slices.Contains([]string{"-h", "-help", "--help", "help"}, args[0]) {
printAuthUsage(stderr)
if len(args) == 0 {
return 2
}
return 0
}
i := slices.IndexFunc(authCommands, func(c authCommand) bool { return c.name == args[0] })
if i < 0 {
fmt.Fprintf(stderr, "lw auth: unknown command %q\n\n", args[0])
printAuthUsage(stderr)
return 2
}
c := authCommands[i]
fs := flag.NewFlagSet("lw auth "+c.name, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), c.synopsis, c.summary)
fs.PrintDefaults()
}
run := c.define(fs)
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2 // already reported by flag, with the usage
}
err := checkArgs(fs, c.required)
if err == nil {
err = run(stdout, stderr)
}
if err == nil {
return 0
}
fmt.Fprintf(stderr, "%s: %v\n", fs.Name(), err)
if _, ok := errors.AsType[usageError](err); ok {
fs.Usage()
return 2
}
return 1
}
func printAuthUsage(w io.Writer) {
fmt.Fprint(w, "Usage: lw auth <command> [flags]\n\n")
for _, c := range authCommands {
fmt.Fprintf(w, " %-12s %s\n", c.name, c.summary)
}
fmt.Fprint(w, "\nRun lw auth <command> -h for its flags. Credential changes apply on SIGHUP.\n"+
"Exit status: 0 success, 1 failure, 2 usage error.\n")
}
func checkArgs(fs *flag.FlagSet, required []string) error {
if fs.NArg() > 0 {
return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0)))
}
for _, name := range required {
if fs.Lookup(name).Value.String() == "" {
return usageError("-" + name + " is required")
}
}
return nil
}
func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
path := fs.String("credentials", "", "credentials `file`, created when missing")
user := fs.String("user", "", "user `name`")
@@ -323,6 +244,7 @@ func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
o := &config.TLSOptions{Enabled: true}
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)")
fs.StringVar(&o.PinSHA256, "pin-sha256", "", "sha256//BASE64 `pin` of the server key, in place of -ca-file (a self_signed listener logs it)")
fs.StringVar(&o.ServerName, "server-name", "", "`name` the server certificate must carry (default: the host)")
fs.StringVar(&o.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`")
+1 -4
View File
@@ -14,10 +14,7 @@ import (
)
func runAuthTest(t *testing.T, args ...string) (code int, stdout, stderr string) {
t.Helper()
var out, errOut bytes.Buffer
code = runAuth(args, &out, &errOut)
return code, out.String(), errOut.String()
return runCommand(t, "auth", args...)
}
// writeCredentials writes users sharing a cheap Argon2 profile. add-user
+25
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
_ "github.com/lixenwraith/logwisp/internal/source/console"
@@ -27,6 +28,7 @@ import (
"github.com/lixenwraith/log"
"github.com/lixenwraith/log/sanitizer"
"github.com/lixenwraith/toml"
)
// bootstrapInitial handles initial service startup with status reporter
@@ -130,6 +132,29 @@ func checkConfig(cfg *config.Config) int {
return 0
}
// dumpConfig prints the effective configuration, flags and environment
// resolved into it, as a file lw -c reads back.
func dumpConfig(cfg *config.Config, w io.Writer) int {
cfg.Dump = false
// Snapshot gave each console sink the top-level color: keep it inherited
for _, p := range cfg.Pipelines {
for _, s := range p.PluginSinks {
if s.Type == "console" && s.Config["color"] == cfg.Color {
delete(s.Config, "color")
}
}
}
data, err := toml.Marshal(cfg)
if err == nil {
_, err = w.Write(data)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return 1
}
return 0
}
// initializeLogger sets up the global logger based on the application's configuration
func initializeLogger(cfg *config.Config) error {
logger = log.NewLogger()
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"cmp"
"errors"
"flag"
"fmt"
"io"
"slices"
"strings"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/toml"
)
// command is a group of subcommands, recognized only as lw's first argument
type command struct {
name, summary, footer string
subcommands []subcommand
}
// subcommand is one `lw COMMAND NAME`: define registers its flags and
// returns the work, which runs once they are parsed and checked.
type subcommand struct {
name, synopsis, summary string
required []string
define func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error
}
var commands = []command{
{"auth", "SCRAM users, bearer tokens, tcp sink viewing",
"Credential changes apply on SIGHUP.", authCommands},
{"tls", "A CA and certificates for TLS and mTLS",
"Files are never overwritten; keys are written mode 0600.", tlsCommands},
{"preset", "Show the pipeline a preset makes, as TOML",
"lw --preset NAME,key=value runs one; flags here are its keys.", presetCommands()},
}
// usageError is a command-line mistake: exit status 2 rather than 1
type usageError string
func (e usageError) Error() string { return string(e) }
// run runs a subcommand and returns the exit status: 0 success or help,
// 1 failure, 2 usage error.
func (c *command) run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 || slices.Contains([]string{"-h", "-help", "--help", "help"}, args[0]) {
c.usage(stderr)
if len(args) == 0 {
return 2
}
return 0
}
i := slices.IndexFunc(c.subcommands, func(s subcommand) bool { return s.name == args[0] })
if i < 0 {
fmt.Fprintf(stderr, "lw %s: unknown command %q\n\n", c.name, args[0])
c.usage(stderr)
return 2
}
s := c.subcommands[i]
fs := flag.NewFlagSet("lw "+c.name+" "+s.name, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), s.synopsis, s.summary)
fs.PrintDefaults()
}
work := s.define(fs)
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2 // already reported by flag, with the usage
}
err := checkArgs(fs, s.required)
if err == nil {
err = work(stdout, stderr)
}
if err == nil {
return 0
}
fmt.Fprintf(stderr, "%s: %v\n", fs.Name(), err)
if _, ok := errors.AsType[usageError](err); ok {
fs.Usage()
return 2
}
return 1
}
func (c *command) usage(w io.Writer) {
fmt.Fprintf(w, "Usage: lw %s <command> [flags]\n\n", c.name)
for _, s := range c.subcommands {
fmt.Fprintf(w, " %-12s %s\n", s.name, s.summary)
}
fmt.Fprintf(w, "\nRun lw %s <command> -h for its flags. %s\n"+
"Exit status: 0 success, 1 failure, 2 usage error.\n", c.name, c.footer)
}
func checkArgs(fs *flag.FlagSet, required []string) error {
if fs.NArg() > 0 {
return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0)))
}
for _, name := range required {
if fs.Lookup(name).Value.String() == "" {
return usageError("-" + name + " is required")
}
}
return nil
}
// invocation is a parsed command line: a command with its arguments, a help
// request, or what config.Load reads.
type invocation struct {
command *command
args []string
help bool
load config.Args
}
// parseCommandLine reads lw's own flags; anything else is a --path=value
// setting for lixenwraith/config, which reports what it does not know. A
// pipeline flag takes the next argument unless it starts with '-'. A help
// request wins over a malformed flag.
func parseCommandLine(argv []string) (inv invocation, err error) {
defer func() {
if inv.help {
err = nil
}
}()
if len(argv) > 0 {
if i := slices.IndexFunc(commands, func(c command) bool { return c.name == argv[0] }); i >= 0 {
return invocation{command: &commands[i], args: argv[1:]}, nil
}
inv.help = argv[0] == "help"
}
specFlags := config.SpecFlags()
next := func(i *int) string {
if *i+1 < len(argv) && !strings.HasPrefix(argv[*i+1], "-") {
*i++
return argv[*i]
}
return ""
}
for i := 0; i < len(argv); i++ {
arg := argv[i]
name, value, inline := strings.Cut(arg, "=")
switch {
case arg == "--":
inv.load.Overrides = append(inv.load.Overrides, argv[i:]...)
return inv, err
case arg == "-h" || arg == "--help":
inv.help = true
case name == "-c" || name == "--config":
if !inline {
value = next(&i)
}
if value == "" {
err = cmp.Or(err, fmt.Errorf("%s requires a configuration file path", name))
}
inv.load.File = value
case arg == "--color":
// Bare, it means always: config takes no bare flag for a string key
inv.load.Overrides = append(inv.load.Overrides, "--color="+cmp.Or(next(&i), "always"))
case strings.HasPrefix(name, "--") && slices.Contains(specFlags, name[2:]):
if !inline {
value = next(&i)
}
inv.load.Specs = append(inv.load.Specs, config.Spec{Flag: name[2:], Value: value})
default:
inv.load.Overrides = append(inv.load.Overrides, arg)
}
}
return inv, err
}
// presetCommands makes each preset a subcommand whose flags are its keys
func presetCommands() []subcommand {
var subs []subcommand
for _, p := range config.Presets() {
s := subcommand{name: p.Name, summary: p.Summary}
var synopsis []string
for _, k := range p.Params {
if k.Required {
s.required = append(s.required, k.Name)
synopsis = append(synopsis, "-"+k.Name+" VALUE")
}
}
s.synopsis = strings.Join(append(synopsis, "[-KEY VALUE...]"), " ")
s.define = func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
values := map[string]*string{}
for _, k := range p.Params {
values[k.Name] = fs.String(k.Name, k.Default, k.Help)
}
return func(stdout, _ io.Writer) error {
set := map[string]string{}
for name, v := range values {
set[name] = *v
}
pipeline, err := config.ExpandPreset(p.Name, set)
if err != nil {
return usageError(err.Error())
}
data, err := toml.Marshal(map[string]any{"pipelines": []config.PipelineConfig{pipeline}})
if err == nil {
_, err = stdout.Write(data)
}
return err
}
}
subs = append(subs, s)
}
return subs
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/tlsx"
)
// lw's own flags: the last -c wins, a pipeline flag or --color takes the next
// argument unless it starts with '-', -- ends lw's flags, and a command is
// only the first argument. Everything else is a setting for config.
func TestCommandLineGrammar(t *testing.T) {
for _, c := range []struct {
argv []string
want invocation
}{
{[]string{"-c", "a.toml", "--config=b.toml", "--quiet"}, invocation{load: config.Args{File: "b.toml", Overrides: []string{"--quiet"}}}},
{[]string{"-c=a.toml", "--config", "b.toml"}, invocation{load: config.Args{File: "b.toml"}}},
{[]string{"--source=null", "--sink", "http,port=1", "--filter", "--sink"},
invocation{load: config.Args{Specs: []config.Spec{{Flag: "source", Value: "null"}, {Flag: "sink", Value: "http,port=1"}, {Flag: "filter"}, {Flag: "sink"}}}}},
{[]string{"--preset", "tail,path=x", "--pipeline", "b"}, invocation{load: config.Args{Specs: []config.Spec{{Flag: "preset", Value: "tail,path=x"}, {Flag: "pipeline", Value: "b"}}}}},
{[]string{"--color", "--color", "never", "--color=auto"}, invocation{load: config.Args{Overrides: []string{"--color=always", "--color=never", "--color=auto"}}}},
{[]string{"--quiet", "--", "-c", "x", "--source", "null"}, invocation{load: config.Args{Overrides: []string{"--quiet", "--", "-c", "x", "--source", "null"}}}},
{[]string{"--quiet", "-h"}, invocation{help: true, load: config.Args{Overrides: []string{"--quiet"}}}},
{[]string{"-c", "-h"}, invocation{help: true}},
{[]string{"help"}, invocation{help: true, load: config.Args{Overrides: []string{"help"}}}},
{[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}},
{[]string{"--quiet", "tls"}, invocation{load: config.Args{Overrides: []string{"--quiet", "tls"}}}},
} {
got, err := parseCommandLine(c.argv)
if err != nil || !reflect.DeepEqual(got, c.want) {
t.Errorf("%q:\n got %+v %v\nwant %+v", c.argv, got, err, c.want)
}
}
for _, argv := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"}} {
if _, err := parseCommandLine(argv); err == nil {
t.Errorf("%q: missing path accepted", argv)
}
}
}
// --dump prints a file that lw -c reads back to the same configuration, its
// console sinks still taking the top-level color
func TestDumpReadsBack(t *testing.T) {
testLogger(t)
dir := t.TempDir()
empty := filepath.Join(dir, "empty.toml")
if err := os.WriteFile(empty, nil, 0o600); err != nil {
t.Fatal(err)
}
args := []string{"--color", "--preset", "serve,listen=127.0.0.1:15841,tls=self,hosts=a.example,hosts=b.example",
"--filter", `exclude,patterns=password=\S{8\,64}`, "--sink", "console", "--logging.level=debug"}
cfg := loadTestConfig(t, empty, args...)
var dump bytes.Buffer
if code := dumpConfig(loadTestConfig(t, empty, args...), &dump); code != 0 {
t.Fatalf("dump exit %d", code)
}
path := filepath.Join(dir, "dump.toml")
if err := os.WriteFile(path, dump.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
again := loadTestConfig(t, path)
if !reflect.DeepEqual(again.Pipelines, cfg.Pipelines) || again.Color != "always" || again.Logging.Level != "debug" ||
strings.Count(dump.String(), "color = ") != 1 {
t.Fatalf("dump does not read back:\n%s\n got %+v\nwant %+v", dump.String(), again.Pipelines, cfg.Pipelines)
}
}
// Every preset expands to plugins that build, as lw --check builds them
func TestEveryPresetBuilds(t *testing.T) {
testLogger(t)
dir := t.TempDir()
users, pass, empty := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge.pass"), filepath.Join(dir, "empty.toml")
writeCredentials(t, users, "edge-01")
for path, data := range map[string]string{pass: "password-edge-01\n", empty: ""} {
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
}
pin := "sha256//" + strings.Repeat("A", 43) + "="
samples := map[string]string{
"pipe": "pipe,format=txt",
"tail": "tail,path=" + dir,
"serve": "serve,path=" + filepath.Join(dir, "*.log") + ",listen=127.0.0.1:15841,tls=self,users=" + users,
"edge": "edge,to=127.0.0.1:15842,transport=http,pin=" + pin + ",user=edge-01,password_file=" + pass,
"aggregator": "aggregator,listen=127.0.0.1:15843,users=" + users + ",out=" + filepath.Join(dir, "out"),
}
for _, p := range config.Presets() {
sample, ok := samples[p.Name]
if !ok {
t.Errorf("preset %s has no sample here", p.Name)
continue
}
if code := checkConfig(loadTestConfig(t, empty, "--preset", sample)); code != 0 {
t.Errorf("%s: lw --check exit %d", sample, code)
}
}
}
// lw tls ca and lw tls cert write a chain a dialer verifies by the CA file,
// keys private, and never replace a file
func TestTLSCommandsIssueAVerifiableChain(t *testing.T) {
dir := t.TempDir()
steps := [][]string{
{"ca", "-dir", dir, "-name", "test CA"},
{"cert", "-ca-dir", dir, "-name", "agg", "-server", "-host", "127.0.0.1"},
}
for _, args := range steps {
if code, _, stderr := runCommand(t, "tls", args...); code != 0 {
t.Fatalf("%v: exit %d: %s", args, code, stderr)
}
}
for _, key := range []string{"ca.key", "agg.key"} {
if fi, err := os.Stat(filepath.Join(dir, key)); err != nil || fi.Mode().Perm() != 0o600 {
t.Fatalf("%s: %v %v", key, fi.Mode(), err)
}
}
server, err := tlsx.Server(&config.TLSOptions{Enabled: true, CertFile: filepath.Join(dir, "agg.crt"), KeyFile: filepath.Join(dir, "agg.key")}, "")
if err != nil {
t.Fatal(err)
}
client, err := tlsx.Client(&config.TLSOptions{Enabled: true, CAFile: filepath.Join(dir, "ca.crt")}, "127.0.0.1")
if err != nil {
t.Fatal(err)
}
if err := testutil.Handshake(t, server, client); err != nil {
t.Fatalf("issued chain: %v", err)
}
if code, _, stderr := runCommand(t, "tls", steps[0]...); code != 1 || !strings.Contains(stderr, "exists") {
t.Fatalf("second ca: exit %d: %s", code, stderr)
}
}
+22 -24
View File
@@ -2,7 +2,7 @@ package main
import (
"fmt"
"os"
"strings"
"github.com/lixenwraith/logwisp/internal/version"
)
@@ -15,19 +15,21 @@ Usage:
line for line, like cat; exits at end of input
lw -c <file> [options] Run the file's pipelines
lw --check [options] Build every pipeline and plugin, report, exit
lw --dump [options] Print the effective configuration as TOML, exit
lw help | -h | --help
lw --version
Subcommands:
lw auth <command> SCRAM credentials files, bearer tokens and tcp
sink viewing; lw auth -h lists commands
Commands (lw <command> -h lists its subcommands):
%s
Scalar configuration keys are settable as flags using their TOML paths:
--<path>=<value> e.g. --logging.level=debug
Common options:
-c, --config <path> Configuration file (default: ./logwisp.toml)
--quiet Suppress console output
--color [<when>] Level names in color on console sinks:
auto (a terminal, NO_COLOR unset; default),
always (bare --color) or never
--status_reporter=<bool> Periodic status logging (default: true with
a configuration file)
--auto_reload=<bool> Config hot reload on file change (default: false)
@@ -39,6 +41,9 @@ at warn without one):
--logging.file.directory=<path>
Pipelines (replace the file's pipelines; see doc/cli.md):
--preset <name>[,k=v...] Start a pipeline with a preset: pipe, tail,
serve, edge, aggregator; lw preset <name> -h
lists its keys, lw preset <name> prints it
--pipeline <name> Start a pipeline; earlier specs go to "cli"
--source <spec> TYPE[,key=value...], repeatable
--sink <spec> e.g. http,host=0.0.0.0,port=8080, repeatable
@@ -56,14 +61,19 @@ Examples:
journalctl -f | lw --sink http,host=127.0.0.1,port=8080
Serve stdin as a live stream
lw --source file,directory=/var/log/app,pattern='*.log' --format txt
lw --preset tail,path=/var/log/app Follow a directory's files
lw --preset serve,path=/var/log/app,tls=self,users=users.toml
HTTPS stream, SCRAM logins
lw --preset edge,path=/var/log/app,to=agg:9000,ca=ca.crt,user=edge-01,\
password_file=edge-01.pass Forward to an aggregator
Environment:
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
e.g. LOGWISP_LOGGING_LEVEL=debug
LOGWISP_CONFIG_FILE Configuration file path
LOGWISP_CONFIG_DIR Configuration directory
LOGWISP_PIPELINE, LOGWISP_SOURCE[_N], LOGWISP_SINK[_N], LOGWISP_FILTER[_N],
LOGWISP_FORMAT, LOGWISP_RATE_LIMIT, LOGWISP_HEARTBEAT
LOGWISP_PIPELINE, LOGWISP_PRESET, LOGWISP_SOURCE[_N], LOGWISP_SINK[_N],
LOGWISP_FILTER[_N], LOGWISP_FORMAT, LOGWISP_RATE_LIMIT, LOGWISP_HEARTBEAT
One pipeline, specs as the flags (_N adds
more); ignored when a pipeline flag is given
@@ -77,22 +87,10 @@ Exit codes:
2 configuration file not found
`
// handleHelp prints usage and exits if a help request is present in args
func handleHelp(args []string) {
if len(args) > 0 && args[0] == "help" {
printHelp()
}
for _, arg := range args {
if arg == "--" {
break // end of flags
}
if arg == "-h" || arg == "--help" {
printHelp()
}
}
}
func printHelp() {
fmt.Printf(helpText, version.Short())
os.Exit(0)
var list strings.Builder
for _, c := range commands {
fmt.Fprintf(&list, " lw %-26s %s\n", c.name+" <command>", c.summary)
}
fmt.Printf(helpText, version.Short(), list.String())
}
+14 -7
View File
@@ -20,19 +20,23 @@ import (
var logger *log.Logger
func main() {
// Before handleHelp, so `lw auth <command> -h` prints the auth usage
if len(os.Args) > 1 && os.Args[1] == "auth" {
os.Exit(runAuth(os.Args[2:], os.Stdout, os.Stderr))
inv, err := parseCommandLine(os.Args[1:])
switch {
case err != nil:
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
case inv.command != nil:
os.Exit(inv.command.run(inv.args, os.Stdout, os.Stderr))
case inv.help:
printHelp()
os.Exit(0)
}
// --- 1. Initial setup ---
// Emulates nohup
signal.Ignore(syscall.SIGHUP)
// Before config parsing: the loader has no help flag
handleHelp(os.Args[1:])
manager, err := config.Load(os.Args[1:])
manager, err := config.Load(inv.load)
if err != nil {
if errors.Is(err, config.ErrConfigNotFound) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -57,6 +61,9 @@ func main() {
if cfg.Check {
os.Exit(checkConfig(cfg))
}
if cfg.Dump {
os.Exit(dumpConfig(cfg, os.Stdout))
}
if err := initializeLogger(cfg); err != nil {
FatalError(1, "Failed to initialize logger: %v\n", err)
+18 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"testing"
"github.com/lixenwraith/logwisp/internal/config"
@@ -19,7 +20,11 @@ func testLogger(t *testing.T) {
func loadTestConfig(t *testing.T, path string, args ...string) *config.Config {
t.Helper()
testutil.ClearEnvPrefix(t, "LOGWISP_")
m, err := config.Load(append([]string{"-c", path, "--quiet", "--status_reporter=false"}, args...))
inv, err := parseCommandLine(append([]string{"-c", path, "--quiet", "--status_reporter=false"}, args...))
if err != nil {
t.Fatal(err)
}
m, err := config.Load(inv.load)
if err != nil {
t.Fatal(err)
}
@@ -30,3 +35,15 @@ func loadTestConfig(t *testing.T, path string, args ...string) *config.Config {
}
return cfg
}
// runCommand runs `lw NAME ARGS...` and returns its exit status and output
func runCommand(t *testing.T, name string, args ...string) (code int, stdout, stderr string) {
t.Helper()
inv, err := parseCommandLine(append([]string{name}, args...))
if err != nil || inv.command == nil {
t.Fatalf("lw %s: not a command: %v", name, err)
}
var out, errOut bytes.Buffer
code = inv.command.run(inv.args, &out, &errOut)
return code, out.String(), errOut.String()
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"crypto"
"crypto/x509"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/lixenwraith/logwisp/internal/tlsx"
)
// maxDays keeps a validity well inside time.Duration, whose overflow would
// date a certificate in the past
const maxDays = 36500
var tlsCommands = []subcommand{
{"ca", "-dir DIR [-name NAME] [-days N]",
"Create a certificate authority, DIR/ca.crt and DIR/ca.key, that signs certificates only",
[]string{"dir"}, defineCA},
{"cert", "-ca-dir DIR -name NAME [-host NAME,...] [-server] [-client] [-days N] [-out DIR]",
"Issue a certificate and key, OUT/NAME.crt and OUT/NAME.key, from DIR/ca.crt and DIR/ca.key",
[]string{"ca-dir", "name"}, defineCert},
}
func defineCA(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
dir := fs.String("dir", "", "`directory` to write ca.crt and ca.key in, created when missing")
name := fs.String("name", "logwisp CA", "subject common `name`")
days := fs.Int("days", int(tlsx.CAValidity/(24*time.Hour)), "validity in `days`")
return func(_, stderr io.Writer) error {
if *days < 1 || *days > maxDays {
return usageError(fmt.Sprintf("-days must be 1 to %d", maxDays))
}
cert, key, err := tlsx.NewCA(*name, time.Duration(*days)*24*time.Hour)
if err != nil {
return err
}
if err := writePair(*dir, "ca", cert, key); err != nil {
return err
}
fmt.Fprintf(stderr, "wrote %s and %s, valid until %s\n"+
"ca.crt verifies: dialers' tls.ca_file, listeners' tls.client_ca_file; with ca.key, it signs: lw tls cert, tls.issuer_cert_file\n",
filepath.Join(*dir, "ca.crt"), filepath.Join(*dir, "ca.key"), cert.NotAfter.UTC().Format(time.DateOnly))
return nil
}
}
func defineCert(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
caDir := fs.String("ca-dir", "", "`directory` holding ca.crt and ca.key (lw tls ca)")
name := fs.String("name", "", "subject common `name`, the mtls identity, and the file names")
hosts := fs.String("host", "", "DNS `names` and IP addresses, ',' between them (default with -server: -name)")
server := fs.Bool("server", false, "for listeners (TLS server authentication)")
client := fs.Bool("client", false, "for dialers presenting a certificate (TLS client authentication)")
days := fs.Int("days", int(tlsx.LeafValidity/(24*time.Hour)), "validity in `days`, at most the CA's")
out := fs.String("out", "", "`directory` to write in (default: -ca-dir)")
return func(_, stderr io.Writer) error {
switch {
case !*server && !*client:
return usageError("-server, -client or both is required")
case *days < 1 || *days > maxDays:
return usageError(fmt.Sprintf("-days must be 1 to %d", maxDays))
case strings.ContainsAny(*name, `/\`) || *name == "ca" || strings.HasPrefix(*name, "."):
return usageError(fmt.Sprintf("-name %q cannot name the files", *name))
}
issuer, signer, err := tlsx.LoadIssuer(filepath.Join(*caDir, "ca.crt"), filepath.Join(*caDir, "ca.key"))
if err != nil {
return err
}
leaf := tlsx.Leaf{Name: *name, Server: *server, Client: *client, Validity: time.Duration(*days) * 24 * time.Hour}
for h := range strings.SplitSeq(*hosts, ",") {
if h = strings.TrimSpace(h); h != "" {
leaf.Hosts = append(leaf.Hosts, h)
}
}
if len(leaf.Hosts) == 0 && *server {
leaf.Hosts = []string{*name}
}
key, err := tlsx.NewKey()
if err != nil {
return err
}
cert, err := leaf.Issue(&key.PublicKey, issuer, signer)
if err != nil {
return err
}
dir := *out
if dir == "" {
dir = *caDir
}
if err := writePair(dir, *name, cert, key); err != nil {
return err
}
fmt.Fprintf(stderr, "wrote %s and %s for %v, valid until %s, pin_sha256 %s\n",
filepath.Join(dir, *name+".crt"), filepath.Join(dir, *name+".key"),
append(leaf.Hosts, "CN="+*name), cert.NotAfter.UTC().Format(time.DateOnly), tlsx.PinSHA256(cert))
return nil
}
}
// writePair writes NAME.crt (0644) and NAME.key (0600) in dir, refusing to
// replace either: a reissued key would silently break every pin and peer.
func writePair(dir, name string, cert *x509.Certificate, key crypto.PrivateKey) error {
keyPEM, err := tlsx.EncodeKey(key)
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
certPath, keyPath := filepath.Join(dir, name+".crt"), filepath.Join(dir, name+".key")
for _, p := range []string{certPath, keyPath} {
if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%s exists; remove it to replace it", p)
}
}
if err := writeNew(keyPath, keyPEM, 0o600); err != nil {
return err
}
if err := writeNew(certPath, tlsx.EncodeCert(cert), 0o644); err != nil {
os.Remove(keyPath)
return err
}
return nil
}
func writeNew(path string, data []byte, mode os.FileMode) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
_, err = f.Write(data)
if err == nil {
err = f.Sync()
}
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(path)
}
return err
}