Monorepo for Tangled
tangled.org
1package knotserver
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "strings"
10 "sync"
11
12 "github.com/bluesky-social/indigo/atproto/syntax"
13 "github.com/go-chi/chi/v5"
14 "tangled.org/core/idresolver"
15 "tangled.org/core/jetstream"
16 "tangled.org/core/knotserver/config"
17 "tangled.org/core/knotserver/db"
18 "tangled.org/core/knotserver/keys"
19 "tangled.org/core/knotserver/sandbox"
20 "tangled.org/core/knotserver/xrpc"
21 "tangled.org/core/log"
22 "tangled.org/core/notifier"
23 "tangled.org/core/rbac"
24 "tangled.org/core/xrpc/serviceauth"
25)
26
27//go:embed motd
28var defaultMotd []byte
29
30type Knot struct {
31 c *config.Config
32 db *db.DB
33 jc *jetstream.JetstreamClient
34 e *rbac.Enforcer
35 l *slog.Logger
36 n *notifier.Notifier
37 resolver *idresolver.Resolver
38 sandbox sandbox.Backend
39 motd []byte
40 motdMu sync.RWMutex
41}
42
43func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier, resolver *idresolver.Resolver, sb sandbox.Backend) (http.Handler, error) {
44 h := Knot{
45 c: c,
46 db: db,
47 e: e,
48 l: log.FromContext(ctx),
49 jc: jc,
50 n: n,
51 resolver: resolver,
52 sandbox: sb,
53 motd: defaultMotd,
54 }
55
56 err := e.AddKnot(rbac.ThisServer)
57 if err != nil {
58 return nil, fmt.Errorf("failed to setup enforcer: %w", err)
59 }
60
61 // configure owner
62 if err = h.configureOwner(ctx); err != nil {
63 return nil, err
64 }
65 h.l.Info("owner set", "did", h.c.Server.Owner)
66 h.jc.AddDid(h.c.Server.Owner)
67
68 // configure known-dids in jetstream consumer
69 dids, err := h.db.GetAllDids()
70 if err != nil {
71 return nil, fmt.Errorf("failed to get all dids: %w", err)
72 }
73 for _, d := range dids {
74 jc.AddDid(d)
75 }
76
77 err = h.jc.StartJetstream(ctx, h.processMessages)
78 if err != nil {
79 return nil, fmt.Errorf("failed to start jetstream: %w", err)
80 }
81
82 return h.Router(), nil
83}
84
85func (h *Knot) Router() http.Handler {
86 r := chi.NewRouter()
87
88 r.Use(h.CORS)
89 r.Use(h.RequestLogger)
90
91 r.Get("/", func(w http.ResponseWriter, r *http.Request) {
92 w.Write(h.GetMotdContent())
93 })
94
95 r.Route("/{did}", func(r chi.Router) {
96 r.Use(h.resolveDidRedirect)
97
98 r.Get("/info/refs", h.InfoRefs)
99 r.Post("/git-upload-archive", h.UploadArchive)
100 r.Post("/git-upload-pack", h.UploadPack)
101 r.Post("/git-receive-pack", h.ReceivePack)
102
103 r.Route("/{name}", func(r chi.Router) {
104 r.Get("/info/refs", h.InfoRefs)
105 r.Post("/git-upload-archive", h.UploadArchive)
106 r.Post("/git-upload-pack", h.UploadPack)
107 r.Post("/git-receive-pack", h.ReceivePack)
108 })
109 })
110
111 // xrpc apis
112 x := h.newXrpc()
113 r.Mount("/xrpc", x.Router())
114 r.Mount("/admin", x.AdminRouter())
115
116 // Socket that streams git oplogs
117 r.Get("/events", h.Events)
118
119 return r
120}
121
122// SetMotdContent sets custom MOTD content, replacing the embedded default.
123func (h *Knot) SetMotdContent(content []byte) {
124 h.motdMu.Lock()
125 defer h.motdMu.Unlock()
126 h.motd = content
127}
128
129// GetMotdContent returns the current MOTD content.
130func (h *Knot) GetMotdContent() []byte {
131 h.motdMu.RLock()
132 defer h.motdMu.RUnlock()
133 return h.motd
134}
135
136func (h *Knot) newXrpc() *xrpc.Xrpc {
137 serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver.Directory(), h.c.Server.Did().String())
138
139 l := log.SubLogger(h.l, "xrpc")
140
141 return &xrpc.Xrpc{
142 Config: h.c,
143 Db: h.db,
144 Ingester: h.jc,
145 Enforcer: h.e,
146 Logger: l,
147 Notifier: h.n,
148 Resolver: h.resolver,
149 ServiceAuth: serviceAuth,
150 Sandbox: h.sandbox,
151 }
152}
153
154func (h *Knot) resolveDidRedirect(next http.Handler) http.Handler {
155 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
156 didOrHandle := chi.URLParam(r, "did")
157 if strings.HasPrefix(didOrHandle, "did:") {
158 next.ServeHTTP(w, r)
159 return
160 }
161
162 trimmed := strings.TrimPrefix(didOrHandle, "@")
163 id, err := h.resolver.ResolveIdent(r.Context(), trimmed)
164 if err != nil {
165 // invalid did or handle
166 h.l.Error("failed to resolve did/handle", "handle", trimmed, "err", err)
167 http.Error(w, fmt.Sprintf("failed to resolve did/handle: %s", trimmed), http.StatusInternalServerError)
168 return
169 }
170
171 suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle)
172 newPath := "/" + id.DID.String() + suffix
173 if r.URL.RawQuery != "" {
174 newPath += "?" + r.URL.RawQuery
175 }
176 http.Redirect(w, r, newPath, http.StatusTemporaryRedirect)
177 })
178}
179
180func (h *Knot) configureOwner(ctx context.Context) error {
181 cfgOwner := h.c.Server.Owner
182
183 rbacDomain := "thisserver"
184
185 existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
186 if err != nil {
187 return err
188 }
189
190 switch len(existing) {
191 case 0:
192 // no owner configured, continue
193 case 1:
194 // find existing owner
195 existingOwner := existing[0]
196
197 // no ownership change, this is okay
198 if existingOwner == h.c.Server.Owner {
199 break
200 }
201
202 // remove existing owner
203 if err = db.RemoveDid(h.db, existingOwner); err != nil {
204 return err
205 }
206 if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil {
207 return err
208 }
209
210 default:
211 return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
212 }
213
214 if err = db.AddDid(h.db, cfgOwner); err != nil {
215 return fmt.Errorf("failed to add owner to DB: %w", err)
216 }
217 if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil {
218 return fmt.Errorf("failed to add owner to RBAC: %w", err)
219 }
220
221 err = keys.FetchAndStore(ctx, h.resolver.Directory(), h.db, syntax.DID(cfgOwner))
222 if err != nil {
223 h.l.Error("fetching and adding owners public keys", "error", err, "did", cfgOwner)
224 }
225
226 return nil
227}