From 7dd3501e500464d9c4581103b823c80f82772373 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 27 May 2025 22:11:39 +0300 Subject: [PATCH] cmd/knot: unified knot cli Signed-off-by: Anirudh Oppiliappan --- cmd/keyfetch/format.go | 15 --- cmd/keyfetch/main.go | 46 --------- cmd/knot/main.go | 33 +++++++ cmd/repoguard/main.go | 207 ---------------------------------------- go.mod | 1 + go.sum | 2 + guard/guard.go | 208 +++++++++++++++++++++++++++++++++++++++++ keyfetch/keyfetch.go | 121 ++++++++++++++++++++++++ knotserver/server.go | 84 +++++++++++++++++ 9 files changed, 449 insertions(+), 268 deletions(-) delete mode 100644 cmd/keyfetch/format.go delete mode 100644 cmd/keyfetch/main.go create mode 100644 cmd/knot/main.go delete mode 100644 cmd/repoguard/main.go create mode 100644 guard/guard.go create mode 100644 keyfetch/keyfetch.go create mode 100644 knotserver/server.go diff --git a/cmd/keyfetch/format.go b/cmd/keyfetch/format.go deleted file mode 100644 index 161c3f6f..00000000 --- a/cmd/keyfetch/format.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import ( - "fmt" -) - -func formatKeyData(repoguardPath, gitDir, logPath, endpoint string, data []map[string]interface{}) string { - var result string - for _, entry := range data { - result += fmt.Sprintf( - `command="%s -base-dir %s -user %s -log-path %s -internal-api %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n", - repoguardPath, gitDir, entry["did"], logPath, endpoint, entry["key"]) - } - return result -} diff --git a/cmd/keyfetch/main.go b/cmd/keyfetch/main.go deleted file mode 100644 index 3246f13f..00000000 --- a/cmd/keyfetch/main.go +++ /dev/null @@ -1,46 +0,0 @@ -// This program must be configured to run as the sshd AuthorizedKeysCommand. -// The format looks something like this: -// Match User git -// AuthorizedKeysCommand /keyfetch -internal-api http://localhost:5444 -repoguard-path /home/git/repoguard -// AuthorizedKeysCommandUser nobody -// -// The command and its parent directories must be owned by root and set to 0755. Hence, the ideal location for this is -// somewhere already owned by root so you don't have to mess with directory perms. - -package main - -import ( - "encoding/json" - "flag" - "fmt" - "io" - "log" - "net/http" -) - -func main() { - endpoint := flag.String("internal-api", "http://localhost:5444", "Internal API endpoint") - repoguardPath := flag.String("repoguard-path", "/home/git/repoguard", "Path to the repoguard binary") - gitDir := flag.String("git-dir", "/home/git", "Path to the git directory") - logPath := flag.String("log-path", "/home/git/log", "Path to log file") - flag.Parse() - - resp, err := http.Get(*endpoint + "/keys") - if err != nil { - log.Fatalf("error fetching keys: %v", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Fatalf("error reading response body: %v", err) - } - - var data []map[string]interface{} - err = json.Unmarshal(body, &data) - if err != nil { - log.Fatalf("error unmarshalling response body: %v", err) - } - - fmt.Print(formatKeyData(*repoguardPath, *gitDir, *logPath, *endpoint, data)) -} diff --git a/cmd/knot/main.go b/cmd/knot/main.go new file mode 100644 index 00000000..525a5763 --- /dev/null +++ b/cmd/knot/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "os" + + "github.com/urfave/cli/v3" + "tangled.sh/tangled.sh/core/guard" + "tangled.sh/tangled.sh/core/keyfetch" + "tangled.sh/tangled.sh/core/knotserver" + "tangled.sh/tangled.sh/core/log" +) + +func main() { + cmd := &cli.Command{ + Name: "knot", + Usage: "knot administration and operation tool", + Commands: []*cli.Command{ + guard.Command(), + knotserver.Command(), + keyfetch.Command(), + }, + } + + ctx := context.Background() + logger := log.New("knot") + ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) + + if err := cmd.Run(ctx, os.Args); err != nil { + logger.Error(err.Error()) + os.Exit(-1) + } +} diff --git a/cmd/repoguard/main.go b/cmd/repoguard/main.go deleted file mode 100644 index c9e8ded9..00000000 --- a/cmd/repoguard/main.go +++ /dev/null @@ -1,207 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - "net/http" - "net/url" - "os" - "os/exec" - "strings" - "time" - - securejoin "github.com/cyphar/filepath-securejoin" - "tangled.sh/tangled.sh/core/appview/idresolver" -) - -var ( - logger *log.Logger - logFile *os.File - clientIP string - - // Command line flags - incomingUser = flag.String("user", "", "Allowed git user") - baseDirFlag = flag.String("base-dir", "/home/git", "Base directory for git repositories") - logPathFlag = flag.String("log-path", "/var/log/git-wrapper.log", "Path to log file") - endpoint = flag.String("internal-api", "http://localhost:5444", "Internal API endpoint") -) - -func main() { - flag.Parse() - - defer cleanup() - initLogger() - - // Get client IP from SSH environment - if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { - parts := strings.Fields(connInfo) - if len(parts) > 0 { - clientIP = parts[0] - } - } - - if *incomingUser == "" { - exitWithLog("access denied: no user specified") - } - - sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") - - logEvent("Connection attempt", map[string]interface{}{ - "user": *incomingUser, - "command": sshCommand, - "client": clientIP, - }) - - if sshCommand == "" { - exitWithLog("access denied: we don't serve interactive shells :)") - } - - cmdParts := strings.Fields(sshCommand) - if len(cmdParts) < 2 { - exitWithLog("invalid command format") - } - - gitCommand := cmdParts[0] - - // did:foo/repo-name or - // handle/repo-name or - // any of the above with a leading slash (/) - - components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") - logEvent("Command components", map[string]interface{}{ - "components": components, - }) - if len(components) != 2 { - exitWithLog("invalid repo format, needs / or //") - } - - didOrHandle := components[0] - did := resolveToDid(didOrHandle) - repoName := components[1] - qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) - - validCommands := map[string]bool{ - "git-receive-pack": true, - "git-upload-pack": true, - "git-upload-archive": true, - } - if !validCommands[gitCommand] { - exitWithLog("access denied: invalid git command") - } - - if gitCommand != "git-upload-pack" { - if !isPushPermitted(*incomingUser, qualifiedRepoName) { - logEvent("all infos", map[string]interface{}{ - "did": *incomingUser, - "reponame": qualifiedRepoName, - }) - exitWithLog("access denied: user not allowed") - } - } - - fullPath, _ := securejoin.SecureJoin(*baseDirFlag, qualifiedRepoName) - - logEvent("Processing command", map[string]interface{}{ - "user": *incomingUser, - "command": gitCommand, - "repo": repoName, - "fullPath": fullPath, - "client": clientIP, - }) - - if gitCommand == "git-upload-pack" { - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") - } else { - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") - } - - cmd := exec.Command(gitCommand, fullPath) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - - if err := cmd.Run(); err != nil { - exitWithLog(fmt.Sprintf("command failed: %v", err)) - } - - logEvent("Command completed", map[string]interface{}{ - "user": *incomingUser, - "command": gitCommand, - "repo": repoName, - "success": true, - }) -} - -func resolveToDid(didOrHandle string) string { - resolver := idresolver.DefaultResolver() - ident, err := resolver.ResolveIdent(context.Background(), didOrHandle) - if err != nil { - exitWithLog(fmt.Sprintf("error resolving handle: %v", err)) - } - - // did:plc:foobarbaz/repo - return ident.DID.String() -} - -func initLogger() { - var err error - logFile, err = os.OpenFile(*logPathFlag, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err) - os.Exit(1) - } - - logger = log.New(logFile, "", 0) -} - -func logEvent(event string, fields map[string]interface{}) { - entry := fmt.Sprintf( - "timestamp=%q event=%q", - time.Now().Format(time.RFC3339), - event, - ) - - for k, v := range fields { - entry += fmt.Sprintf(" %s=%q", k, v) - } - - logger.Println(entry) -} - -func exitWithLog(message string) { - logEvent("Access denied", map[string]interface{}{ - "error": message, - }) - logFile.Sync() - fmt.Fprintf(os.Stderr, "error: %s\n", message) - os.Exit(1) -} - -func cleanup() { - if logFile != nil { - logFile.Sync() - logFile.Close() - } -} - -func isPushPermitted(user, qualifiedRepoName string) bool { - u, _ := url.Parse(*endpoint + "/push-allowed") - q := u.Query() - q.Add("user", user) - q.Add("repo", qualifiedRepoName) - u.RawQuery = q.Encode() - - req, err := http.Get(u.String()) - if err != nil { - exitWithLog(fmt.Sprintf("error verifying permissions: %v", err)) - } - - logEvent("url", map[string]interface{}{ - "url": u.String(), - "status": req.Status, - }) - - return req.StatusCode == http.StatusNoContent -} diff --git a/go.mod b/go.mod index 27cdeb6b..633e55cb 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/posthog/posthog-go v1.5.5 github.com/resend/resend-go/v2 v2.15.0 github.com/sethvargo/go-envconfig v1.1.0 + github.com/urfave/cli/v3 v3.3.3 github.com/whyrusleeping/cbor-gen v0.3.1 github.com/yuin/goldmark v1.4.13 golang.org/x/net v0.39.0 diff --git a/go.sum b/go.sum index 9b6e2b35..29cedd3d 100644 --- a/go.sum +++ b/go.sum @@ -348,6 +348,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= +github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= diff --git a/guard/guard.go b/guard/guard.go new file mode 100644 index 00000000..864ea3ad --- /dev/null +++ b/guard/guard.go @@ -0,0 +1,208 @@ +package guard + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/urfave/cli/v3" + "tangled.sh/tangled.sh/core/appview/idresolver" + "tangled.sh/tangled.sh/core/log" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "guard", + Usage: "role-based access control for git over ssh (not for manual use)", + Action: Run, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "user", + Usage: "allowed git user", + Required: true, + }, + &cli.StringFlag{ + Name: "git-dir", + Usage: "base directory for git repos", + Value: "/home/git", + }, + &cli.StringFlag{ + Name: "log-path", + Usage: "path to log file", + Value: "/home/git/guard.log", + }, + &cli.StringFlag{ + Name: "internal-api", + Usage: "internal API endpoint", + Value: "http://localhost:5444", + }, + }, + } +} + +func Run(ctx context.Context, cmd *cli.Command) error { + l := log.FromContext(ctx) + + incomingUser := cmd.String("user") + gitDir := cmd.String("git-dir") + logPath := cmd.String("log-path") + endpoint := cmd.String("internal-api") + + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + l.Error("failed to open log file", "error", err) + return err + } else { + fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo}) + l = slog.New(fileHandler) + } + + var clientIP string + if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { + parts := strings.Fields(connInfo) + if len(parts) > 0 { + clientIP = parts[0] + } + } + + if incomingUser == "" { + l.Error("access denied: no user specified") + fmt.Fprintln(os.Stderr, "access denied: no user specified") + return fmt.Errorf("access denied: no user specified") + } + + sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") + + l.Info("connection attempt", + "user", incomingUser, + "command", sshCommand, + "client", clientIP) + + if sshCommand == "" { + l.Error("access denied: no interactive shells", "user", incomingUser) + fmt.Fprintln(os.Stderr, "access denied: we don't serve interactive shells :)") + return fmt.Errorf("access denied: no interactive shells") + } + + cmdParts := strings.Fields(sshCommand) + if len(cmdParts) < 2 { + l.Error("invalid command format", "command", sshCommand) + fmt.Fprintln(os.Stderr, "invalid command format") + return fmt.Errorf("invalid command format") + } + + gitCommand := cmdParts[0] + + // did:foo/repo-name or + // handle/repo-name or + // any of the above with a leading slash (/) + + components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") + l.Info("command components", "components", components) + + if len(components) != 2 { + l.Error("invalid repo format", "components", components) + fmt.Fprintln(os.Stderr, "invalid repo format, needs / or //") + return fmt.Errorf("invalid repo format, needs / or //") + } + + didOrHandle := components[0] + did := resolveToDid(ctx, l, didOrHandle) + repoName := components[1] + qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) + + validCommands := map[string]bool{ + "git-receive-pack": true, + "git-upload-pack": true, + "git-upload-archive": true, + } + if !validCommands[gitCommand] { + l.Error("access denied: invalid git command", "command", gitCommand) + fmt.Fprintln(os.Stderr, "access denied: invalid git command") + return fmt.Errorf("access denied: invalid git command") + } + + if gitCommand != "git-upload-pack" { + if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { + l.Error("access denied: user not allowed", + "did", incomingUser, + "reponame", qualifiedRepoName) + fmt.Fprintln(os.Stderr, "access denied: user not allowed") + return fmt.Errorf("access denied: user not allowed") + } + } + + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) + + l.Info("processing command", + "user", incomingUser, + "command", gitCommand, + "repo", repoName, + "fullPath", fullPath, + "client", clientIP) + + if gitCommand == "git-upload-pack" { + fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") + } else { + fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") + } + + gitCmd := exec.Command(gitCommand, fullPath) + gitCmd.Stdout = os.Stdout + gitCmd.Stderr = os.Stderr + gitCmd.Stdin = os.Stdin + + if err := gitCmd.Run(); err != nil { + l.Error("command failed", "error", err) + fmt.Fprintf(os.Stderr, "command failed: %v\n", err) + return fmt.Errorf("command failed: %v", err) + } + + l.Info("command completed", + "user", incomingUser, + "command", gitCommand, + "repo", repoName, + "success", true) + + return nil +} + +func resolveToDid(ctx context.Context, l *slog.Logger, didOrHandle string) string { + resolver := idresolver.DefaultResolver() + ident, err := resolver.ResolveIdent(ctx, didOrHandle) + if err != nil { + l.Error("Error resolving handle", "error", err, "handle", didOrHandle) + fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) + os.Exit(1) + } + + // did:plc:foobarbaz/repo + return ident.DID.String() +} + +func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { + u, _ := url.Parse(endpoint + "/push-allowed") + q := u.Query() + q.Add("user", user) + q.Add("repo", qualifiedRepoName) + u.RawQuery = q.Encode() + + req, err := http.Get(u.String()) + if err != nil { + l.Error("Error verifying permissions", "error", err) + fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) + os.Exit(1) + } + + l.Info("Checking push permission", + "url", u.String(), + "status", req.Status) + + return req.StatusCode == http.StatusNoContent +} diff --git a/keyfetch/keyfetch.go b/keyfetch/keyfetch.go new file mode 100644 index 00000000..06214a3d --- /dev/null +++ b/keyfetch/keyfetch.go @@ -0,0 +1,121 @@ +package keyfetch + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/urfave/cli/v3" + "tangled.sh/tangled.sh/core/log" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "keys", + Usage: "fetch public keys from the knot server", + Action: Run, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "output format (table, json, authorized-keys)", + Value: "table", + }, + &cli.StringFlag{ + Name: "internal-api", + Usage: "internal API endpoint", + Value: "http://localhost:5444", + }, + &cli.StringFlag{ + Name: "repoguard-path", + Usage: "path to the repoguard binary", + Value: "/home/git/repoguard", + }, + &cli.StringFlag{ + Name: "git-dir", + Usage: "base directory for git repos", + Value: "/home/git", + }, + &cli.StringFlag{ + Name: "log-path", + Usage: "path to log file", + Value: "/home/git/log", + }, + }, + } +} + +func Run(ctx context.Context, cmd *cli.Command) error { + internalApi := cmd.String("internal-api") + repoguardPath := cmd.String("repoguard-path") + gitDir := cmd.String("git-dir") + logPath := cmd.String("log-path") + output := cmd.String("output") + + l := log.FromContext(ctx) + + resp, err := http.Get(internalApi + "/keys") + if err != nil { + l.Error("error reaching internal API endpoint; is the knot server running?", "error", err) + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + l.Error("error reading response body", "error", err) + return err + } + + var data []map[string]any + err = json.Unmarshal(body, &data) + if err != nil { + l.Error("error unmarshalling response body", "error", err) + return err + } + + switch output { + case "json": + prettyJSON, err := json.MarshalIndent(data, "", " ") + if err != nil { + l.Error("error pretty printing JSON", "error", err) + return err + } + + if _, err := os.Stdout.Write(prettyJSON); err != nil { + l.Error("error writing to stdout", "error", err) + return err + } + case "authorized-keys": + formatted := formatKeyData(repoguardPath, gitDir, logPath, internalApi, data) + _, err := os.Stdout.Write([]byte(formatted)) + if err != nil { + l.Error("error writing to stdout", "error", err) + return err + } + case "table": + fmt.Printf("%-40s %-40s\n", "DID", "KEY") + fmt.Println(strings.Repeat("-", 80)) + + for _, entry := range data { + did, _ := entry["did"].(string) + key, _ := entry["key"].(string) + fmt.Printf("%-40s %-40s\n", did, key) + } + } + return nil +} + +func formatKeyData(repoguardPath, gitDir, logPath, endpoint string, data []map[string]any) string { + var result string + for _, entry := range data { + result += fmt.Sprintf( + `command="%s -base-dir %s -user %s -log-path %s -internal-api %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n", + repoguardPath, gitDir, entry["did"], logPath, endpoint, entry["key"]) + } + return result +} diff --git a/knotserver/server.go b/knotserver/server.go new file mode 100644 index 00000000..1d5b88b7 --- /dev/null +++ b/knotserver/server.go @@ -0,0 +1,84 @@ +package knotserver + +import ( + "context" + "fmt" + "net/http" + + "github.com/urfave/cli/v3" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/jetstream" + "tangled.sh/tangled.sh/core/knotserver/config" + "tangled.sh/tangled.sh/core/knotserver/db" + "tangled.sh/tangled.sh/core/log" + "tangled.sh/tangled.sh/core/rbac" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "server", + Usage: "run a knot server", + Action: Run, + Description: ` +Environment variables: + KNOT_SERVER_SECRET (required) + KNOT_SERVER_HOSTNAME (required) + KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) + KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) + KNOT_SERVER_DB_PATH (default: knotserver.db) + KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) + KNOT_SERVER_DEV (default: false) + KNOT_REPO_SCAN_PATH (default: /home/git) + KNOT_REPO_README (comma-separated list) + KNOT_REPO_MAIN_BRANCH (default: main) + APPVIEW_ENDPOINT (default: https://tangled.sh) +`, + } +} + +func Run(ctx context.Context, cmd *cli.Command) error { + l := log.FromContext(ctx) + + c, err := config.Load(ctx) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if c.Server.Dev { + l.Info("running in dev mode, signature verification is disabled") + } + + db, err := db.Setup(c.Server.DBPath) + if err != nil { + return fmt.Errorf("failed to load db: %w", err) + } + + e, err := rbac.NewEnforcer(c.Server.DBPath) + if err != nil { + return fmt.Errorf("failed to setup rbac enforcer: %w", err) + } + + e.E.EnableAutoSave(true) + + jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ + tangled.PublicKeyNSID, + tangled.KnotMemberNSID, + }, nil, l, db, true) + if err != nil { + l.Error("failed to setup jetstream", "error", err) + } + + mux, err := Setup(ctx, c, db, e, jc, l) + if err != nil { + return fmt.Errorf("failed to setup server: %w", err) + } + imux := Internal(ctx, db, e) + + l.Info("starting internal server", "address", c.Server.InternalListenAddr) + go http.ListenAndServe(c.Server.InternalListenAddr, imux) + + l.Info("starting main server", "address", c.Server.ListenAddr) + l.Error("server error", "error", http.ListenAndServe(c.Server.ListenAddr, mux)) + + return nil +} -- 2.51.2