v0.23.0 color, presets, certificates made at startup, cli.go, sink flush
This commit is contained in:
1 parent
15745682bc
commit
bf410e1225
50 files changed
+2649
-426
No files matched your search
+2
-80
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user