diff --git a/PROGRESS.md b/PROGRESS.md index f60565c..f9acf41 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -46,6 +46,15 @@ Phase 6: Manifest Signing (to be planned). ## Standalone Additions +- **Deployment to rift**: sgardd deployed as Podman container on rift behind + mc-proxy (L4 SNI passthrough on :9443, multiplexed with metacrypt gRPC). + TLS cert issued by Metacrypt, SSH-key auth. DNS at + `sgard.svc.mcp.metacircular.net`. +- **Default remote config**: `sgard remote set/show` commands. Saves addr, + TLS, and CA path to `/remote.yaml`. `dialRemote` merges saved config + with CLI flags (flags win). Removes need for `--remote`/`--tls` on every + push/pull. + ## Known Issues / Decisions Deferred - **Manifest signing**: deferred — trust model (which key signs, how do @@ -100,3 +109,5 @@ Phase 6: Manifest Signing (to be planned). | 2026-03-24 | 31 | Proto + sync: only/never fields on ManifestEntry, conversion, round-trip test. | | 2026-03-24 | 32 | Phase 5 polish: e2e test (targeting + push/pull + restore), docs updated. Phase 5 complete. | | 2026-03-25 | — | `sgard info` command: shows detailed file information (status, hash, timestamps, mode, encryption, targeting). 5 tests. | +| 2026-03-25 | — | Deploy sgardd to rift: Dockerfile, docker-compose, mc-proxy L4 route on :9443, Metacrypt TLS cert, DNS. | +| 2026-03-25 | — | `sgard remote set/show`: persistent remote config in `/remote.yaml` (addr, tls, tls_ca). | diff --git a/cmd/sgard/main.go b/cmd/sgard/main.go index babb36f..e207e01 100644 --- a/cmd/sgard/main.go +++ b/cmd/sgard/main.go @@ -37,28 +37,49 @@ func defaultRepo() string { return filepath.Join(home, ".sgard") } -// resolveRemote returns the remote address from flag, env, or repo config file. -func resolveRemote() (string, error) { - if remoteFlag != "" { - return remoteFlag, nil +// resolveRemoteConfig returns the effective remote address, TLS flag, and CA +// path by merging CLI flags, environment, and the saved remote.yaml config. +// CLI flags take precedence, then env, then the saved config. +func resolveRemoteConfig() (addr string, useTLS bool, caPath string, err error) { + // Start with saved config as baseline. + saved, _ := loadRemoteConfig() + + // Address: flag > env > saved > legacy file. + addr = remoteFlag + if addr == "" { + addr = os.Getenv("SGARD_REMOTE") } - if env := os.Getenv("SGARD_REMOTE"); env != "" { - return env, nil + if addr == "" && saved != nil { + addr = saved.Addr } - // Try /remote file. - data, err := os.ReadFile(filepath.Join(repoFlag, "remote")) - if err == nil { - addr := strings.TrimSpace(string(data)) - if addr != "" { - return addr, nil + if addr == "" { + data, ferr := os.ReadFile(filepath.Join(repoFlag, "remote")) + if ferr == nil { + addr = strings.TrimSpace(string(data)) } } - return "", fmt.Errorf("no remote configured; use --remote, SGARD_REMOTE, or create %s/remote", repoFlag) + if addr == "" { + return "", false, "", fmt.Errorf("no remote configured; use 'sgard remote set' or --remote") + } + + // TLS: flag wins if explicitly set, otherwise use saved. + useTLS = tlsFlag + if !useTLS && saved != nil { + useTLS = saved.TLS + } + + // CA: flag wins if set, otherwise use saved. + caPath = tlsCAFlag + if caPath == "" && saved != nil { + caPath = saved.TLSCA + } + + return addr, useTLS, caPath, nil } // dialRemote creates a gRPC client with token-based auth and auto-renewal. func dialRemote(ctx context.Context) (*client.Client, func(), error) { - addr, err := resolveRemote() + addr, useTLS, caPath, err := resolveRemoteConfig() if err != nil { return nil, nil, err } @@ -72,16 +93,16 @@ func dialRemote(ctx context.Context) (*client.Client, func(), error) { creds := client.NewTokenCredentials(cachedToken) var transportCreds grpc.DialOption - if tlsFlag { + if useTLS { tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} - if tlsCAFlag != "" { - caPEM, err := os.ReadFile(tlsCAFlag) + if caPath != "" { + caPEM, err := os.ReadFile(caPath) if err != nil { return nil, nil, fmt.Errorf("reading CA cert: %w", err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caPEM) { - return nil, nil, fmt.Errorf("failed to parse CA cert %s", tlsCAFlag) + return nil, nil, fmt.Errorf("failed to parse CA cert %s", caPath) } tlsCfg.RootCAs = pool } diff --git a/cmd/sgard/prune.go b/cmd/sgard/prune.go index 3bc26a5..dd32742 100644 --- a/cmd/sgard/prune.go +++ b/cmd/sgard/prune.go @@ -13,7 +13,7 @@ var pruneCmd = &cobra.Command{ Short: "Remove orphaned blobs not referenced by the manifest", Long: "Remove orphaned blobs locally, or on the remote server with --remote.", RunE: func(cmd *cobra.Command, args []string) error { - addr, _ := resolveRemote() + addr, _, _, _ := resolveRemoteConfig() if addr != "" { return pruneRemote() diff --git a/cmd/sgard/remote.go b/cmd/sgard/remote.go new file mode 100644 index 0000000..23edc45 --- /dev/null +++ b/cmd/sgard/remote.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type remoteConfig struct { + Addr string `yaml:"addr"` + TLS bool `yaml:"tls"` + TLSCA string `yaml:"tls_ca,omitempty"` +} + +func remoteConfigPath() string { + return filepath.Join(repoFlag, "remote.yaml") +} + +func loadRemoteConfig() (*remoteConfig, error) { + data, err := os.ReadFile(remoteConfigPath()) + if err != nil { + return nil, err + } + var cfg remoteConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing remote config: %w", err) + } + return &cfg, nil +} + +func saveRemoteConfig(cfg *remoteConfig) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("encoding remote config: %w", err) + } + return os.WriteFile(remoteConfigPath(), data, 0o644) +} + +var remoteCmd = &cobra.Command{ + Use: "remote", + Short: "Manage default remote server", +} + +var remoteSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set the default remote address", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := &remoteConfig{ + Addr: args[0], + TLS: tlsFlag, + TLSCA: tlsCAFlag, + } + if err := saveRemoteConfig(cfg); err != nil { + return err + } + fmt.Printf("Remote set: %s", cfg.Addr) + if cfg.TLS { + fmt.Print(" (TLS") + if cfg.TLSCA != "" { + fmt.Printf(", CA: %s", cfg.TLSCA) + } + fmt.Print(")") + } + fmt.Println() + return nil + }, +} + +var remoteShowCmd = &cobra.Command{ + Use: "show", + Short: "Show the configured remote", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadRemoteConfig() + if err != nil { + if os.IsNotExist(err) { + fmt.Println("No remote configured.") + return nil + } + return err + } + fmt.Printf("addr: %s\n", cfg.Addr) + fmt.Printf("tls: %v\n", cfg.TLS) + if cfg.TLSCA != "" { + fmt.Printf("tls-ca: %s\n", cfg.TLSCA) + } + return nil + }, +} + +func init() { + remoteCmd.AddCommand(remoteSetCmd, remoteShowCmd) + rootCmd.AddCommand(remoteCmd) +} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..cf06144 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,30 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /sgardd ./cmd/sgardd + +# Runtime stage +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -h /srv/sgard sgard + +COPY --from=builder /sgardd /usr/local/bin/sgardd + +VOLUME /srv/sgard +EXPOSE 9473 + +USER sgard + +ENTRYPOINT ["sgardd", \ + "--repo", "/srv/sgard", \ + "--authorized-keys", "/srv/sgard/authorized_keys", \ + "--tls-cert", "/srv/sgard/certs/sgard.pem", \ + "--tls-key", "/srv/sgard/certs/sgard.key"] diff --git a/deploy/docker/docker-compose-rift.yml b/deploy/docker/docker-compose-rift.yml new file mode 100644 index 0000000..77eca22 --- /dev/null +++ b/deploy/docker/docker-compose-rift.yml @@ -0,0 +1,16 @@ +services: + sgardd: + image: localhost/sgardd:latest + container_name: sgardd + restart: unless-stopped + user: "0:0" + ports: + - "127.0.0.1:19473:9473" + volumes: + - /srv/sgard:/srv/sgard + healthcheck: + test: ["CMD", "true"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s