forked from
willdot.net/cocoon
A fork of the Cocoon PDS but being made more distributed.
1package main
2
3import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "encoding/json"
8 "fmt"
9 "log/slog"
10 "os"
11 "strings"
12 "time"
13
14 "github.com/bluesky-social/go-util/pkg/telemetry"
15 "github.com/bluesky-social/indigo/atproto/atcrypto"
16 "github.com/bluesky-social/indigo/atproto/syntax"
17 "github.com/haileyok/cocoon/internal/helpers"
18 "github.com/haileyok/cocoon/server"
19 _ "github.com/joho/godotenv/autoload"
20 "github.com/lestrrat-go/jwx/v2/jwk"
21 "github.com/urfave/cli/v2"
22 "golang.org/x/crypto/bcrypt"
23 "gorm.io/driver/postgres"
24 "gorm.io/driver/sqlite"
25 "gorm.io/gorm"
26)
27
28var Version = "dev"
29
30func main() {
31 app := &cli.App{
32 Name: "cocoon",
33 Usage: "An atproto PDS",
34 Flags: []cli.Flag{
35 &cli.StringFlag{
36 Name: "addr",
37 Value: ":8080",
38 EnvVars: []string{"COCOON_ADDR"},
39 },
40 &cli.StringFlag{
41 Name: "db-name",
42 Value: "cocoon.db",
43 EnvVars: []string{"COCOON_DB_NAME"},
44 },
45 &cli.StringFlag{
46 Name: "db-type",
47 Value: "sqlite",
48 Usage: "Database type: sqlite or postgres",
49 EnvVars: []string{"COCOON_DB_TYPE"},
50 },
51 &cli.StringFlag{
52 Name: "database-url",
53 Aliases: []string{"db-url"},
54 Usage: "PostgreSQL connection string (required if db-type is postgres)",
55 EnvVars: []string{"COCOON_DATABASE_URL", "DATABASE_URL"},
56 },
57 &cli.StringFlag{
58 Name: "turso-token",
59 Usage: "Token for a cloud Turso instance",
60 EnvVars: []string{"COCOON_TURSO_TOKEN", "TURSO_TOKEN"},
61 },
62 &cli.StringFlag{
63 Name: "did",
64 EnvVars: []string{"COCOON_DID"},
65 },
66 &cli.StringFlag{
67 Name: "hostname",
68 EnvVars: []string{"COCOON_HOSTNAME"},
69 },
70 &cli.StringFlag{
71 Name: "rotation-key-path",
72 EnvVars: []string{"COCOON_ROTATION_KEY_PATH"},
73 },
74 &cli.StringFlag{
75 Name: "jwk-path",
76 EnvVars: []string{"COCOON_JWK_PATH"},
77 },
78 &cli.StringFlag{
79 Name: "contact-email",
80 EnvVars: []string{"COCOON_CONTACT_EMAIL"},
81 },
82 &cli.StringSliceFlag{
83 Name: "relays",
84 EnvVars: []string{"COCOON_RELAYS"},
85 },
86 &cli.StringFlag{
87 Name: "admin-password",
88 EnvVars: []string{"COCOON_ADMIN_PASSWORD"},
89 },
90 &cli.BoolFlag{
91 Name: "require-invite",
92 EnvVars: []string{"COCOON_REQUIRE_INVITE"},
93 Value: true,
94 },
95 &cli.StringFlag{
96 Name: "smtp-user",
97 EnvVars: []string{"COCOON_SMTP_USER"},
98 },
99 &cli.StringFlag{
100 Name: "smtp-pass",
101 EnvVars: []string{"COCOON_SMTP_PASS"},
102 },
103 &cli.StringFlag{
104 Name: "smtp-host",
105 EnvVars: []string{"COCOON_SMTP_HOST"},
106 },
107 &cli.StringFlag{
108 Name: "smtp-port",
109 EnvVars: []string{"COCOON_SMTP_PORT"},
110 },
111 &cli.StringFlag{
112 Name: "smtp-email",
113 EnvVars: []string{"COCOON_SMTP_EMAIL"},
114 },
115 &cli.StringFlag{
116 Name: "smtp-name",
117 EnvVars: []string{"COCOON_SMTP_NAME"},
118 },
119 &cli.BoolFlag{
120 Name: "s3-backups-enabled",
121 EnvVars: []string{"COCOON_S3_BACKUPS_ENABLED"},
122 },
123 &cli.BoolFlag{
124 Name: "s3-blobstore-enabled",
125 EnvVars: []string{"COCOON_S3_BLOBSTORE_ENABLED"},
126 },
127 &cli.StringFlag{
128 Name: "s3-region",
129 EnvVars: []string{"COCOON_S3_REGION"},
130 },
131 &cli.StringFlag{
132 Name: "s3-bucket",
133 EnvVars: []string{"COCOON_S3_BUCKET"},
134 },
135 &cli.StringFlag{
136 Name: "s3-endpoint",
137 EnvVars: []string{"COCOON_S3_ENDPOINT"},
138 },
139 &cli.StringFlag{
140 Name: "s3-access-key",
141 EnvVars: []string{"COCOON_S3_ACCESS_KEY"},
142 },
143 &cli.StringFlag{
144 Name: "s3-secret-key",
145 EnvVars: []string{"COCOON_S3_SECRET_KEY"},
146 },
147 &cli.StringFlag{
148 Name: "s3-cdn-url",
149 EnvVars: []string{"COCOON_S3_CDN_URL"},
150 Usage: "Public URL for S3 blob redirects (e.g., https://cdn.example.com). When set, getBlob redirects to this URL instead of proxying.",
151 },
152 &cli.StringFlag{
153 Name: "session-secret",
154 EnvVars: []string{"COCOON_SESSION_SECRET"},
155 },
156 &cli.StringFlag{
157 Name: "session-cookie-key",
158 EnvVars: []string{"COCOON_SESSION_COOKIE_KEY"},
159 Value: "session",
160 },
161 &cli.StringFlag{
162 Name: "blockstore-variant",
163 EnvVars: []string{"COCOON_BLOCKSTORE_VARIANT"},
164 Value: "sqlite",
165 },
166 &cli.StringFlag{
167 Name: "fallback-proxy",
168 EnvVars: []string{"COCOON_FALLBACK_PROXY"},
169 },
170 telemetry.CLIFlagDebug,
171 telemetry.CLIFlagMetricsListenAddress,
172 &cli.StringFlag{
173 Name: "subscribe-repos-service-url",
174 EnvVars: []string{"SUBSCRIBE_REPOS_SERVICE_URL"},
175 },
176 &cli.BoolFlag{
177 Name: "push-based-events",
178 EnvVars: []string{"PUSH_BASED_EVENTS"},
179 },
180 &cli.StringFlag{
181 Name: "nonce-secret",
182 Usage: "To set a nonce secret",
183 EnvVars: []string{"COCOON_NONCE_SECRET"},
184 },
185 },
186 Commands: []*cli.Command{
187 runServe,
188 runCreateRotationKey,
189 runCreatePrivateJwk,
190 runCreateInviteCode,
191 runResetPassword,
192 },
193 ErrWriter: os.Stdout,
194 Version: Version,
195 }
196
197 if err := app.Run(os.Args); err != nil {
198 fmt.Printf("Error: %v\n", err)
199 }
200}
201
202var runServe = &cli.Command{
203 Name: "run",
204 Usage: "Start the cocoon PDS",
205 Flags: []cli.Flag{
206 &cli.StringFlag{
207 Name: "log-level",
208 Usage: "Log level: debug, info, warn, error",
209 EnvVars: []string{"COCOON_LOG_LEVEL", "LOG_LEVEL"},
210 Value: "info",
211 },
212 },
213 Action: func(cmd *cli.Context) error {
214
215 logger := telemetry.StartLogger(cmd)
216 telemetry.StartMetrics(cmd)
217
218 var level slog.Level
219 switch strings.ToLower(cmd.String("log-level")) {
220 case "debug":
221 level = slog.LevelDebug
222 case "info":
223 level = slog.LevelInfo
224 case "warn":
225 level = slog.LevelWarn
226 case "error":
227 level = slog.LevelError
228 default:
229 level = slog.LevelInfo
230 }
231
232 s, err := server.New(&server.Args{
233 Logger: logger,
234 LogLevel: level,
235 Addr: cmd.String("addr"),
236 DbName: cmd.String("db-name"),
237 DbType: cmd.String("db-type"),
238 DatabaseURL: cmd.String("database-url"),
239 TursoToken: cmd.String("turso-token"),
240 Did: cmd.String("did"),
241 Hostname: cmd.String("hostname"),
242 RotationKeyPath: cmd.String("rotation-key-path"),
243 JwkPath: cmd.String("jwk-path"),
244 ContactEmail: cmd.String("contact-email"),
245 Version: Version,
246 Relays: cmd.StringSlice("relays"),
247 AdminPassword: cmd.String("admin-password"),
248 RequireInvite: cmd.Bool("require-invite"),
249 NonceSecret: cmd.String("nonce-secret"),
250 SmtpUser: cmd.String("smtp-user"),
251 SmtpPass: cmd.String("smtp-pass"),
252 SmtpHost: cmd.String("smtp-host"),
253 SmtpPort: cmd.String("smtp-port"),
254 SmtpEmail: cmd.String("smtp-email"),
255 SmtpName: cmd.String("smtp-name"),
256 S3Config: &server.S3Config{
257 BackupsEnabled: cmd.Bool("s3-backups-enabled"),
258 BlobstoreEnabled: cmd.Bool("s3-blobstore-enabled"),
259 Region: cmd.String("s3-region"),
260 Bucket: cmd.String("s3-bucket"),
261 Endpoint: cmd.String("s3-endpoint"),
262 AccessKey: cmd.String("s3-access-key"),
263 SecretKey: cmd.String("s3-secret-key"),
264 CDNUrl: cmd.String("s3-cdn-url"),
265 },
266 SessionSecret: cmd.String("session-secret"),
267 SessionCookieKey: cmd.String("session-cookie-key"),
268 BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
269 FallbackProxy: cmd.String("fallback-proxy"),
270 PushBasedEvents: cmd.Bool("push-based-events"),
271 SubscribeReposServiceURL: cmd.String("subscribe-repos-service-url"),
272 })
273 if err != nil {
274 fmt.Printf("error creating cocoon: %v", err)
275 return err
276 }
277
278 if err := s.Serve(cmd.Context); err != nil {
279 fmt.Printf("error starting cocoon: %v", err)
280 return err
281 }
282
283 return nil
284 },
285}
286
287var runCreateRotationKey = &cli.Command{
288 Name: "create-rotation-key",
289 Usage: "creates a rotation key for your pds",
290 Flags: []cli.Flag{
291 &cli.StringFlag{
292 Name: "out",
293 Required: true,
294 Usage: "output file for your rotation key",
295 },
296 },
297 Action: func(cmd *cli.Context) error {
298 key, err := atcrypto.GeneratePrivateKeyK256()
299 if err != nil {
300 return err
301 }
302
303 bytes := key.Bytes()
304
305 if err := os.WriteFile(cmd.String("out"), bytes, 0644); err != nil {
306 return err
307 }
308
309 return nil
310 },
311}
312
313var runCreatePrivateJwk = &cli.Command{
314 Name: "create-private-jwk",
315 Usage: "creates a private jwk for your pds",
316 Flags: []cli.Flag{
317 &cli.StringFlag{
318 Name: "out",
319 Required: true,
320 Usage: "output file for your jwk",
321 },
322 },
323 Action: func(cmd *cli.Context) error {
324 privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
325 if err != nil {
326 return err
327 }
328
329 key, err := jwk.FromRaw(privKey)
330 if err != nil {
331 return err
332 }
333
334 kid := fmt.Sprintf("%d", time.Now().Unix())
335
336 if err := key.Set(jwk.KeyIDKey, kid); err != nil {
337 return err
338 }
339
340 b, err := json.Marshal(key)
341 if err != nil {
342 return err
343 }
344
345 if err := os.WriteFile(cmd.String("out"), b, 0644); err != nil {
346 return err
347 }
348
349 return nil
350 },
351}
352
353var runCreateInviteCode = &cli.Command{
354 Name: "create-invite-code",
355 Usage: "creates an invite code",
356 Flags: []cli.Flag{
357 &cli.StringFlag{
358 Name: "for",
359 Usage: "optional did to assign the invite code to",
360 },
361 &cli.IntFlag{
362 Name: "uses",
363 Usage: "number of times the invite code can be used",
364 Value: 1,
365 },
366 },
367 Action: func(cmd *cli.Context) error {
368 db, err := newDb(cmd)
369 if err != nil {
370 return err
371 }
372
373 forDid := "did:plc:123"
374 if cmd.String("for") != "" {
375 did, err := syntax.ParseDID(cmd.String("for"))
376 if err != nil {
377 return err
378 }
379
380 forDid = did.String()
381 }
382
383 uses := cmd.Int("uses")
384
385 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(8), helpers.RandomVarchar(8))
386
387 if err := db.Exec("INSERT INTO invite_codes (did, code, remaining_use_count) VALUES (?, ?, ?)", forDid, code, uses).Error; err != nil {
388 return err
389 }
390
391 fmt.Printf("New invite code created with %d uses: %s\n", uses, code)
392
393 return nil
394 },
395}
396
397var runResetPassword = &cli.Command{
398 Name: "reset-password",
399 Usage: "resets a password",
400 Flags: []cli.Flag{
401 &cli.StringFlag{
402 Name: "did",
403 Usage: "did of the user who's password you want to reset",
404 },
405 },
406 Action: func(cmd *cli.Context) error {
407 db, err := newDb(cmd)
408 if err != nil {
409 return err
410 }
411
412 didStr := cmd.String("did")
413 did, err := syntax.ParseDID(didStr)
414 if err != nil {
415 return err
416 }
417
418 newPass := fmt.Sprintf("%s-%s", helpers.RandomVarchar(12), helpers.RandomVarchar(12))
419 hashed, err := bcrypt.GenerateFromPassword([]byte(newPass), 10)
420 if err != nil {
421 return err
422 }
423
424 if err := db.Exec("UPDATE repos SET password = ? WHERE did = ?", hashed, did.String()).Error; err != nil {
425 return err
426 }
427
428 fmt.Printf("Password for %s has been reset to: %s", did.String(), newPass)
429
430 return nil
431 },
432}
433
434func newDb(cmd *cli.Context) (*gorm.DB, error) {
435 dbType := cmd.String("db-type")
436 if dbType == "" {
437 dbType = "sqlite"
438 }
439
440 switch dbType {
441 case "postgres":
442 databaseURL := cmd.String("database-url")
443 if databaseURL == "" {
444 return nil, fmt.Errorf("COCOON_DATABASE_URL or DATABASE_URL must be set when using postgres")
445 }
446 return gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
447 default:
448 dbName := cmd.String("db-name")
449 if dbName == "" {
450 dbName = "cocoon.db"
451 }
452 return gorm.Open(sqlite.Open(dbName), &gorm.Config{})
453 }
454}