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