mirror of
https://github.com/ferdzo/fs.git
synced 2026-04-04 20:36:25 +00:00
Initial FS CLI
This commit is contained in:
28
README.md
28
README.md
@@ -2,6 +2,13 @@
|
||||
|
||||
An experimental Object Storage written in Go that should be partially compatible with S3
|
||||
|
||||
## Running
|
||||
|
||||
Single binary, two modes:
|
||||
- `fs` (no subcommand) starts the server (backward compatible)
|
||||
- `fs server` starts the server explicitly
|
||||
- `fs admin ...` runs admin CLI commands
|
||||
|
||||
## Features
|
||||
|
||||
Bucket operations:
|
||||
@@ -42,6 +49,18 @@ Admin API (JSON):
|
||||
- `PUT /_admin/v1/users/{accessKeyId}/status`
|
||||
- `DELETE /_admin/v1/users/{accessKeyId}`
|
||||
|
||||
Admin CLI:
|
||||
- `fs admin user create --access-key backup-user --role readwrite`
|
||||
- `fs admin user list`
|
||||
- `fs admin user get backup-user`
|
||||
- `fs admin user set-status backup-user --status disabled`
|
||||
- `fs admin user set-role backup-user --role readonly --bucket backup-bucket --prefix restic/`
|
||||
- `fs admin user set-role backup-user --role readwrite --bucket backups-2` (appends another statement)
|
||||
- `fs admin user set-role backup-user --role admin --replace` (replaces all statements)
|
||||
- `fs admin user delete backup-user`
|
||||
- `fs admin diag health`
|
||||
- `fs admin diag version`
|
||||
|
||||
## Auth Setup
|
||||
|
||||
Required when `FS_AUTH_ENABLED=true`:
|
||||
@@ -55,6 +74,15 @@ Additional docs:
|
||||
- Admin OpenAPI spec: `docs/admin-api-openapi.yaml`
|
||||
- S3 compatibility matrix: `docs/s3-compatibility.md`
|
||||
|
||||
CLI credential/env resolution for `fs admin`:
|
||||
- Flags: `--access-key`, `--secret-key`, `--endpoint`, `--region`
|
||||
- Env fallback:
|
||||
- `FS_ROOT_USER` / `FS_ROOT_PASSWORD` (same defaults as server bootstrap)
|
||||
- `FSCLI_ACCESS_KEY` / `FSCLI_SECRET_KEY`
|
||||
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
|
||||
- `FSCLI_ENDPOINT` (fallback to `ADDRESS` + `PORT`, then `http://localhost:3000`)
|
||||
- `FSCLI_REGION` (fallback `FS_AUTH_REGION`, default `us-east-1`)
|
||||
|
||||
Health:
|
||||
- `GET /healthz`
|
||||
- `HEAD /healthz`
|
||||
|
||||
91
app/server.go
Normal file
91
app/server.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"fs/api"
|
||||
"fs/auth"
|
||||
"fs/logging"
|
||||
"fs/metadata"
|
||||
"fs/service"
|
||||
"fs/storage"
|
||||
"fs/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RunServer(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
config := utils.NewConfig()
|
||||
logConfig := logging.ConfigFromValues(config.LogLevel, config.LogFormat, config.AuditLog)
|
||||
authConfig := auth.ConfigFromValues(
|
||||
config.AuthEnabled,
|
||||
config.AuthRegion,
|
||||
config.AuthSkew,
|
||||
config.AuthMaxPresign,
|
||||
config.AuthMasterKey,
|
||||
config.AuthBootstrapAccessKey,
|
||||
config.AuthBootstrapSecretKey,
|
||||
config.AuthBootstrapPolicy,
|
||||
)
|
||||
logger := logging.NewLogger(logConfig)
|
||||
logger.Info("boot",
|
||||
"log_level", logConfig.LevelName,
|
||||
"log_format", logConfig.Format,
|
||||
"audit_log", logConfig.Audit,
|
||||
"data_path", config.DataPath,
|
||||
"multipart_retention_hours", int(config.MultipartCleanupRetention/time.Hour),
|
||||
"auth_enabled", authConfig.Enabled,
|
||||
"auth_region", authConfig.Region,
|
||||
"admin_api_enabled", config.AdminAPIEnabled,
|
||||
)
|
||||
|
||||
if err := os.MkdirAll(config.DataPath, 0o755); err != nil {
|
||||
logger.Error("failed_to_prepare_data_path", "path", config.DataPath, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(config.DataPath, "metadata.db")
|
||||
metadataHandler, err := metadata.NewMetadataHandler(dbPath)
|
||||
if err != nil {
|
||||
logger.Error("failed_to_initialize_metadata_handler", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
blobHandler, err := storage.NewBlobStore(config.DataPath, config.ChunkSize)
|
||||
if err != nil {
|
||||
_ = metadataHandler.Close()
|
||||
logger.Error("failed_to_initialize_blob_store", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
objectService := service.NewObjectService(metadataHandler, blobHandler, config.MultipartCleanupRetention)
|
||||
authService, err := auth.NewService(authConfig, metadataHandler)
|
||||
if err != nil {
|
||||
_ = metadataHandler.Close()
|
||||
logger.Error("failed_to_initialize_auth_service", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := authService.EnsureBootstrap(); err != nil {
|
||||
_ = metadataHandler.Close()
|
||||
logger.Error("failed_to_ensure_bootstrap_auth_identity", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
handler := api.NewHandler(objectService, logger, logConfig, authService, config.AdminAPIEnabled)
|
||||
addr := config.Address + ":" + strconv.Itoa(config.Port)
|
||||
if config.GcEnabled {
|
||||
go objectService.RunGC(ctx, config.GcInterval)
|
||||
}
|
||||
|
||||
if err := handler.Start(ctx, addr); err != nil {
|
||||
logger.Error("server_stopped_with_error", "error", err)
|
||||
return fmt.Errorf("server start failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
122
cmd/admin.go
Normal file
122
cmd/admin.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"fs/utils"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAdminEndpoint = "http://localhost:3000"
|
||||
defaultAdminRegion = "us-east-1"
|
||||
)
|
||||
|
||||
type adminOptions struct {
|
||||
Endpoint string
|
||||
Region string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
JSON bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func newAdminCommand(build BuildInfo) *cobra.Command {
|
||||
opts := &adminOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "admin",
|
||||
Short: "Admin operations over the fs admin API",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVar(&opts.Endpoint, "endpoint", "", "Admin API endpoint (env: FSCLI_ENDPOINT, fallback ADDRESS+PORT)")
|
||||
cmd.PersistentFlags().StringVar(&opts.Region, "region", "", "SigV4 region (env: FSCLI_REGION or FS_AUTH_REGION)")
|
||||
cmd.PersistentFlags().StringVar(&opts.AccessKey, "access-key", "", "Admin access key (env: FS_ROOT_USER, FSCLI_ACCESS_KEY)")
|
||||
cmd.PersistentFlags().StringVar(&opts.SecretKey, "secret-key", "", "Admin secret key (env: FS_ROOT_PASSWORD, FSCLI_SECRET_KEY)")
|
||||
cmd.PersistentFlags().BoolVar(&opts.JSON, "json", false, "Emit JSON output")
|
||||
cmd.PersistentFlags().DurationVar(&opts.Timeout, "timeout", 15*time.Second, "HTTP timeout")
|
||||
|
||||
cmd.AddCommand(newAdminUserCommand(opts))
|
||||
cmd.AddCommand(newAdminDiagCommand(opts, build))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (o *adminOptions) resolve(requireCredentials bool) error {
|
||||
serverCfg := utils.NewConfig()
|
||||
o.Endpoint = strings.TrimSpace(firstNonEmpty(
|
||||
o.Endpoint,
|
||||
os.Getenv("FSCLI_ENDPOINT"),
|
||||
endpointFromServerConfig(serverCfg.Address, serverCfg.Port),
|
||||
defaultAdminEndpoint,
|
||||
))
|
||||
o.Region = strings.TrimSpace(firstNonEmpty(
|
||||
o.Region,
|
||||
os.Getenv("FSCLI_REGION"),
|
||||
os.Getenv("FS_AUTH_REGION"),
|
||||
serverCfg.AuthRegion,
|
||||
defaultAdminRegion,
|
||||
))
|
||||
o.AccessKey = strings.TrimSpace(firstNonEmpty(
|
||||
o.AccessKey,
|
||||
os.Getenv("FS_ROOT_USER"),
|
||||
os.Getenv("FSCLI_ACCESS_KEY"),
|
||||
os.Getenv("AWS_ACCESS_KEY_ID"),
|
||||
serverCfg.AuthBootstrapAccessKey,
|
||||
))
|
||||
o.SecretKey = strings.TrimSpace(firstNonEmpty(
|
||||
o.SecretKey,
|
||||
os.Getenv("FS_ROOT_PASSWORD"),
|
||||
os.Getenv("FSCLI_SECRET_KEY"),
|
||||
os.Getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
serverCfg.AuthBootstrapSecretKey,
|
||||
))
|
||||
|
||||
if o.Timeout <= 0 {
|
||||
o.Timeout = 15 * time.Second
|
||||
}
|
||||
|
||||
if o.Endpoint == "" {
|
||||
return errors.New("admin endpoint is required")
|
||||
}
|
||||
parsed, err := url.Parse(o.Endpoint)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("invalid endpoint %q", o.Endpoint)
|
||||
}
|
||||
if o.Region == "" {
|
||||
return errors.New("region is required")
|
||||
}
|
||||
if requireCredentials && (o.AccessKey == "" || o.SecretKey == "") {
|
||||
return errors.New("credentials required: set --access-key/--secret-key or FSCLI_ACCESS_KEY/FSCLI_SECRET_KEY")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func endpointFromServerConfig(address string, port int) string {
|
||||
host := strings.TrimSpace(address)
|
||||
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
|
||||
host = "localhost"
|
||||
}
|
||||
if port <= 0 || port > 65535 {
|
||||
port = 3000
|
||||
}
|
||||
return "http://" + net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
281
cmd/admin_client.go
Normal file
281
cmd/admin_client.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type adminUserListItem struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type adminUserListResponse struct {
|
||||
Items []adminUserListItem `json:"items"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
type adminPolicyStatement struct {
|
||||
Effect string `json:"effect"`
|
||||
Actions []string `json:"actions"`
|
||||
Bucket string `json:"bucket"`
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
type adminPolicy struct {
|
||||
Principal string `json:"principal,omitempty"`
|
||||
Statements []adminPolicyStatement `json:"statements"`
|
||||
}
|
||||
|
||||
type adminUserResponse struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Policy *adminPolicy `json:"policy,omitempty"`
|
||||
SecretKey string `json:"secretKey,omitempty"`
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretKey string `json:"secretKey,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Policy adminPolicy `json:"policy"`
|
||||
}
|
||||
|
||||
type setStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type setPolicyRequest struct {
|
||||
Policy adminPolicy `json:"policy"`
|
||||
}
|
||||
|
||||
type adminErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
type adminAPIError struct {
|
||||
StatusCode int
|
||||
Code string
|
||||
Message string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func (e *adminAPIError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Code == "" {
|
||||
return fmt.Sprintf("admin API request failed: status=%d", e.StatusCode)
|
||||
}
|
||||
if e.RequestID == "" {
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s (requestId=%s)", e.Code, e.Message, e.RequestID)
|
||||
}
|
||||
|
||||
type adminAPIClient struct {
|
||||
baseURL *url.URL
|
||||
region string
|
||||
accessKey string
|
||||
secretKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newAdminAPIClient(opts *adminOptions, requireCredentials bool) (*adminAPIClient, error) {
|
||||
if opts == nil {
|
||||
return nil, errors.New("admin options are required")
|
||||
}
|
||||
if err := opts.resolve(requireCredentials); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseURL, err := url.Parse(opts.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &adminAPIClient{
|
||||
baseURL: baseURL,
|
||||
region: opts.Region,
|
||||
accessKey: opts.AccessKey,
|
||||
secretKey: opts.SecretKey,
|
||||
client: &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) CreateUser(ctx context.Context, request createUserRequest) (*adminUserResponse, error) {
|
||||
var out adminUserResponse
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/_admin/v1/users", nil, request, &out, http.StatusCreated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) ListUsers(ctx context.Context, limit int, cursor string) (*adminUserListResponse, error) {
|
||||
query := make(url.Values)
|
||||
if limit > 0 {
|
||||
query.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
if strings.TrimSpace(cursor) != "" {
|
||||
query.Set("cursor", strings.TrimSpace(cursor))
|
||||
}
|
||||
|
||||
var out adminUserListResponse
|
||||
if err := c.doJSON(ctx, http.MethodGet, "/_admin/v1/users", query, nil, &out, http.StatusOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) GetUser(ctx context.Context, accessKeyID string) (*adminUserResponse, error) {
|
||||
var out adminUserResponse
|
||||
path := "/_admin/v1/users/" + url.PathEscape(strings.TrimSpace(accessKeyID))
|
||||
if err := c.doJSON(ctx, http.MethodGet, path, nil, nil, &out, http.StatusOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) DeleteUser(ctx context.Context, accessKeyID string) error {
|
||||
path := "/_admin/v1/users/" + url.PathEscape(strings.TrimSpace(accessKeyID))
|
||||
return c.doJSON(ctx, http.MethodDelete, path, nil, nil, nil, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) SetUserStatus(ctx context.Context, accessKeyID, status string) (*adminUserResponse, error) {
|
||||
var out adminUserResponse
|
||||
path := "/_admin/v1/users/" + url.PathEscape(strings.TrimSpace(accessKeyID)) + "/status"
|
||||
if err := c.doJSON(ctx, http.MethodPut, path, nil, setStatusRequest{Status: status}, &out, http.StatusOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) SetUserPolicy(ctx context.Context, accessKeyID string, policy adminPolicy) (*adminUserResponse, error) {
|
||||
var out adminUserResponse
|
||||
path := "/_admin/v1/users/" + url.PathEscape(strings.TrimSpace(accessKeyID)) + "/policy"
|
||||
if err := c.doJSON(ctx, http.MethodPut, path, nil, setPolicyRequest{Policy: policy}, &out, http.StatusOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) Health(ctx context.Context) (string, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
|
||||
text := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if text == "" {
|
||||
text = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
return text, fmt.Errorf("health check failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
if text == "" {
|
||||
text = "ok"
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) doJSON(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
path string,
|
||||
query url.Values,
|
||||
body any,
|
||||
out any,
|
||||
expectedStatus int,
|
||||
) error {
|
||||
var payload []byte
|
||||
var err error
|
||||
if body != nil {
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
req, err := c.newRequest(ctx, method, path, query, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if err := signSigV4Request(req, payload, c.accessKey, c.secretKey, c.region, "s3"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if resp.StatusCode != expectedStatus {
|
||||
apiErr := &adminAPIError{StatusCode: resp.StatusCode}
|
||||
parsed := adminErrorResponse{}
|
||||
if len(raw) > 0 && json.Unmarshal(raw, &parsed) == nil {
|
||||
apiErr.Code = parsed.Code
|
||||
apiErr.Message = parsed.Message
|
||||
apiErr.RequestID = parsed.RequestID
|
||||
}
|
||||
if apiErr.Message == "" {
|
||||
apiErr.Message = strings.TrimSpace(string(raw))
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
if out == nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(bytes.NewReader(raw)).Decode(out)
|
||||
}
|
||||
|
||||
func (c *adminAPIClient) newRequest(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
path string,
|
||||
query url.Values,
|
||||
payload []byte,
|
||||
) (*http.Request, error) {
|
||||
u := *c.baseURL
|
||||
u.Path = strings.TrimRight(c.baseURL.Path, "/") + path
|
||||
u.RawQuery = ""
|
||||
if len(query) > 0 {
|
||||
u.RawQuery = query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
68
cmd/admin_diag.go
Normal file
68
cmd/admin_diag.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newAdminDiagCommand(opts *adminOptions, build BuildInfo) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "diag",
|
||||
Short: "Diagnostics and connectivity checks",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newAdminDiagHealthCommand(opts))
|
||||
cmd.AddCommand(newAdminDiagVersionCommand(build, opts))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAdminDiagHealthCommand(opts *adminOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Check server health endpoint",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAdminAPIClient(opts, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := client.Health(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.JSON {
|
||||
return writeJSON(cmd.OutOrStdout(), map[string]string{
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "health: %s\n", status)
|
||||
return err
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAdminDiagVersionCommand(build BuildInfo, opts *adminOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print CLI version metadata",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
out := map[string]string{
|
||||
"version": build.Version,
|
||||
"commit": build.Commit,
|
||||
"date": build.Date,
|
||||
"go": runtime.Version(),
|
||||
}
|
||||
if opts.JSON {
|
||||
return writeJSON(cmd.OutOrStdout(), out)
|
||||
}
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "version=%s commit=%s date=%s go=%s\n", out["version"], out["commit"], out["date"], out["go"])
|
||||
return err
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
80
cmd/admin_output.go
Normal file
80
cmd/admin_output.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeJSON(out io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(value)
|
||||
}
|
||||
|
||||
func formatUnix(ts int64) string {
|
||||
if ts <= 0 {
|
||||
return "-"
|
||||
}
|
||||
return time.Unix(ts, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func writeUserListTable(out io.Writer, value *adminUserListResponse) error {
|
||||
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintln(w, "ACCESS_KEY_ID\tSTATUS\tCREATED_AT\tUPDATED_AT"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range value.Items {
|
||||
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", item.AccessKeyID, item.Status, formatUnix(item.CreatedAt), formatUnix(item.UpdatedAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(value.NextCursor) != "" {
|
||||
if _, err := fmt.Fprintf(w, "\nNEXT_CURSOR\t%s\t\t\n", value.NextCursor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func writeUserTable(out io.Writer, value *adminUserResponse, includeSecret bool) error {
|
||||
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintf(w, "accessKeyId\t%s\n", value.AccessKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "status\t%s\n", value.Status); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "createdAt\t%s\n", formatUnix(value.CreatedAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "updatedAt\t%s\n", formatUnix(value.UpdatedAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
if includeSecret && strings.TrimSpace(value.SecretKey) != "" {
|
||||
if _, err := fmt.Fprintf(w, "secretKey\t%s\n", value.SecretKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if value.Policy != nil {
|
||||
for i, stmt := range value.Policy.Statements {
|
||||
idx := i + 1
|
||||
if _, err := fmt.Fprintf(w, "policy[%d].effect\t%s\n", idx, stmt.Effect); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "policy[%d].actions\t%s\n", idx, strings.Join(stmt.Actions, ",")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "policy[%d].bucket\t%s\n", idx, stmt.Bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "policy[%d].prefix\t%s\n", idx, stmt.Prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
47
cmd/admin_policy.go
Normal file
47
cmd/admin_policy.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type rolePolicyOptions struct {
|
||||
Role string
|
||||
Bucket string
|
||||
Prefix string
|
||||
}
|
||||
|
||||
func buildPolicyFromRole(opts rolePolicyOptions) (adminPolicy, error) {
|
||||
role := strings.ToLower(strings.TrimSpace(opts.Role))
|
||||
bucket := strings.TrimSpace(opts.Bucket)
|
||||
prefix := strings.TrimSpace(opts.Prefix)
|
||||
if bucket == "" {
|
||||
bucket = "*"
|
||||
}
|
||||
if prefix == "" {
|
||||
prefix = "*"
|
||||
}
|
||||
|
||||
var actions []string
|
||||
switch role {
|
||||
case "admin":
|
||||
actions = []string{"s3:*"}
|
||||
case "readwrite":
|
||||
actions = []string{"s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"}
|
||||
case "readonly":
|
||||
actions = []string{"s3:ListBucket", "s3:GetObject"}
|
||||
default:
|
||||
return adminPolicy{}, fmt.Errorf("invalid role %q (allowed: admin, readwrite, readonly)", opts.Role)
|
||||
}
|
||||
|
||||
return adminPolicy{
|
||||
Statements: []adminPolicyStatement{
|
||||
{
|
||||
Effect: "allow",
|
||||
Actions: actions,
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
166
cmd/admin_sigv4.go
Normal file
166
cmd/admin_sigv4.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sigV4Algorithm = "AWS4-HMAC-SHA256"
|
||||
|
||||
func signSigV4Request(req *http.Request, body []byte, accessKey, secretKey, region, service string) error {
|
||||
if req == nil {
|
||||
return fmt.Errorf("nil request")
|
||||
}
|
||||
if strings.TrimSpace(accessKey) == "" || strings.TrimSpace(secretKey) == "" {
|
||||
return fmt.Errorf("missing signing credentials")
|
||||
}
|
||||
if strings.TrimSpace(region) == "" || strings.TrimSpace(service) == "" {
|
||||
return fmt.Errorf("missing signing scope")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
shortDate := now.Format("20060102")
|
||||
scope := shortDate + "/" + region + "/" + service + "/aws4_request"
|
||||
|
||||
payloadHash := sha256Hex(body)
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", payloadHash)
|
||||
|
||||
host := req.URL.Host
|
||||
signedHeaders := []string{"host", "x-amz-content-sha256", "x-amz-date"}
|
||||
canonicalRequest, signedHeadersRaw := buildCanonicalRequest(req, signedHeaders, payloadHash)
|
||||
stringToSign := buildStringToSign(amzDate, scope, canonicalRequest)
|
||||
signature := hex.EncodeToString(hmacSHA256(deriveSigningKey(secretKey, shortDate, region, service), stringToSign))
|
||||
|
||||
authHeader := fmt.Sprintf(
|
||||
"%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
sigV4Algorithm,
|
||||
accessKey,
|
||||
scope,
|
||||
signedHeadersRaw,
|
||||
signature,
|
||||
)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
req.Host = host
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCanonicalRequest(req *http.Request, signedHeaders []string, payloadHash string) (string, string) {
|
||||
canonicalHeaders, signedHeadersRaw := canonicalHeaders(req, signedHeaders)
|
||||
return strings.Join([]string{
|
||||
req.Method,
|
||||
canonicalPath(req.URL),
|
||||
canonicalQuery(req.URL),
|
||||
canonicalHeaders,
|
||||
signedHeadersRaw,
|
||||
payloadHash,
|
||||
}, "\n"), signedHeadersRaw
|
||||
}
|
||||
|
||||
func canonicalPath(u *url.URL) string {
|
||||
if u == nil {
|
||||
return "/"
|
||||
}
|
||||
path := u.EscapedPath()
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func canonicalQuery(u *url.URL) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
values := u.Query()
|
||||
type pair struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
pairs := make([]pair, 0, len(values))
|
||||
for key, vals := range values {
|
||||
if len(vals) == 0 {
|
||||
pairs = append(pairs, pair{key: key, value: ""})
|
||||
continue
|
||||
}
|
||||
for _, v := range vals {
|
||||
pairs = append(pairs, pair{key: key, value: v})
|
||||
}
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].key == pairs[j].key {
|
||||
return pairs[i].value < pairs[j].value
|
||||
}
|
||||
return pairs[i].key < pairs[j].key
|
||||
})
|
||||
out := make([]string, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
out = append(out, awsEncodeQuery(p.key)+"="+awsEncodeQuery(p.value))
|
||||
}
|
||||
return strings.Join(out, "&")
|
||||
}
|
||||
|
||||
func awsEncodeQuery(value string) string {
|
||||
encoded := url.QueryEscape(value)
|
||||
encoded = strings.ReplaceAll(encoded, "+", "%20")
|
||||
encoded = strings.ReplaceAll(encoded, "*", "%2A")
|
||||
encoded = strings.ReplaceAll(encoded, "%7E", "~")
|
||||
return encoded
|
||||
}
|
||||
|
||||
func canonicalHeaders(req *http.Request, headers []string) (string, string) {
|
||||
names := make([]string, 0, len(headers))
|
||||
lines := make([]string, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
name := strings.ToLower(strings.TrimSpace(h))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var value string
|
||||
if name == "host" {
|
||||
value = req.URL.Host
|
||||
} else {
|
||||
value = strings.Join(req.Header.Values(http.CanonicalHeaderKey(name)), ",")
|
||||
}
|
||||
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
|
||||
names = append(names, name)
|
||||
lines = append(lines, name+":"+value)
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n", strings.Join(names, ";")
|
||||
}
|
||||
|
||||
func buildStringToSign(amzDate, scope, canonicalRequest string) string {
|
||||
hash := sha256.Sum256([]byte(canonicalRequest))
|
||||
return strings.Join([]string{
|
||||
sigV4Algorithm,
|
||||
amzDate,
|
||||
scope,
|
||||
hex.EncodeToString(hash[:]),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func deriveSigningKey(secret, date, region, service string) []byte {
|
||||
kDate := hmacSHA256([]byte("AWS4"+secret), date)
|
||||
kRegion := hmacSHA256(kDate, region)
|
||||
kService := hmacSHA256(kRegion, service)
|
||||
return hmacSHA256(kService, "aws4_request")
|
||||
}
|
||||
|
||||
func hmacSHA256(key []byte, message string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(message))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func sha256Hex(payload []byte) string {
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
306
cmd/admin_user.go
Normal file
306
cmd/admin_user.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newAdminUserCommand(opts *adminOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "user",
|
||||
Short: "Manage auth users",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newAdminUserCreateCommand(opts))
|
||||
cmd.AddCommand(newAdminUserListCommand(opts))
|
||||
cmd.AddCommand(newAdminUserGetCommand(opts))
|
||||
cmd.AddCommand(newAdminUserDeleteCommand(opts))
|
||||
cmd.AddCommand(newAdminUserSetStatusCommand(opts))
|
||||
cmd.AddCommand(newAdminUserSetRoleCommand(opts))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAdminUserCreateCommand(opts *adminOptions) *cobra.Command {
|
||||
var (
|
||||
accessKey string
|
||||
secretKey string
|
||||
status string
|
||||
role string
|
||||
bucket string
|
||||
prefix string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a user",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
accessKey = strings.TrimSpace(accessKey)
|
||||
if accessKey == "" {
|
||||
return usageError("fs admin user create --access-key <id> --role admin|readwrite|readonly [--status active|disabled] [--bucket <name>] [--prefix <path>]", "--access-key is required")
|
||||
}
|
||||
policy, err := buildPolicyFromRole(rolePolicyOptions{
|
||||
Role: role,
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
})
|
||||
if err != nil {
|
||||
return usageError("fs admin user create --access-key <id> --role admin|readwrite|readonly [--status active|disabled] [--bucket <name>] [--prefix <path>]", err.Error())
|
||||
}
|
||||
|
||||
client, err := newAdminAPIClient(opts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := client.CreateUser(context.Background(), createUserRequest{
|
||||
AccessKeyID: accessKey,
|
||||
SecretKey: strings.TrimSpace(secretKey),
|
||||
Status: strings.TrimSpace(status),
|
||||
Policy: policy,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.JSON {
|
||||
return writeJSON(cmd.OutOrStdout(), out)
|
||||
}
|
||||
if err := writeUserTable(cmd.OutOrStdout(), out, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(out.SecretKey) != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nsecretKey is only returned once during create; store it securely.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&accessKey, "access-key", "", "User access key ID")
|
||||
cmd.Flags().StringVar(&secretKey, "secret-key", "", "User secret key (optional; auto-generated when omitted)")
|
||||
cmd.Flags().StringVar(&status, "status", "active", "User status: active|disabled")
|
||||
cmd.Flags().StringVar(&role, "role", "readwrite", "Role: admin|readwrite|readonly")
|
||||
cmd.Flags().StringVar(&bucket, "bucket", "*", "Bucket scope, defaults to *")
|
||||
cmd.Flags().StringVar(&prefix, "prefix", "*", "Prefix scope, defaults to *")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAdminUserListCommand(opts *adminOptions) *cobra.Command {
|
||||
var (
|
||||
limit int
|
||||
cursor string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List users",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if limit < 1 || limit > 1000 {
|
||||
return usageError("fs admin user list [--limit 1-1000] [--cursor <token>]", "--limit must be between 1 and 1000")
|
||||
}
|
||||
client, err := newAdminAPIClient(opts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := client.ListUsers(context.Background(), limit, cursor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.JSON {
|
||||
return writeJSON(cmd.OutOrStdout(), out)
|
||||
}
|
||||
return writeUserListTable(cmd.OutOrStdout(), out)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&limit, "limit", 100, "List page size (1-1000)")
|
||||
cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from previous list call")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAdminUserGetCommand(opts *adminOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <access-key-id>",
|
||||
Short: "Get one user",
|
||||
Args: requireAccessKeyArg("fs admin user get <access-key-id>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAdminAPIClient(opts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := client.GetUser(context.Background(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.JSON {
|
||||
return writeJSON(cmd.OutOrStdout(), out)
|
||||
}
|
||||
return writeUserTable(cmd.OutOrStdout(), out, false)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAdminUserDeleteCommand(opts *adminOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <access-key-id>",
|
||||
Short: "Delete one user",
|
||||
Args: requireAccessKeyArg("fs admin user delete <access-key-id>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAdminAPIClient(opts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.DeleteUser(context.Background(), args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.JSON {
|
||||
return writeJSON(cmd.OutOrStdout(), map[string]string{
|
||||
"status": "deleted",
|
||||
"accessKeyId": args[0],
|
||||
})
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted user %s\n", args[0])
|
||||
return err
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAdminUserSetStatusCommand(opts *adminOptions) *cobra.Command {
|
||||
var status string
|
||||
cmd := &cobra.Command{
|
||||
Use: "set-status <access-key-id>",
|
||||
Short: "Set user status",
|
||||
Args: requireAccessKeyArg("fs admin user set-status <access-key-id> --status active|disabled"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return usageError("fs admin user set-status <access-key-id> --status active|disabled", "--status is required")
|
||||
}
|
||||
normalized := strings.ToLower(status)
|
||||
if normalized != "active" && normalized != "disabled" {
|
||||
return usageError("fs admin user set-status <access-key-id> --status active|disabled", "--status must be active or disabled")
|
||||
}
|
||||
client, err := newAdminAPIClient(opts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := client.SetUserStatus(context.Background(), args[0], normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.JSON {
|
||||
return writeJSON(cmd.OutOrStdout(), out)
|
||||
}
|
||||
return writeUserTable(cmd.OutOrStdout(), out, false)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&status, "status", "", "User status: active|disabled")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAdminUserSetRoleCommand(opts *adminOptions) *cobra.Command {
|
||||
var (
|
||||
role string
|
||||
bucket string
|
||||
prefix string
|
||||
replace bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "set-role <access-key-id>",
|
||||
Short: "Add or replace user role policy statement",
|
||||
Args: requireAccessKeyArg("fs admin user set-role <access-key-id> --role admin|readwrite|readonly [--bucket <name>] [--prefix <path>] [--replace]"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
policy, err := buildPolicyFromRole(rolePolicyOptions{
|
||||
Role: role,
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
})
|
||||
if err != nil {
|
||||
return usageError("fs admin user set-role <access-key-id> --role admin|readwrite|readonly [--bucket <name>] [--prefix <path>] [--replace]", err.Error())
|
||||
}
|
||||
|
||||
client, err := newAdminAPIClient(opts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
finalPolicy := policy
|
||||
if !replace {
|
||||
existing, err := client.GetUser(context.Background(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finalPolicy = mergePolicyStatements(existing.Policy, policy)
|
||||
}
|
||||
|
||||
out, err := client.SetUserPolicy(context.Background(), args[0], finalPolicy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.JSON {
|
||||
return writeJSON(cmd.OutOrStdout(), out)
|
||||
}
|
||||
return writeUserTable(cmd.OutOrStdout(), out, false)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&role, "role", "readwrite", "Role: admin|readwrite|readonly")
|
||||
cmd.Flags().StringVar(&bucket, "bucket", "*", "Bucket scope, defaults to *")
|
||||
cmd.Flags().StringVar(&prefix, "prefix", "*", "Prefix scope, defaults to *")
|
||||
cmd.Flags().BoolVar(&replace, "replace", false, "Replace all existing policy statements instead of appending")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mergePolicyStatements(existing *adminPolicy, addition adminPolicy) adminPolicy {
|
||||
merged := adminPolicy{}
|
||||
if existing != nil {
|
||||
merged.Principal = existing.Principal
|
||||
merged.Statements = append(merged.Statements, existing.Statements...)
|
||||
}
|
||||
|
||||
if len(addition.Statements) == 0 {
|
||||
return merged
|
||||
}
|
||||
|
||||
stmt := addition.Statements[0]
|
||||
for _, current := range merged.Statements {
|
||||
if policyStatementsEqual(current, stmt) {
|
||||
return merged
|
||||
}
|
||||
}
|
||||
merged.Statements = append(merged.Statements, stmt)
|
||||
return merged
|
||||
}
|
||||
|
||||
func policyStatementsEqual(a, b adminPolicyStatement) bool {
|
||||
if a.Effect != b.Effect || a.Bucket != b.Bucket || a.Prefix != b.Prefix {
|
||||
return false
|
||||
}
|
||||
if len(a.Actions) != len(b.Actions) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Actions {
|
||||
if a.Actions[i] != b.Actions[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requireAccessKeyArg(usage string) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return usageError(usage, "missing or invalid <access-key-id> argument")
|
||||
}
|
||||
if strings.TrimSpace(args[0]) == "" {
|
||||
return usageError(usage, "access key id cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func usageError(usage, message string) error {
|
||||
return fmt.Errorf("%s\nusage: %s", message, usage)
|
||||
}
|
||||
20
cmd/buildinfo.go
Normal file
20
cmd/buildinfo.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package cmd
|
||||
|
||||
type BuildInfo struct {
|
||||
Version string
|
||||
Commit string
|
||||
Date string
|
||||
}
|
||||
|
||||
func (b BuildInfo) normalized() BuildInfo {
|
||||
if b.Version == "" {
|
||||
b.Version = "dev"
|
||||
}
|
||||
if b.Commit == "" {
|
||||
b.Commit = "none"
|
||||
}
|
||||
if b.Date == "" {
|
||||
b.Date = "unknown"
|
||||
}
|
||||
return b
|
||||
}
|
||||
26
cmd/execute.go
Normal file
26
cmd/execute.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fs/app"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func Execute(build BuildInfo) error {
|
||||
build = build.normalized()
|
||||
|
||||
if len(os.Args) == 1 {
|
||||
return runServerWithSignals()
|
||||
}
|
||||
|
||||
root := newRootCommand(build)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func runServerWithSignals() error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return app.RunServer(ctx)
|
||||
}
|
||||
36
cmd/root.go
Normal file
36
cmd/root.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRootCommand(build BuildInfo) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "fs",
|
||||
Short: "fs object storage server and admin CLI",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: build.Version,
|
||||
}
|
||||
|
||||
root.SetVersionTemplate(versionTemplate(build))
|
||||
root.AddCommand(newServerCommand())
|
||||
root.AddCommand(newAdminCommand(build))
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print build and runtime version information",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "version=%s commit=%s date=%s go=%s\n", build.Version, build.Commit, build.Date, runtime.Version())
|
||||
return err
|
||||
},
|
||||
})
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func versionTemplate(build BuildInfo) string {
|
||||
return fmt.Sprintf("version=%s commit=%s date=%s\n", build.Version, build.Commit, build.Date)
|
||||
}
|
||||
15
cmd/server.go
Normal file
15
cmd/server.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newServerCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Run fs object storage server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runServerWithSignals()
|
||||
},
|
||||
}
|
||||
}
|
||||
5
go.mod
5
go.mod
@@ -5,10 +5,13 @@ go 1.25.7
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/spf13/cobra v1.10.1
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
)
|
||||
|
||||
9
go.sum
9
go.sum
@@ -1,13 +1,21 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
@@ -16,5 +24,6 @@ golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
94
main.go
94
main.go
@@ -1,90 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fs/api"
|
||||
"fs/auth"
|
||||
"fs/logging"
|
||||
"fs/metadata"
|
||||
"fs/service"
|
||||
"fs/storage"
|
||||
"fs/utils"
|
||||
"fs/cmd"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := utils.NewConfig()
|
||||
logConfig := logging.ConfigFromValues(config.LogLevel, config.LogFormat, config.AuditLog)
|
||||
authConfig := auth.ConfigFromValues(
|
||||
config.AuthEnabled,
|
||||
config.AuthRegion,
|
||||
config.AuthSkew,
|
||||
config.AuthMaxPresign,
|
||||
config.AuthMasterKey,
|
||||
config.AuthBootstrapAccessKey,
|
||||
config.AuthBootstrapSecretKey,
|
||||
config.AuthBootstrapPolicy,
|
||||
)
|
||||
logger := logging.NewLogger(logConfig)
|
||||
logger.Info("boot",
|
||||
"log_level", logConfig.LevelName,
|
||||
"log_format", logConfig.Format,
|
||||
"audit_log", logConfig.Audit,
|
||||
"data_path", config.DataPath,
|
||||
"multipart_retention_hours", int(config.MultipartCleanupRetention/time.Hour),
|
||||
"auth_enabled", authConfig.Enabled,
|
||||
"auth_region", authConfig.Region,
|
||||
"admin_api_enabled", config.AdminAPIEnabled,
|
||||
)
|
||||
|
||||
if err := os.MkdirAll(config.DataPath, 0o755); err != nil {
|
||||
logger.Error("failed_to_prepare_data_path", "path", config.DataPath, "error", err)
|
||||
return
|
||||
build := cmd.BuildInfo{
|
||||
Version: version,
|
||||
Commit: commit,
|
||||
Date: date,
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(config.DataPath, "metadata.db")
|
||||
metadataHandler, err := metadata.NewMetadataHandler(dbPath)
|
||||
if err != nil {
|
||||
logger.Error("failed_to_initialize_metadata_handler", "error", err)
|
||||
return
|
||||
if err := cmd.Execute(build); err != nil {
|
||||
_, _ = os.Stderr.WriteString(err.Error() + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
blobHandler, err := storage.NewBlobStore(config.DataPath, config.ChunkSize)
|
||||
if err != nil {
|
||||
_ = metadataHandler.Close()
|
||||
logger.Error("failed_to_initialize_blob_store", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
objectService := service.NewObjectService(metadataHandler, blobHandler, config.MultipartCleanupRetention)
|
||||
authService, err := auth.NewService(authConfig, metadataHandler)
|
||||
if err != nil {
|
||||
_ = metadataHandler.Close()
|
||||
logger.Error("failed_to_initialize_auth_service", "error", err)
|
||||
return
|
||||
}
|
||||
if err := authService.EnsureBootstrap(); err != nil {
|
||||
_ = metadataHandler.Close()
|
||||
logger.Error("failed_to_ensure_bootstrap_auth_identity", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
handler := api.NewHandler(objectService, logger, logConfig, authService, config.AdminAPIEnabled)
|
||||
addr := config.Address + ":" + strconv.Itoa(config.Port)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if config.GcEnabled {
|
||||
go objectService.RunGC(ctx, config.GcInterval)
|
||||
}
|
||||
|
||||
if err = handler.Start(ctx, addr); err != nil {
|
||||
logger.Error("server_stopped_with_error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user