diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96d662a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Local working copy of the GitHub wiki (its own git repo: team.wiki.git) +team.wiki/ diff --git a/client/commands/commands.go b/client/commands/commands.go index dc0e2d6..f04cc15 100644 --- a/client/commands/commands.go +++ b/client/commands/commands.go @@ -90,13 +90,23 @@ func PostRun(client *client.Client) command.CobraRunnerE { func clientCommands(cli *client.Client) *cobra.Command { teamCmd := &cobra.Command{ - Use: "teamclient", - Short: "Client-only teamserver commands (import configs, show users, etc)", + Use: "teamclient", + Short: "Client-only teamserver commands (import configs, show users, etc)", + Long: fmt.Sprintf(`Client-only commands for reaching the %s teamserver. + +Import a connection config an administrator gave you, then query the server: + + import save a *.teamclient.cfg into your client configs directory + users list the team's users and their online status + version show client and server build versions + +Commands connect automatically using your imported config. If you have several and +none is marked default, you'll be prompted to choose one.`, cli.Name()), SilenceUsage: true, } teamFlags := pflag.NewFlagSet("teamserver", pflag.ContinueOnError) - teamFlags.CountP("verbosity", "v", "Counter flag (-vvv) to increase log verbosity on stdout (1:panic -> 7:debug)") + teamFlags.CountP("verbosity", "v", "Increase stdout log verbosity; repeat to go louder (-v, -vv, -vvv)") teamFlags.String("log-format", "", "console log format (console, text, json)") teamCmd.PersistentFlags().AddFlagSet(teamFlags) @@ -116,7 +126,10 @@ func clientCommands(cli *client.Client) *cobra.Command { versionCmd := &cobra.Command{ Use: "version", Short: "Print teamserver client version", - RunE: versionCmd(cli), + Long: `Print the client build version and, after connecting to the teamserver, the +server's version (both include commit and build platform).`, + Example: ` teamclient version`, + RunE: versionCmd(cli), } teamCmd.AddCommand(versionCmd) @@ -124,7 +137,12 @@ func clientCommands(cli *client.Client) *cobra.Command { importCmd := &cobra.Command{ Use: "import", Short: "Import a teamserver client configuration file for " + cli.Name(), - Run: importCmd(cli), + Long: `Import one or more *.teamclient.cfg connection files (given by an administrator) +into your client configs directory. Use --default to mark it the default when you +have none yet.`, + Example: ` teamclient import ~/alice_teamserver.example.com.teamclient.cfg + teamclient import --default ~/alice_teamserver.example.com.teamclient.cfg`, + Run: importCmd(cli), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{}, cobra.ShellCompDirectiveDefault }, @@ -147,7 +165,10 @@ func clientCommands(cli *client.Client) *cobra.Command { usersCmd := &cobra.Command{ Use: "users", Short: "Display a table of teamserver users and their status", - RunE: usersCmd(cli), + Long: `Connect to the teamserver and print a table of its users with online status and +the time since each was last seen.`, + Example: ` teamclient users`, + RunE: usersCmd(cli), } teamCmd.AddCommand(usersCmd) diff --git a/example/README.md b/example/README.md index 2069da0..264801f 100644 --- a/example/README.md +++ b/example/README.md @@ -26,7 +26,7 @@ go install github.com/reeflective/team/example/teamclient # Install the completion engine and source both tools' scripts (optional) # See this project documentation for setup with your own shell. Below is bash/zsh. -go install github.com/rsteube/carapace-bin@latest +go install github.com/carapace-sh/carapace-bin@latest source <(teamserver _carapace) source <(teamclient _carapace) diff --git a/example/teamclient/main.go b/example/teamclient/main.go index 64638ff..e435e53 100644 --- a/example/teamclient/main.go +++ b/example/teamclient/main.go @@ -3,7 +3,7 @@ package main import ( "log" - "github.com/rsteube/carapace" + "github.com/carapace-sh/carapace" "github.com/reeflective/team/client" "github.com/reeflective/team/client/commands" diff --git a/example/teamserver/main.go b/example/teamserver/main.go index c63a768..b5e0d4e 100644 --- a/example/teamserver/main.go +++ b/example/teamserver/main.go @@ -3,7 +3,7 @@ package main import ( "log" - "github.com/rsteube/carapace" + "github.com/carapace-sh/carapace" "github.com/reeflective/team/client" grpc "github.com/reeflective/team/example/transports/grpc/server" diff --git a/go.mod b/go.mod index 7018a13..23ae289 100644 --- a/go.mod +++ b/go.mod @@ -12,11 +12,11 @@ require ( github.com/jedib0t/go-pretty/v6 v6.4.6 github.com/ncruces/go-sqlite3 v0.22.0 github.com/ncruces/go-sqlite3/gormlite v0.22.0 - github.com/rsteube/carapace v0.47.4 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.14.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 + github.com/tetratelabs/wazero v1.8.2 google.golang.org/grpc v1.56.1 google.golang.org/protobuf v1.31.0 gorm.io/driver/mysql v1.5.7 @@ -46,9 +46,7 @@ require ( github.com/ncruces/julianday v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect - github.com/rsteube/carapace-shlex v0.1.1 // indirect github.com/stretchr/testify v1.8.2 // indirect - github.com/tetratelabs/wazero v1.8.2 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.13.0 // indirect diff --git a/go.sum b/go.sum index 619f95e..4726fcb 100644 --- a/go.sum +++ b/go.sum @@ -17,7 +17,6 @@ github.com/carapace-sh/carapace-shlex v1.0.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0Xp github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -109,20 +108,14 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/rsteube/carapace v0.47.4 h1:LwnkFsvRxc2WhZjM63QS7sCi3DlM9XGuATQM5rehgps= -github.com/rsteube/carapace v0.47.4/go.mod h1:4ZC5bulItu9t9sZ5yPcHgPREd8rPf274Q732n+wfl/o= -github.com/rsteube/carapace-shlex v0.1.1 h1:fRQEBBKyYKm4TXUabm4tzH904iFWSmXJl3UZhMfQNYU= -github.com/rsteube/carapace-shlex v0.1.1/go.mod h1:zPw1dOFwvLPKStUy9g2BYKanI6bsQMATzDMYQQybo3o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/db/sql-go.go b/internal/db/sql-go.go index 20553b5..ea204bc 100644 --- a/internal/db/sql-go.go +++ b/internal/db/sql-go.go @@ -32,6 +32,10 @@ import ( ) func sqliteClient(dsn string, log logger.Interface) (*gorm.DB, error) { + // Reuse a persistent on-disk cache of the compiled SQLite WASM module, so we + // don't pay the ~2s wazero compilation cost on every process start. + configureSQLiteRuntime() + return gorm.Open(gormlite.Open(dsn), &gorm.Config{ PrepareStmt: true, Logger: log, diff --git a/internal/db/sql-wasm.go b/internal/db/sql-wasm.go index 3997eba..eb1403c 100644 --- a/internal/db/sql-wasm.go +++ b/internal/db/sql-wasm.go @@ -32,6 +32,10 @@ import ( ) func sqliteClient(dsn string, log logger.Interface) (*gorm.DB, error) { + // Reuse a persistent on-disk cache of the compiled SQLite WASM module, so we + // don't pay the ~2s wazero compilation cost on every process start. + configureSQLiteRuntime() + return gorm.Open(gormlite.Open(dsn), &gorm.Config{ PrepareStmt: true, Logger: log, diff --git a/internal/db/sql.go b/internal/db/sql.go index 168d065..102bc75 100644 --- a/internal/db/sql.go +++ b/internal/db/sql.go @@ -59,7 +59,7 @@ func NewClient(dbConfig *Config, dbLogger *slog.Logger) (*gorm.DB, error) { switch dbConfig.Dialect { case Sqlite: - dbLogger.Info(fmt.Sprintf("Connecting to SQLite database %s", logDbDsn)) + dbLogger.Debug(fmt.Sprintf("Connecting to SQLite database %s", logDbDsn)) dbClient, err = sqliteClient(dsn, dbLog) if err != nil { @@ -67,7 +67,7 @@ func NewClient(dbConfig *Config, dbLogger *slog.Logger) (*gorm.DB, error) { } case Postgres: - dbLogger.Info(fmt.Sprintf("Connecting to PostgreSQL database %s", logDbDsn)) + dbLogger.Debug(fmt.Sprintf("Connecting to PostgreSQL database %s", logDbDsn)) dbClient, err = postgresClient(dsn, dbLog) if err != nil { @@ -75,7 +75,7 @@ func NewClient(dbConfig *Config, dbLogger *slog.Logger) (*gorm.DB, error) { } case MySQL: - dbLogger.Info(fmt.Sprintf("Connecting to MySQL database %s", logDbDsn)) + dbLogger.Debug(fmt.Sprintf("Connecting to MySQL database %s", logDbDsn)) dbClient, err = mySQLClient(dsn, dbLog) if err != nil { diff --git a/internal/db/sqlite_runtime.go b/internal/db/sqlite_runtime.go new file mode 100644 index 0000000..fbdcad7 --- /dev/null +++ b/internal/db/sqlite_runtime.go @@ -0,0 +1,85 @@ +//go:build !cgo_sqlite + +package db + +/* + team - Embedded teamserver for Go programs and CLI applications + Copyright (C) 2023 Reeflective + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import ( + "math/bits" + "os" + "path/filepath" + "sync" + + "github.com/ncruces/go-sqlite3" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +// sqliteRuntimeOnce guards the one-time global wazero runtime configuration. +var sqliteRuntimeOnce sync.Once + +// configureSQLiteRuntime makes the pure-Go (wazero) SQLite engine reuse a +// persistent, on-disk cache of the compiled WASM module. +// +// Without it, wazero recompiles the ~1.5MB SQLite module on every process +// start (~2s on a typical machine), which makes short-lived invocations — +// shell completion in particular — painfully slow. With the cache, only the +// first run per wazero version pays that cost; subsequent runs load the +// precompiled module in tens of milliseconds. +// +// It is best-effort and never fatal: if the cache directory is unavailable, we +// leave ncruces' default runtime configuration in place (correct, just not +// cached). We also defer to any RuntimeConfig an embedding application may have +// set itself. +func configureSQLiteRuntime() { + sqliteRuntimeOnce.Do(func() { + if sqlite3.RuntimeConfig != nil { + return + } + + cacheRoot, err := os.UserCacheDir() + if err != nil { + return + } + + cacheDir := filepath.Join(cacheRoot, "reeflective-team", "sqlite-wasm") + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + return + } + + cache, err := wazero.NewCompilationCacheWithDir(cacheDir) + if err != nil { + return + } + + // Match ncruces' default memory limit, which is otherwise skipped when a + // custom RuntimeConfig is supplied: 256MB on 64-bit, 32MB on 32-bit. + pages := uint32(4096) + if bits.UintSize < 64 { + pages = 512 + } + + // NewRuntimeConfig() selects the optimizing compiler where supported and + // the interpreter otherwise; the cache is simply ignored by the latter. + sqlite3.RuntimeConfig = wazero.NewRuntimeConfig(). + WithCompilationCache(cache). + WithMemoryLimitPages(pages). + WithCoreFeatures(api.CoreFeaturesV2) + }) +} diff --git a/internal/version/version.go b/internal/version/version.go index a6a83ae..1586d20 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -36,16 +36,36 @@ var ErrNoBuildInfo = errors.New("No binary build info") // Semantic - Get the structured semantic // version of the application binary. func Semantic() []int { - semVer := make([]int, semVerLen) - info, ok := debug.ReadBuildInfo() if !ok { - return semVer + return make([]int, semVerLen) } - version := info.Main.Version + return parseSemantic(info.Main.Version) +} + +// parseSemantic turns a module version string into a fixed-length +// [major, minor, patch] slice. It is tolerant of anything the Go module system +// can hand us: a leading "v", pre-release/build metadata ("-rc1", "+incompatible"), +// pseudo-versions ("v0.3.1-0.20260718181500-abcdef"), "(devel)", or an empty +// string. Missing or non-numeric fields become 0, and any parts beyond patch are +// ignored (rather than panicking with an out-of-range index). +func parseSemantic(version string) []int { + semVer := make([]int, semVerLen) + + version = strings.TrimPrefix(version, "v") + + // Drop pre-release and build metadata so extra '.'-separated fields inside + // them (as in pseudo-versions) can't overflow the semantic version. + if i := strings.IndexAny(version, "-+"); i != -1 { + version = version[:i] + } for i, part := range strings.Split(version, ".") { + if i >= semVerLen { + break + } + number, _ := strconv.ParseInt(part, 10, 32) semVer[i] = int(number) } diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..3355d70 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,70 @@ +package version + +/* + team - Embedded teamserver for Go programs and CLI applications + Copyright (C) 2023 Reeflective + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import ( + "testing" +) + +// TestParseSemantic ensures version parsing never panics and yields a +// fixed-length [major, minor, patch] slice for anything the Go module system +// can hand us — including the pseudo-version that previously caused an +// index-out-of-range panic. +func TestParseSemantic(t *testing.T) { + cases := []struct { + name string + version string + want []int + }{ + {"tagged", "v0.3.0", []int{0, 3, 0}}, + {"tagged no v", "1.2.3", []int{1, 2, 3}}, + {"pseudo-version", "v0.3.1-0.20260718181500-abcdef123456", []int{0, 3, 1}}, + {"pre-release", "v1.4.0-rc1", []int{1, 4, 0}}, + {"incompatible", "v2.0.0+incompatible", []int{2, 0, 0}}, + {"devel", "(devel)", []int{0, 0, 0}}, + {"empty", "", []int{0, 0, 0}}, + {"too many fields", "v1.2.3.4.5", []int{1, 2, 3}}, + {"short", "v0.5", []int{0, 5, 0}}, + {"garbage", "not-a-version", []int{0, 0, 0}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseSemantic(tc.version) + + if len(got) != semVerLen { + t.Fatalf("parseSemantic(%q) length = %d, want %d", tc.version, len(got), semVerLen) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("parseSemantic(%q) = %v, want %v", tc.version, got, tc.want) + } + } + }) + } +} + +// TestSemanticDoesNotPanic guards the exported entry point: whatever version the +// test binary reports, Semantic must return a well-formed slice and never panic. +func TestSemanticDoesNotPanic(t *testing.T) { + got := Semantic() + if len(got) != semVerLen { + t.Fatalf("Semantic() length = %d, want %d", len(got), semVerLen) + } +} diff --git a/server/commands/commands.go b/server/commands/commands.go index 02e31ea..4ecb298 100644 --- a/server/commands/commands.go +++ b/server/commands/commands.go @@ -66,9 +66,27 @@ func Generate(teamserver *server.Server, teamclient *client.Client) *cobra.Comma } func serverCommands(server *server.Server, client *client.Client) *cobra.Command { + name := server.Name() teamCmd := &cobra.Command{ - Use: "teamserver", - Short: fmt.Sprintf("Manage the %s teamserver and users", server.Name()), + Use: "teamserver", + Short: fmt.Sprintf("Manage the %s teamserver and users", name), + Long: fmt.Sprintf(`Manage the %[1]s teamserver: users, listeners, and the connection configs +operators use to reach it. + +The teamserver is embedded in %[1]s; these commands administer it. A typical +bring-up is: + + 1. Create users teamserver user --name alice --host + 2. Add listeners teamserver listen --host --persistent + 3. Run the server teamserver daemon --host --port + +Each 'user' command writes a *.teamclient.cfg file to hand to that operator; they +import it with 'teamserver client import' (or a client-only binary's 'import'). The +teamserver only AUTHENTICATES users (proves identity); what a user is allowed to do +is decided by %[1]s itself. + +Client-side commands (import a config, list users, show version) live under the +'client' subcommand. Run 'teamserver guide' for a fuller walkthrough.`, name), SilenceUsage: true, } @@ -79,7 +97,7 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command ) teamFlags := pflag.NewFlagSet("teamserver", pflag.ContinueOnError) - teamFlags.CountP("verbosity", "v", "Counter flag (-vvv) to increase log verbosity on stdout (1:info-> 3:trace)") + teamFlags.CountP("verbosity", "v", "Increase stdout log verbosity; repeat to go louder (-v, -vv, -vvv)") teamFlags.String("log-format", "", "console log format (console, text, json)") teamCmd.PersistentFlags().AddFlagSet(teamFlags) @@ -100,8 +118,16 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command // Start a listener listenCmd := &cobra.Command{ - Use: "listen", - Short: "Start a teamserver listener (non-blocking)", + Use: "listen", + Short: "Start a teamserver listener (non-blocking)", + Long: `Start a listener (a bind job) for a registered transport stack, without blocking. +Use --persistent to save it so 'daemon' restarts it automatically. Pick a non-default +transport with --listener (completed by stack name).`, + Example: ` # Default stack on localhost, remembered across restarts + teamserver listen --host localhost --persistent + + # A specific stack on another interface/port + teamserver listen --host 10.0.0.5 --port 32333 --listener gRPC --persistent`, GroupID: command.TeamServerGroup, RunE: startListenerCmd(server), } @@ -122,8 +148,12 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command // Close a listener closeCmd := &cobra.Command{ - Use: "close", - Short: "Close a listener and remove it from persistent ones if it's one", + Use: "close", + Short: "Close a listener and remove it from persistent ones if it's one", + Long: `Close one or more running listeners by ID (a unique prefix is enough) and remove +them from the saved/persistent set. IDs are shown by 'status' and are completed.`, + Example: ` teamserver close 3f9ab21c + teamserver close 3f9ab21c 8c1de490`, Args: cobra.MinimumNArgs(1), GroupID: command.TeamServerGroup, Run: closeCmd(server), @@ -146,8 +176,13 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command // Daemon (blocking listener and persistent jobs) daemonCmd := &cobra.Command{ - Use: "daemon", - Short: "Start the teamserver in daemon mode (blocking)", + Use: "daemon", + Short: "Start the teamserver in daemon mode (blocking)", + Long: `Run the teamserver in the foreground (blocking) until SIGTERM / Ctrl-C. Starts the +main listener plus every persistent listener. With no --host/--port, the values from +the teamserver config are used.`, + Example: ` teamserver daemon --host 0.0.0.0 --port 31337 + teamserver daemon # use config defaults`, GroupID: command.TeamServerGroup, RunE: daemoncmd(server), } @@ -162,8 +197,13 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command // Systemd configuration output systemdCmd := &cobra.Command{ - Use: "systemd", - Short: "Print a systemd unit file for the application teamserver, with options", + Use: "systemd", + Short: "Print a systemd unit file for the application teamserver, with options", + Long: `Render a systemd unit that runs 'teamserver daemon'. Prints to stdout unless --save +is given. --user sets the OS user the service runs as (a value, e.g. --user myapp), +--binpath the executable path baked into the unit.`, + Example: ` teamserver systemd --binpath /usr/local/bin/myapp --host 0.0.0.0 --port 31337 + teamserver systemd --user myapp --save /etc/systemd/system/myapp.service`, GroupID: command.TeamServerGroup, RunE: systemdConfigCmd(server), } @@ -185,8 +225,10 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command teamCmd.AddCommand(systemdCmd) statusCmd := &cobra.Command{ - Use: "status", - Short: "Show the status of the teamserver (listeners, configurations, health...)", + Use: "status", + Short: "Show the status of the teamserver (listeners, configurations, health...)", + Long: `Show the teamserver's home directory, database, config path, log files and levels, +certificate files, and the state of all listeners (running and saved/persistent).`, GroupID: command.TeamServerGroup, Run: statusCmd(server), } @@ -197,8 +239,15 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command // Add user userCmd := &cobra.Command{ - Use: "user", - Short: "Create a user for this teamserver and generate its client configuration file", + Use: "user", + Short: "Create a user for this teamserver and generate its client configuration file", + Long: `Create a user and generate its connection config (*.teamclient.cfg): a client +certificate and API token the operator uses to authenticate. The file is written to +the current directory unless --save is given, or --system (use the current OS +user and save into this app's client configs directory).`, + Example: ` teamserver user --name alice --host teamserver.example.com + teamserver user --name bob --host 10.0.0.5 --port 32333 --save ~/handout/ + teamserver user --system`, GroupID: command.UserManagementGroup, Run: createUserCmd(server, client), } @@ -220,8 +269,12 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command // Delete and kick user rmUserCmd := &cobra.Command{ - Use: "delete", - Short: "Remove a user from the teamserver, and revoke all its current tokens", + Use: "delete", + Short: "Remove a user from the teamserver, and revoke all its current tokens", + Long: `Delete a user and its cryptographic material. This takes effect immediately: the +user's live sessions are refused on their next request and its TLS credentials stop +working.`, + Example: ` teamserver delete alice`, GroupID: command.UserManagementGroup, Args: cobra.ExactArgs(1), Run: rmUserCmd(server), @@ -231,7 +284,7 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command rmUserComps := carapace.Gen(rmUserCmd) - rmUserComps.PositionalCompletion(carapace.ActionCallback(userCompleter(client, server))) + rmUserComps.PositionalCompletion(carapace.ActionCallback(userCompleter(server))) rmUserComps.PreRun(func(cmd *cobra.Command, args []string) { if cmd.PersistentPreRunE != nil { @@ -245,8 +298,11 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command // Import a list of users and their credentials. cmdImportCA := &cobra.Command{ - Use: "import", - Short: "Import a certificate Authority file containing teamserver users", + Use: "import", + Short: "Import a certificate Authority file containing teamserver users", + Long: `Import a users Certificate Authority exported by another teamserver, adding its +users to this one. The file is JSON of the form {"certificate":"...","private_key":"..."}.`, + Example: ` teamserver import ~/.other_app/teamserver/certs/other_app_user-ca-cert.teamserver.pem`, GroupID: command.UserManagementGroup, Args: cobra.ExactArgs(1), Run: importCACmd(server), @@ -264,8 +320,12 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command // Export the list of users and their credentials. cmdExportCA := &cobra.Command{ - Use: "export", - Short: "Export a Certificate Authority file containing the teamserver users", + Use: "export", + Short: "Export a Certificate Authority file containing the teamserver users", + Long: `Export this teamserver's users CA (all users) to a file, so another teamserver can +import and trust the same operators. Writes to the current directory when no path is +given.`, + Example: ` teamserver export ~/myapp-users.teamserver.ca`, GroupID: command.UserManagementGroup, Args: cobra.RangeArgs(0, 1), Run: exportCACmd(server), @@ -274,5 +334,59 @@ func serverCommands(server *server.Server, client *client.Client) *cobra.Command carapace.Gen(cmdExportCA).PositionalCompletion(carapace.ActionFiles()) teamCmd.AddCommand(cmdExportCA) + // [ Holistic help ] ------------------------------------------------------------------- + + // A cobra "additional help topic" (no Run): a single walkthrough that stays out of + // the command list but is discoverable via ' teamserver guide'. + guideCmd := &cobra.Command{ + Use: "guide", + Short: "In-depth guide: user lifecycle, listeners, daemon/systemd, revocation", + Long: fmt.Sprintf(`%[1]s teamserver — operator guide + +The teamserver is embedded directly in %[1]s. There is no separate server to install: +the same binary authenticates operators, serves listeners, and (as a client) connects +to them. + +1. Users and configs + Create one config per operator: + teamserver user --name alice --host + This writes alice's *.teamclient.cfg (a client certificate + API token) to the + current directory (or --save , or --system for the current OS user). Hand that + file to the operator; they import it with: + teamserver client import alice.teamclient.cfg + The teamserver only proves WHO an operator is. Authorization — what each operator + may do — is entirely up to %[1]s. + +2. Listeners + A listener is a bind job for a transport stack. Start one without blocking: + teamserver listen --host --port --persistent + --persistent saves it so the daemon restarts it automatically. Inspect and close + listeners with: + teamserver status + teamserver close + +3. Running as a service + Run in the foreground (starts the main + all persistent listeners): + teamserver daemon --host --port + Generate a systemd unit for it: + teamserver systemd --binpath $(which %[1]s) --save unit.service + +4. Sharing users between servers + Export the users CA and import it on another teamserver so both trust the same + operators: + teamserver export users.ca + teamserver import users.ca + +5. Revoking access + Delete a user; its live sessions are refused on the next request and its TLS + credentials stop working immediately: + teamserver delete alice + +Shell completion: + source <(teamserver _carapace )`, name), + } + + teamCmd.AddCommand(guideCmd) + return teamCmd } diff --git a/server/commands/commands_test.go b/server/commands/commands_test.go new file mode 100644 index 0000000..697d01f --- /dev/null +++ b/server/commands/commands_test.go @@ -0,0 +1,176 @@ +package commands_test + +/* + team - Embedded teamserver for Go programs and CLI applications + Copyright (C) 2023 Reeflective + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import ( + "bytes" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/reeflective/team/client" + "github.com/reeflective/team/server" + "github.com/reeflective/team/server/commands" +) + +// These tests drive the real cobra command handlers in-process against an +// isolated, on-disk teamserver. They exist to catch the class of bug that unit +// tests on the library API miss entirely: panics and regressions inside the CLI +// handlers themselves (a nil certificate manager on `user`, a version-parsing +// panic on `systemd`, ...). Each command runs in a throwaway sandbox. + +// newSandbox builds an isolated teamserver + self-teamclient whose commands can +// be executed in-process. Logging is discarded so no log file is held open +// (which would block t.TempDir() cleanup on Windows), and every path lives under +// a throwaway temp directory. +func newSandbox(t *testing.T) (*server.Server, *client.Client, string) { + t.Helper() + + home := t.TempDir() + discard := slog.NewTextHandler(io.Discard, nil) + + ts, err := server.New("test", + server.WithHomeDirectory(home), + server.WithLogger(discard), + ) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + tc := ts.Self( + client.WithHomeDirectory(home), + client.WithLogger(discard), + ) + + return ts, tc, home +} + +// runCommand executes a single teamserver command against a freshly-generated +// command tree (cobra keeps per-tree flag state, so we rebuild each time) and +// captures its combined stdout/stderr. A panic in a handler fails the test +// naturally through the testing runtime. +func runCommand(t *testing.T, ts *server.Server, tc *client.Client, args ...string) (string, error) { + t.Helper() + + root := commands.Generate(ts, tc) + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + + err := root.Execute() + + return out.String(), err +} + +// TestCommandUserLifecycle runs `user` then `delete` end to end. This is the +// path that previously panicked on a nil certificate manager. +func TestCommandUserLifecycle(t *testing.T) { + ts, tc, home := newSandbox(t) + + out, err := runCommand(t, ts, tc, "user", "--name", "alice", "--host", "localhost", "--save", home) + if err != nil { + t.Fatalf("user create: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "alice") { + t.Fatalf("expected the new identity details in output, got:\n%s", out) + } + + configs, _ := filepath.Glob(filepath.Join(home, "*.teamclient.cfg")) + if len(configs) == 0 { + t.Fatalf("expected a *.teamclient.cfg to be written under %s", home) + } + + out, err = runCommand(t, ts, tc, "delete", "alice") + if err != nil { + t.Fatalf("delete: %v\noutput:\n%s", err, out) + } + if !strings.Contains(strings.ToLower(out), "deleted") { + t.Fatalf("expected a deletion confirmation, got:\n%s", out) + } +} + +// TestCommandSystemd is the regression guard for the version-parsing panic: the +// systemd config generator calls version.Semantic(). +func TestCommandSystemd(t *testing.T) { + ts, tc, _ := newSandbox(t) + + out, err := runCommand(t, ts, tc, + "systemd", "--binpath", "/usr/bin/teamserver", "--user", "svc", "--port", "5432") + if err != nil { + t.Fatalf("systemd: %v\noutput:\n%s", err, out) + } + + for _, want := range []string{"[Unit]", "[Service]", "ExecStart", "5432"} { + if !strings.Contains(out, want) { + t.Fatalf("systemd unit missing %q, got:\n%s", want, out) + } + } +} + +// TestCommandStatus renders the teamserver status view. +func TestCommandStatus(t *testing.T) { + ts, tc, _ := newSandbox(t) + + out, err := runCommand(t, ts, tc, "status") + if err != nil { + t.Fatalf("status: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "General") { + t.Fatalf("status output looks wrong:\n%s", out) + } +} + +// TestCommandExportCA exports the user certificate authority to disk, which +// forces lazy certificate-infrastructure initialization through the CLI. +func TestCommandExportCA(t *testing.T) { + ts, tc, home := newSandbox(t) + + dest := filepath.Join(home, "ca") + if err := os.MkdirAll(dest, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + + out, err := runCommand(t, ts, tc, "export", dest) + if err != nil { + t.Fatalf("export: %v\noutput:\n%s", err, out) + } + + files, _ := filepath.Glob(filepath.Join(dest, "*")) + if len(files) == 0 { + t.Fatalf("expected an exported CA file under %s", dest) + } +} + +// TestCommandGuide renders the built-in usage guide. +func TestCommandGuide(t *testing.T) { + ts, tc, _ := newSandbox(t) + + out, err := runCommand(t, ts, tc, "guide") + if err != nil { + t.Fatalf("guide: %v", err) + } + if strings.TrimSpace(out) == "" { + t.Fatal("guide printed nothing") + } +} diff --git a/server/commands/completers.go b/server/commands/completers.go index 832805c..a4c65e1 100644 --- a/server/commands/completers.go +++ b/server/commands/completers.go @@ -62,9 +62,13 @@ func interfacesCompleter() carapace.Action { } // userCompleter completes usernames of the application teamserver. -func userCompleter(client *client.Client, server *server.Server) carapace.CompletionCallback { +// +// This is a server-side command completer, so it reads the users straight from +// the teamserver database (server.Users()) instead of going through a teamclient +// transport, which has no working remote connection during completion. +func userCompleter(server *server.Server) carapace.CompletionCallback { return func(c carapace.Context) carapace.Action { - users, err := client.Users() + users, err := server.Users() if err != nil { return carapace.ActionMessage("Failed to get users: %s", err) } diff --git a/server/commands/user.go b/server/commands/user.go index 3dc64c8..45faf77 100644 --- a/server/commands/user.go +++ b/server/commands/user.go @@ -1,19 +1,25 @@ package commands import ( + "crypto/x509" "encoding/json" + "encoding/pem" "fmt" + "log/slog" + "net" "os" "os/user" "path/filepath" + "strconv" "strings" + "time" + + "github.com/spf13/cobra" "github.com/reeflective/team/client" "github.com/reeflective/team/internal/assets" "github.com/reeflective/team/internal/command" "github.com/reeflective/team/server" - "github.com/spf13/cobra" - "log/slog" ) func createUserCmd(serv *server.Server, cli *client.Client) func(cmd *cobra.Command, args []string) { @@ -68,8 +74,8 @@ func createUserCmd(serv *server.Server, cli *client.Client) func(cmd *cobra.Comm } } - fmt.Fprintf(cmd.OutOrStdout(), command.Info+"Generating new client certificate, please wait ... \n") - + // Certificate generation is logged by the teamserver's own (slog) logger, + // so it honors the configured --log-format instead of being a raw print. config, err := serv.UserCreate(name, lhost, lport) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), command.Warn+"%s\n", err) @@ -90,8 +96,34 @@ func createUserCmd(serv *server.Server, cli *client.Client) func(cmd *cobra.Comm return } - fmt.Fprintf(cmd.OutOrStdout(), command.Info+"Saved new client config to: %s\n", saveTo) + // Success: report the new identity and its salient details. + out := cmd.OutOrStdout() + fmt.Fprintf(out, command.Info+"Created new teamclient identity %q\n", config.User) + fmt.Fprintf(out, " server: %s\n", net.JoinHostPort(config.Host, strconv.Itoa(config.Port))) + + if expiry, ok := certExpiry(config.Certificate); ok { + fmt.Fprintf(out, " expires: %s\n", expiry.Format(time.RFC1123)) + } + + fmt.Fprintf(out, " config: %s\n", saveTo) + } +} + +// certExpiry extracts the NotAfter date from a PEM-encoded certificate, so the +// user command can display when the newly-minted identity will expire. It fails +// gracefully (ok == false) rather than erroring the whole command. +func certExpiry(certPEM string) (time.Time, bool) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return time.Time{}, false } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return time.Time{}, false + } + + return cert.NotAfter, true } func rmUserCmd(serv *server.Server) func(cmd *cobra.Command, args []string) { @@ -105,15 +137,15 @@ func rmUserCmd(serv *server.Server) func(cmd *cobra.Command, args []string) { user := args[0] - fmt.Fprintf(cmd.OutOrStdout(), command.Info+"Removing client certificate(s)/token(s) for %s, please wait ... \n", user) - + // Certificate/token removal is logged by the teamserver's own (slog) + // logger, so it honors the configured --log-format. err := serv.UserDelete(user) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), command.Warn+"Failed to remove the user certificate: %v\n", err) return } - fmt.Fprintf(cmd.OutOrStdout(), command.Info+"User %s has been deleted from the teamserver, and kicked out.\n", user) + fmt.Fprintf(cmd.OutOrStdout(), command.Info+"User %q has been deleted from the teamserver, and kicked out.\n", user) } } diff --git a/server/core.go b/server/core.go index 16c005c..e7ec6fc 100644 --- a/server/core.go +++ b/server/core.go @@ -72,6 +72,7 @@ type Server struct { // Users userTokens *sync.Map // Refreshed entirely when a user is kicked. certs *certs.Manager // Manages all the certificate infrastructure. + certsInit sync.Once // The certificate infrastructure is initialized once, lazily. db *gorm.DB // Stores certificates and users data. dbInit sync.Once // A single database can be used in a teamserver lifetime. diff --git a/server/db.go b/server/db.go index 5f764f8..2607ebf 100644 --- a/server/db.go +++ b/server/db.go @@ -63,12 +63,9 @@ func (ts *Server) DatabaseConfig() *db.Config { // GetDatabaseConfigPath - File path to config.json. func (ts *Server) dbConfigPath() string { appDir := ts.ConfigsDir() - log := ts.NamedLogger("config", "database") dbFileName := fmt.Sprintf("%s.%s", ts.Name()+"_database", command.ServerConfigExt) - databaseConfigPath := filepath.Join(appDir, dbFileName) - log.Debug(fmt.Sprintf("Loading config from %s", databaseConfigPath)) - return databaseConfigPath + return filepath.Join(appDir, dbFileName) } // Save - Save config file to disk. If the server is configured @@ -113,6 +110,8 @@ func (ts *Server) getDatabaseConfig() (*db.Config, error) { configPath := ts.dbConfigPath() if _, err := ts.fs.Stat(configPath); !os.IsNotExist(err) { + log.Debug(fmt.Sprintf("Loading database config from %s", configPath)) + data, err := ts.fs.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("Failed to read config file %w", err) diff --git a/server/server.go b/server/server.go index f0b4311..475a5d2 100644 --- a/server/server.go +++ b/server/server.go @@ -45,7 +45,7 @@ import ( // - Listen is the transport-SPECIFIC binding phase. // // Keeping them separate lets implementations compose by embedding a base handler -// and overriding only Listen() (see the Tailscale variant in the example transports). +// and overriding only Listen() (see the gRPC handler under example/transports/grpc). // // Errors: all errors returned by the handler interface methods are considered // critical, and thus will stop the handler start/serve process when raised. Thus, @@ -265,7 +265,24 @@ func (ts *Server) init(opts ...Options) error { // contained in options, or the default one. ts.opts.config = ts.GetConfig() - // Certificate infrastructure, will make the code panic if unable to work properly. + // Certificate infrastructure. + err = ts.initCerts() + }) + + return err +} + +// initCerts initializes the certificate infrastructure exactly once, lazily. +// It is called both from init() (the serve path) and directly from the user/ +// certificate methods (UserCreate, UsersTLSConfig, ...), so that these keep +// working when driven from the CLI without ever starting a listener. +func (ts *Server) initCerts() (err error) { + ts.certsInit.Do(func() { + if err = ts.initDatabase(); err != nil { + return + } + + // Will make the code panic if unable to work properly. certsLog := ts.NamedLogger("certs", "certificates") ts.certs = certs.NewManager(ts.fs, ts.Database(), certsLog, ts.Name(), ts.TeamDir()) }) diff --git a/server/users.go b/server/users.go index 289187c..03a8aad 100644 --- a/server/users.go +++ b/server/users.go @@ -48,7 +48,7 @@ var namePattern = regexp.MustCompile("^[a-zA-Z0-9_-]*$") // Only allow alphanume // user permissions/roles. Applications that need per-user authorization own that model // themselves (a separate table keyed by name), and enforce it in their own middleware. func (ts *Server) UserCreate(name string, lhost string, lport uint16) (*client.Config, error) { - if err := ts.initDatabase(); err != nil { + if err := ts.initCerts(); err != nil { return nil, ts.errorf("%w: %w", ErrDatabase, err) } @@ -119,7 +119,7 @@ func (ts *Server) UserCreate(name string, lhost string, lport uint16) (*client.C // Certificate files, API authentication token are deleted from the teamserver database, // conformingly to its configured backend/filesystem (can be in-memory or on filesystem). func (ts *Server) UserDelete(name string) error { - if err := ts.initDatabase(); err != nil { + if err := ts.initCerts(); err != nil { return ts.errorf("%w: %w", ErrDatabase, err) } @@ -202,7 +202,7 @@ func (ts *Server) Authenticate(rawToken string) (*team.User, error) { func (ts *Server) UsersTLSConfig() (*tls.Config, error) { log := ts.NamedLogger("certs", "mtls") - if err := ts.initDatabase(); err != nil { + if err := ts.initCerts(); err != nil { return nil, ts.errorf("%w: %w", ErrDatabase, err) } @@ -249,7 +249,7 @@ func (ts *Server) UsersTLSConfig() (*tls.Config, error) { // UsersGetCA returns the bytes of a PEM-encoded certificate authority, // which contains certificates of all users of this teamserver. func (ts *Server) UsersGetCA() ([]byte, []byte, error) { - if err := ts.initDatabase(); err != nil { + if err := ts.initCerts(); err != nil { return nil, nil, ts.errorf("%w: %w", ErrDatabase, err) } @@ -259,7 +259,7 @@ func (ts *Server) UsersGetCA() ([]byte, []byte, error) { // UsersSaveCA accepts the public and private parts of a Certificate // Authority containing one or more users to add to the teamserver. func (ts *Server) UsersSaveCA(cert, key []byte) { - if err := ts.initDatabase(); err != nil { + if err := ts.initCerts(); err != nil { return } diff --git a/server/users_test.go b/server/users_test.go index fc9e520..ff48b86 100644 --- a/server/users_test.go +++ b/server/users_test.go @@ -42,6 +42,32 @@ func newTestServer(t *testing.T) *Server { return ts } +// TestUserCreateWithoutServe is a regression guard for the nil-certificate +// panic: creating a user through the API (as the `user` CLI command does) must +// work without a prior Serve()/init() call. Previously UserCreate only ran +// initDatabase(), while ts.certs was built solely on the serve path, so this +// dereferenced a nil *certs.Manager. +func TestUserCreateWithoutServe(t *testing.T) { + ts, err := New("nocerts", WithInMemory()) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + // Deliberately NOT calling ts.init() / Serve() here. + cfg, err := ts.UserCreate("alice", "localhost", 31337) + if err != nil { + t.Fatalf("UserCreate without serve: %v", err) + } + if cfg == nil || cfg.Token == "" { + t.Fatal("expected a valid client config with a non-empty token") + } + + // The freshly-minted identity must authenticate. + if u, err := ts.Authenticate(cfg.Token); err != nil || u == nil || u.Name != "alice" { + t.Fatalf("authenticate minted identity: user=%v err=%v", u, err) + } +} + // TestUserCreateValidation pins the input validation on UserCreate: user names // are restricted to alphanumerics (plus - and _), and neither the name nor the // host may be empty. All rejections surface as ErrUserConfig.