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 @@ + + +
+ + + +