Monorepo for Tangled tangled.org

knot2: knot rewrite for PoC

- no internal server (use yaml config file instead)
- doesn't support most of the xrpc endpoints used by appview
those aren't necessary when appview has local sync anyways
- testing 'knot as a service' idea

Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me 6611cbe9 d2d68396

verified
+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
··· 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
··· 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
··· 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
··· 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
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + ) 6 + 7 + // GetPubkeyDidListMap returns a PubKey->[]DID map 8 + func GetPubkeyDidListMap(d *sql.DB) (map[string][]string, error) { 9 + return nil, nil 10 + }
+12
knot2/db/users.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + func AddUser(tx *sql.Tx, did syntax.DID) error { 10 + _, err := tx.Exec(`insert into users (did) values (?)`, did) 11 + return err 12 + }
+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
··· 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
··· 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
··· 1 + package models 2 + 3 + import "tangled.org/core/api/tangled" 4 + 5 + type PublicKey struct { 6 + Did string 7 + tangled.PublicKey 8 + }
+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
··· 1 + package handler 2 + 3 + import "net/http" 4 + 5 + func GitReceivePack() http.HandlerFunc { 6 + return func(w http.ResponseWriter, r *http.Request) { 7 + panic("unimplemented") 8 + } 9 + }
+9
knot2/server/handler/git_upload_pack.go
··· 1 + package handler 2 + 3 + import "net/http" 4 + 5 + func GitUploadPack() http.HandlerFunc { 6 + return func(w http.ResponseWriter, r *http.Request) { 7 + panic("unimplemented") 8 + } 9 + }
+9
knot2/server/handler/info_refs.go
··· 1 + package handler 2 + 3 + import "net/http" 4 + 5 + func InfoRefs() http.HandlerFunc { 6 + return func(w http.ResponseWriter, r *http.Request) { 7 + panic("unimplemented") 8 + } 9 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
+1
nix/vm.nix
··· 92 92 jetstreamEndpoint = jetstream; 93 93 listenAddr = "0.0.0.0:6444"; 94 94 }; 95 + environmentFile = "${config.services.tangled.knot.stateDir}/.env"; 95 96 }; 96 97 services.tangled.spindle = { 97 98 enable = true;