mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
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:
@@ -55,6 +55,7 @@ jobs:
|
||||
michael: ['packages/michael/**', '.dockerignore']
|
||||
columbo: ['packages/columbo/**', '.dockerignore']
|
||||
monk: ['packages/monk/**', '.dockerignore']
|
||||
restic-proxy: ['packages/restic-proxy/**', '.dockerignore']
|
||||
|
||||
# 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
|
||||
@@ -63,10 +64,10 @@ jobs:
|
||||
# name whose filter key is renamed or dropped never builds again.
|
||||
- name: Require every Dockerfile to carry a filter
|
||||
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'
|
||||
# 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' ' ')"
|
||||
[ -n "${deploy_apps// /}" ] || { echo "::error::no apps parsed from the Deploy build matrix"; exit 1; }
|
||||
fail=0
|
||||
|
||||
@@ -43,3 +43,15 @@ jobs:
|
||||
image: futo-backups-standalone
|
||||
context: .
|
||||
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
@@ -1,7 +1,7 @@
|
||||
monorepo_root = true
|
||||
|
||||
[monorepo]
|
||||
config_roots = ["ansible/ceph", "ansible/talos"]
|
||||
config_roots = ["ansible/ceph", "ansible/talos", "packages/restic-proxy"]
|
||||
|
||||
[tools]
|
||||
node = "25.9.0"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
set -e
|
||||
|
||||
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
|
||||
done
|
||||
exit $status
|
||||
|
||||
@@ -85,3 +85,30 @@ docker compose up -d
|
||||
> This will bump you to the latest<sup>†</sup> `main`/development version of Immich.
|
||||
>
|
||||
> <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
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
tmp
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ./..."]
|
||||
@@ -1,6 +1,7 @@
|
||||
ARG ALPINE_IMAGE=alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
|
||||
ARG NODE_IMAGE=node:25-alpine3.23@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4
|
||||
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
|
||||
WORKDIR /src
|
||||
@@ -24,6 +25,13 @@ RUN --mount=type=cache,id=mise-${TARGETPLATFORM},target=/mise-data \
|
||||
mise install 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
|
||||
WORKDIR /build-stage
|
||||
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=tools /usr/local/bin/restic /usr/local/bin/
|
||||
COPY --from=proxy /restic-proxy /usr/local/bin/
|
||||
COPY --from=builder /deploy ./
|
||||
COPY --chmod=0755 packages/standalone-app/docker-entrypoint.sh /usr/local/bin/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user