+6
-6
cmd/knot/main.go
+6
-6
cmd/knot/main.go
···
6
6
"os"
7
7
8
8
"github.com/urfave/cli/v3"
9
-
"tangled.org/core/guard"
10
-
"tangled.org/core/hook"
11
-
"tangled.org/core/keyfetch"
12
-
"tangled.org/core/knotserver"
9
+
"tangled.org/core/knot2/guard"
10
+
"tangled.org/core/knot2/hook"
11
+
"tangled.org/core/knot2/keys"
12
+
"tangled.org/core/knot2/server"
13
13
tlog "tangled.org/core/log"
14
14
)
15
15
···
19
19
Usage: "knot administration and operation tool",
20
20
Commands: []*cli.Command{
21
21
guard.Command(),
22
-
knotserver.Command(),
23
-
keyfetch.Command(),
22
+
server.Command(),
23
+
keys.Command(),
24
24
hook.Command(),
25
25
},
26
26
}
+1
go.mod
+1
go.mod
···
18
18
github.com/cloudflare/cloudflare-go v0.115.0
19
19
github.com/cyphar/filepath-securejoin v0.4.1
20
20
github.com/dgraph-io/ristretto v0.2.0
21
+
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038
21
22
github.com/docker/docker v28.2.2+incompatible
22
23
github.com/dustin/go-humanize v1.0.1
23
24
github.com/gliderlabs/ssh v0.3.8
+2
go.sum
+2
go.sum
···
131
131
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
132
132
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
133
133
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
134
+
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 h1:AGh+Vn9fXhf9eo8erG1CK4+LACduPo64P1OICQLDv88=
135
+
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM=
134
136
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
135
137
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
136
138
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+101
knot2/config/config.go
+101
knot2/config/config.go
···
1
+
package config
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"net"
7
+
"os"
8
+
"path"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/sethvargo/go-envconfig"
12
+
"gopkg.in/yaml.v3"
13
+
)
14
+
15
+
type Config struct {
16
+
Dev bool `yaml:"dev"`
17
+
HostName string `yaml:"hostname"`
18
+
OwnerDid syntax.DID `yaml:"owner_did"`
19
+
ListenHost string `yaml:"listen_host"`
20
+
ListenPort string `yaml:"listen_port"`
21
+
DataDir string `yaml:"data_dir"`
22
+
RepoDir string `yaml:"repo_dir"`
23
+
PlcUrl string `yaml:"plc_url"`
24
+
JetstreamEndpoint string `yaml:"jetstream_endpoint"`
25
+
AppviewEndpoint string `yaml:"appview_endpoint"`
26
+
GitUserName string `yaml:"git_user_name"`
27
+
GitUserEmail string `yaml:"git_user_email"`
28
+
OAuth OAuthConfig
29
+
}
30
+
31
+
type OAuthConfig struct {
32
+
CookieSecret string `env:"KNOT2_COOKIE_SECRET, default=00000000000000000000000000000000"`
33
+
ClientSecret string `env:"KNOT2_OAUTH_CLIENT_SECRET"`
34
+
ClientKid string `env:"KNOT2_OAUTH_CLIENT_KID"`
35
+
}
36
+
37
+
func (c *Config) Uri() string {
38
+
// TODO: make port configurable
39
+
if c.Dev {
40
+
return "http://127.0.0.1:6444"
41
+
}
42
+
return "https://" + c.HostName
43
+
}
44
+
45
+
func (c *Config) ListenAddr() string {
46
+
return net.JoinHostPort(c.ListenHost, c.ListenPort)
47
+
}
48
+
49
+
func (c *Config) DbPath() string {
50
+
return path.Join(c.DataDir, "knot.db")
51
+
}
52
+
53
+
func (c *Config) GitMotdFilePath() string {
54
+
return path.Join(c.DataDir, "motd")
55
+
}
56
+
57
+
func (c *Config) Validate() error {
58
+
if c.HostName == "" {
59
+
return fmt.Errorf("knot hostname cannot be empty")
60
+
}
61
+
if c.OwnerDid == "" {
62
+
return fmt.Errorf("knot owner did cannot be empty")
63
+
}
64
+
return nil
65
+
}
66
+
67
+
func Load(ctx context.Context, path string) (Config, error) {
68
+
// NOTE: yaml.v3 package doesn't support "default" struct tag
69
+
cfg := Config{
70
+
Dev: true,
71
+
ListenHost: "0.0.0.0",
72
+
ListenPort: "5555",
73
+
DataDir: "/home/git",
74
+
RepoDir: "/home/git",
75
+
PlcUrl: "https://plc.directory",
76
+
JetstreamEndpoint: "wss://jetstream1.us-west.bsky.network/subscribe",
77
+
AppviewEndpoint: "https://tangled.org",
78
+
GitUserName: "Tangled",
79
+
GitUserEmail: "noreply@tangled.org",
80
+
}
81
+
// load config from env vars
82
+
err := envconfig.Process(ctx, &cfg.OAuth)
83
+
if err != nil {
84
+
return cfg, err
85
+
}
86
+
87
+
// load config from toml config file
88
+
bytes, err := os.ReadFile(path)
89
+
if err != nil {
90
+
return cfg, err
91
+
}
92
+
if err := yaml.Unmarshal(bytes, &cfg); err != nil {
93
+
return cfg, err
94
+
}
95
+
96
+
// validate the config
97
+
if err = cfg.Validate(); err != nil {
98
+
return cfg, err
99
+
}
100
+
return cfg, nil
101
+
}
+52
knot2/db/db.go
+52
knot2/db/db.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"strings"
6
+
7
+
_ "github.com/mattn/go-sqlite3"
8
+
)
9
+
10
+
func New(dbPath string) (*sql.DB, error) {
11
+
// https://github.com/mattn/go-sqlite3#connection-string
12
+
opts := []string{
13
+
"_foreign_keys=1",
14
+
"_journal_mode=WAL",
15
+
"_synchronous=NORMAL",
16
+
"_auto_vacuum=incremental",
17
+
}
18
+
19
+
return sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
20
+
}
21
+
22
+
func Init(d *sql.DB) error {
23
+
_, err := d.Exec(`
24
+
create table if not exists _jetstream (
25
+
id integer primary key autoincrement,
26
+
last_time_us integer not null
27
+
);
28
+
29
+
create table if not exists events (
30
+
rkey text not null,
31
+
nsid text not null,
32
+
event text not null, -- json
33
+
created integer not null -- unix nanos
34
+
);
35
+
36
+
create table if not exists users (
37
+
id integer primary key autoincrement,
38
+
did text not null unique,
39
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
40
+
);
41
+
42
+
create table if not exists public_keys (
43
+
id integer primary key autoincrement,
44
+
did text not null,
45
+
key text not null,
46
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
47
+
unique(did, key)
48
+
);
49
+
`)
50
+
51
+
return err
52
+
}
+10
knot2/db/pubkeys.go
+10
knot2/db/pubkeys.go
+12
knot2/db/users.go
+12
knot2/db/users.go
+31
knot2/guard/guard.go
+31
knot2/guard/guard.go
···
1
+
package guard
2
+
3
+
import (
4
+
"context"
5
+
6
+
"github.com/urfave/cli/v3"
7
+
"tangled.org/core/log"
8
+
)
9
+
10
+
func Command() *cli.Command {
11
+
return &cli.Command{
12
+
Name: "guard",
13
+
Usage: "role-based access control for git over ssh (not for manual use)",
14
+
Action: Run,
15
+
Flags: []cli.Flag{
16
+
&cli.StringFlag{
17
+
Name: "user",
18
+
Usage: "allowed git user",
19
+
Required: true,
20
+
},
21
+
},
22
+
}
23
+
}
24
+
25
+
func Run(ctx context.Context, cmd *cli.Command) error {
26
+
l := log.FromContext(ctx)
27
+
l = log.SubLogger(l, cmd.Name)
28
+
ctx = log.IntoContext(ctx, l)
29
+
30
+
panic("unimplemented")
31
+
}
+27
knot2/hook/hook.go
+27
knot2/hook/hook.go
···
1
+
package hook
2
+
3
+
import (
4
+
"context"
5
+
6
+
"github.com/urfave/cli/v3"
7
+
"tangled.org/core/log"
8
+
)
9
+
10
+
func Command() *cli.Command {
11
+
return &cli.Command{
12
+
Name: "hook",
13
+
Usage: "run git hooks",
14
+
Action: Run,
15
+
Flags: []cli.Flag{
16
+
// TODO:
17
+
},
18
+
}
19
+
}
20
+
21
+
func Run(ctx context.Context, cmd *cli.Command) error {
22
+
l := log.FromContext(ctx)
23
+
l = log.SubLogger(l, cmd.Name)
24
+
ctx = log.IntoContext(ctx, l)
25
+
26
+
panic("unimplemented")
27
+
}
+103
knot2/keys/keys.go
+103
knot2/keys/keys.go
···
1
+
package keys
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"os"
8
+
"strings"
9
+
10
+
"github.com/urfave/cli/v3"
11
+
"tangled.org/core/knot2/config"
12
+
"tangled.org/core/knot2/db"
13
+
"tangled.org/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: "config",
24
+
Aliases: []string{"c"},
25
+
Usage: "config path",
26
+
Required: true,
27
+
},
28
+
&cli.StringFlag{
29
+
Name: "output",
30
+
Aliases: []string{"o"},
31
+
Usage: "output format (table, json, authorized-keys)",
32
+
Value: "table",
33
+
},
34
+
},
35
+
}
36
+
}
37
+
38
+
func Run(ctx context.Context, cmd *cli.Command) error {
39
+
l := log.FromContext(ctx)
40
+
l = log.SubLogger(l, cmd.Name)
41
+
ctx = log.IntoContext(ctx, l)
42
+
43
+
var (
44
+
output = cmd.String("output")
45
+
configPath = cmd.String("config")
46
+
)
47
+
48
+
cfg, err := config.Load(ctx, configPath)
49
+
if err != nil {
50
+
return fmt.Errorf("failed to load config: %w", err)
51
+
}
52
+
53
+
d, err := db.New(cfg.DbPath())
54
+
if err != nil {
55
+
return fmt.Errorf("failed to load db: %w", err)
56
+
}
57
+
58
+
pubkeyDidListMap, err := db.GetPubkeyDidListMap(d)
59
+
if err != nil {
60
+
return err
61
+
}
62
+
63
+
switch output {
64
+
case "json":
65
+
prettyJSON, err := json.MarshalIndent(pubkeyDidListMap, "", " ")
66
+
if err != nil {
67
+
return err
68
+
}
69
+
if _, err := os.Stdout.Write(prettyJSON); err != nil {
70
+
return err
71
+
}
72
+
case "table":
73
+
fmt.Printf("%-40s %-40s\n", "KEY", "DID")
74
+
fmt.Println(strings.Repeat("-", 80))
75
+
76
+
for key, didList := range pubkeyDidListMap {
77
+
fmt.Printf("%-40s %-40s\n", key, strings.Join(didList, ","))
78
+
}
79
+
case "authorized-keys":
80
+
for key, didList := range pubkeyDidListMap {
81
+
executablePath, err := os.Executable()
82
+
if err != nil {
83
+
l.Error("error getting path of executable", "error", err)
84
+
return err
85
+
}
86
+
command := fmt.Sprintf("%s guard", executablePath)
87
+
for _, did := range didList {
88
+
command += fmt.Sprintf(" -user %s", did)
89
+
}
90
+
fmt.Printf(
91
+
`command="%s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n",
92
+
command,
93
+
key,
94
+
)
95
+
}
96
+
if err != nil {
97
+
l.Error("error writing to stdout", "error", err)
98
+
return err
99
+
}
100
+
}
101
+
102
+
return nil
103
+
}
+8
knot2/models/pubkeys.go
+8
knot2/models/pubkeys.go
+18
knot2/server/handler/events.go
+18
knot2/server/handler/events.go
···
1
+
package handler
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/gorilla/websocket"
7
+
)
8
+
9
+
var upgrader = websocket.Upgrader{
10
+
ReadBufferSize: 1024,
11
+
WriteBufferSize: 1024,
12
+
}
13
+
14
+
func Events() http.HandlerFunc {
15
+
return func(w http.ResponseWriter, r *http.Request) {
16
+
panic("unimplemented")
17
+
}
18
+
}
+9
knot2/server/handler/git_receive_pack.go
+9
knot2/server/handler/git_receive_pack.go
+9
knot2/server/handler/git_upload_pack.go
+9
knot2/server/handler/git_upload_pack.go
+9
knot2/server/handler/info_refs.go
+9
knot2/server/handler/info_refs.go
+241
knot2/server/handler/register.go
+241
knot2/server/handler/register.go
···
1
+
package handler
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
_ "embed"
7
+
"encoding/json"
8
+
"fmt"
9
+
"html/template"
10
+
"net/http"
11
+
"strings"
12
+
13
+
"github.com/bluesky-social/indigo/api/agnostic"
14
+
"github.com/bluesky-social/indigo/api/atproto"
15
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
16
+
"github.com/bluesky-social/indigo/atproto/syntax"
17
+
"github.com/did-method-plc/go-didplc"
18
+
"github.com/gorilla/sessions"
19
+
"tangled.org/core/knot2/config"
20
+
"tangled.org/core/knot2/db"
21
+
"tangled.org/core/log"
22
+
)
23
+
24
+
const (
25
+
// atproto
26
+
serviceId = "tangled_knot"
27
+
serviceType = "TangledKnot"
28
+
// cookies
29
+
sessionName = "oauth-demo"
30
+
sessionId = "sessionId"
31
+
sessionDid = "sessionDID"
32
+
)
33
+
34
+
//go:embed "templates/register.html"
35
+
var tmplRegisgerText string
36
+
var tmplRegister = template.Must(template.New("register.html").Parse(tmplRegisgerText))
37
+
38
+
func Register(jar *sessions.CookieStore) http.HandlerFunc {
39
+
return func(w http.ResponseWriter, r *http.Request) {
40
+
ctx := r.Context()
41
+
l := log.FromContext(ctx).With("handler", "Register")
42
+
43
+
sess, _ := jar.Get(r, sessionName)
44
+
var data map[string]any
45
+
46
+
if !sess.IsNew {
47
+
// render Register { Handle, Web: true }
48
+
did := syntax.DID(sess.Values[sessionDid].(string))
49
+
plcop := did.Method() == "plc" && r.URL.Query().Get("method") != "web"
50
+
data = map[string]any{
51
+
"Did": did,
52
+
"PlcOp": plcop,
53
+
}
54
+
}
55
+
56
+
err := tmplRegister.Execute(w, data)
57
+
if err != nil {
58
+
l.Error("failed to render", "err", err)
59
+
}
60
+
}
61
+
}
62
+
63
+
func OauthClientMetadata(cfg *config.Config, clientApp *oauth.ClientApp) http.HandlerFunc {
64
+
return func(w http.ResponseWriter, r *http.Request) {
65
+
doc := clientApp.Config.ClientMetadata()
66
+
var (
67
+
clientName = cfg.HostName
68
+
clientUri = cfg.Uri()
69
+
jwksUri = clientUri + "/oauth/jwks.json"
70
+
)
71
+
doc.ClientName = &clientName
72
+
doc.ClientURI = &clientUri
73
+
doc.JWKSURI = &jwksUri
74
+
75
+
w.Header().Set("Content-Type", "application/json")
76
+
if err := json.NewEncoder(w).Encode(doc); err != nil {
77
+
http.Error(w, err.Error(), http.StatusInternalServerError)
78
+
return
79
+
}
80
+
}
81
+
}
82
+
83
+
func OauthJwks(clientApp *oauth.ClientApp) http.HandlerFunc {
84
+
return func(w http.ResponseWriter, r *http.Request) {
85
+
w.Header().Set("Content-Type", "application/json")
86
+
body := clientApp.Config.PublicJWKS()
87
+
if err := json.NewEncoder(w).Encode(body); err != nil {
88
+
http.Error(w, err.Error(), http.StatusInternalServerError)
89
+
return
90
+
}
91
+
}
92
+
}
93
+
94
+
func OauthLoginPost(clientApp *oauth.ClientApp) http.HandlerFunc {
95
+
return func(w http.ResponseWriter, r *http.Request) {
96
+
ctx := r.Context()
97
+
l := log.FromContext(ctx).With("handler", "OauthLoginPost")
98
+
99
+
handle := r.FormValue("handle")
100
+
101
+
handle = strings.TrimPrefix(handle, "\u202a")
102
+
handle = strings.TrimSuffix(handle, "\u202c")
103
+
// `@` is harmless
104
+
handle = strings.TrimPrefix(handle, "@")
105
+
106
+
redirectURL, err := clientApp.StartAuthFlow(ctx, handle)
107
+
if err != nil {
108
+
l.Error("failed to start auth flow", "err", err)
109
+
panic(err)
110
+
}
111
+
112
+
w.Header().Set("HX-Redirect", redirectURL)
113
+
w.WriteHeader(http.StatusOK)
114
+
}
115
+
}
116
+
117
+
func OauthCallback(oauth *oauth.ClientApp, jar *sessions.CookieStore) http.HandlerFunc {
118
+
return func(w http.ResponseWriter, r *http.Request) {
119
+
ctx := r.Context()
120
+
l := log.FromContext(ctx).With("handler", "OauthCallback")
121
+
122
+
data, err := oauth.ProcessCallback(ctx, r.URL.Query())
123
+
if err != nil {
124
+
l.Error("failed to process oauth callback", "err", err)
125
+
panic(err)
126
+
}
127
+
128
+
// store session data to cookie jar
129
+
sess, _ := jar.Get(r, sessionName)
130
+
sess.Values[sessionDid] = data.AccountDID.String()
131
+
sess.Values[sessionId] = data.SessionID
132
+
if err = sess.Save(r, w); err != nil {
133
+
l.Error("failed to save session", "err", err)
134
+
panic(err)
135
+
}
136
+
137
+
if data.AccountDID.Method() == "plc" {
138
+
sess, err := oauth.ResumeSession(ctx, data.AccountDID, data.SessionID)
139
+
if err != nil {
140
+
l.Error("failed to resume atproto session", "err", err)
141
+
panic(err)
142
+
}
143
+
client := sess.APIClient()
144
+
err = atproto.IdentityRequestPlcOperationSignature(ctx, client)
145
+
if err != nil {
146
+
l.Error("failed to request plc operation signature", "err", err)
147
+
panic(err)
148
+
}
149
+
}
150
+
151
+
http.Redirect(w, r, "/register", http.StatusSeeOther)
152
+
}
153
+
}
154
+
155
+
func RegisterPost(cfg *config.Config, d *sql.DB, clientApp *oauth.ClientApp, jar *sessions.CookieStore) http.HandlerFunc {
156
+
plcop := func(ctx context.Context, did syntax.DID, sessId, token string) error {
157
+
sess, err := clientApp.ResumeSession(ctx, did, sessId)
158
+
if err != nil {
159
+
return fmt.Errorf("failed to resume atproto session: %w", err)
160
+
}
161
+
client := sess.APIClient()
162
+
163
+
identity, err := clientApp.Dir.LookupDID(ctx, did)
164
+
services := make(map[string]didplc.OpService)
165
+
for id, service := range identity.Services {
166
+
services[id] = didplc.OpService{
167
+
Type: service.Type,
168
+
Endpoint: service.URL,
169
+
}
170
+
}
171
+
services[serviceId] = didplc.OpService{
172
+
Type: serviceType,
173
+
Endpoint: cfg.Uri(),
174
+
}
175
+
176
+
rawServices, err := json.Marshal(services)
177
+
if err != nil {
178
+
return fmt.Errorf("failed to marshal services map: %w", err)
179
+
}
180
+
raw := json.RawMessage(rawServices)
181
+
182
+
signed, err := agnostic.IdentitySignPlcOperation(ctx, client, &agnostic.IdentitySignPlcOperation_Input{
183
+
Services: &raw,
184
+
Token: &token,
185
+
})
186
+
if err != nil {
187
+
return fmt.Errorf("failed to sign plc operatino: %w", err)
188
+
}
189
+
190
+
err = agnostic.IdentitySubmitPlcOperation(ctx, client, &agnostic.IdentitySubmitPlcOperation_Input{
191
+
Operation: signed.Operation,
192
+
})
193
+
if err != nil {
194
+
return fmt.Errorf("failed to submit plc operatino: %w", err)
195
+
}
196
+
197
+
return nil
198
+
}
199
+
return func(w http.ResponseWriter, r *http.Request) {
200
+
ctx := r.Context()
201
+
l := log.FromContext(ctx).With("handler", "RegisterPost")
202
+
203
+
sess, _ := jar.Get(r, sessionName)
204
+
205
+
var (
206
+
did = syntax.DID(sess.Values[sessionDid].(string))
207
+
sessId = sess.Values[sessionId].(string)
208
+
token = r.FormValue("token")
209
+
doPlcOp = r.FormValue("plcop") == "on"
210
+
)
211
+
212
+
tx, err := d.BeginTx(ctx, nil)
213
+
if err != nil {
214
+
l.Error("failed to begin db tx", "err", err)
215
+
panic(err)
216
+
}
217
+
defer tx.Rollback()
218
+
219
+
if err := db.AddUser(tx, did); err != nil {
220
+
l.Error("failed to add user", "err", err)
221
+
http.Error(w, err.Error(), http.StatusInternalServerError)
222
+
return
223
+
}
224
+
225
+
if doPlcOp {
226
+
l.Debug("performing plc op", "did", did, "token", token)
227
+
if err := plcop(ctx, did, sessId, token); err != nil {
228
+
l.Error("failed to perform plc op", "err", err)
229
+
http.Error(w, err.Error(), http.StatusInternalServerError)
230
+
}
231
+
} else {
232
+
// TODO: check if did doc already include the knot service
233
+
tx.Rollback()
234
+
panic("unimplemented")
235
+
}
236
+
if err := tx.Commit(); err != nil {
237
+
l.Error("failed to commit tx", "err", err)
238
+
http.Error(w, err.Error(), http.StatusInternalServerError)
239
+
}
240
+
}
241
+
}
+41
knot2/server/handler/templates/register.html
+41
knot2/server/handler/templates/register.html
···
1
+
<!doctype html>
2
+
<html lang="en" class="dark:bg-gray-900">
3
+
<head>
4
+
<meta charset="UTF-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+
<meta name="description" content="knot server"/>
7
+
<title>Register to Knot</title>
8
+
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
9
+
</head>
10
+
<body>
11
+
{{ if (not .) }}
12
+
{{/* step 1. login */}}
13
+
<form hx-post="/oauth/login" hx-swap="none">
14
+
<input type="text" name="handle">
15
+
<button type="submit">Login</button>
16
+
</form>
17
+
{{ else }}
18
+
{{/* step 2. register user with plc operation */}}
19
+
<form hx-post="/register" hx-swap="none">
20
+
<input type="hidden" name="plcop" value="{{ if .PlcOp }}on{{ end }}">
21
+
22
+
<div>
23
+
<label for="handle">User Handle:</label>
24
+
<input type="text" name="handle" value="{{ .Did }}" readonly>
25
+
</div>
26
+
27
+
{{ if (not .Web) }}
28
+
<h2>Please enter your PLC Token you received in an email</h2>
29
+
<div>
30
+
<label for="token">PLC Token:</label>
31
+
<input type="text" name="token" required placeholder="XXXXX-XXXXX">
32
+
</div>
33
+
34
+
<button type="submit">add Knot to identity</button>
35
+
{{ else }}
36
+
<button type="submit">register to Knot</button>
37
+
{{ end }}
38
+
</form>
39
+
{{ end }}
40
+
</body>
41
+
</html>
+21
knot2/server/middleware/cors.go
+21
knot2/server/middleware/cors.go
···
1
+
package middleware
2
+
3
+
import "net/http"
4
+
5
+
func CORS(next http.Handler) http.Handler {
6
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7
+
// Set CORS headers
8
+
w.Header().Set("Access-Control-Allow-Origin", "*")
9
+
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
10
+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
11
+
w.Header().Set("Access-Control-Max-Age", "86400")
12
+
13
+
// Handle preflight requests
14
+
if r.Method == "OPTIONS" {
15
+
w.WriteHeader(http.StatusOK)
16
+
return
17
+
}
18
+
19
+
next.ServeHTTP(w, r)
20
+
})
21
+
}
+40
knot2/server/middleware/requestlogger.go
+40
knot2/server/middleware/requestlogger.go
···
1
+
package middleware
2
+
3
+
import (
4
+
"log/slog"
5
+
"net/http"
6
+
"time"
7
+
8
+
"tangled.org/core/log"
9
+
)
10
+
11
+
func RequestLogger(next http.Handler) http.Handler {
12
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13
+
ctx := r.Context()
14
+
l := log.FromContext(ctx)
15
+
16
+
start := time.Now()
17
+
18
+
next.ServeHTTP(w, r)
19
+
20
+
// Build query params as slog.Attrs for the group
21
+
queryParams := r.URL.Query()
22
+
queryAttrs := make([]any, 0, len(queryParams))
23
+
for key, values := range queryParams {
24
+
if len(values) == 1 {
25
+
queryAttrs = append(queryAttrs, slog.String(key, values[0]))
26
+
} else {
27
+
queryAttrs = append(queryAttrs, slog.Any(key, values))
28
+
}
29
+
}
30
+
31
+
l.LogAttrs(ctx, slog.LevelInfo, "",
32
+
slog.Group("request",
33
+
slog.String("method", r.Method),
34
+
slog.String("path", r.URL.Path),
35
+
slog.Group("query", queryAttrs...),
36
+
slog.Duration("duration", time.Since(start)),
37
+
),
38
+
)
39
+
})
40
+
}
+40
knot2/server/oauth.go
+40
knot2/server/oauth.go
···
1
+
package server
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
7
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
8
+
"tangled.org/core/idresolver"
9
+
"tangled.org/core/knot2/config"
10
+
)
11
+
12
+
func newAtClientApp(cfg *config.Config) *oauth.ClientApp {
13
+
idResolver := idresolver.DefaultResolver(cfg.PlcUrl)
14
+
scopes := []string{"atproto", "identity:*"}
15
+
var oauthConfig oauth.ClientConfig
16
+
if cfg.Dev {
17
+
oauthConfig = oauth.NewLocalhostConfig(
18
+
cfg.Uri()+"/oauth/callback",
19
+
scopes,
20
+
)
21
+
} else {
22
+
oauthConfig = oauth.NewPublicConfig(
23
+
cfg.Uri()+"/oauth/client-metadata.json",
24
+
cfg.Uri()+"/oauth/callback",
25
+
scopes,
26
+
)
27
+
}
28
+
priv, err := atcrypto.ParsePrivateMultibase(cfg.OAuth.ClientSecret)
29
+
if err != nil {
30
+
panic(err)
31
+
}
32
+
if err := oauthConfig.SetClientSecret(priv, cfg.OAuth.ClientKid); err != nil {
33
+
panic(err)
34
+
}
35
+
// we can just use in-memory auth store
36
+
clientApp := oauth.NewClientApp(&oauthConfig, oauth.NewMemStore())
37
+
clientApp.Dir = idResolver.Directory()
38
+
clientApp.Resolver.Client.Transport = http.DefaultTransport
39
+
return clientApp
40
+
}
+51
knot2/server/routes.go
+51
knot2/server/routes.go
···
1
+
package server
2
+
3
+
import (
4
+
"database/sql"
5
+
"net/http"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
8
+
"github.com/go-chi/chi/v5"
9
+
"github.com/gorilla/sessions"
10
+
"tangled.org/core/knot2/config"
11
+
"tangled.org/core/knot2/server/handler"
12
+
"tangled.org/core/knot2/server/middleware"
13
+
)
14
+
15
+
func Routes(
16
+
cfg *config.Config,
17
+
d *sql.DB,
18
+
clientApp *oauth.ClientApp,
19
+
) http.Handler {
20
+
r := chi.NewRouter()
21
+
22
+
r.Use(middleware.CORS)
23
+
r.Use(middleware.RequestLogger)
24
+
25
+
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
26
+
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
27
+
})
28
+
29
+
jar := sessions.NewCookieStore([]byte(cfg.OAuth.CookieSecret))
30
+
31
+
r.Get("/register", handler.Register(jar))
32
+
r.Post("/register", handler.RegisterPost(cfg, d, clientApp, jar))
33
+
r.Post("/oauth/login", handler.OauthLoginPost(clientApp))
34
+
r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(cfg, clientApp))
35
+
r.Get("/oauth/jwks.json", handler.OauthJwks(clientApp))
36
+
r.Get("/oauth/callback", handler.OauthCallback(clientApp, jar))
37
+
38
+
r.Route("/{did}/{name}", func(r chi.Router) {
39
+
r.Get("/info/refs", handler.InfoRefs())
40
+
r.Post("/git-upload-pack", handler.GitUploadPack())
41
+
r.Post("/git-receive-pack", handler.GitReceivePack())
42
+
})
43
+
44
+
r.Get("/events", handler.Events())
45
+
46
+
// r.Route("/xrpc", func(r chi.Router) {
47
+
// r.Post("/"+tangled.GitKeepRefNSID, handler.GitKeepRef())
48
+
// })
49
+
50
+
return r
51
+
}
+65
knot2/server/server.go
+65
knot2/server/server.go
···
1
+
package server
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"github.com/urfave/cli/v3"
9
+
"tangled.org/core/knot2/config"
10
+
"tangled.org/core/knot2/db"
11
+
"tangled.org/core/log"
12
+
)
13
+
14
+
func Command() *cli.Command {
15
+
return &cli.Command{
16
+
Name: "server",
17
+
Usage: "run a knot server",
18
+
Action: Run,
19
+
Flags: []cli.Flag{
20
+
&cli.StringFlag{
21
+
Name: "config",
22
+
Aliases: []string{"c"},
23
+
Usage: "config path",
24
+
Required: true,
25
+
},
26
+
},
27
+
}
28
+
}
29
+
30
+
func Run(ctx context.Context, cmd *cli.Command) error {
31
+
l := log.FromContext(ctx)
32
+
l = log.SubLogger(l, cmd.Name)
33
+
ctx = log.IntoContext(ctx, l)
34
+
35
+
configPath := cmd.String("config")
36
+
37
+
cfg, err := config.Load(ctx, configPath)
38
+
if err != nil {
39
+
return fmt.Errorf("failed to load config: %w", err)
40
+
}
41
+
fmt.Println("config:", cfg)
42
+
43
+
// TODO: start listening to jetstream
44
+
45
+
d, err := db.New(cfg.DbPath())
46
+
if err != nil {
47
+
panic(err)
48
+
}
49
+
err = db.Init(d)
50
+
if err != nil {
51
+
panic(err)
52
+
}
53
+
54
+
clientApp := newAtClientApp(&cfg)
55
+
56
+
mux := Routes(&cfg, d, clientApp)
57
+
58
+
l.Info("starting knot server", "address", cfg.ListenAddr())
59
+
err = http.ListenAndServe(cfg.ListenAddr(), mux)
60
+
if err != nil {
61
+
l.Error("server error", "err", err)
62
+
}
63
+
64
+
return nil
65
+
}
+3
nix/gomod2nix.toml
+3
nix/gomod2nix.toml
···
171
171
[mod."github.com/dgryski/go-rendezvous"]
172
172
version = "v0.0.0-20200823014737-9f7001d12a5f"
173
173
hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI="
174
+
[mod."github.com/did-method-plc/go-didplc"]
175
+
version = "v0.0.0-20250716171643-635da8b4e038"
176
+
hash = "sha256-o0uB/5tryjdB44ssALFr49PtfY3nRJnEENmE187md1w="
174
177
[mod."github.com/distribution/reference"]
175
178
version = "v0.6.0"
176
179
hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4="
+18
-5
nix/modules/knot.nix
+18
-5
nix/modules/knot.nix
···
170
170
description = "Enable development mode (disables signature verification)";
171
171
};
172
172
};
173
+
174
+
environmentFile = mkOption {
175
+
type = with types; nullOr path;
176
+
default = null;
177
+
example = "/etc/appview.env";
178
+
description = ''
179
+
Additional environment file as defined in {manpage}`systemd.exec(5)`.
180
+
181
+
Sensitive secrets such as {env}`KNOT_COOKIE_SECRET`,
182
+
{env}`KNOT_OAUTH_CLIENT_SECRET`, and {env}`KNOT_OAUTH_CLIENT_KID`
183
+
may be passed to the service without making them world readable in the nix store.
184
+
'';
185
+
};
173
186
};
174
187
};
175
188
···
205
218
text = ''
206
219
#!${pkgs.stdenv.shell}
207
220
${cfg.package}/bin/knot keys \
208
-
-output authorized-keys \
209
-
-internal-api "http://${cfg.server.internalListenAddr}" \
210
-
-git-dir "${cfg.repo.scanPath}" \
211
-
-log-path /tmp/knotguard.log
221
+
-config ${cfg.stateDir}/config.yml \
222
+
-output authorized-keys
212
223
'';
213
224
};
214
225
···
273
284
else "false"
274
285
}"
275
286
];
276
-
ExecStart = "${cfg.package}/bin/knot server";
287
+
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
288
+
ExecStart = "${cfg.package}/bin/knot server -config ${cfg.stateDir}/config.yml";
277
289
Restart = "always";
290
+
RestartSec = 5;
278
291
};
279
292
};
280
293