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.