From b947431bf6da62f7a349ea200a2f0db63bd9902e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Friedrich=20Gro=C3=9Fe?= <733004+fgrosse@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:53:33 +0100 Subject: [PATCH] Enhancement: Support loading REST server password from a file Add support for the RESTIC_REST_PASSWORD_FILE environment variable, which loads the HTTP password for the REST backend from a file instead of specifying it directly via RESTIC_REST_PASSWORD. If both variables are set, RESTIC_REST_PASSWORD_FILE takes precedence, consistent with how RESTIC_PASSWORD_FILE behaves for repository passwords. --- changelog/unreleased/pull-REST-password-file | 6 ++ doc/030_preparing_a_new_repo.rst | 10 ++++ doc/075_scripting.rst | 1 + internal/backend/rest/config.go | 18 +++++- internal/backend/rest/config_test.go | 61 ++++++++++++++++++++ internal/backend/rest/rest.go | 8 +++ 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 changelog/unreleased/pull-REST-password-file diff --git a/changelog/unreleased/pull-REST-password-file b/changelog/unreleased/pull-REST-password-file new file mode 100644 index 000000000..558e5c4f1 --- /dev/null +++ b/changelog/unreleased/pull-REST-password-file @@ -0,0 +1,6 @@ +Enhancement: Support loading REST server password from a file + +The new environment variable ``RESTIC_REST_PASSWORD_FILE`` allows loading +the HTTP password for the REST backend from a file instead of specifying +it directly via ``RESTIC_REST_PASSWORD``. If both variables are set, +``RESTIC_REST_PASSWORD_FILE`` takes precedence. diff --git a/doc/030_preparing_a_new_repo.rst b/doc/030_preparing_a_new_repo.rst index 254deded9..db21f93c7 100644 --- a/doc/030_preparing_a_new_repo.rst +++ b/doc/030_preparing_a_new_repo.rst @@ -220,6 +220,16 @@ variables as well: $ export RESTIC_REST_USERNAME= $ export RESTIC_REST_PASSWORD= +To avoid storing the password in plain text in the environment, it can +be read from a file instead: + +.. code-block:: console + + $ export RESTIC_REST_PASSWORD_FILE=/path/to/password_file + +If both ``RESTIC_REST_PASSWORD`` and ``RESTIC_REST_PASSWORD_FILE`` are +set, the file takes precedence. + If you use TLS, restic will use the system's CA certificates to verify the server certificate. When the verification fails, restic refuses to proceed and exits with an error. If you have your own self-signed certificate, or a custom diff --git a/doc/075_scripting.rst b/doc/075_scripting.rst index f0c225d84..1d8cd5a80 100644 --- a/doc/075_scripting.rst +++ b/doc/075_scripting.rst @@ -96,6 +96,7 @@ environment variables, which are listed below. RESTIC_REST_USERNAME Restic REST Server username RESTIC_REST_PASSWORD Restic REST Server password + RESTIC_REST_PASSWORD_FILE Location of file containing the Restic REST Server password (takes precedence over RESTIC_REST_PASSWORD) ST_AUTH Auth URL for keystone v1 authentication ST_USER Username for keystone v1 authentication diff --git a/internal/backend/rest/config.go b/internal/backend/rest/config.go index 8f17d444a..5de1ee363 100644 --- a/internal/backend/rest/config.go +++ b/internal/backend/rest/config.go @@ -8,12 +8,14 @@ import ( "github.com/restic/restic/internal/backend" "github.com/restic/restic/internal/errors" "github.com/restic/restic/internal/options" + "github.com/restic/restic/internal/textfile" ) // Config contains all configuration necessary to connect to a REST server. type Config struct { - URL *url.URL - Connections uint `option:"connections" help:"set a limit for the number of concurrent connections (default: 5)"` + URL *url.URL + Connections uint `option:"connections" help:"set a limit for the number of concurrent connections (default: 5)"` + PasswordFile string // path to file containing the HTTP password, set via RESTIC_REST_PASSWORD_FILE } func init() { @@ -84,7 +86,17 @@ func (cfg *Config) ApplyEnvironment(prefix string) { if username == "" && !pwdSet { envName := os.Getenv(prefix + "RESTIC_REST_USERNAME") envPwd := os.Getenv(prefix + "RESTIC_REST_PASSWORD") - + cfg.PasswordFile = os.Getenv(prefix + "RESTIC_REST_PASSWORD_FILE") cfg.URL.User = url.UserPassword(envName, envPwd) } } + +// loadPasswordFromFile reads the password from the given file, stripping a BOM +// and converting to UTF-8 if necessary. +func loadPasswordFromFile(path string) (string, error) { + s, err := textfile.Read(path) + if errors.Is(err, os.ErrNotExist) { + return "", errors.Fatalf("%s does not exist", path) + } + return strings.TrimSpace(string(s)), errors.Wrap(err, "Readfile") +} diff --git a/internal/backend/rest/config_test.go b/internal/backend/rest/config_test.go index 13a1ebb13..a3ebedbef 100644 --- a/internal/backend/rest/config_test.go +++ b/internal/backend/rest/config_test.go @@ -1,10 +1,14 @@ package rest import ( + "net/http" "net/url" + "os" + "path/filepath" "testing" "github.com/restic/restic/internal/backend/test" + rtest "github.com/restic/restic/internal/test" ) func parseURL(s string) *url.URL { @@ -111,3 +115,60 @@ func TestStripPassword(t *testing.T) { }) } } + +func TestApplyEnvironmentPasswordFile(t *testing.T) { + cfg, err := ParseConfig("rest:http://localhost:1234/") + rtest.OK(t, err) + + t.Setenv("RESTIC_REST_PASSWORD_FILE", "/some/path/password") + cfg.ApplyEnvironment("") + + rtest.Equals(t, "/some/path/password", cfg.PasswordFile) +} + +func TestOpenPasswordFile(t *testing.T) { + dir := t.TempDir() + pwdFile := filepath.Join(dir, "password") + rtest.OK(t, os.WriteFile(pwdFile, []byte("secret\n"), 0600)) + + cfg, err := ParseConfig("rest:http://localhost:1234/") + rtest.OK(t, err) + cfg.PasswordFile = pwdFile + + be, err := Open(t.Context(), *cfg, http.DefaultTransport, nil) + rtest.OK(t, err) + + pwd, set := be.url.User.Password() + rtest.Assert(t, set, "expected password to be set") + rtest.Equals(t, "secret", pwd) +} + +func TestOpenPasswordFileMissing(t *testing.T) { + cfg, err := ParseConfig("rest:http://localhost:1234/") + rtest.OK(t, err) + cfg.PasswordFile = "/nonexistent/path/password" + + _, err = Open(t.Context(), *cfg, http.DefaultTransport, nil) + rtest.Assert(t, err != nil, "expected error for missing password file") +} + +func TestOpenPasswordFilePreferredOverPassword(t *testing.T) { + dir := t.TempDir() + pwdFile := filepath.Join(dir, "password") + rtest.OK(t, os.WriteFile(pwdFile, []byte("from-file\n"), 0600)) + + cfg, err := ParseConfig("rest:http://localhost:1234/") + rtest.OK(t, err) + + // Simulate both env vars being set: ApplyEnvironment stores the direct password + // in the URL and the file path in PasswordFile. Open should prefer the file. + cfg.URL.User = url.UserPassword("", "from-env") + cfg.PasswordFile = pwdFile + + be, err := Open(t.Context(), *cfg, http.DefaultTransport, nil) + rtest.OK(t, err) + + pwd, set := be.url.User.Password() + rtest.Assert(t, set, "expected password to be set") + rtest.Equals(t, "from-file", pwd) +} diff --git a/internal/backend/rest/rest.go b/internal/backend/rest/rest.go index d0158ab58..c0a82ede2 100644 --- a/internal/backend/rest/rest.go +++ b/internal/backend/rest/rest.go @@ -56,6 +56,14 @@ const ( // Open opens the REST backend with the given config. func Open(_ context.Context, cfg Config, rt http.RoundTripper, _ func(string, ...interface{})) (*Backend, error) { + if cfg.PasswordFile != "" { + pwd, err := loadPasswordFromFile(cfg.PasswordFile) + if err != nil { + return nil, errors.Wrap(err, "Loading password from file failed") + } + cfg.URL.User = url.UserPassword(cfg.URL.User.Username(), pwd) + } + // use url without trailing slash for layout url := cfg.URL.String() if url[len(url)-1] == '/' {