diff --git a/cmd/knot/main.go b/cmd/knot/main.go index 89c6c7ef..9d23d59f 100644 --- a/cmd/knot/main.go +++ b/cmd/knot/main.go @@ -6,10 +6,10 @@ import ( "os" "github.com/urfave/cli/v3" - "tangled.org/core/guard" - "tangled.org/core/hook" - "tangled.org/core/keyfetch" - "tangled.org/core/knotserver" + "tangled.org/core/knot2/guard" + "tangled.org/core/knot2/hook" + "tangled.org/core/knot2/keys" + "tangled.org/core/knot2/server" tlog "tangled.org/core/log" ) @@ -19,8 +19,8 @@ func main() { Usage: "knot administration and operation tool", Commands: []*cli.Command{ guard.Command(), - knotserver.Command(), - keyfetch.Command(), + server.Command(), + keys.Command(), hook.Command(), }, } diff --git a/go.mod b/go.mod index 29ef27c5..30abf3f3 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/cloudflare/cloudflare-go v0.115.0 github.com/cyphar/filepath-securejoin v0.4.1 github.com/dgraph-io/ristretto v0.2.0 + github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 github.com/docker/docker v28.2.2+incompatible github.com/dustin/go-humanize v1.0.1 github.com/gliderlabs/ssh v0.3.8 diff --git a/go.sum b/go.sum index fdb6ccc0..085a79eb 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WA github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 h1:AGh+Vn9fXhf9eo8erG1CK4+LACduPo64P1OICQLDv88= +github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= diff --git a/knot2/config/config.go b/knot2/config/config.go new file mode 100644 index 00000000..9c2a59f4 --- /dev/null +++ b/knot2/config/config.go @@ -0,0 +1,101 @@ +package config + +import ( + "context" + "fmt" + "net" + "os" + "path" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/sethvargo/go-envconfig" + "gopkg.in/yaml.v3" +) + +type Config struct { + Dev bool `yaml:"dev"` + HostName string `yaml:"hostname"` + OwnerDid syntax.DID `yaml:"owner_did"` + ListenHost string `yaml:"listen_host"` + ListenPort string `yaml:"listen_port"` + DataDir string `yaml:"data_dir"` + RepoDir string `yaml:"repo_dir"` + PlcUrl string `yaml:"plc_url"` + JetstreamEndpoint string `yaml:"jetstream_endpoint"` + AppviewEndpoint string `yaml:"appview_endpoint"` + GitUserName string `yaml:"git_user_name"` + GitUserEmail string `yaml:"git_user_email"` + OAuth OAuthConfig +} + +type OAuthConfig struct { + CookieSecret string `env:"KNOT2_COOKIE_SECRET, default=00000000000000000000000000000000"` + ClientSecret string `env:"KNOT2_OAUTH_CLIENT_SECRET"` + ClientKid string `env:"KNOT2_OAUTH_CLIENT_KID"` +} + +func (c *Config) Uri() string { + // TODO: make port configurable + if c.Dev { + return "http://127.0.0.1:6444" + } + return "https://" + c.HostName +} + +func (c *Config) ListenAddr() string { + return net.JoinHostPort(c.ListenHost, c.ListenPort) +} + +func (c *Config) DbPath() string { + return path.Join(c.DataDir, "knot.db") +} + +func (c *Config) GitMotdFilePath() string { + return path.Join(c.DataDir, "motd") +} + +func (c *Config) Validate() error { + if c.HostName == "" { + return fmt.Errorf("knot hostname cannot be empty") + } + if c.OwnerDid == "" { + return fmt.Errorf("knot owner did cannot be empty") + } + return nil +} + +func Load(ctx context.Context, path string) (Config, error) { + // NOTE: yaml.v3 package doesn't support "default" struct tag + cfg := Config{ + Dev: true, + ListenHost: "0.0.0.0", + ListenPort: "5555", + DataDir: "/home/git", + RepoDir: "/home/git", + PlcUrl: "https://plc.directory", + JetstreamEndpoint: "wss://jetstream1.us-west.bsky.network/subscribe", + AppviewEndpoint: "https://tangled.org", + GitUserName: "Tangled", + GitUserEmail: "noreply@tangled.org", + } + // load config from env vars + err := envconfig.Process(ctx, &cfg.OAuth) + if err != nil { + return cfg, err + } + + // load config from toml config file + bytes, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + if err := yaml.Unmarshal(bytes, &cfg); err != nil { + return cfg, err + } + + // validate the config + if err = cfg.Validate(); err != nil { + return cfg, err + } + return cfg, nil +} diff --git a/knot2/db/db.go b/knot2/db/db.go new file mode 100644 index 00000000..79c1f4fe --- /dev/null +++ b/knot2/db/db.go @@ -0,0 +1,52 @@ +package db + +import ( + "database/sql" + "strings" + + _ "github.com/mattn/go-sqlite3" +) + +func New(dbPath string) (*sql.DB, error) { + // https://github.com/mattn/go-sqlite3#connection-string + opts := []string{ + "_foreign_keys=1", + "_journal_mode=WAL", + "_synchronous=NORMAL", + "_auto_vacuum=incremental", + } + + return sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) +} + +func Init(d *sql.DB) error { + _, err := d.Exec(` + create table if not exists _jetstream ( + id integer primary key autoincrement, + last_time_us integer not null + ); + + create table if not exists events ( + rkey text not null, + nsid text not null, + event text not null, -- json + created integer not null -- unix nanos + ); + + create table if not exists users ( + id integer primary key autoincrement, + did text not null unique, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + create table if not exists public_keys ( + id integer primary key autoincrement, + did text not null, + key text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + unique(did, key) + ); + `) + + return err +} diff --git a/knot2/db/pubkeys.go b/knot2/db/pubkeys.go new file mode 100644 index 00000000..b7fb15b7 --- /dev/null +++ b/knot2/db/pubkeys.go @@ -0,0 +1,10 @@ +package db + +import ( + "database/sql" +) + +// GetPubkeyDidListMap returns a PubKey->[]DID map +func GetPubkeyDidListMap(d *sql.DB) (map[string][]string, error) { + return nil, nil +} diff --git a/knot2/db/users.go b/knot2/db/users.go new file mode 100644 index 00000000..39b84768 --- /dev/null +++ b/knot2/db/users.go @@ -0,0 +1,12 @@ +package db + +import ( + "database/sql" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func AddUser(tx *sql.Tx, did syntax.DID) error { + _, err := tx.Exec(`insert into users (did) values (?)`, did) + return err +} diff --git a/knot2/guard/guard.go b/knot2/guard/guard.go new file mode 100644 index 00000000..e6c1f222 --- /dev/null +++ b/knot2/guard/guard.go @@ -0,0 +1,31 @@ +package guard + +import ( + "context" + + "github.com/urfave/cli/v3" + "tangled.org/core/log" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "guard", + Usage: "role-based access control for git over ssh (not for manual use)", + Action: Run, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "user", + Usage: "allowed git user", + Required: true, + }, + }, + } +} + +func Run(ctx context.Context, cmd *cli.Command) error { + l := log.FromContext(ctx) + l = log.SubLogger(l, cmd.Name) + ctx = log.IntoContext(ctx, l) + + panic("unimplemented") +} diff --git a/knot2/hook/hook.go b/knot2/hook/hook.go new file mode 100644 index 00000000..861fe4e0 --- /dev/null +++ b/knot2/hook/hook.go @@ -0,0 +1,27 @@ +package hook + +import ( + "context" + + "github.com/urfave/cli/v3" + "tangled.org/core/log" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "hook", + Usage: "run git hooks", + Action: Run, + Flags: []cli.Flag{ + // TODO: + }, + } +} + +func Run(ctx context.Context, cmd *cli.Command) error { + l := log.FromContext(ctx) + l = log.SubLogger(l, cmd.Name) + ctx = log.IntoContext(ctx, l) + + panic("unimplemented") +} diff --git a/knot2/keys/keys.go b/knot2/keys/keys.go new file mode 100644 index 00000000..9ccfdcd9 --- /dev/null +++ b/knot2/keys/keys.go @@ -0,0 +1,103 @@ +package keys + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/urfave/cli/v3" + "tangled.org/core/knot2/config" + "tangled.org/core/knot2/db" + "tangled.org/core/log" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "keys", + Usage: "fetch public keys from the knot server", + Action: Run, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "config path", + Required: true, + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "output format (table, json, authorized-keys)", + Value: "table", + }, + }, + } +} + +func Run(ctx context.Context, cmd *cli.Command) error { + l := log.FromContext(ctx) + l = log.SubLogger(l, cmd.Name) + ctx = log.IntoContext(ctx, l) + + var ( + output = cmd.String("output") + configPath = cmd.String("config") + ) + + cfg, err := config.Load(ctx, configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + d, err := db.New(cfg.DbPath()) + if err != nil { + return fmt.Errorf("failed to load db: %w", err) + } + + pubkeyDidListMap, err := db.GetPubkeyDidListMap(d) + if err != nil { + return err + } + + switch output { + case "json": + prettyJSON, err := json.MarshalIndent(pubkeyDidListMap, "", " ") + if err != nil { + return err + } + if _, err := os.Stdout.Write(prettyJSON); err != nil { + return err + } + case "table": + fmt.Printf("%-40s %-40s\n", "KEY", "DID") + fmt.Println(strings.Repeat("-", 80)) + + for key, didList := range pubkeyDidListMap { + fmt.Printf("%-40s %-40s\n", key, strings.Join(didList, ",")) + } + case "authorized-keys": + for key, didList := range pubkeyDidListMap { + executablePath, err := os.Executable() + if err != nil { + l.Error("error getting path of executable", "error", err) + return err + } + command := fmt.Sprintf("%s guard", executablePath) + for _, did := range didList { + command += fmt.Sprintf(" -user %s", did) + } + fmt.Printf( + `command="%s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n", + command, + key, + ) + } + if err != nil { + l.Error("error writing to stdout", "error", err) + return err + } + } + + return nil +} diff --git a/knot2/models/pubkeys.go b/knot2/models/pubkeys.go new file mode 100644 index 00000000..ca36b90a --- /dev/null +++ b/knot2/models/pubkeys.go @@ -0,0 +1,8 @@ +package models + +import "tangled.org/core/api/tangled" + +type PublicKey struct { + Did string + tangled.PublicKey +} diff --git a/knot2/server/handler/events.go b/knot2/server/handler/events.go new file mode 100644 index 00000000..c500f3a5 --- /dev/null +++ b/knot2/server/handler/events.go @@ -0,0 +1,18 @@ +package handler + +import ( + "net/http" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +func Events() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} diff --git a/knot2/server/handler/git_receive_pack.go b/knot2/server/handler/git_receive_pack.go new file mode 100644 index 00000000..c7ac32c5 --- /dev/null +++ b/knot2/server/handler/git_receive_pack.go @@ -0,0 +1,9 @@ +package handler + +import "net/http" + +func GitReceivePack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} diff --git a/knot2/server/handler/git_upload_pack.go b/knot2/server/handler/git_upload_pack.go new file mode 100644 index 00000000..fd66a222 --- /dev/null +++ b/knot2/server/handler/git_upload_pack.go @@ -0,0 +1,9 @@ +package handler + +import "net/http" + +func GitUploadPack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} diff --git a/knot2/server/handler/info_refs.go b/knot2/server/handler/info_refs.go new file mode 100644 index 00000000..8ff37e93 --- /dev/null +++ b/knot2/server/handler/info_refs.go @@ -0,0 +1,9 @@ +package handler + +import "net/http" + +func InfoRefs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} diff --git a/knot2/server/handler/register.go b/knot2/server/handler/register.go new file mode 100644 index 00000000..d6a5049e --- /dev/null +++ b/knot2/server/handler/register.go @@ -0,0 +1,241 @@ +package handler + +import ( + "context" + "database/sql" + _ "embed" + "encoding/json" + "fmt" + "html/template" + "net/http" + "strings" + + "github.com/bluesky-social/indigo/api/agnostic" + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/did-method-plc/go-didplc" + "github.com/gorilla/sessions" + "tangled.org/core/knot2/config" + "tangled.org/core/knot2/db" + "tangled.org/core/log" +) + +const ( + // atproto + serviceId = "tangled_knot" + serviceType = "TangledKnot" + // cookies + sessionName = "oauth-demo" + sessionId = "sessionId" + sessionDid = "sessionDID" +) + +//go:embed "templates/register.html" +var tmplRegisgerText string +var tmplRegister = template.Must(template.New("register.html").Parse(tmplRegisgerText)) + +func Register(jar *sessions.CookieStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "Register") + + sess, _ := jar.Get(r, sessionName) + var data map[string]any + + if !sess.IsNew { + // render Register { Handle, Web: true } + did := syntax.DID(sess.Values[sessionDid].(string)) + plcop := did.Method() == "plc" && r.URL.Query().Get("method") != "web" + data = map[string]any{ + "Did": did, + "PlcOp": plcop, + } + } + + err := tmplRegister.Execute(w, data) + if err != nil { + l.Error("failed to render", "err", err) + } + } +} + +func OauthClientMetadata(cfg *config.Config, clientApp *oauth.ClientApp) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + doc := clientApp.Config.ClientMetadata() + var ( + clientName = cfg.HostName + clientUri = cfg.Uri() + jwksUri = clientUri + "/oauth/jwks.json" + ) + doc.ClientName = &clientName + doc.ClientURI = &clientUri + doc.JWKSURI = &jwksUri + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func OauthJwks(clientApp *oauth.ClientApp) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := clientApp.Config.PublicJWKS() + if err := json.NewEncoder(w).Encode(body); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func OauthLoginPost(clientApp *oauth.ClientApp) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "OauthLoginPost") + + handle := r.FormValue("handle") + + handle = strings.TrimPrefix(handle, "\u202a") + handle = strings.TrimSuffix(handle, "\u202c") + // `@` is harmless + handle = strings.TrimPrefix(handle, "@") + + redirectURL, err := clientApp.StartAuthFlow(ctx, handle) + if err != nil { + l.Error("failed to start auth flow", "err", err) + panic(err) + } + + w.Header().Set("HX-Redirect", redirectURL) + w.WriteHeader(http.StatusOK) + } +} + +func OauthCallback(oauth *oauth.ClientApp, jar *sessions.CookieStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "OauthCallback") + + data, err := oauth.ProcessCallback(ctx, r.URL.Query()) + if err != nil { + l.Error("failed to process oauth callback", "err", err) + panic(err) + } + + // store session data to cookie jar + sess, _ := jar.Get(r, sessionName) + sess.Values[sessionDid] = data.AccountDID.String() + sess.Values[sessionId] = data.SessionID + if err = sess.Save(r, w); err != nil { + l.Error("failed to save session", "err", err) + panic(err) + } + + if data.AccountDID.Method() == "plc" { + sess, err := oauth.ResumeSession(ctx, data.AccountDID, data.SessionID) + if err != nil { + l.Error("failed to resume atproto session", "err", err) + panic(err) + } + client := sess.APIClient() + err = atproto.IdentityRequestPlcOperationSignature(ctx, client) + if err != nil { + l.Error("failed to request plc operation signature", "err", err) + panic(err) + } + } + + http.Redirect(w, r, "/register", http.StatusSeeOther) + } +} + +func RegisterPost(cfg *config.Config, d *sql.DB, clientApp *oauth.ClientApp, jar *sessions.CookieStore) http.HandlerFunc { + plcop := func(ctx context.Context, did syntax.DID, sessId, token string) error { + sess, err := clientApp.ResumeSession(ctx, did, sessId) + if err != nil { + return fmt.Errorf("failed to resume atproto session: %w", err) + } + client := sess.APIClient() + + identity, err := clientApp.Dir.LookupDID(ctx, did) + services := make(map[string]didplc.OpService) + for id, service := range identity.Services { + services[id] = didplc.OpService{ + Type: service.Type, + Endpoint: service.URL, + } + } + services[serviceId] = didplc.OpService{ + Type: serviceType, + Endpoint: cfg.Uri(), + } + + rawServices, err := json.Marshal(services) + if err != nil { + return fmt.Errorf("failed to marshal services map: %w", err) + } + raw := json.RawMessage(rawServices) + + signed, err := agnostic.IdentitySignPlcOperation(ctx, client, &agnostic.IdentitySignPlcOperation_Input{ + Services: &raw, + Token: &token, + }) + if err != nil { + return fmt.Errorf("failed to sign plc operatino: %w", err) + } + + err = agnostic.IdentitySubmitPlcOperation(ctx, client, &agnostic.IdentitySubmitPlcOperation_Input{ + Operation: signed.Operation, + }) + if err != nil { + return fmt.Errorf("failed to submit plc operatino: %w", err) + } + + return nil + } + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "RegisterPost") + + sess, _ := jar.Get(r, sessionName) + + var ( + did = syntax.DID(sess.Values[sessionDid].(string)) + sessId = sess.Values[sessionId].(string) + token = r.FormValue("token") + doPlcOp = r.FormValue("plcop") == "on" + ) + + tx, err := d.BeginTx(ctx, nil) + if err != nil { + l.Error("failed to begin db tx", "err", err) + panic(err) + } + defer tx.Rollback() + + if err := db.AddUser(tx, did); err != nil { + l.Error("failed to add user", "err", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if doPlcOp { + l.Debug("performing plc op", "did", did, "token", token) + if err := plcop(ctx, did, sessId, token); err != nil { + l.Error("failed to perform plc op", "err", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } else { + // TODO: check if did doc already include the knot service + tx.Rollback() + panic("unimplemented") + } + if err := tx.Commit(); err != nil { + l.Error("failed to commit tx", "err", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} diff --git a/knot2/server/handler/templates/register.html b/knot2/server/handler/templates/register.html new file mode 100644 index 00000000..65f1c560 --- /dev/null +++ b/knot2/server/handler/templates/register.html @@ -0,0 +1,41 @@ + + + + + + + Register to Knot + + + + {{ if (not .) }} + {{/* step 1. login */}} +
+ + +
+ {{ else }} + {{/* step 2. register user with plc operation */}} +
+ + +
+ + +
+ + {{ if (not .Web) }} +

Please enter your PLC Token you received in an email

+
+ + +
+ + + {{ else }} + + {{ end }} +
+ {{ end }} + + diff --git a/knot2/server/middleware/cors.go b/knot2/server/middleware/cors.go new file mode 100644 index 00000000..7da69835 --- /dev/null +++ b/knot2/server/middleware/cors.go @@ -0,0 +1,21 @@ +package middleware + +import "net/http" + +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Max-Age", "86400") + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/knot2/server/middleware/requestlogger.go b/knot2/server/middleware/requestlogger.go new file mode 100644 index 00000000..6b4d1a90 --- /dev/null +++ b/knot2/server/middleware/requestlogger.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "log/slog" + "net/http" + "time" + + "tangled.org/core/log" +) + +func RequestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx) + + start := time.Now() + + next.ServeHTTP(w, r) + + // Build query params as slog.Attrs for the group + queryParams := r.URL.Query() + queryAttrs := make([]any, 0, len(queryParams)) + for key, values := range queryParams { + if len(values) == 1 { + queryAttrs = append(queryAttrs, slog.String(key, values[0])) + } else { + queryAttrs = append(queryAttrs, slog.Any(key, values)) + } + } + + l.LogAttrs(ctx, slog.LevelInfo, "", + slog.Group("request", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.Group("query", queryAttrs...), + slog.Duration("duration", time.Since(start)), + ), + ) + }) +} diff --git a/knot2/server/oauth.go b/knot2/server/oauth.go new file mode 100644 index 00000000..b8f7f5d5 --- /dev/null +++ b/knot2/server/oauth.go @@ -0,0 +1,40 @@ +package server + +import ( + "net/http" + + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "tangled.org/core/idresolver" + "tangled.org/core/knot2/config" +) + +func newAtClientApp(cfg *config.Config) *oauth.ClientApp { + idResolver := idresolver.DefaultResolver(cfg.PlcUrl) + scopes := []string{"atproto", "identity:*"} + var oauthConfig oauth.ClientConfig + if cfg.Dev { + oauthConfig = oauth.NewLocalhostConfig( + cfg.Uri()+"/oauth/callback", + scopes, + ) + } else { + oauthConfig = oauth.NewPublicConfig( + cfg.Uri()+"/oauth/client-metadata.json", + cfg.Uri()+"/oauth/callback", + scopes, + ) + } + priv, err := atcrypto.ParsePrivateMultibase(cfg.OAuth.ClientSecret) + if err != nil { + panic(err) + } + if err := oauthConfig.SetClientSecret(priv, cfg.OAuth.ClientKid); err != nil { + panic(err) + } + // we can just use in-memory auth store + clientApp := oauth.NewClientApp(&oauthConfig, oauth.NewMemStore()) + clientApp.Dir = idResolver.Directory() + clientApp.Resolver.Client.Transport = http.DefaultTransport + return clientApp +} diff --git a/knot2/server/routes.go b/knot2/server/routes.go new file mode 100644 index 00000000..cdf28624 --- /dev/null +++ b/knot2/server/routes.go @@ -0,0 +1,51 @@ +package server + +import ( + "database/sql" + "net/http" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/go-chi/chi/v5" + "github.com/gorilla/sessions" + "tangled.org/core/knot2/config" + "tangled.org/core/knot2/server/handler" + "tangled.org/core/knot2/server/middleware" +) + +func Routes( + cfg *config.Config, + d *sql.DB, + clientApp *oauth.ClientApp, +) http.Handler { + r := chi.NewRouter() + + r.Use(middleware.CORS) + r.Use(middleware.RequestLogger) + + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) + }) + + jar := sessions.NewCookieStore([]byte(cfg.OAuth.CookieSecret)) + + r.Get("/register", handler.Register(jar)) + r.Post("/register", handler.RegisterPost(cfg, d, clientApp, jar)) + r.Post("/oauth/login", handler.OauthLoginPost(clientApp)) + r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(cfg, clientApp)) + r.Get("/oauth/jwks.json", handler.OauthJwks(clientApp)) + r.Get("/oauth/callback", handler.OauthCallback(clientApp, jar)) + + r.Route("/{did}/{name}", func(r chi.Router) { + r.Get("/info/refs", handler.InfoRefs()) + r.Post("/git-upload-pack", handler.GitUploadPack()) + r.Post("/git-receive-pack", handler.GitReceivePack()) + }) + + r.Get("/events", handler.Events()) + + // r.Route("/xrpc", func(r chi.Router) { + // r.Post("/"+tangled.GitKeepRefNSID, handler.GitKeepRef()) + // }) + + return r +} diff --git a/knot2/server/server.go b/knot2/server/server.go new file mode 100644 index 00000000..09dc903e --- /dev/null +++ b/knot2/server/server.go @@ -0,0 +1,65 @@ +package server + +import ( + "context" + "fmt" + "net/http" + + "github.com/urfave/cli/v3" + "tangled.org/core/knot2/config" + "tangled.org/core/knot2/db" + "tangled.org/core/log" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "server", + Usage: "run a knot server", + Action: Run, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "config path", + Required: true, + }, + }, + } +} + +func Run(ctx context.Context, cmd *cli.Command) error { + l := log.FromContext(ctx) + l = log.SubLogger(l, cmd.Name) + ctx = log.IntoContext(ctx, l) + + configPath := cmd.String("config") + + cfg, err := config.Load(ctx, configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + fmt.Println("config:", cfg) + + // TODO: start listening to jetstream + + d, err := db.New(cfg.DbPath()) + if err != nil { + panic(err) + } + err = db.Init(d) + if err != nil { + panic(err) + } + + clientApp := newAtClientApp(&cfg) + + mux := Routes(&cfg, d, clientApp) + + l.Info("starting knot server", "address", cfg.ListenAddr()) + err = http.ListenAndServe(cfg.ListenAddr(), mux) + if err != nil { + l.Error("server error", "err", err) + } + + return nil +} diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml index 1f3bde66..42792b77 100644 --- a/nix/gomod2nix.toml +++ b/nix/gomod2nix.toml @@ -171,6 +171,9 @@ schema = 3 [mod."github.com/dgryski/go-rendezvous"] version = "v0.0.0-20200823014737-9f7001d12a5f" hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI=" + [mod."github.com/did-method-plc/go-didplc"] + version = "v0.0.0-20250716171643-635da8b4e038" + hash = "sha256-o0uB/5tryjdB44ssALFr49PtfY3nRJnEENmE187md1w=" [mod."github.com/distribution/reference"] version = "v0.6.0" hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4=" diff --git a/nix/modules/knot.nix b/nix/modules/knot.nix index 06a6caba..938bee65 100644 --- a/nix/modules/knot.nix +++ b/nix/modules/knot.nix @@ -170,6 +170,19 @@ in description = "Enable development mode (disables signature verification)"; }; }; + + environmentFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/etc/appview.env"; + description = '' + Additional environment file as defined in {manpage}`systemd.exec(5)`. + + Sensitive secrets such as {env}`KNOT_COOKIE_SECRET`, + {env}`KNOT_OAUTH_CLIENT_SECRET`, and {env}`KNOT_OAUTH_CLIENT_KID` + may be passed to the service without making them world readable in the nix store. + ''; + }; }; }; @@ -205,10 +218,8 @@ in text = '' #!${pkgs.stdenv.shell} ${cfg.package}/bin/knot keys \ - -output authorized-keys \ - -internal-api "http://${cfg.server.internalListenAddr}" \ - -git-dir "${cfg.repo.scanPath}" \ - -log-path /tmp/knotguard.log + -config ${cfg.stateDir}/config.yml \ + -output authorized-keys ''; }; @@ -273,8 +284,10 @@ in else "false" }" ]; - ExecStart = "${cfg.package}/bin/knot server"; + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; + ExecStart = "${cfg.package}/bin/knot server -config ${cfg.stateDir}/config.yml"; Restart = "always"; + RestartSec = 5; }; }; diff --git a/nix/vm.nix b/nix/vm.nix index f9dd9923..bed8129c 100644 --- a/nix/vm.nix +++ b/nix/vm.nix @@ -92,6 +92,7 @@ in jetstreamEndpoint = jetstream; listenAddr = "0.0.0.0:6444"; }; + environmentFile = "${config.services.tangled.knot.stateDir}/.env"; }; services.tangled.spindle = { enable = true;