Skip to content

Commit 35a68a7

Browse files
authored
feat: implement authentication for admin UI
1 parent b934520 commit 35a68a7

71 files changed

Lines changed: 3747 additions & 619 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
# SYNAPS3_FILECOIN_PRIVATE_KEY=0x...
77
# SYNAPS3_FILECOIN_RPC_URL=https://api.calibration.node.glif.io/rpc/v1
88
# SYNAPS3_CACHE_MAX_SIZE_GB=100
9+
# SYNAPS3_ADMIN_AUTH_USERNAME=admin

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ SynapS3 is an S3-compatible gateway for storing objects on Filecoin.
1818

1919
- S3-compatible bucket and object APIs.
2020
- Object storage backed by Filecoin storage providers.
21-
- Web dashboard for buckets, objects, wallet, tasks, topology, settings, and health.
21+
- Admin-authenticated web dashboard for buckets, objects, wallet, tasks, topology, settings, and health.
2222
- Multipart uploads for large objects.
2323
- Wallet funding, USDFC deposit, and background task controls.
2424

@@ -35,8 +35,8 @@ SynapS3 is an S3-compatible gateway for storing objects on Filecoin.
3535
| Object | `PutObject` || Stores an object |
3636
| Object | `GetObject` || Reads an object |
3737
| Object | `HeadObject` || Reads object metadata |
38-
| Object | `DeleteObject` || Soft-deletes one object |
39-
| Object | `DeleteObjects` || Soft-deletes multiple objects |
38+
| Object | `DeleteObject` || Creates a delete marker, or deletes a specific `versionId` |
39+
| Object | `DeleteObjects` || Creates delete markers, or deletes specific `versionId` entries |
4040
| Object | `CopyObject` || Source object must be readable from cache or committed provider storage |
4141
| Object | `ListObjects` || Marker pagination |
4242
| Object | `ListObjectsV2` || Continuation-token pagination |

cmd/synaps3/admin.go

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"net"
1111
"net/http"
1212
"net/url"
13+
"os"
14+
"path/filepath"
1315
"reflect"
1416
"sort"
1517
"strconv"
@@ -19,20 +21,23 @@ import (
1921

2022
"github.com/strahe/synaps3/internal/config"
2123
"github.com/urfave/cli/v3"
24+
"golang.org/x/term"
2225
)
2326

2427
const (
25-
defaultAdminTimeout = 10 * time.Second
26-
adminSettingsWriteHeader = "X-SynapS3-Settings-Write"
27-
adminSettingsWriteValue = "1"
28+
defaultAdminTimeout = 10 * time.Second
2829
)
2930

3031
type adminCommandOptions struct {
31-
AdminURL string
32-
ConfigPath string
33-
ConfigSet bool
34-
Timeout time.Duration
35-
JSON bool
32+
AdminURL string
33+
ConfigPath string
34+
ConfigSet bool
35+
Timeout time.Duration
36+
JSON bool
37+
AdminUsername string
38+
AdminPassword string
39+
AuthDisabled bool
40+
ConfigSource string
3641
}
3742

3843
func adminCommand() *cli.Command {
@@ -452,17 +457,29 @@ func adminTaskCommand() *cli.Command {
452457

453458
type adminAPIClient struct {
454459
baseURL string
460+
username string
461+
password string
455462
httpClient *http.Client
456463
}
457464

458465
func newAdminClientFromCommand(ctx context.Context, cmd *cli.Command) (*adminAPIClient, adminCommandOptions, error) {
459466
opts := adminOptionsFromCommand(cmd)
467+
if err := resolveAdminClientConfig(ctx, &opts); err != nil {
468+
return nil, adminCommandOptions{}, err
469+
}
470+
password, err := resolveAdminPassword(cmd, opts)
471+
if err != nil {
472+
return nil, adminCommandOptions{}, err
473+
}
474+
opts.AdminPassword = password
460475
baseURL, err := resolveAdminBaseURL(ctx, opts)
461476
if err != nil {
462477
return nil, adminCommandOptions{}, err
463478
}
464479
return &adminAPIClient{
465-
baseURL: baseURL,
480+
baseURL: baseURL,
481+
username: opts.AdminUsername,
482+
password: opts.AdminPassword,
466483
httpClient: &http.Client{
467484
Timeout: opts.Timeout,
468485
},
@@ -476,12 +493,77 @@ func adminOptionsFromCommand(cmd *cli.Command) adminCommandOptions {
476493
timeout = defaultAdminTimeout
477494
}
478495
return adminCommandOptions{
479-
AdminURL: cmd.String("admin-url"),
480-
ConfigPath: root.String("config"),
481-
ConfigSet: root.IsSet("config"),
482-
Timeout: timeout,
483-
JSON: cmd.Bool("json"),
496+
AdminURL: cmd.String("admin-url"),
497+
ConfigPath: root.String("config"),
498+
ConfigSet: root.IsSet("config"),
499+
Timeout: timeout,
500+
JSON: cmd.Bool("json"),
501+
AdminUsername: "admin",
502+
AdminPassword: os.Getenv("SYNAPS3_ADMIN_PASSWORD"),
503+
}
504+
}
505+
506+
func resolveAdminClientConfig(_ context.Context, opts *adminCommandOptions) error {
507+
src, err := config.ResolveSource(opts.ConfigPath, opts.ConfigSet)
508+
if err != nil {
509+
if strings.TrimSpace(opts.AdminURL) != "" && !opts.ConfigSet {
510+
return nil
511+
}
512+
return err
513+
}
514+
opts.ConfigSource = src.Path
515+
cfg, err := config.LoadSource(src)
516+
if err != nil {
517+
if strings.TrimSpace(opts.AdminURL) != "" && !opts.ConfigSet {
518+
return nil
519+
}
520+
return fmt.Errorf("loading config for admin client: %w", err)
521+
}
522+
if username := strings.TrimSpace(cfg.Admin.Auth.Username); username != "" {
523+
opts.AdminUsername = username
524+
}
525+
opts.AuthDisabled = !cfg.Admin.Auth.Enabled
526+
return nil
527+
}
528+
529+
func resolveAdminPassword(cmd *cli.Command, opts adminCommandOptions) (string, error) {
530+
if opts.AdminPassword != "" || opts.AuthDisabled {
531+
return opts.AdminPassword, nil
484532
}
533+
if opts.ConfigSource != "" {
534+
password, ok, err := config.ReadAdminInitialPasswordFile(filepath.Dir(opts.ConfigSource))
535+
if err != nil {
536+
return "", err
537+
}
538+
if ok {
539+
return password, nil
540+
}
541+
}
542+
if !adminPasswordPromptAvailable(cmd.Root().ErrWriter) {
543+
if strings.TrimSpace(opts.AdminURL) != "" && !opts.ConfigSet {
544+
return opts.AdminPassword, nil
545+
}
546+
return "", errors.New("admin password is required but terminal is not interactive; set SYNAPS3_ADMIN_PASSWORD or create admin-initial-password next to the config file")
547+
}
548+
if _, err := fmt.Fprint(cmd.Root().ErrWriter, "Admin password: "); err != nil {
549+
return "", err
550+
}
551+
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
552+
if err != nil {
553+
return "", fmt.Errorf("reading admin password: %w", err)
554+
}
555+
if _, err := fmt.Fprintln(cmd.Root().ErrWriter); err != nil {
556+
return "", err
557+
}
558+
return string(passwordBytes), nil
559+
}
560+
561+
func adminPasswordPromptAvailable(errWriter io.Writer) bool {
562+
errFile, ok := errWriter.(*os.File)
563+
if !ok || errFile != os.Stderr {
564+
return false
565+
}
566+
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(errFile.Fd()))
485567
}
486568

487569
func resolveAdminBaseURL(_ context.Context, opts adminCommandOptions) (string, error) {
@@ -582,12 +664,10 @@ func (c *adminAPIClient) doJSON(ctx context.Context, method, path string, body a
582664
if err != nil {
583665
return err
584666
}
585-
if hasBody || writeHeader {
667+
c.applyAuth(req)
668+
if hasBody {
586669
req.Header.Set("Content-Type", "application/json")
587670
}
588-
if writeHeader {
589-
req.Header.Set(adminSettingsWriteHeader, adminSettingsWriteValue)
590-
}
591671

592672
resp, err := c.httpClient.Do(req)
593673
if err != nil {
@@ -607,6 +687,17 @@ func (c *adminAPIClient) doJSON(ctx context.Context, method, path string, body a
607687
return nil
608688
}
609689

690+
func (c *adminAPIClient) applyAuth(req *http.Request) {
691+
if c.password == "" {
692+
return
693+
}
694+
username := strings.TrimSpace(c.username)
695+
if username == "" {
696+
username = "admin"
697+
}
698+
req.SetBasicAuth(username, c.password)
699+
}
700+
610701
func (c *adminAPIClient) endpoint(path string) string {
611702
if strings.HasPrefix(path, "/") {
612703
return c.baseURL + path

cmd/synaps3/admin_auth.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/strahe/synaps3/internal/config"
13+
"github.com/urfave/cli/v3"
14+
)
15+
16+
var saveAdminAuthSettings = config.SaveForSettings
17+
18+
func adminAuthCommand() *cli.Command {
19+
return &cli.Command{
20+
Name: "admin-auth",
21+
Usage: "manage local Admin UI authentication",
22+
Commands: []*cli.Command{
23+
{
24+
Name: "reset-password",
25+
Usage: "generate a new local Admin password",
26+
Action: func(_ context.Context, cmd *cli.Command) error {
27+
if cmd.Args().Len() > 0 {
28+
return fmt.Errorf("unexpected argument %q, reset-password takes no positional arguments", cmd.Args().First())
29+
}
30+
root := cmd.Root()
31+
if !root.IsSet("config") {
32+
return errors.New("admin-auth reset-password requires --config")
33+
}
34+
src, err := configSourceFromCommand(cmd)
35+
if err != nil {
36+
return err
37+
}
38+
cfg, presence, err := config.LoadFileForSettings(src.Path)
39+
if err != nil {
40+
return fmt.Errorf("loading config: %w", err)
41+
}
42+
bootstrap, err := config.NewAdminAuthBootstrap()
43+
if err != nil {
44+
return err
45+
}
46+
if strings.TrimSpace(cfg.Admin.Auth.Username) == "" {
47+
cfg.Admin.Auth.Username = "admin"
48+
}
49+
cfg.Admin.Auth.Enabled = true
50+
cfg.Admin.Auth.PasswordHash = bootstrap.PasswordHash
51+
cfg.Admin.Auth.SessionSecret = bootstrap.SessionSecret
52+
presence.AdminAuthEnabled = true
53+
presence.AdminAuthUsername = true
54+
presence.AdminAuthPasswordHash = true
55+
presence.AdminAuthSessionSecret = true
56+
passwordFile := config.AdminInitialPasswordFilePath(filepath.Dir(src.Path))
57+
backup, err := backupAdminInitialPasswordFile(passwordFile)
58+
if err != nil {
59+
return err
60+
}
61+
passwordPath, err := config.WriteAdminInitialPasswordFile(filepath.Dir(src.Path), bootstrap.Password)
62+
if err != nil {
63+
return err
64+
}
65+
if err := saveAdminAuthSettings(src.Path, cfg, presence); err != nil {
66+
if restoreErr := restoreAdminInitialPasswordFile(passwordPath, backup); restoreErr != nil {
67+
return fmt.Errorf("saving config: %w; restoring admin initial password file: %v", err, restoreErr)
68+
}
69+
return fmt.Errorf("saving config: %w", err)
70+
}
71+
_, err = fmt.Fprintf(root.Writer, "Admin password reset\nAdmin username: %s\nAdmin initial password file: %s\n", cfg.Admin.Auth.Username, passwordPath)
72+
return err
73+
},
74+
},
75+
},
76+
}
77+
}
78+
79+
type adminInitialPasswordBackup struct {
80+
Exists bool
81+
Data []byte
82+
Mode fs.FileMode
83+
}
84+
85+
func backupAdminInitialPasswordFile(path string) (adminInitialPasswordBackup, error) {
86+
info, err := os.Stat(path)
87+
if err != nil {
88+
if os.IsNotExist(err) {
89+
return adminInitialPasswordBackup{}, nil
90+
}
91+
return adminInitialPasswordBackup{}, fmt.Errorf("checking admin initial password file %s: %w", path, err)
92+
}
93+
if info.IsDir() {
94+
return adminInitialPasswordBackup{}, fmt.Errorf("admin initial password file %s is a directory", path)
95+
}
96+
data, err := os.ReadFile(path)
97+
if err != nil {
98+
return adminInitialPasswordBackup{}, fmt.Errorf("reading admin initial password file %s: %w", path, err)
99+
}
100+
return adminInitialPasswordBackup{Exists: true, Data: data, Mode: info.Mode().Perm()}, nil
101+
}
102+
103+
func restoreAdminInitialPasswordFile(path string, backup adminInitialPasswordBackup) error {
104+
if !backup.Exists {
105+
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
106+
return err
107+
}
108+
return nil
109+
}
110+
if err := os.WriteFile(path, backup.Data, backup.Mode); err != nil {
111+
return err
112+
}
113+
return os.Chmod(path, backup.Mode)
114+
}

0 commit comments

Comments
 (0)