forked from tangled.org/core
Monorepo for Tangled

cmd/knot: unified knot cli

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

authored by anirudh.fi and committed by Tangled 7dd3501e 4239e09b

Changed files
+449 -268
cmd
keyfetch
knot
repoguard
guard
keyfetch
knotserver
-15
cmd/keyfetch/format.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - func formatKeyData(repoguardPath, gitDir, logPath, endpoint string, data []map[string]interface{}) string { 8 - var result string 9 - for _, entry := range data { 10 - result += fmt.Sprintf( 11 - `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", 12 - repoguardPath, gitDir, entry["did"], logPath, endpoint, entry["key"]) 13 - } 14 - return result 15 - }
-46
cmd/keyfetch/main.go
··· 1 - // This program must be configured to run as the sshd AuthorizedKeysCommand. 2 - // The format looks something like this: 3 - // Match User git 4 - // AuthorizedKeysCommand /keyfetch -internal-api http://localhost:5444 -repoguard-path /home/git/repoguard 5 - // AuthorizedKeysCommandUser nobody 6 - // 7 - // The command and its parent directories must be owned by root and set to 0755. Hence, the ideal location for this is 8 - // somewhere already owned by root so you don't have to mess with directory perms. 9 - 10 - package main 11 - 12 - import ( 13 - "encoding/json" 14 - "flag" 15 - "fmt" 16 - "io" 17 - "log" 18 - "net/http" 19 - ) 20 - 21 - func main() { 22 - endpoint := flag.String("internal-api", "http://localhost:5444", "Internal API endpoint") 23 - repoguardPath := flag.String("repoguard-path", "/home/git/repoguard", "Path to the repoguard binary") 24 - gitDir := flag.String("git-dir", "/home/git", "Path to the git directory") 25 - logPath := flag.String("log-path", "/home/git/log", "Path to log file") 26 - flag.Parse() 27 - 28 - resp, err := http.Get(*endpoint + "/keys") 29 - if err != nil { 30 - log.Fatalf("error fetching keys: %v", err) 31 - } 32 - defer resp.Body.Close() 33 - 34 - body, err := io.ReadAll(resp.Body) 35 - if err != nil { 36 - log.Fatalf("error reading response body: %v", err) 37 - } 38 - 39 - var data []map[string]interface{} 40 - err = json.Unmarshal(body, &data) 41 - if err != nil { 42 - log.Fatalf("error unmarshalling response body: %v", err) 43 - } 44 - 45 - fmt.Print(formatKeyData(*repoguardPath, *gitDir, *logPath, *endpoint, data)) 46 - }
+33
cmd/knot/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "os" 6 + 7 + "github.com/urfave/cli/v3" 8 + "tangled.sh/tangled.sh/core/guard" 9 + "tangled.sh/tangled.sh/core/keyfetch" 10 + "tangled.sh/tangled.sh/core/knotserver" 11 + "tangled.sh/tangled.sh/core/log" 12 + ) 13 + 14 + func main() { 15 + cmd := &cli.Command{ 16 + Name: "knot", 17 + Usage: "knot administration and operation tool", 18 + Commands: []*cli.Command{ 19 + guard.Command(), 20 + knotserver.Command(), 21 + keyfetch.Command(), 22 + }, 23 + } 24 + 25 + ctx := context.Background() 26 + logger := log.New("knot") 27 + ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 28 + 29 + if err := cmd.Run(ctx, os.Args); err != nil { 30 + logger.Error(err.Error()) 31 + os.Exit(-1) 32 + } 33 + }
-207
cmd/repoguard/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "flag" 6 - "fmt" 7 - "log" 8 - "net/http" 9 - "net/url" 10 - "os" 11 - "os/exec" 12 - "strings" 13 - "time" 14 - 15 - securejoin "github.com/cyphar/filepath-securejoin" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 - ) 18 - 19 - var ( 20 - logger *log.Logger 21 - logFile *os.File 22 - clientIP string 23 - 24 - // Command line flags 25 - incomingUser = flag.String("user", "", "Allowed git user") 26 - baseDirFlag = flag.String("base-dir", "/home/git", "Base directory for git repositories") 27 - logPathFlag = flag.String("log-path", "/var/log/git-wrapper.log", "Path to log file") 28 - endpoint = flag.String("internal-api", "http://localhost:5444", "Internal API endpoint") 29 - ) 30 - 31 - func main() { 32 - flag.Parse() 33 - 34 - defer cleanup() 35 - initLogger() 36 - 37 - // Get client IP from SSH environment 38 - if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { 39 - parts := strings.Fields(connInfo) 40 - if len(parts) > 0 { 41 - clientIP = parts[0] 42 - } 43 - } 44 - 45 - if *incomingUser == "" { 46 - exitWithLog("access denied: no user specified") 47 - } 48 - 49 - sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") 50 - 51 - logEvent("Connection attempt", map[string]interface{}{ 52 - "user": *incomingUser, 53 - "command": sshCommand, 54 - "client": clientIP, 55 - }) 56 - 57 - if sshCommand == "" { 58 - exitWithLog("access denied: we don't serve interactive shells :)") 59 - } 60 - 61 - cmdParts := strings.Fields(sshCommand) 62 - if len(cmdParts) < 2 { 63 - exitWithLog("invalid command format") 64 - } 65 - 66 - gitCommand := cmdParts[0] 67 - 68 - // did:foo/repo-name or 69 - // handle/repo-name or 70 - // any of the above with a leading slash (/) 71 - 72 - components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 73 - logEvent("Command components", map[string]interface{}{ 74 - "components": components, 75 - }) 76 - if len(components) != 2 { 77 - exitWithLog("invalid repo format, needs <user>/<repo> or /<user>/<repo>") 78 - } 79 - 80 - didOrHandle := components[0] 81 - did := resolveToDid(didOrHandle) 82 - repoName := components[1] 83 - qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 84 - 85 - validCommands := map[string]bool{ 86 - "git-receive-pack": true, 87 - "git-upload-pack": true, 88 - "git-upload-archive": true, 89 - } 90 - if !validCommands[gitCommand] { 91 - exitWithLog("access denied: invalid git command") 92 - } 93 - 94 - if gitCommand != "git-upload-pack" { 95 - if !isPushPermitted(*incomingUser, qualifiedRepoName) { 96 - logEvent("all infos", map[string]interface{}{ 97 - "did": *incomingUser, 98 - "reponame": qualifiedRepoName, 99 - }) 100 - exitWithLog("access denied: user not allowed") 101 - } 102 - } 103 - 104 - fullPath, _ := securejoin.SecureJoin(*baseDirFlag, qualifiedRepoName) 105 - 106 - logEvent("Processing command", map[string]interface{}{ 107 - "user": *incomingUser, 108 - "command": gitCommand, 109 - "repo": repoName, 110 - "fullPath": fullPath, 111 - "client": clientIP, 112 - }) 113 - 114 - if gitCommand == "git-upload-pack" { 115 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 116 - } else { 117 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 118 - } 119 - 120 - cmd := exec.Command(gitCommand, fullPath) 121 - cmd.Stdout = os.Stdout 122 - cmd.Stderr = os.Stderr 123 - cmd.Stdin = os.Stdin 124 - 125 - if err := cmd.Run(); err != nil { 126 - exitWithLog(fmt.Sprintf("command failed: %v", err)) 127 - } 128 - 129 - logEvent("Command completed", map[string]interface{}{ 130 - "user": *incomingUser, 131 - "command": gitCommand, 132 - "repo": repoName, 133 - "success": true, 134 - }) 135 - } 136 - 137 - func resolveToDid(didOrHandle string) string { 138 - resolver := idresolver.DefaultResolver() 139 - ident, err := resolver.ResolveIdent(context.Background(), didOrHandle) 140 - if err != nil { 141 - exitWithLog(fmt.Sprintf("error resolving handle: %v", err)) 142 - } 143 - 144 - // did:plc:foobarbaz/repo 145 - return ident.DID.String() 146 - } 147 - 148 - func initLogger() { 149 - var err error 150 - logFile, err = os.OpenFile(*logPathFlag, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 151 - if err != nil { 152 - fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err) 153 - os.Exit(1) 154 - } 155 - 156 - logger = log.New(logFile, "", 0) 157 - } 158 - 159 - func logEvent(event string, fields map[string]interface{}) { 160 - entry := fmt.Sprintf( 161 - "timestamp=%q event=%q", 162 - time.Now().Format(time.RFC3339), 163 - event, 164 - ) 165 - 166 - for k, v := range fields { 167 - entry += fmt.Sprintf(" %s=%q", k, v) 168 - } 169 - 170 - logger.Println(entry) 171 - } 172 - 173 - func exitWithLog(message string) { 174 - logEvent("Access denied", map[string]interface{}{ 175 - "error": message, 176 - }) 177 - logFile.Sync() 178 - fmt.Fprintf(os.Stderr, "error: %s\n", message) 179 - os.Exit(1) 180 - } 181 - 182 - func cleanup() { 183 - if logFile != nil { 184 - logFile.Sync() 185 - logFile.Close() 186 - } 187 - } 188 - 189 - func isPushPermitted(user, qualifiedRepoName string) bool { 190 - u, _ := url.Parse(*endpoint + "/push-allowed") 191 - q := u.Query() 192 - q.Add("user", user) 193 - q.Add("repo", qualifiedRepoName) 194 - u.RawQuery = q.Encode() 195 - 196 - req, err := http.Get(u.String()) 197 - if err != nil { 198 - exitWithLog(fmt.Sprintf("error verifying permissions: %v", err)) 199 - } 200 - 201 - logEvent("url", map[string]interface{}{ 202 - "url": u.String(), 203 - "status": req.Status, 204 - }) 205 - 206 - return req.StatusCode == http.StatusNoContent 207 - }
+1
go.mod
··· 28 28 github.com/posthog/posthog-go v1.5.5 29 29 github.com/resend/resend-go/v2 v2.15.0 30 30 github.com/sethvargo/go-envconfig v1.1.0 31 + github.com/urfave/cli/v3 v3.3.3 31 32 github.com/whyrusleeping/cbor-gen v0.3.1 32 33 github.com/yuin/goldmark v1.4.13 33 34 golang.org/x/net v0.39.0
+2
go.sum
··· 348 348 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 349 349 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 350 350 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 351 + github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 352 + github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 351 353 github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= 352 354 github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= 353 355 github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
+208
guard/guard.go
··· 1 + package guard 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "net/url" 9 + "os" 10 + "os/exec" 11 + "strings" 12 + 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "github.com/urfave/cli/v3" 15 + "tangled.sh/tangled.sh/core/appview/idresolver" 16 + "tangled.sh/tangled.sh/core/log" 17 + ) 18 + 19 + func Command() *cli.Command { 20 + return &cli.Command{ 21 + Name: "guard", 22 + Usage: "role-based access control for git over ssh (not for manual use)", 23 + Action: Run, 24 + Flags: []cli.Flag{ 25 + &cli.StringFlag{ 26 + Name: "user", 27 + Usage: "allowed git user", 28 + Required: true, 29 + }, 30 + &cli.StringFlag{ 31 + Name: "git-dir", 32 + Usage: "base directory for git repos", 33 + Value: "/home/git", 34 + }, 35 + &cli.StringFlag{ 36 + Name: "log-path", 37 + Usage: "path to log file", 38 + Value: "/home/git/guard.log", 39 + }, 40 + &cli.StringFlag{ 41 + Name: "internal-api", 42 + Usage: "internal API endpoint", 43 + Value: "http://localhost:5444", 44 + }, 45 + }, 46 + } 47 + } 48 + 49 + func Run(ctx context.Context, cmd *cli.Command) error { 50 + l := log.FromContext(ctx) 51 + 52 + incomingUser := cmd.String("user") 53 + gitDir := cmd.String("git-dir") 54 + logPath := cmd.String("log-path") 55 + endpoint := cmd.String("internal-api") 56 + 57 + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 58 + if err != nil { 59 + l.Error("failed to open log file", "error", err) 60 + return err 61 + } else { 62 + fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo}) 63 + l = slog.New(fileHandler) 64 + } 65 + 66 + var clientIP string 67 + if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { 68 + parts := strings.Fields(connInfo) 69 + if len(parts) > 0 { 70 + clientIP = parts[0] 71 + } 72 + } 73 + 74 + if incomingUser == "" { 75 + l.Error("access denied: no user specified") 76 + fmt.Fprintln(os.Stderr, "access denied: no user specified") 77 + return fmt.Errorf("access denied: no user specified") 78 + } 79 + 80 + sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") 81 + 82 + l.Info("connection attempt", 83 + "user", incomingUser, 84 + "command", sshCommand, 85 + "client", clientIP) 86 + 87 + if sshCommand == "" { 88 + l.Error("access denied: no interactive shells", "user", incomingUser) 89 + fmt.Fprintln(os.Stderr, "access denied: we don't serve interactive shells :)") 90 + return fmt.Errorf("access denied: no interactive shells") 91 + } 92 + 93 + cmdParts := strings.Fields(sshCommand) 94 + if len(cmdParts) < 2 { 95 + l.Error("invalid command format", "command", sshCommand) 96 + fmt.Fprintln(os.Stderr, "invalid command format") 97 + return fmt.Errorf("invalid command format") 98 + } 99 + 100 + gitCommand := cmdParts[0] 101 + 102 + // did:foo/repo-name or 103 + // handle/repo-name or 104 + // any of the above with a leading slash (/) 105 + 106 + components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 107 + l.Info("command components", "components", components) 108 + 109 + if len(components) != 2 { 110 + l.Error("invalid repo format", "components", components) 111 + fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 112 + return fmt.Errorf("invalid repo format, needs <user>/<repo> or /<user>/<repo>") 113 + } 114 + 115 + didOrHandle := components[0] 116 + did := resolveToDid(ctx, l, didOrHandle) 117 + repoName := components[1] 118 + qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 119 + 120 + validCommands := map[string]bool{ 121 + "git-receive-pack": true, 122 + "git-upload-pack": true, 123 + "git-upload-archive": true, 124 + } 125 + if !validCommands[gitCommand] { 126 + l.Error("access denied: invalid git command", "command", gitCommand) 127 + fmt.Fprintln(os.Stderr, "access denied: invalid git command") 128 + return fmt.Errorf("access denied: invalid git command") 129 + } 130 + 131 + if gitCommand != "git-upload-pack" { 132 + if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 133 + l.Error("access denied: user not allowed", 134 + "did", incomingUser, 135 + "reponame", qualifiedRepoName) 136 + fmt.Fprintln(os.Stderr, "access denied: user not allowed") 137 + return fmt.Errorf("access denied: user not allowed") 138 + } 139 + } 140 + 141 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 142 + 143 + l.Info("processing command", 144 + "user", incomingUser, 145 + "command", gitCommand, 146 + "repo", repoName, 147 + "fullPath", fullPath, 148 + "client", clientIP) 149 + 150 + if gitCommand == "git-upload-pack" { 151 + fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 152 + } else { 153 + fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 154 + } 155 + 156 + gitCmd := exec.Command(gitCommand, fullPath) 157 + gitCmd.Stdout = os.Stdout 158 + gitCmd.Stderr = os.Stderr 159 + gitCmd.Stdin = os.Stdin 160 + 161 + if err := gitCmd.Run(); err != nil { 162 + l.Error("command failed", "error", err) 163 + fmt.Fprintf(os.Stderr, "command failed: %v\n", err) 164 + return fmt.Errorf("command failed: %v", err) 165 + } 166 + 167 + l.Info("command completed", 168 + "user", incomingUser, 169 + "command", gitCommand, 170 + "repo", repoName, 171 + "success", true) 172 + 173 + return nil 174 + } 175 + 176 + func resolveToDid(ctx context.Context, l *slog.Logger, didOrHandle string) string { 177 + resolver := idresolver.DefaultResolver() 178 + ident, err := resolver.ResolveIdent(ctx, didOrHandle) 179 + if err != nil { 180 + l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 181 + fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 182 + os.Exit(1) 183 + } 184 + 185 + // did:plc:foobarbaz/repo 186 + return ident.DID.String() 187 + } 188 + 189 + func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 190 + u, _ := url.Parse(endpoint + "/push-allowed") 191 + q := u.Query() 192 + q.Add("user", user) 193 + q.Add("repo", qualifiedRepoName) 194 + u.RawQuery = q.Encode() 195 + 196 + req, err := http.Get(u.String()) 197 + if err != nil { 198 + l.Error("Error verifying permissions", "error", err) 199 + fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 200 + os.Exit(1) 201 + } 202 + 203 + l.Info("Checking push permission", 204 + "url", u.String(), 205 + "status", req.Status) 206 + 207 + return req.StatusCode == http.StatusNoContent 208 + }
+121
keyfetch/keyfetch.go
··· 1 + package keyfetch 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "os" 10 + "strings" 11 + 12 + "github.com/urfave/cli/v3" 13 + "tangled.sh/tangled.sh/core/log" 14 + ) 15 + 16 + func Command() *cli.Command { 17 + return &cli.Command{ 18 + Name: "keys", 19 + Usage: "fetch public keys from the knot server", 20 + Action: Run, 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "output", 24 + Aliases: []string{"o"}, 25 + Usage: "output format (table, json, authorized-keys)", 26 + Value: "table", 27 + }, 28 + &cli.StringFlag{ 29 + Name: "internal-api", 30 + Usage: "internal API endpoint", 31 + Value: "http://localhost:5444", 32 + }, 33 + &cli.StringFlag{ 34 + Name: "repoguard-path", 35 + Usage: "path to the repoguard binary", 36 + Value: "/home/git/repoguard", 37 + }, 38 + &cli.StringFlag{ 39 + Name: "git-dir", 40 + Usage: "base directory for git repos", 41 + Value: "/home/git", 42 + }, 43 + &cli.StringFlag{ 44 + Name: "log-path", 45 + Usage: "path to log file", 46 + Value: "/home/git/log", 47 + }, 48 + }, 49 + } 50 + } 51 + 52 + func Run(ctx context.Context, cmd *cli.Command) error { 53 + internalApi := cmd.String("internal-api") 54 + repoguardPath := cmd.String("repoguard-path") 55 + gitDir := cmd.String("git-dir") 56 + logPath := cmd.String("log-path") 57 + output := cmd.String("output") 58 + 59 + l := log.FromContext(ctx) 60 + 61 + resp, err := http.Get(internalApi + "/keys") 62 + if err != nil { 63 + l.Error("error reaching internal API endpoint; is the knot server running?", "error", err) 64 + return err 65 + } 66 + defer resp.Body.Close() 67 + 68 + body, err := io.ReadAll(resp.Body) 69 + if err != nil { 70 + l.Error("error reading response body", "error", err) 71 + return err 72 + } 73 + 74 + var data []map[string]any 75 + err = json.Unmarshal(body, &data) 76 + if err != nil { 77 + l.Error("error unmarshalling response body", "error", err) 78 + return err 79 + } 80 + 81 + switch output { 82 + case "json": 83 + prettyJSON, err := json.MarshalIndent(data, "", " ") 84 + if err != nil { 85 + l.Error("error pretty printing JSON", "error", err) 86 + return err 87 + } 88 + 89 + if _, err := os.Stdout.Write(prettyJSON); err != nil { 90 + l.Error("error writing to stdout", "error", err) 91 + return err 92 + } 93 + case "authorized-keys": 94 + formatted := formatKeyData(repoguardPath, gitDir, logPath, internalApi, data) 95 + _, err := os.Stdout.Write([]byte(formatted)) 96 + if err != nil { 97 + l.Error("error writing to stdout", "error", err) 98 + return err 99 + } 100 + case "table": 101 + fmt.Printf("%-40s %-40s\n", "DID", "KEY") 102 + fmt.Println(strings.Repeat("-", 80)) 103 + 104 + for _, entry := range data { 105 + did, _ := entry["did"].(string) 106 + key, _ := entry["key"].(string) 107 + fmt.Printf("%-40s %-40s\n", did, key) 108 + } 109 + } 110 + return nil 111 + } 112 + 113 + func formatKeyData(repoguardPath, gitDir, logPath, endpoint string, data []map[string]any) string { 114 + var result string 115 + for _, entry := range data { 116 + result += fmt.Sprintf( 117 + `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", 118 + repoguardPath, gitDir, entry["did"], logPath, endpoint, entry["key"]) 119 + } 120 + return result 121 + }
+84
knotserver/server.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/urfave/cli/v3" 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/jetstream" 11 + "tangled.sh/tangled.sh/core/knotserver/config" 12 + "tangled.sh/tangled.sh/core/knotserver/db" 13 + "tangled.sh/tangled.sh/core/log" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + ) 16 + 17 + func Command() *cli.Command { 18 + return &cli.Command{ 19 + Name: "server", 20 + Usage: "run a knot server", 21 + Action: Run, 22 + Description: ` 23 + Environment variables: 24 + KNOT_SERVER_SECRET (required) 25 + KNOT_SERVER_HOSTNAME (required) 26 + KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 27 + KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 28 + KNOT_SERVER_DB_PATH (default: knotserver.db) 29 + KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 30 + KNOT_SERVER_DEV (default: false) 31 + KNOT_REPO_SCAN_PATH (default: /home/git) 32 + KNOT_REPO_README (comma-separated list) 33 + KNOT_REPO_MAIN_BRANCH (default: main) 34 + APPVIEW_ENDPOINT (default: https://tangled.sh) 35 + `, 36 + } 37 + } 38 + 39 + func Run(ctx context.Context, cmd *cli.Command) error { 40 + l := log.FromContext(ctx) 41 + 42 + c, err := config.Load(ctx) 43 + if err != nil { 44 + return fmt.Errorf("failed to load config: %w", err) 45 + } 46 + 47 + if c.Server.Dev { 48 + l.Info("running in dev mode, signature verification is disabled") 49 + } 50 + 51 + db, err := db.Setup(c.Server.DBPath) 52 + if err != nil { 53 + return fmt.Errorf("failed to load db: %w", err) 54 + } 55 + 56 + e, err := rbac.NewEnforcer(c.Server.DBPath) 57 + if err != nil { 58 + return fmt.Errorf("failed to setup rbac enforcer: %w", err) 59 + } 60 + 61 + e.E.EnableAutoSave(true) 62 + 63 + jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 64 + tangled.PublicKeyNSID, 65 + tangled.KnotMemberNSID, 66 + }, nil, l, db, true) 67 + if err != nil { 68 + l.Error("failed to setup jetstream", "error", err) 69 + } 70 + 71 + mux, err := Setup(ctx, c, db, e, jc, l) 72 + if err != nil { 73 + return fmt.Errorf("failed to setup server: %w", err) 74 + } 75 + imux := Internal(ctx, db, e) 76 + 77 + l.Info("starting internal server", "address", c.Server.InternalListenAddr) 78 + go http.ListenAndServe(c.Server.InternalListenAddr, imux) 79 + 80 + l.Info("starting main server", "address", c.Server.ListenAddr) 81 + l.Error("server error", "error", http.ListenAndServe(c.Server.ListenAddr, mux)) 82 + 83 + return nil 84 + }