-15
cmd/keyfetch/format.go
-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
-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
+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
-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
+1
go.mod
+2
go.sum
+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
+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
+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
+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
+
}