feat: restic proxy (#611)

* feat(restic proxy): initial commit

* feat(restic proxy): working impl.

* fix(restic proxy): use repoId as grant key

* fix(restic proxy): handle http errors for meta

* fix(restic proxy): close http response body

* fix(restic proxy): use path not encoded path

* fix(restic proxy): unset RawPath

* docs(restic proxy): notes for later work

* fix(restic proxy): timeout on meta requests

* fix(restic proxy): concurrent minting

* fix(restic proxy): don't nil error

* fix(restic proxy): robust claims/exp check

* docs(restic proxy): document next work

* fix(restic proxy): respect log level

* chore(restic proxy): set default log level to info

* fix(restic proxy): permit temporary failures

* refactor(restic proxy): remove exit dead code

* chore: mise config

* feat(restic proxy): configurable well known URL

* chore(restic proxy): use real Exp

* refactor(restic proxy): use username as repoId

* fix(restic proxy): check repositoryId is set

* test(restic proxy): generate tests

* feat(restic proxy): publish Dockerfile

* feat(standalone app): include restic-proxy binary

* refactor(restic proxy): allow configuring api & meta URLs
test: generate accompanying tests

* chore: set config_roots

* test: drop comments from generated tests

* docs(restic proxy): add to USER_MANUAL

* fix(restic proxy): remint grant if auth token changes

* chore(restic proxy): update mise.toml

* refactor(restic proxy): differentiate session token w. 'token'
feat(restic proxy): denial cache
test(restic proxy): update generated tests for deny cache

* chore: remove `restic-proxy:check` from `check` dependencies (covered by
lint)
This commit is contained in:
Paul Makles
2026-09-02 16:37:31 +01:00
committed by GitHub
parent f8497b8033
commit 76d48c26b4
28 changed files with 2313 additions and 4 deletions
+3 -2
View File
@@ -55,6 +55,7 @@ jobs:
michael: ['packages/michael/**', '.dockerignore'] michael: ['packages/michael/**', '.dockerignore']
columbo: ['packages/columbo/**', '.dockerignore'] columbo: ['packages/columbo/**', '.dockerignore']
monk: ['packages/monk/**', '.dockerignore'] monk: ['packages/monk/**', '.dockerignore']
restic-proxy: ['packages/restic-proxy/**', '.dockerignore']
# A Dockerfile without a filter is the silent gap this workflow exists # A Dockerfile without a filter is the silent gap this workflow exists
# to close: its first build would happen post-merge (or at release, for # to close: its first build would happen post-merge (or at release, for
@@ -63,10 +64,10 @@ jobs:
# name whose filter key is renamed or dropped never builds again. # name whose filter key is renamed or dropped never builds again.
- name: Require every Dockerfile to carry a filter - name: Require every Dockerfile to carry a filter
run: | run: |
covered='yucca-api yucca-admin-api yucca-metrics-worker futo-backups-bot web standalone-app michael columbo monk' covered='yucca-api yucca-admin-api yucca-metrics-worker futo-backups-bot web standalone-app michael columbo monk restic-proxy'
excluded='mock-oidc-provider mock-postmark-provider' excluded='mock-oidc-provider mock-postmark-provider'
# Apps with no Deploy leg, delivered by the release-event workflows. # Apps with no Deploy leg, delivered by the release-event workflows.
publish_only='standalone-app' publish_only='standalone-app restic-proxy'
deploy_apps="$(yq '.jobs.build.strategy.matrix.app[].name' .github/workflows/deploy.yml | tr '\n' ' ')" deploy_apps="$(yq '.jobs.build.strategy.matrix.app[].name' .github/workflows/deploy.yml | tr '\n' ' ')"
[ -n "${deploy_apps// /}" ] || { echo "::error::no apps parsed from the Deploy build matrix"; exit 1; } [ -n "${deploy_apps// /}" ] || { echo "::error::no apps parsed from the Deploy build matrix"; exit 1; }
fail=0 fail=0
+12
View File
@@ -43,3 +43,15 @@ jobs:
image: futo-backups-standalone image: futo-backups-standalone
context: . context: .
dockerfile: packages/standalone-app/Dockerfile dockerfile: packages/standalone-app/Dockerfile
docker-restic-proxy:
name: Publish restic-proxy image
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@50dc3a14f0606ecd8fc28d78d3a3c655115ab695 # multi-runner-build-workflow-v3.1.0
permissions:
contents: read
actions: read
packages: write
with:
image: futo-backups-restic-proxy
context: .
dockerfile: packages/restic-proxy/Dockerfile
+1 -1
View File
@@ -1,7 +1,7 @@
monorepo_root = true monorepo_root = true
[monorepo] [monorepo]
config_roots = ["ansible/ceph", "ansible/talos"] config_roots = ["ansible/ceph", "ansible/talos", "packages/restic-proxy"]
[tools] [tools]
node = "25.9.0" node = "25.9.0"
+1 -1
View File
@@ -3,7 +3,7 @@
set -e set -e
status=0 status=0
for pkg in michael yuctl columbo monk; do for pkg in michael yuctl columbo monk restic-proxy; do
(cd "packages/$pkg" && golangci-lint run ./... "$@") || status=1 (cd "packages/$pkg" && golangci-lint run ./... "$@") || status=1
done done
exit $status exit $status
+27
View File
@@ -85,3 +85,30 @@ docker compose up -d
> This will bump you to the latest<sup>†</sup> `main`/development version of Immich. > This will bump you to the latest<sup>†</sup> `main`/development version of Immich.
> >
> <sup>†</sup> Or at least, generally quite recent. > <sup>†</sup> Or at least, generally quite recent.
### Using with restic
> [!CAUTION]
> This is experimental.
> [!CAUTION]
> This is intentionally left uncomplete, as the token side of things is still being worked on.
```yaml
# compose.yml
name: futo-backups-proxy
services:
restic-proxy:
image: ghcr.io/immich-app/futo-backups-restic-proxy:v0
ports:
- 127.0.0.1:1434:1434
restart: always
```
And then point restic at it:
```bash
# parameters subject to change
restic -r rest:http://<REPOSITORY>:<SESSION TOKEN>@127.0.0.1:1434 init
```
+1
View File
@@ -0,0 +1 @@
tmp
+27
View File
@@ -0,0 +1,27 @@
ARG ALPINE_VERSION=3.23
ARG ALPINE_IMAGE=alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
FROM golang:1.27-alpine${ALPINE_VERSION} AS builder
WORKDIR /app
COPY packages/restic-proxy/go.mod packages/restic-proxy/go.sum ./
RUN go mod download
COPY packages/restic-proxy/ ./
RUN CGO_ENABLED=0 go build -o /restic-proxy .
FROM ${ALPINE_IMAGE}
RUN apk add --no-cache dumb-init \
&& addgroup -g 1000 restic-proxy && adduser -u 1000 -G restic-proxy -s /bin/sh -D restic-proxy
USER restic-proxy
COPY --from=builder /restic-proxy /usr/local/bin/restic-proxy
ENV RESTIC_PROXY_HOST=0.0.0.0
ENV RESTIC_PROXY_PORT=1434
ENV LOG_FORMAT=json
EXPOSE 1434
CMD ["dumb-init", "restic-proxy"]
+17
View File
@@ -0,0 +1,17 @@
module restic-proxy
go 1.27.0
require (
github.com/caarlos0/env/v11 v11.4.1 // direct
github.com/cornelk/hashmap v1.0.8 // direct
github.com/rs/zerolog v1.35.1
)
require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rs/xid v1.6.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.29.0 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cornelk/hashmap v1.0.8 h1:nv0AWgw02n+iDcawr5It4CjQIAcdMMKRrs10HOJYlrc=
github.com/cornelk/hashmap v1.0.8/go.mod h1:RfZb7JO3RviW/rT6emczVuC/oxpdz4UsSB2LJSclR1k=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU=
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -0,0 +1,22 @@
package client
import (
"net/http"
"time"
"restic-proxy/internal/meta"
)
type Client struct {
api meta.Api
http *http.Client
}
func New(api meta.Api) Client {
return Client{
api: api,
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
@@ -0,0 +1,116 @@
package client
import (
"context"
"encoding/base64"
"encoding/json/v2"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var accessTokenCookie = "yucca-access-token"
type StatusError struct {
Code int
Status string
}
func (err *StatusError) Error() string {
return fmt.Sprintf("could not generate restic URL: %s", err.Status)
}
type Grant struct {
SessionToken string
Username string
Password string
Scheme string
Host string
Path string
ExpiresAt time.Time
}
func (client *Client) Grant(ctx context.Context, token string, repositoryId string) (Grant, error) {
request, err := http.NewRequestWithContext(ctx, "POST", client.api.Url+"/repository/"+repositoryId+"/restic", nil)
if err != nil {
return Grant{}, err
}
request.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: token})
response, err := client.http.Do(request)
if err != nil {
return Grant{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return Grant{}, &StatusError{Code: response.StatusCode, Status: response.Status}
}
body, err := io.ReadAll(response.Body)
if err != nil {
return Grant{}, err
}
var repositoryGrant struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &repositoryGrant); err != nil {
return Grant{}, err
}
parsed, err := url.Parse(strings.TrimPrefix(repositoryGrant.URL, "rest:"))
if err != nil {
return Grant{}, fmt.Errorf("could not parse restic URL: %w", err)
}
tokenString, ok := parsed.User.Password()
if !ok {
return Grant{}, fmt.Errorf("no credential in restic URL")
}
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return Grant{}, fmt.Errorf("invalid JWT from server")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Grant{}, err
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return Grant{}, err
}
if claims.Exp == 0 {
return Grant{}, fmt.Errorf("expiry missing from grant")
}
expiresAt := time.Unix(claims.Exp, 0)
if expiresAt.Before(time.Now()) {
return Grant{}, fmt.Errorf("received grant in the past")
}
return Grant{
SessionToken: token,
Username: parsed.User.Username(),
Password: tokenString,
Scheme: parsed.Scheme,
Host: parsed.Host,
Path: parsed.Path,
ExpiresAt: expiresAt,
}, nil
}
@@ -0,0 +1,278 @@
package client
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"restic-proxy/internal/meta"
)
const testToken = "user-access-token"
func makeJWT(t *testing.T, claims map[string]any) string {
t.Helper()
payload, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
}
func validJWT(t *testing.T) string {
t.Helper()
return makeJWT(t, map[string]any{"exp": time.Now().Add(time.Hour).Unix()})
}
type request struct {
method string
path string
cookie string
}
func newAPI(t *testing.T, status int, body string, seen *request) Client {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) {
if seen != nil {
seen.method = r.Method
seen.path = r.URL.Path
if cookie, err := r.Cookie(accessTokenCookie); err == nil {
seen.cookie = cookie.Value
}
}
writer.WriteHeader(status)
fmt.Fprint(writer, body)
}))
t.Cleanup(server.Close)
return New(meta.Api{Url: server.URL})
}
func resticURL(t *testing.T, host string) string {
t.Helper()
return fmt.Sprintf(`{"url":"rest:http://restic:%s@%s/repo-1"}`, validJWT(t), host)
}
func TestGrant_Success(t *testing.T) {
var seen request
client := newAPI(t, http.StatusCreated, resticURL(t, "backend.example:8000"), &seen)
grant, err := client.Grant(context.Background(), testToken, "repo-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if grant.Username != "restic" {
t.Errorf("expected username restic, got %s", grant.Username)
}
if grant.Scheme != "http" {
t.Errorf("expected scheme http, got %s", grant.Scheme)
}
if grant.Host != "backend.example:8000" {
t.Errorf("expected host backend.example:8000, got %s", grant.Host)
}
if grant.Path != "/repo-1" {
t.Errorf("expected path /repo-1, got %s", grant.Path)
}
if grant.Password == "" {
t.Error("expected the minted JWT as the password")
}
if !grant.ExpiresAt.After(time.Now()) {
t.Errorf("expected an expiry in the future, got %s", grant.ExpiresAt)
}
}
func TestGrant_SendsAccessTokenCookie(t *testing.T) {
var seen request
client := newAPI(t, http.StatusCreated, resticURL(t, "backend.example:8000"), &seen)
if _, err := client.Grant(context.Background(), testToken, "repo-1"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if seen.cookie != testToken {
t.Errorf("expected the access token in the %s cookie, got %q", accessTokenCookie, seen.cookie)
}
if seen.method != http.MethodPost {
t.Errorf("expected POST, got %s", seen.method)
}
if seen.path != "/repository/repo-1/restic" {
t.Errorf("unexpected path: %s", seen.path)
}
}
func TestGrant_StatusError(t *testing.T) {
cases := []struct {
name string
status int
}{
{name: "unauthorized", status: http.StatusUnauthorized},
{name: "forbidden", status: http.StatusForbidden},
{name: "not found", status: http.StatusNotFound},
{name: "server error", status: http.StatusInternalServerError},
{name: "ok is not created", status: http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := newAPI(t, tc.status, `{}`, nil)
_, err := client.Grant(context.Background(), testToken, "repo-1")
if err == nil {
t.Fatalf("expected an error for status %d", tc.status)
}
var status *StatusError
if !errors.As(err, &status) {
t.Fatalf("expected a *StatusError, got %T", err)
}
if status.Code != tc.status {
t.Errorf("expected code %d, got %d", tc.status, status.Code)
}
})
}
}
func TestGrant_MalformedResponses(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{name: "not json", body: `<html>`, want: ""},
{name: "no credential", body: `{"url":"rest:http://backend.example/repo-1"}`, want: "no credential in restic URL"},
{name: "credential is not a jwt", body: `{"url":"rest:http://restic:opaque@backend.example/repo-1"}`, want: "invalid JWT from server"},
{name: "payload is not base64", body: `{"url":"rest:http://restic:header.!!!.signature@backend.example/repo-1"}`, want: ""},
{name: "payload is not json", body: `{"url":"rest:http://restic:header.bm90LWpzb24.signature@backend.example/repo-1"}`, want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := newAPI(t, http.StatusCreated, tc.body, nil)
_, err := client.Grant(context.Background(), testToken, "repo-1")
if err == nil {
t.Fatal("expected an error")
}
if tc.want != "" && !strings.Contains(err.Error(), tc.want) {
t.Errorf("expected %q, got %v", tc.want, err)
}
})
}
}
func TestGrant_ExpiryMissing(t *testing.T) {
token := makeJWT(t, map[string]any{"sub": "someone"})
body := fmt.Sprintf(`{"url":"rest:http://restic:%s@backend.example/repo-1"}`, token)
client := newAPI(t, http.StatusCreated, body, nil)
_, err := client.Grant(context.Background(), testToken, "repo-1")
if err == nil {
t.Fatal("expected an error for a JWT carrying no exp")
}
if !strings.Contains(err.Error(), "expiry missing from grant") {
t.Errorf("unexpected error: %v", err)
}
}
func TestGrant_Unreachable(t *testing.T) {
client := New(meta.Api{Url: "http://127.0.0.1:1"})
_, err := client.Grant(context.Background(), testToken, "repo-1")
if err == nil {
t.Fatal("expected an error when the API refuses the connection")
}
var status *StatusError
if errors.As(err, &status) {
t.Error("a transport failure must not surface as a StatusError")
}
}
func TestGrant_ContextCancelled(t *testing.T) {
client := newAPI(t, http.StatusCreated, resticURL(t, "backend.example:8000"), nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := client.Grant(ctx, testToken, "repo-1"); err == nil {
t.Fatal("expected an error for a cancelled context")
}
}
func TestStatusError_Error(t *testing.T) {
err := &StatusError{Code: http.StatusNotFound, Status: "404 Not Found"}
if got := err.Error(); got != "could not generate restic URL: 404 Not Found" {
t.Errorf("unexpected message: %s", got)
}
}
func TestGrant_TruncatedBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Length", "1000")
writer.WriteHeader(http.StatusCreated)
fmt.Fprint(writer, "short")
writer.(http.Flusher).Flush()
panic(http.ErrAbortHandler)
}))
t.Cleanup(server.Close)
client := New(meta.Api{Url: server.URL})
if _, err := client.Grant(context.Background(), testToken, "repo-1"); err == nil {
t.Fatal("expected an error when the body is cut short")
}
}
func TestGrant_UnparseableResticURL(t *testing.T) {
client := newAPI(t, http.StatusCreated, `{"url":"rest:http://restic:jwt@[::1"}`, nil)
_, err := client.Grant(context.Background(), testToken, "repo-1")
if err == nil {
t.Fatal("expected an error for an unparseable restic URL")
}
if !strings.Contains(err.Error(), "could not parse restic URL") {
t.Errorf("unexpected error: %v", err)
}
}
func TestGrant_UnbuildableRequest(t *testing.T) {
client := New(meta.Api{Url: "http://api.example\n"})
if _, err := client.Grant(context.Background(), testToken, "repo-1"); err == nil {
t.Fatal("expected an error when the API URL cannot form a request")
}
}
func TestGrant_ExpiredJWT(t *testing.T) {
token := makeJWT(t, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})
body := fmt.Sprintf(`{"url":"rest:http://restic:%s@backend.example/repo-1"}`, token)
client := newAPI(t, http.StatusCreated, body, nil)
_, err := client.Grant(context.Background(), testToken, "repo-1")
if err == nil {
t.Fatal("expected an error for a grant that has already expired")
}
if !strings.Contains(err.Error(), "received grant in the past") {
t.Errorf("unexpected error: %v", err)
}
}
func TestGrant_ExpiryComesFromTheJWT(t *testing.T) {
exp := time.Now().Add(42 * time.Minute).Truncate(time.Second)
token := makeJWT(t, map[string]any{"exp": exp.Unix()})
body := fmt.Sprintf(`{"url":"rest:http://restic:%s@backend.example/repo-1"}`, token)
client := newAPI(t, http.StatusCreated, body, nil)
grant, err := client.Grant(context.Background(), testToken, "repo-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !grant.ExpiresAt.Equal(exp) {
t.Errorf("expected the JWT exp %s, got %s", exp, grant.ExpiresAt)
}
}
@@ -0,0 +1,45 @@
package config
import (
"reflect"
"github.com/caarlos0/env/v11"
"github.com/rs/zerolog"
)
type Config struct {
Host string `env:"RESTIC_PROXY_HOST" envDefault:"127.0.0.1"`
Port int `env:"RESTIC_PROXY_PORT" envDefault:"1434"`
WellKnown string `env:"RESTIC_PROXY_WELL_KNOWN" envDefault:"https://meta.futo.cloud/.well-known/yucca.json"`
MetaUrl string `env:"RESTIC_PROXY_META_URL"`
ApiUrl string `env:"RESTIC_PROXY_API_URL"`
LogLevel LogLevelNewType `env:"LOG_LEVEL" envDefault:"info"`
LogPretty LogPrettyNewType `env:"LOG_FORMAT" envDefault:"pretty"`
}
type LogLevelNewType struct {
Level zerolog.Level
}
type LogPrettyNewType struct {
Pretty bool
}
func LoadConfig() (Config, error) {
return env.ParseAsWithOptions[Config](env.Options{
FuncMap: map[reflect.Type]env.ParserFunc{
reflect.TypeOf(LogLevelNewType{}): func(v string) (any, error) {
parsed, err := zerolog.ParseLevel(v)
if err != nil {
return nil, err
}
return LogLevelNewType{Level: parsed}, nil
},
reflect.TypeOf(LogPrettyNewType{}): func(v string) (any, error) {
return LogPrettyNewType{Pretty: v == "pretty"}, nil
},
},
})
}
@@ -0,0 +1,136 @@
package config
import (
"os"
"testing"
"github.com/rs/zerolog"
)
func clearEnv(t *testing.T) {
t.Helper()
for _, key := range []string{"RESTIC_PROXY_HOST", "RESTIC_PROXY_PORT", "RESTIC_PROXY_WELL_KNOWN", "LOG_LEVEL", "LOG_FORMAT"} {
t.Setenv(key, "")
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
func TestLoadConfig_Defaults(t *testing.T) {
clearEnv(t)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Host != "127.0.0.1" {
t.Errorf("expected host 127.0.0.1, got %s", cfg.Host)
}
if cfg.Port != 1434 {
t.Errorf("expected port 1434, got %d", cfg.Port)
}
if cfg.WellKnown != "https://meta.futo.cloud/.well-known/yucca.json" {
t.Errorf("expected the production well-known, got %s", cfg.WellKnown)
}
if cfg.LogLevel.Level != zerolog.InfoLevel {
t.Errorf("expected info level, got %s", cfg.LogLevel.Level)
}
if !cfg.LogPretty.Pretty {
t.Error("expected pretty logging by default")
}
}
func TestLoadConfig_Overrides(t *testing.T) {
clearEnv(t)
t.Setenv("RESTIC_PROXY_HOST", "0.0.0.0")
t.Setenv("RESTIC_PROXY_PORT", "9999")
t.Setenv("RESTIC_PROXY_WELL_KNOWN", "http://localhost:8080/.well-known/yucca.json")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Host != "0.0.0.0" {
t.Errorf("expected host 0.0.0.0, got %s", cfg.Host)
}
if cfg.Port != 9999 {
t.Errorf("expected port 9999, got %d", cfg.Port)
}
if cfg.WellKnown != "http://localhost:8080/.well-known/yucca.json" {
t.Errorf("unexpected well-known: %s", cfg.WellKnown)
}
}
func TestLoadConfig_LogLevel(t *testing.T) {
cases := []struct {
name string
value string
want zerolog.Level
}{
{name: "trace", value: "trace", want: zerolog.TraceLevel},
{name: "debug", value: "debug", want: zerolog.DebugLevel},
{name: "warn", value: "warn", want: zerolog.WarnLevel},
{name: "error", value: "error", want: zerolog.ErrorLevel},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
clearEnv(t)
t.Setenv("LOG_LEVEL", tc.value)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.LogLevel.Level != tc.want {
t.Errorf("expected %s, got %s", tc.want, cfg.LogLevel.Level)
}
})
}
}
func TestLoadConfig_LogLevelInvalid(t *testing.T) {
clearEnv(t)
t.Setenv("LOG_LEVEL", "gibberish")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected an error for an unparseable level")
}
}
func TestLoadConfig_LogFormat(t *testing.T) {
cases := []struct {
name string
value string
want bool
}{
{name: "pretty", value: "pretty", want: true},
{name: "json", value: "json", want: false},
{name: "anything else is structured", value: "console", want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
clearEnv(t)
t.Setenv("LOG_FORMAT", tc.value)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.LogPretty.Pretty != tc.want {
t.Errorf("expected pretty %v, got %v", tc.want, cfg.LogPretty.Pretty)
}
})
}
}
func TestLoadConfig_PortInvalid(t *testing.T) {
clearEnv(t)
t.Setenv("RESTIC_PROXY_PORT", "not-a-port")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected an error for a non-numeric port")
}
}
@@ -0,0 +1,25 @@
package meta
import "restic-proxy/internal/config"
type Api struct {
Url string
}
func ApiFromConfig(cfg config.Config) (Api, error) {
if cfg.ApiUrl != "" {
return Api{Url: cfg.ApiUrl}, nil
}
metaUrl, err := MetaUrlFromConfig(cfg)
if err != nil {
return Api{}, err
}
meta, err := GetMeta(metaUrl)
if err != nil {
return Api{}, err
}
return Api{Url: meta.ApiUrl}, nil
}
@@ -0,0 +1,138 @@
package meta
import (
"testing"
"restic-proxy/internal/config"
)
type chain struct {
wellKnownUrl string
metaUrl string
apiUrl string
wellKnownHit int
metaHit int
}
func newChain(t *testing.T) *chain {
t.Helper()
resolved := &chain{apiUrl: "https://backups.example/api"}
meta := newCountingServer(t, &resolved.metaHit, func() string {
return `{"api_root":"` + resolved.apiUrl + `"}`
})
resolved.metaUrl = meta.URL
wellKnown := newCountingServer(t, &resolved.wellKnownHit, func() string {
return `{"meta_url":"` + resolved.metaUrl + `"}`
})
resolved.wellKnownUrl = wellKnown.URL
return resolved
}
func TestApiFromConfig_ResolvesBothHops(t *testing.T) {
resolved := newChain(t)
api, err := ApiFromConfig(config.Config{WellKnown: resolved.wellKnownUrl})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if api.Url != resolved.apiUrl {
t.Errorf("expected api url %s, got %s", resolved.apiUrl, api.Url)
}
if resolved.wellKnownHit != 1 || resolved.metaHit != 1 {
t.Errorf("expected one request per hop, got well-known=%d meta=%d", resolved.wellKnownHit, resolved.metaHit)
}
}
func TestApiFromConfig_ApiUrlSkipsDiscovery(t *testing.T) {
resolved := newChain(t)
api, err := ApiFromConfig(config.Config{
WellKnown: resolved.wellKnownUrl,
ApiUrl: "https://configured.example/api",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if api.Url != "https://configured.example/api" {
t.Errorf("expected the configured api url, got %s", api.Url)
}
if resolved.wellKnownHit != 0 || resolved.metaHit != 0 {
t.Errorf("expected no discovery requests, got well-known=%d meta=%d", resolved.wellKnownHit, resolved.metaHit)
}
}
func TestApiFromConfig_MetaUrlSkipsWellKnown(t *testing.T) {
resolved := newChain(t)
api, err := ApiFromConfig(config.Config{
WellKnown: resolved.wellKnownUrl,
MetaUrl: resolved.metaUrl,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if api.Url != resolved.apiUrl {
t.Errorf("expected api url %s, got %s", resolved.apiUrl, api.Url)
}
if resolved.wellKnownHit != 0 {
t.Errorf("expected the well-known to be skipped, got %d requests", resolved.wellKnownHit)
}
if resolved.metaHit != 1 {
t.Errorf("expected one meta request, got %d", resolved.metaHit)
}
}
func TestApiFromConfig_PropagatesFailures(t *testing.T) {
cases := []struct {
name string
cfg config.Config
}{
{name: "well-known unreachable", cfg: config.Config{WellKnown: "http://127.0.0.1:1/.well-known/yucca.json"}},
{name: "meta unreachable", cfg: config.Config{MetaUrl: "http://127.0.0.1:1/meta"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
api, err := ApiFromConfig(tc.cfg)
if err == nil {
t.Fatal("expected the failure to reach the caller")
}
if api.Url != "" {
t.Errorf("expected no api url on failure, got %s", api.Url)
}
})
}
}
func TestMetaUrlFromConfig_PrefersConfiguredUrl(t *testing.T) {
resolved := newChain(t)
metaUrl, err := MetaUrlFromConfig(config.Config{
WellKnown: resolved.wellKnownUrl,
MetaUrl: "https://configured.example/meta",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if metaUrl != "https://configured.example/meta" {
t.Errorf("expected the configured meta url, got %s", metaUrl)
}
if resolved.wellKnownHit != 0 {
t.Errorf("expected the well-known to be skipped, got %d requests", resolved.wellKnownHit)
}
}
func TestWellKnownFromConfig(t *testing.T) {
resolved := newChain(t)
wellKnown, err := WellKnownFromConfig(config.Config{WellKnown: resolved.wellKnownUrl})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if wellKnown.MetaUrl != resolved.metaUrl {
t.Errorf("expected meta url %s, got %s", resolved.metaUrl, wellKnown.MetaUrl)
}
}
@@ -0,0 +1,54 @@
package meta
import (
"encoding/json"
"fmt"
"io"
"net/http"
"restic-proxy/internal/config"
"time"
)
type Meta struct {
ApiUrl string `json:"api_root"`
}
func GetMeta(metaUrl string) (Meta, error) {
client := http.Client{Timeout: 30 * time.Second}
var meta Meta
response, err := client.Get(metaUrl)
if err != nil {
return meta, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return meta, fmt.Errorf("could not fetch meta: %s", response.Status)
}
body, err := io.ReadAll(response.Body)
if err != nil {
return meta, err
}
if err := json.Unmarshal(body, &meta); err != nil {
return meta, err
}
return meta, nil
}
func MetaUrlFromConfig(cfg config.Config) (string, error) {
if cfg.MetaUrl != "" {
return cfg.MetaUrl, nil
}
wellKnown, err := WellKnownFromConfig(cfg)
if err != nil {
return "", err
}
return wellKnown.MetaUrl, nil
}
@@ -0,0 +1,117 @@
package meta
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newMetaServer(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(status)
fmt.Fprint(writer, body)
}))
t.Cleanup(server.Close)
return server
}
func TestGetMeta_Success(t *testing.T) {
server := newMetaServer(t, http.StatusOK, `{"api_root":"https://backups.example/api"}`)
meta, err := GetMeta(server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if meta.ApiUrl != "https://backups.example/api" {
t.Errorf("expected api root https://backups.example/api, got %s", meta.ApiUrl)
}
}
func TestGetMeta_IgnoresUnknownFields(t *testing.T) {
server := newMetaServer(t, http.StatusOK, `{"api_root":"https://backups.example/api","sites":[{"code":"fsn"}]}`)
meta, err := GetMeta(server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if meta.ApiUrl != "https://backups.example/api" {
t.Errorf("expected api root https://backups.example/api, got %s", meta.ApiUrl)
}
}
func TestGetMeta_NonOK(t *testing.T) {
cases := []struct {
name string
status int
}{
{name: "not found", status: http.StatusNotFound},
{name: "server error", status: http.StatusInternalServerError},
{name: "bad gateway", status: http.StatusBadGateway},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := newMetaServer(t, tc.status, `{"api_root":"https://backups.example/api"}`)
_, err := GetMeta(server.URL)
if err == nil {
t.Fatalf("expected an error for status %d", tc.status)
}
if !strings.Contains(err.Error(), http.StatusText(tc.status)) {
t.Errorf("expected the status in the error, got %v", err)
}
})
}
}
func TestGetMeta_MalformedBody(t *testing.T) {
server := newMetaServer(t, http.StatusOK, `<html>not json</html>`)
if _, err := GetMeta(server.URL); err == nil {
t.Fatal("expected an error for an unparseable body")
}
}
func TestGetMeta_Unreachable(t *testing.T) {
server := newMetaServer(t, http.StatusOK, `{}`)
url := server.URL
server.Close()
if _, err := GetMeta(url); err == nil {
t.Fatal("expected an error when the meta host refuses the connection")
}
}
func newTruncatedServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Length", "1000")
writer.WriteHeader(http.StatusOK)
fmt.Fprint(writer, "short")
writer.(http.Flusher).Flush()
panic(http.ErrAbortHandler)
}))
t.Cleanup(server.Close)
return server
}
func TestGetMeta_TruncatedBody(t *testing.T) {
server := newTruncatedServer(t)
if _, err := GetMeta(server.URL); err == nil {
t.Fatal("expected an error when the body is cut short")
}
}
func newCountingServer(t *testing.T, hits *int, body func() string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
*hits++
fmt.Fprint(writer, body())
}))
t.Cleanup(server.Close)
return server
}
@@ -0,0 +1,45 @@
package meta
import (
"encoding/json"
"fmt"
"io"
"net/http"
"restic-proxy/internal/config"
"time"
)
type WellKnown struct {
MetaUrl string `json:"meta_url"`
}
func GetWellKnown(wellKnownUrl string) (WellKnown, error) {
client := http.Client{Timeout: 30 * time.Second}
response, err := client.Get(wellKnownUrl)
if err != nil {
return WellKnown{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return WellKnown{}, fmt.Errorf("could not fetch well-known: %s", response.Status)
}
body, err := io.ReadAll(response.Body)
if err != nil {
return WellKnown{}, err
}
wellKnown := WellKnown{}
if err := json.Unmarshal(body, &wellKnown); err != nil {
return wellKnown, err
}
return wellKnown, nil
}
func WellKnownFromConfig(cfg config.Config) (WellKnown, error) {
return GetWellKnown(cfg.WellKnown)
}
@@ -0,0 +1,100 @@
package meta
import (
"net/http"
"strings"
"testing"
)
func TestGetWellKnown_Success(t *testing.T) {
server := newMetaServer(t, http.StatusOK, `{"meta_url":"https://meta.example/api/meta"}`)
wellKnown, err := GetWellKnown(server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if wellKnown.MetaUrl != "https://meta.example/api/meta" {
t.Errorf("expected meta url https://meta.example/api/meta, got %s", wellKnown.MetaUrl)
}
}
func TestGetWellKnown_IgnoresUnknownFields(t *testing.T) {
server := newMetaServer(t, http.StatusOK, `{"meta_url":"https://meta.example/api/meta","issuer":"https://id.example"}`)
wellKnown, err := GetWellKnown(server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if wellKnown.MetaUrl != "https://meta.example/api/meta" {
t.Errorf("expected meta url https://meta.example/api/meta, got %s", wellKnown.MetaUrl)
}
}
func TestGetWellKnown_NonOK(t *testing.T) {
cases := []struct {
name string
status int
}{
{name: "not found", status: http.StatusNotFound},
{name: "server error", status: http.StatusInternalServerError},
{name: "bad gateway", status: http.StatusBadGateway},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := newMetaServer(t, tc.status, `{"meta_url":"https://meta.example/api/meta"}`)
_, err := GetWellKnown(server.URL)
if err == nil {
t.Fatalf("expected an error for status %d", tc.status)
}
if !strings.Contains(err.Error(), http.StatusText(tc.status)) {
t.Errorf("expected the status in the error, got %v", err)
}
})
}
}
func TestGetWellKnown_MalformedBody(t *testing.T) {
server := newMetaServer(t, http.StatusOK, `<html>not json</html>`)
if _, err := GetWellKnown(server.URL); err == nil {
t.Fatal("expected an error for an unparseable body")
}
}
func TestGetWellKnown_Unreachable(t *testing.T) {
server := newMetaServer(t, http.StatusOK, `{}`)
url := server.URL
server.Close()
if _, err := GetWellKnown(url); err == nil {
t.Fatal("expected an error when the well-known host refuses the connection")
}
}
func TestGetWellKnown_ResolvesIntoGetMeta(t *testing.T) {
meta := newMetaServer(t, http.StatusOK, `{"api_root":"https://backups.example/api"}`)
wellKnown := newMetaServer(t, http.StatusOK, `{"meta_url":"`+meta.URL+`"}`)
pointer, err := GetWellKnown(wellKnown.URL)
if err != nil {
t.Fatalf("resolve well-known: %v", err)
}
resolved, err := GetMeta(pointer.MetaUrl)
if err != nil {
t.Fatalf("resolve meta: %v", err)
}
if resolved.ApiUrl != "https://backups.example/api" {
t.Errorf("expected api root https://backups.example/api, got %s", resolved.ApiUrl)
}
}
func TestGetWellKnown_TruncatedBody(t *testing.T) {
server := newTruncatedServer(t)
if _, err := GetWellKnown(server.URL); err == nil {
t.Fatal("expected an error when the body is cut short")
}
}
@@ -0,0 +1,159 @@
package proxy
import (
"context"
"errors"
"net/http"
"net/http/httputil"
"restic-proxy/internal/client"
"strings"
"time"
"github.com/cornelk/hashmap"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
)
const refreshTime = 10 * time.Minute
const refreshTimeout = 30 * time.Second
const denialTime = time.Minute
type denial struct {
err error
expiresAt time.Time
}
type Handler struct {
client client.Client
reverse *httputil.ReverseProxy
grants *hashmap.Map[string, client.Grant]
denials *hashmap.Map[string, denial]
minting singleflight.Group
}
func New(cl client.Client) *Handler {
grants := hashmap.New[string, client.Grant]()
handler := &Handler{
client: cl,
grants: grants,
denials: hashmap.New[string, denial](),
reverse: reverseProxy(grants),
}
return handler
}
func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
log := zerolog.Ctx(request.Context())
repositoryId, sessionToken, ok := request.BasicAuth()
if !ok || repositoryId == "" || sessionToken == "" {
writer.Header().Set("WWW-Authenticate", `Basic realm="restic"`)
http.Error(writer, "no credential specified", http.StatusUnauthorized)
log.Error().Msg("no credential specified")
return
}
path := strings.TrimPrefix(request.URL.Path, "/")
grant, err := handler.grant(repositoryId, sessionToken)
if err != nil {
status, message := describe(err)
http.Error(writer, message, status)
log.Error().Err(err).Int("status", status).Msg("failed to generate restic URL")
return
}
log.Debug().Msg("handled request")
route := routed{key: repositoryId, grant: grant, path: path}
handler.reverse.ServeHTTP(writer, request.WithContext(context.WithValue(request.Context(), contextKey{}, route)))
}
func describe(err error) (int, string) {
var status *client.StatusError
if !errors.As(err, &status) {
return http.StatusServiceUnavailable, "backups unreachable"
}
switch status.Code {
case http.StatusUnauthorized, http.StatusForbidden:
return http.StatusUnauthorized, "access token rejected"
case http.StatusNotFound:
return http.StatusNotFound, "no such repository"
default:
return http.StatusServiceUnavailable, "backups unavailable"
}
}
func (handler *Handler) grant(key string, sessionToken string) (client.Grant, error) {
grant, ok := handler.grants.Get(key)
if grant.SessionToken != sessionToken {
return handler.mint(key, sessionToken)
}
if ok && time.Until(grant.ExpiresAt) > refreshTime {
return grant, nil
}
if ok && time.Until(grant.ExpiresAt) > 0 {
handler.refresh(key, sessionToken)
return grant, nil
}
return handler.mint(key, sessionToken)
}
func (handler *Handler) refresh(key string, token string) {
go func() {
_, err := handler.mint(key, token)
if err != nil {
log.Error().Err(err).Msg("failed to refresh grant")
}
}()
}
func (handler *Handler) mint(key string, sessionToken string) (client.Grant, error) {
reference := sessionToken + key
denied, ok := handler.denials.Get(reference)
if ok && time.Now().Before(denied.expiresAt) {
return client.Grant{}, denied.err
}
value, err, _ := handler.minting.Do(reference, func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
defer cancel()
grant, err := handler.client.Grant(ctx, sessionToken, key)
if err != nil {
handler.deny(reference, err)
return client.Grant{}, err
}
log.Info().Time("expires_at", grant.ExpiresAt).Msg("Minted a new token")
handler.grants.Set(key, grant)
return grant, nil
})
if err != nil {
return client.Grant{}, err
}
return value.(client.Grant), nil
}
func (handler *Handler) deny(reference string, err error) {
status, _ := describe(err)
if status == http.StatusServiceUnavailable {
return
}
expiresAt := time.Now().Add(denialTime)
log.Warn().Time("expires_at", expiresAt).Msg("Token minting temporarily denied")
handler.denials.Set(reference, denial{err: err, expiresAt: expiresAt})
}
@@ -0,0 +1,119 @@
package proxy
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"restic-proxy/internal/client"
"restic-proxy/internal/meta"
)
type sink struct{ header http.Header }
func (s *sink) Header() http.Header {
if s.header == nil {
s.header = http.Header{}
}
return s.header
}
func (s *sink) Write(payload []byte) (int, error) { return len(payload), nil }
func (s *sink) WriteHeader(int) {}
func warmHandler(b *testing.B, backendURL string) *Handler {
b.Helper()
handler := New(client.New(meta.Api{Url: "http://unused.example"}))
handler.grants.Set(testRepository, client.Grant{
SessionToken: testToken,
Scheme: "http",
Host: hostForBench(b, backendURL),
Path: "/" + testRepository,
Password: "minted-jwt",
ExpiresAt: time.Now().Add(time.Hour),
})
return handler
}
func hostForBench(b *testing.B, rawURL string) string {
b.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
b.Fatalf("parse %s: %v", rawURL, err)
}
return parsed.Host
}
func benchmarkUpload(b *testing.B, size int) {
b.Helper()
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = io.Copy(io.Discard, request.Body)
writer.WriteHeader(http.StatusOK)
}))
defer backend.Close()
handler := warmHandler(b, backend.URL)
payload := bytes.Repeat([]byte("x"), size)
b.SetBytes(int64(size))
b.ReportAllocs()
b.ResetTimer()
for range b.N {
request := httptest.NewRequest(http.MethodPost, "/data/ab", bytes.NewReader(payload))
request.SetBasicAuth(testRepository, testToken)
handler.ServeHTTP(&sink{}, request)
}
}
func benchmarkDownload(b *testing.B, size int) {
b.Helper()
payload := bytes.Repeat([]byte("x"), size)
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write(payload)
}))
defer backend.Close()
handler := warmHandler(b, backend.URL)
b.SetBytes(int64(size))
b.ReportAllocs()
b.ResetTimer()
for range b.N {
request := httptest.NewRequest(http.MethodGet, "/data/ab", nil)
request.SetBasicAuth(testRepository, testToken)
handler.ServeHTTP(&sink{}, request)
}
}
func BenchmarkUpload16MiB(b *testing.B) { benchmarkUpload(b, 16<<20) }
func BenchmarkUpload64KiB(b *testing.B) { benchmarkUpload(b, 64<<10) }
func BenchmarkDownload16MiB(b *testing.B) { benchmarkDownload(b, 16<<20) }
func BenchmarkDownload64KiB(b *testing.B) { benchmarkDownload(b, 64<<10) }
func BenchmarkGrantCacheHit(b *testing.B) {
handler := New(client.New(meta.Api{Url: "http://unused.example"}))
handler.grants.Set(testRepository, client.Grant{SessionToken: testToken, ExpiresAt: time.Now().Add(time.Hour)})
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, err := handler.grant(testRepository, testToken); err != nil {
b.Fatalf("unexpected error: %v", err)
}
}
}
@@ -0,0 +1,554 @@
package proxy
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"net/url"
"restic-proxy/internal/client"
"restic-proxy/internal/meta"
"github.com/rs/zerolog"
)
const (
testToken = "user-access-token"
testRepository = "repo-1"
)
func TestMain(m *testing.M) {
zerolog.SetGlobalLevel(zerolog.InfoLevel)
os.Exit(m.Run())
}
func makeJWT(t *testing.T, claims map[string]any) string {
t.Helper()
payload, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
}
type backendRequest struct {
path string
user string
password string
}
func newBackend(t *testing.T, status int, seen *backendRequest) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if seen != nil {
user, password, _ := request.BasicAuth()
*seen = backendRequest{path: request.URL.Path, user: user, password: password}
}
writer.WriteHeader(status)
fmt.Fprint(writer, "backend-body")
}))
t.Cleanup(server.Close)
return server
}
func newAPI(t *testing.T, backendURL string, status int, mints *atomic.Int64) client.Client {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
if mints != nil {
mints.Add(1)
}
if status != http.StatusCreated {
writer.WriteHeader(status)
return
}
token := makeJWT(t, map[string]any{"exp": time.Now().Add(time.Hour).Unix()})
writer.WriteHeader(http.StatusCreated)
fmt.Fprintf(writer, `{"url":"rest:%s://restic:%s@%s/%s"}`, "http", token, hostOf(t, backendURL), testRepository)
}))
t.Cleanup(server.Close)
return client.New(meta.Api{Url: server.URL})
}
func hostOf(t *testing.T, rawURL string) string {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse %s: %v", rawURL, err)
}
return parsed.Host
}
func newProxy(t *testing.T, cl client.Client) (*Handler, *httptest.Server) {
t.Helper()
handler := New(cl)
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return handler, server
}
func do(t *testing.T, server *httptest.Server, path, repository, token string) *http.Response {
t.Helper()
request, err := http.NewRequest(http.MethodGet, server.URL+path, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
if token != "" {
request.SetBasicAuth(repository, token)
}
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("do request: %v", err)
}
t.Cleanup(func() { response.Body.Close() })
return response
}
func TestServeHTTP_NoCredential(t *testing.T) {
cases := []struct {
name string
repository string
token string
basicAuth bool
}{
{name: "no authorization header", repository: "", token: "", basicAuth: false},
{name: "no repository", repository: "", token: testToken, basicAuth: true},
{name: "no token", repository: testRepository, token: "", basicAuth: true},
{name: "neither", repository: "", token: "", basicAuth: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var mints atomic.Int64
_, proxy := newProxy(t, newAPI(t, "http://unused.example", http.StatusCreated, &mints))
request, err := http.NewRequest(http.MethodGet, proxy.URL+"/config", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
if tc.basicAuth {
request.SetBasicAuth(tc.repository, tc.token)
}
response, err := proxy.Client().Do(request)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", response.StatusCode)
}
if got := response.Header.Get("WWW-Authenticate"); got != `Basic realm="restic"` {
t.Errorf("expected a basic-auth challenge, got %q", got)
}
if mints.Load() != 0 {
t.Errorf("expected no mint for a rejected credential, got %d", mints.Load())
}
})
}
}
func TestServeHTTP_ForwardsToBackend(t *testing.T) {
var seen backendRequest
backend := newBackend(t, http.StatusOK, &seen)
_, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, nil))
response := do(t, proxy, "/data/abc123", testRepository, testToken)
if response.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", response.StatusCode)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != "backend-body" {
t.Errorf("expected the backend body to pass through, got %q", body)
}
if seen.path != "/"+testRepository+"/data/abc123" {
t.Errorf("unexpected forwarded path: %s", seen.path)
}
if seen.user != "restic" {
t.Errorf("expected the backend user restic, got %s", seen.user)
}
if seen.password == testToken {
t.Error("expected the minted JWT to replace the user's access token")
}
}
func TestServeHTTP_RepositoryRoot(t *testing.T) {
var seen backendRequest
backend := newBackend(t, http.StatusOK, &seen)
_, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, nil))
do(t, proxy, "/", testRepository, testToken)
if seen.path != "/"+testRepository+"/" {
t.Errorf("unexpected forwarded path: %s", seen.path)
}
}
func TestServeHTTP_RepositoryComesFromCredential(t *testing.T) {
var seen backendRequest
backend := newBackend(t, http.StatusOK, &seen)
_, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, nil))
do(t, proxy, "/repo-x/config", testRepository, testToken)
if seen.path != "/"+testRepository+"/repo-x/config" {
t.Errorf("unexpected forwarded path: %s", seen.path)
}
}
func TestServeHTTP_MintFailureStatuses(t *testing.T) {
cases := []struct {
name string
api int
want int
reason string
}{
{name: "token rejected", api: http.StatusUnauthorized, want: http.StatusUnauthorized},
{name: "token forbidden", api: http.StatusForbidden, want: http.StatusUnauthorized},
{name: "unknown repository", api: http.StatusNotFound, want: http.StatusNotFound},
{name: "api broken", api: http.StatusInternalServerError, want: http.StatusServiceUnavailable},
{name: "api shedding", api: http.StatusBadGateway, want: http.StatusServiceUnavailable},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, proxy := newProxy(t, newAPI(t, "http://unused.example", tc.api, nil))
response := do(t, proxy, "/config", testRepository, testToken)
if response.StatusCode != tc.want {
t.Errorf("expected %d, got %d", tc.want, response.StatusCode)
}
})
}
}
func TestServeHTTP_ApiUnreachableIsRetryable(t *testing.T) {
_, proxy := newProxy(t, client.New(meta.Api{Url: "http://127.0.0.1:1"}))
response := do(t, proxy, "/config", testRepository, testToken)
if response.StatusCode != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", response.StatusCode)
}
}
func TestGrant_ServesCachedGrant(t *testing.T) {
var mints atomic.Int64
backend := newBackend(t, http.StatusOK, nil)
handler, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, &mints))
handler.grants.Set(testRepository, client.Grant{
SessionToken: testToken,
Scheme: "http",
Host: hostOf(t, backend.URL),
Path: "/" + testRepository,
Password: "cached-jwt",
ExpiresAt: time.Now().Add(time.Hour),
})
do(t, proxy, "/config", testRepository, testToken)
if mints.Load() != 0 {
t.Errorf("expected no mint for a fresh cached grant, got %d", mints.Load())
}
}
func TestGrant_DifferentTokenDoesNotReuseGrant(t *testing.T) {
var seen backendRequest
backend := newBackend(t, http.StatusOK, &seen)
var mints atomic.Int64
handler, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, &mints))
handler.grants.Set(testRepository, client.Grant{
SessionToken: testToken,
Scheme: "http",
Host: hostOf(t, backend.URL),
Path: "/" + testRepository,
Password: "someone-elses-jwt",
ExpiresAt: time.Now().Add(time.Hour),
})
do(t, proxy, "/config", testRepository, "a-different-token")
if mints.Load() != 1 {
t.Errorf("expected a fresh mint for an unrecognised token, got %d", mints.Load())
}
if seen.password == "someone-elses-jwt" {
t.Error("expected the cached grant not to be reused for a different token")
}
}
func TestGrant_RejectedTokenDoesNotReuseGrant(t *testing.T) {
backend := newBackend(t, http.StatusOK, nil)
handler, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusUnauthorized, nil))
handler.grants.Set(testRepository, client.Grant{
SessionToken: testToken,
Scheme: "http",
Host: hostOf(t, backend.URL),
Path: "/" + testRepository,
Password: "someone-elses-jwt",
ExpiresAt: time.Now().Add(time.Hour),
})
response := do(t, proxy, "/config", testRepository, "a-different-token")
if response.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", response.StatusCode)
}
}
func TestGrant_MintFailureCaching(t *testing.T) {
cases := []struct {
name string
api int
mints int64
}{
{name: "session rejected", api: http.StatusUnauthorized, mints: 1},
{name: "session forbidden", api: http.StatusForbidden, mints: 1},
{name: "unknown repository", api: http.StatusNotFound, mints: 1},
{name: "api broken", api: http.StatusInternalServerError, mints: 2},
{name: "api shedding", api: http.StatusBadGateway, mints: 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var mints atomic.Int64
_, proxy := newProxy(t, newAPI(t, "http://unused.example", tc.api, &mints))
first := do(t, proxy, "/config", testRepository, testToken)
second := do(t, proxy, "/config", testRepository, testToken)
if first.StatusCode != second.StatusCode {
t.Errorf("expected the cached rejection to match the fresh one, got %d then %d", first.StatusCode, second.StatusCode)
}
if mints.Load() != tc.mints {
t.Errorf("expected %d mints, got %d", tc.mints, mints.Load())
}
})
}
}
func TestGrant_DeniedSessionIsRecheckedWhenStale(t *testing.T) {
var mints atomic.Int64
handler, proxy := newProxy(t, newAPI(t, "http://unused.example", http.StatusUnauthorized, &mints))
do(t, proxy, "/config", testRepository, testToken)
denied, ok := handler.denials.Get(testToken + testRepository)
if !ok {
t.Fatal("expected the rejection to be cached")
}
denied.expiresAt = time.Now().Add(-time.Second)
handler.denials.Set(testToken+testRepository, denied)
do(t, proxy, "/config", testRepository, testToken)
if mints.Load() != 2 {
t.Errorf("expected the session to be rechecked once the denial went stale, got %d mints", mints.Load())
}
}
func TestGrant_DeniedSessionIsPerCredential(t *testing.T) {
var mints atomic.Int64
_, proxy := newProxy(t, newAPI(t, "http://unused.example", http.StatusUnauthorized, &mints))
do(t, proxy, "/config", testRepository, testToken)
do(t, proxy, "/config", testRepository, "another-session-token")
do(t, proxy, "/config", "repo-2", testToken)
if mints.Load() != 3 {
t.Errorf("expected each credential to be checked once, got %d mints", mints.Load())
}
}
func TestGrant_MintsWhenExpired(t *testing.T) {
var mints atomic.Int64
backend := newBackend(t, http.StatusOK, nil)
handler, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, &mints))
handler.grants.Set(testRepository, client.Grant{
SessionToken: testToken,
Scheme: "http",
Host: hostOf(t, backend.URL),
Path: "/" + testRepository,
Password: "stale-jwt",
ExpiresAt: time.Now().Add(-time.Minute),
})
do(t, proxy, "/config", testRepository, testToken)
if mints.Load() != 1 {
t.Errorf("expected one mint for an expired grant, got %d", mints.Load())
}
}
func TestGrant_SeparateGrantPerRepository(t *testing.T) {
var mints atomic.Int64
backend := newBackend(t, http.StatusOK, nil)
_, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, &mints))
do(t, proxy, "/config", "repo-a", testToken)
do(t, proxy, "/config", "repo-b", testToken)
if mints.Load() != 2 {
t.Errorf("expected a mint per repository, got %d", mints.Load())
}
}
func TestDescribe(t *testing.T) {
cases := []struct {
name string
err error
want int
message string
}{
{name: "transport failure", err: errors.New("dial tcp: refused"), want: http.StatusServiceUnavailable, message: "backups unreachable"},
{name: "unauthorized", err: &client.StatusError{Code: http.StatusUnauthorized}, want: http.StatusUnauthorized, message: "access token rejected"},
{name: "forbidden", err: &client.StatusError{Code: http.StatusForbidden}, want: http.StatusUnauthorized, message: "access token rejected"},
{name: "not found", err: &client.StatusError{Code: http.StatusNotFound}, want: http.StatusNotFound, message: "no such repository"},
{name: "server error", err: &client.StatusError{Code: http.StatusInternalServerError}, want: http.StatusServiceUnavailable, message: "backups unavailable"},
{name: "wrapped", err: fmt.Errorf("minting: %w", &client.StatusError{Code: http.StatusNotFound}), want: http.StatusNotFound, message: "no such repository"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, message := describe(tc.err)
if status != tc.want {
t.Errorf("expected status %d, got %d", tc.want, status)
}
if message != tc.message {
t.Errorf("expected message %q, got %q", tc.message, message)
}
})
}
}
func newSignallingAPI(t *testing.T, backendURL string, status int) (client.Client, chan struct{}) {
t.Helper()
minted := make(chan struct{}, 4)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
defer func() { minted <- struct{}{} }()
if status != http.StatusCreated {
writer.WriteHeader(status)
return
}
token := makeJWT(t, map[string]any{"exp": time.Now().Add(time.Hour).Unix()})
writer.WriteHeader(http.StatusCreated)
fmt.Fprintf(writer, `{"url":"rest:http://restic:%s@%s/%s"}`, token, hostOf(t, backendURL), testRepository)
}))
t.Cleanup(server.Close)
return client.New(meta.Api{Url: server.URL}), minted
}
func awaitMint(t *testing.T, minted chan struct{}) {
t.Helper()
select {
case <-minted:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for a background mint")
}
}
func awaitGrant(t *testing.T, handler *Handler, want func(client.Grant) bool) client.Grant {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if grant, ok := handler.grants.Get(testRepository); ok && want(grant) {
return grant
}
time.Sleep(time.Millisecond)
}
t.Fatal("timed out waiting for the refreshed grant")
return client.Grant{}
}
func TestGrant_RefreshesInBackground(t *testing.T) {
var seen backendRequest
backend := newBackend(t, http.StatusOK, &seen)
cl, minted := newSignallingAPI(t, backend.URL, http.StatusCreated)
handler, proxy := newProxy(t, cl)
stale := client.Grant{
SessionToken: testToken,
Scheme: "http",
Host: hostOf(t, backend.URL),
Path: "/" + testRepository,
Password: "cached-jwt",
ExpiresAt: time.Now().Add(time.Minute),
}
handler.grants.Set(testRepository, stale)
response := do(t, proxy, "/config", testRepository, testToken)
if response.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", response.StatusCode)
}
if seen.password != "cached-jwt" {
t.Errorf("expected the cached grant to serve the request, got %q", seen.password)
}
awaitMint(t, minted)
refreshed := awaitGrant(t, handler, func(grant client.Grant) bool {
return grant.Password != stale.Password
})
if !refreshed.ExpiresAt.After(stale.ExpiresAt) {
t.Error("expected the refreshed grant to expire later than the stale one")
}
}
func TestGrant_RefreshFailureKeepsServing(t *testing.T) {
backend := newBackend(t, http.StatusOK, nil)
cl, minted := newSignallingAPI(t, backend.URL, http.StatusInternalServerError)
handler, proxy := newProxy(t, cl)
stale := client.Grant{
SessionToken: testToken,
Scheme: "http",
Host: hostOf(t, backend.URL),
Path: "/" + testRepository,
Password: "cached-jwt",
ExpiresAt: time.Now().Add(time.Minute),
}
handler.grants.Set(testRepository, stale)
response := do(t, proxy, "/config", testRepository, testToken)
if response.StatusCode != http.StatusOK {
t.Errorf("expected the cached grant to serve the request, got %d", response.StatusCode)
}
awaitMint(t, minted)
kept, ok := handler.grants.Get(testRepository)
if !ok {
t.Fatal("expected the grant to survive a failed refresh")
}
if kept.Password != "cached-jwt" {
t.Errorf("expected the cached grant to be kept, got %q", kept.Password)
}
}
@@ -0,0 +1,95 @@
package proxy
import (
"net"
"net/http"
"net/http/httputil"
"restic-proxy/internal/client"
"sync"
"time"
"github.com/cornelk/hashmap"
"github.com/rs/zerolog/log"
)
type routed struct {
key string
grant client.Grant
path string
}
type contextKey struct{}
func reverseProxy(grants *hashmap.Map[string, client.Grant]) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Transport: newTransport(),
BufferPool: newBufferPool(),
Rewrite: func(request *httputil.ProxyRequest) {
route := request.In.Context().Value(contextKey{}).(routed)
request.Out.URL.Scheme = route.grant.Scheme
request.Out.URL.Host = route.grant.Host
request.Out.Host = route.grant.Host
request.Out.URL.Path = route.grant.Path + "/" + route.path
request.Out.URL.RawPath = ""
request.Out.SetBasicAuth("restic", route.grant.Password)
log.Debug().Any("host", request.Out.Host).Str("path", request.Out.URL.Path).Msg("forwarded request")
},
ModifyResponse: func(response *http.Response) error {
if response.StatusCode != http.StatusUnauthorized {
return nil
}
route := response.Request.Context().Value(contextKey{}).(routed)
grants.Del(route.key)
if err := response.Body.Close(); err != nil {
return err
}
response.StatusCode = http.StatusServiceUnavailable
response.Status = http.StatusText(http.StatusServiceUnavailable)
response.Header = http.Header{"Content-Type": []string{"application/json"}}
response.Body = http.NoBody
response.ContentLength = 0
return nil
},
ErrorHandler: func(writer http.ResponseWriter, _ *http.Request, err error) {
http.Error(writer, "backend unreachable", http.StatusBadGateway)
log.Error().Err(err).Msg("backend unreachable")
},
}
}
func newTransport() *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 32,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
DisableCompression: true,
}
}
type bufferPool struct{ pool sync.Pool }
const copyBufferSize = 32 << 10
func newBufferPool() *bufferPool {
return &bufferPool{pool: sync.Pool{New: func() any {
buffer := make([]byte, copyBufferSize)
return &buffer
}}}
}
func (b *bufferPool) Get() []byte {
return *b.pool.Get().(*[]byte)
}
func (b *bufferPool) Put(buffer []byte) {
b.pool.Put(&buffer)
}
@@ -0,0 +1,99 @@
package proxy
import (
"net/http"
"sync/atomic"
"testing"
"time"
"restic-proxy/internal/client"
)
func TestReverse_BackendUnauthorizedBecomesRetryable(t *testing.T) {
backend := newBackend(t, http.StatusUnauthorized, nil)
handler, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, nil))
response := do(t, proxy, "/config", testRepository, testToken)
if response.StatusCode != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", response.StatusCode)
}
if _, ok := handler.grants.Get(testRepository); ok {
t.Error("expected the rejected grant to be evicted")
}
}
func TestReverse_RetryAfterEvictionMintsAgain(t *testing.T) {
var mints atomic.Int64
backend := newBackend(t, http.StatusUnauthorized, nil)
_, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, &mints))
do(t, proxy, "/config", testRepository, testToken)
do(t, proxy, "/config", testRepository, testToken)
if mints.Load() != 2 {
t.Errorf("expected the second attempt to mint again, got %d mints", mints.Load())
}
}
func TestReverse_BackendSuccessIsUntouched(t *testing.T) {
backend := newBackend(t, http.StatusOK, nil)
handler, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, nil))
response := do(t, proxy, "/config", testRepository, testToken)
if response.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", response.StatusCode)
}
if _, ok := handler.grants.Get(testRepository); !ok {
t.Error("expected the grant to survive a successful request")
}
}
func TestReverse_BackendUnreachable(t *testing.T) {
backend := newBackend(t, http.StatusOK, nil)
host := hostOf(t, backend.URL)
handler, proxy := newProxy(t, newAPI(t, backend.URL, http.StatusCreated, nil))
backend.Close()
handler.grants.Set(testRepository, client.Grant{
SessionToken: testToken,
Scheme: "http",
Host: host,
Path: "/" + testRepository,
Password: "some-jwt",
ExpiresAt: time.Now().Add(time.Hour),
})
response := do(t, proxy, "/config", testRepository, testToken)
if response.StatusCode != http.StatusBadGateway {
t.Errorf("expected 502, got %d", response.StatusCode)
}
}
func TestBufferPool_RoundTrip(t *testing.T) {
pool := newBufferPool()
buffer := pool.Get()
if len(buffer) != copyBufferSize {
t.Errorf("expected a %d byte buffer, got %d", copyBufferSize, len(buffer))
}
pool.Put(buffer)
if reused := pool.Get(); len(reused) != copyBufferSize {
t.Errorf("expected a %d byte buffer after reuse, got %d", copyBufferSize, len(reused))
}
}
func TestNewTransport_DoesNotCompress(t *testing.T) {
transport := newTransport()
if !transport.DisableCompression {
t.Error("expected compression to be disabled")
}
if transport.MaxIdleConnsPerHost != 32 {
t.Errorf("expected 32 idle connections per host, got %d", transport.MaxIdleConnsPerHost)
}
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"restic-proxy/internal/client"
"restic-proxy/internal/config"
"restic-proxy/internal/meta"
"restic-proxy/internal/proxy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
"github.com/rs/zerolog/log"
)
func main() {
zerolog.TimeFieldFormat = time.RFC3339
zerolog.TimestampFunc = func() time.Time { return time.Now().UTC() }
cfg, err := config.LoadConfig()
if err != nil {
log.Error().Err(err).Msg("failed to parse config")
os.Exit(3)
}
zerolog.SetGlobalLevel(cfg.LogLevel.Level)
if cfg.LogPretty.Pretty {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
}
log.Info().Str("wellknown", cfg.WellKnown).Msg("Loaded config for restic proxy")
api, err := meta.ApiFromConfig(cfg)
if err != nil {
log.Error().Err(err).Msg("failed to find API url")
os.Exit(4)
}
log.Info().Str("api_url", api.Url).Msg("Resolved API")
listen := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
listener, err := net.Listen("tcp", listen)
if err != nil {
log.Error().Err(err).Msg("failed to bind listener")
os.Exit(6)
}
client := client.New(api)
proxy := proxy.New(client)
handler := hlog.NewHandler(log.Logger)(hlog.MethodHandler("method")(hlog.URLHandler("path")(hlog.RemoteAddrHandler("remote_addr")(proxy))))
server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 30 * time.Second,
}
log.Info().Str("address", listener.Addr().String()).Msg("Listening for requests")
log.Info().Msg(fmt.Sprintf("Point restic at rest:http://<REPOSITORY>:<TOKEN>@%s", listener.Addr().String()))
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err).Msg("server stopped")
}
}
+22
View File
@@ -0,0 +1,22 @@
[tools]
air = "latest"
[tasks.build]
description = "Build restic-proxy"
run = "go build -o ../../dist/restic-proxy ."
[tasks.dev]
description = "Run restic-proxy in development mode"
run = "air"
[tasks.test]
description = "Run unit tests for restic-proxy"
run = "go test ./..."
[tasks.vet]
description = "Vet restic-proxy"
run = ["go vet ./..."]
[tasks.lint]
description = "Lint restic-proxy"
run = ["golangci-lint run ./..."]
+9
View File
@@ -1,6 +1,7 @@
ARG ALPINE_IMAGE=alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 ARG ALPINE_IMAGE=alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
ARG NODE_IMAGE=node:25-alpine3.23@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 ARG NODE_IMAGE=node:25-alpine3.23@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4
ARG MISE_IMAGE=ghcr.io/jdx/mise:2026.7.18@sha256:ed804bc4b6c6c8db9bbd0883e489db17b70c4a4ee2ef60b1841c43d295ff430e ARG MISE_IMAGE=ghcr.io/jdx/mise:2026.7.18@sha256:ed804bc4b6c6c8db9bbd0883e489db17b70c4a4ee2ef60b1841c43d295ff430e
ARG GOLANG_IMAGE=golang:1.27-alpine3.23@sha256:4441ef16de1cbb69a44ab7c3cadc2c4b85d6e63494a4c0df252c5aae6204b865
FROM ${ALPINE_IMAGE} AS manifests FROM ${ALPINE_IMAGE} AS manifests
WORKDIR /src WORKDIR /src
@@ -24,6 +25,13 @@ RUN --mount=type=cache,id=mise-${TARGETPLATFORM},target=/mise-data \
mise install restic && \ mise install restic && \
cp "$(mise which restic)" /usr/local/bin/restic cp "$(mise which restic)" /usr/local/bin/restic
FROM ${GOLANG_IMAGE} AS proxy
WORKDIR /src
COPY packages/restic-proxy/go.mod packages/restic-proxy/go.sum ./
RUN go mod download
COPY packages/restic-proxy/ ./
RUN CGO_ENABLED=0 go build -o /restic-proxy .
FROM ${NODE_IMAGE} AS builder FROM ${NODE_IMAGE} AS builder
WORKDIR /build-stage WORKDIR /build-stage
ENV PNPM_HOME=/pnpm-store ENV PNPM_HOME=/pnpm-store
@@ -52,6 +60,7 @@ RUN apk add --no-cache libstdc++ dumb-init su-exec \
COPY --from=builder /usr/local/bin/node /usr/local/bin/ COPY --from=builder /usr/local/bin/node /usr/local/bin/
COPY --from=tools /usr/local/bin/restic /usr/local/bin/ COPY --from=tools /usr/local/bin/restic /usr/local/bin/
COPY --from=proxy /restic-proxy /usr/local/bin/
COPY --from=builder /deploy ./ COPY --from=builder /deploy ./
COPY --chmod=0755 packages/standalone-app/docker-entrypoint.sh /usr/local/bin/ COPY --chmod=0755 packages/standalone-app/docker-entrypoint.sh /usr/local/bin/