aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJakob Borg <jakob@kastelo.net>2024-01-04 11:07:12 +0100
committerGitHub <noreply@github.com>2024-01-04 10:07:12 +0000
commitaa901790b99e13c6cbf2bb3fda9b8ba3b765d700 (patch)
tree76ca687c3fefe3701de174ad9a17136fe6b23b70
parent17df4b8634b55cee8961480272daca70e30009ed (diff)
downloadsyncthing-aa901790b99e13c6cbf2bb3fda9b8ba3b765d700.tar.gz
syncthing-aa901790b99e13c6cbf2bb3fda9b8ba3b765d700.zip
lib/api: Save session & CSRF tokens to database, add option to stay logged in (fixes #9151) (#9284)
This adds a "token manager" which handles storing and checking expired tokens, used for both sessions and CSRF tokens. It removes the old, corresponding functionality for CSRFs which saved things in a file. The result is less crap in the state directory, and active login sessions now survive a Syncthing restart (this really annoyed me). It also adds a boolean on login to create a longer-lived session cookie, which is now possible and useful. Thus we can remain logged in over browser restarts, which was also annoying... :) <img width="1001" alt="Screenshot 2023-12-12 at 09 56 34" src="https://github.com/syncthing/syncthing/assets/125426/55cb20c8-78fc-453e-825d-655b94c8623b"> Best viewed with whitespace-insensitive diff, as a bunch of the auth functions became methods instead of closures which changed indentation.
-rw-r--r--cmd/syncthing/main.go1
-rw-r--r--gui/default/index.html6
-rwxr-xr-xgui/default/syncthing/core/syncthingController.js3
-rw-r--r--lib/api/api.go15
-rw-r--r--lib/api/api_auth.go200
-rw-r--r--lib/api/api_auth_test.go77
-rw-r--r--lib/api/api_csrf.go108
-rw-r--r--lib/api/api_test.go80
-rw-r--r--lib/api/tokenmanager.go137
-rw-r--r--lib/api/tokenset.pb.go411
-rw-r--r--lib/locations/locations.go3
-rw-r--r--lib/syncthing/syncthing.go6
-rw-r--r--proto/generate.go2
-rw-r--r--proto/lib/api/tokenset.proto8
14 files changed, 785 insertions, 272 deletions
diff --git a/cmd/syncthing/main.go b/cmd/syncthing/main.go
index f321ebc8b..ab3c76e73 100644
--- a/cmd/syncthing/main.go
+++ b/cmd/syncthing/main.go
@@ -862,6 +862,7 @@ func cleanConfigDirectory() {
"backup-of-v0.8": 30 * 24 * time.Hour, // these neither
"tmp-index-sorter.*": time.Minute, // these should never exist on startup
"support-bundle-*": 30 * 24 * time.Hour, // keep old support bundle zip or folder for a month
+ "csrftokens.txt": 0, // deprecated, remove immediately
}
for pat, dur := range patterns {
diff --git a/gui/default/index.html b/gui/default/index.html
index 6e26461ce..7f5baa99e 100644
--- a/gui/default/index.html
+++ b/gui/default/index.html
@@ -359,6 +359,12 @@
<input id="password" class="form-control" type="password" name="password" ng-model="login.password" ng-trim="false" autocomplete="current-password" />
</div>
+ <div class="form-group">
+ <label>
+ <input type="checkbox" ng-model="login.stayLoggedIn" >&nbsp;<span translate>Stay logged in</span>
+ </label>
+ </div>
+
<div class="row">
<div class="col-md-9 login-form-messages">
<p ng-if="login.errors.badLogin" class="text-danger" translate>
diff --git a/gui/default/syncthing/core/syncthingController.js b/gui/default/syncthing/core/syncthingController.js
index b31f94188..54b0a96ee 100755
--- a/gui/default/syncthing/core/syncthingController.js
+++ b/gui/default/syncthing/core/syncthingController.js
@@ -103,6 +103,7 @@ angular.module('syncthing.core')
$http.post(authUrlbase + '/password', {
username: $scope.login.username,
password: $scope.login.password,
+ stayLoggedIn: $scope.login.stayLoggedIn,
}).then(function () {
location.reload();
}).catch(function (response) {
@@ -3602,7 +3603,7 @@ angular.module('syncthing.core')
return n.match !== "";
});
};
-
+
// The showModal and hideModal functions are a bandaid for a Bootstrap
// bug (see https://github.com/twbs/bootstrap/issues/3902) that causes
// multiple consecutively shown or hidden modals to overlap which leads
diff --git a/lib/api/api.go b/lib/api/api.go
index c316310c8..b96fc14ee 100644
--- a/lib/api/api.go
+++ b/lib/api/api.go
@@ -91,6 +91,7 @@ type service struct {
startupErr error
listenerAddr net.Addr
exitChan chan *svcutil.FatalErr
+ miscDB *db.NamespacedKV
guiErrors logger.Recorder
systemLog logger.Recorder
@@ -104,7 +105,7 @@ type Service interface {
WaitForStart() error
}
-func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonName string, m model.Model, defaultSub, diskSub events.BufferedSubscription, evLogger events.Logger, discoverer discover.Manager, connectionsService connections.Service, urService *ur.Service, fss model.FolderSummaryService, errors, systemLog logger.Recorder, noUpgrade bool) Service {
+func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonName string, m model.Model, defaultSub, diskSub events.BufferedSubscription, evLogger events.Logger, discoverer discover.Manager, connectionsService connections.Service, urService *ur.Service, fss model.FolderSummaryService, errors, systemLog logger.Recorder, noUpgrade bool, miscDB *db.NamespacedKV) Service {
return &service{
id: id,
cfg: cfg,
@@ -127,6 +128,7 @@ func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonNam
configChanged: make(chan struct{}),
startedOnce: make(chan struct{}),
exitChan: make(chan *svcutil.FatalErr, 1),
+ miscDB: miscDB,
}
}
@@ -364,7 +366,7 @@ func (s *service) Serve(ctx context.Context) error {
// Wrap everything in CSRF protection. The /rest prefix should be
// protected, other requests will grant cookies.
- var handler http.Handler = newCsrfManager(s.id.Short().String(), "/rest", guiCfg, mux, locations.Get(locations.CsrfTokens))
+ var handler http.Handler = newCsrfManager(s.id.Short().String(), "/rest", guiCfg, mux, s.miscDB)
// Add our version and ID as a header to responses
handler = withDetailsMiddleware(s.id, handler)
@@ -372,12 +374,13 @@ func (s *service) Serve(ctx context.Context) error {
// Wrap everything in basic auth, if user/password is set.
if guiCfg.IsAuthEnabled() {
sessionCookieName := "sessionid-" + s.id.Short().String()
- handler = basicAuthAndSessionMiddleware(sessionCookieName, s.id.Short().String(), guiCfg, s.cfg.LDAP(), handler, s.evLogger)
- handlePasswordAuth := passwordAuthHandler(sessionCookieName, guiCfg, s.cfg.LDAP(), s.evLogger)
- restMux.Handler(http.MethodPost, "/rest/noauth/auth/password", handlePasswordAuth)
+ authMW := newBasicAuthAndSessionMiddleware(sessionCookieName, s.id.Short().String(), guiCfg, s.cfg.LDAP(), handler, s.evLogger, s.miscDB)
+ handler = authMW
+
+ restMux.Handler(http.MethodPost, "/rest/noauth/auth/password", http.HandlerFunc(authMW.passwordAuthHandler))
// Logout is a no-op without a valid session cookie, so /noauth/ is fine here
- restMux.Handler(http.MethodPost, "/rest/noauth/auth/logout", handleLogout(sessionCookieName))
+ restMux.Handler(http.MethodPost, "/rest/noauth/auth/logout", http.HandlerFunc(authMW.handleLogout))
}
// Redirect to HTTPS if we are supposed to
diff --git a/lib/api/api_auth.go b/lib/api/api_auth.go
index 7af4faacb..cee0397ad 100644
--- a/lib/api/api_auth.go
+++ b/lib/api/api_auth.go
@@ -16,15 +16,16 @@ import (
ldap "github.com/go-ldap/ldap/v3"
"github.com/syncthing/syncthing/lib/config"
+ "github.com/syncthing/syncthing/lib/db"
"github.com/syncthing/syncthing/lib/events"
"github.com/syncthing/syncthing/lib/rand"
- "github.com/syncthing/syncthing/lib/sync"
"golang.org/x/exp/slices"
)
-var (
- sessions = make(map[string]bool)
- sessionsMut = sync.NewMutex()
+const (
+ maxSessionLifetime = 7 * 24 * time.Hour
+ maxActiveSessions = 25
+ randomTokenLength = 64
)
func emitLoginAttempt(success bool, username, address string, evLogger events.Logger) {
@@ -78,75 +79,91 @@ func isNoAuthPath(path string) bool {
})
}
-func basicAuthAndSessionMiddleware(cookieName, shortID string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, next http.Handler, evLogger events.Logger) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if hasValidAPIKeyHeader(r, guiCfg) {
- next.ServeHTTP(w, r)
- return
- }
+type basicAuthAndSessionMiddleware struct {
+ cookieName string
+ shortID string
+ guiCfg config.GUIConfiguration
+ ldapCfg config.LDAPConfiguration
+ next http.Handler
+ evLogger events.Logger
+ tokens *tokenManager
+}
+
+func newBasicAuthAndSessionMiddleware(cookieName, shortID string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, next http.Handler, evLogger events.Logger, miscDB *db.NamespacedKV) *basicAuthAndSessionMiddleware {
+ return &basicAuthAndSessionMiddleware{
+ cookieName: cookieName,
+ shortID: shortID,
+ guiCfg: guiCfg,
+ ldapCfg: ldapCfg,
+ next: next,
+ evLogger: evLogger,
+ tokens: newTokenManager("sessions", miscDB, maxSessionLifetime, maxActiveSessions),
+ }
+}
+
+func (m *basicAuthAndSessionMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if hasValidAPIKeyHeader(r, m.guiCfg) {
+ m.next.ServeHTTP(w, r)
+ return
+ }
- for _, cookie := range r.Cookies() {
- // We iterate here since there may, historically, be multiple
- // cookies with the same name but different path. Any "old" ones
- // won't match an existing session and will be ignored, then
- // later removed on logout or when timing out.
- if cookie.Name == cookieName {
- sessionsMut.Lock()
- _, ok := sessions[cookie.Value]
- sessionsMut.Unlock()
- if ok {
- next.ServeHTTP(w, r)
- return
- }
+ for _, cookie := range r.Cookies() {
+ // We iterate here since there may, historically, be multiple
+ // cookies with the same name but different path. Any "old" ones
+ // won't match an existing session and will be ignored, then
+ // later removed on logout or when timing out.
+ if cookie.Name == m.cookieName {
+ if m.tokens.Check(cookie.Value) {
+ m.next.ServeHTTP(w, r)
+ return
}
}
+ }
- // Fall back to Basic auth if provided
- if username, ok := attemptBasicAuth(r, guiCfg, ldapCfg, evLogger); ok {
- createSession(cookieName, username, guiCfg, evLogger, w, r)
- next.ServeHTTP(w, r)
- return
- }
+ // Fall back to Basic auth if provided
+ if username, ok := attemptBasicAuth(r, m.guiCfg, m.ldapCfg, m.evLogger); ok {
+ m.createSession(username, false, w, r)
+ m.next.ServeHTTP(w, r)
+ return
+ }
- // Exception for static assets and REST calls that don't require authentication.
- if isNoAuthPath(r.URL.Path) {
- next.ServeHTTP(w, r)
- return
- }
+ // Exception for static assets and REST calls that don't require authentication.
+ if isNoAuthPath(r.URL.Path) {
+ m.next.ServeHTTP(w, r)
+ return
+ }
- // Some browsers don't send the Authorization request header unless prompted by a 401 response.
- // This enables https://user:pass@localhost style URLs to keep working.
- if guiCfg.SendBasicAuthPrompt {
- unauthorized(w, shortID)
- return
- }
+ // Some browsers don't send the Authorization request header unless prompted by a 401 response.
+ // This enables https://user:pass@localhost style URLs to keep working.
+ if m.guiCfg.SendBasicAuthPrompt {
+ unauthorized(w, m.shortID)
+ return
+ }
- forbidden(w)
- })
+ forbidden(w)
}
-func passwordAuthHandler(cookieName string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- var req struct {
- Username string
- Password string
- }
- if err := unmarshalTo(r.Body, &req); err != nil {
- l.Debugln("Failed to parse username and password:", err)
- http.Error(w, "Failed to parse username and password.", http.StatusBadRequest)
- return
- }
+func (m *basicAuthAndSessionMiddleware) passwordAuthHandler(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Username string
+ Password string
+ StayLoggedIn bool
+ }
+ if err := unmarshalTo(r.Body, &req); err != nil {
+ l.Debugln("Failed to parse username and password:", err)
+ http.Error(w, "Failed to parse username and password.", http.StatusBadRequest)
+ return
+ }
- if auth(req.Username, req.Password, guiCfg, ldapCfg) {
- createSession(cookieName, req.Username, guiCfg, evLogger, w, r)
- w.WriteHeader(http.StatusNoContent)
- return
- }
+ if auth(req.Username, req.Password, m.guiCfg, m.ldapCfg) {
+ m.createSession(req.Username, req.StayLoggedIn, w, r)
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
- emitLoginAttempt(false, req.Username, r.RemoteAddr, evLogger)
- antiBruteForceSleep()
- forbidden(w)
- })
+ emitLoginAttempt(false, req.Username, r.RemoteAddr, m.evLogger)
+ antiBruteForceSleep()
+ forbidden(w)
}
func attemptBasicAuth(r *http.Request, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) (string, bool) {
@@ -172,11 +189,8 @@ func attemptBasicAuth(r *http.Request, guiCfg config.GUIConfiguration, ldapCfg c
return "", false
}
-func createSession(cookieName string, username string, guiCfg config.GUIConfiguration, evLogger events.Logger, w http.ResponseWriter, r *http.Request) {
- sessionid := rand.String(32)
- sessionsMut.Lock()
- sessions[sessionid] = true
- sessionsMut.Unlock()
+func (m *basicAuthAndSessionMiddleware) createSession(username string, persistent bool, w http.ResponseWriter, r *http.Request) {
+ sessionid := m.tokens.New()
// Best effort detection of whether the connection is HTTPS --
// either directly to us, or as used by the client towards a reverse
@@ -186,45 +200,45 @@ func createSession(cookieName string, username string, guiCfg config.GUIConfigur
strings.Contains(strings.ToLower(r.Header.Get("forwarded")), "proto=https")
// If the connection is HTTPS, or *should* be HTTPS, set the Secure
// bit in cookies.
- useSecureCookie := connectionIsHTTPS || guiCfg.UseTLS()
+ useSecureCookie := connectionIsHTTPS || m.guiCfg.UseTLS()
+ maxAge := 0
+ if persistent {
+ maxAge = int(maxSessionLifetime.Seconds())
+ }
http.SetCookie(w, &http.Cookie{
- Name: cookieName,
+ Name: m.cookieName,
Value: sessionid,
// In HTTP spec Max-Age <= 0 means delete immediately,
// but in http.Cookie MaxAge = 0 means unspecified (session) and MaxAge < 0 means delete immediately
- MaxAge: 0,
+ MaxAge: maxAge,
Secure: useSecureCookie,
Path: "/",
})
- emitLoginAttempt(true, username, r.RemoteAddr, evLogger)
+ emitLoginAttempt(true, username, r.RemoteAddr, m.evLogger)
}
-func handleLogout(cookieName string) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- for _, cookie := range r.Cookies() {
- // We iterate here since there may, historically, be multiple
- // cookies with the same name but different path. We drop them
- // all.
- if cookie.Name == cookieName {
- sessionsMut.Lock()
- delete(sessions, cookie.Value)
- sessionsMut.Unlock()
-
- // Delete the cookie
- http.SetCookie(w, &http.Cookie{
- Name: cookieName,
- Value: "",
- MaxAge: -1,
- Secure: cookie.Secure,
- Path: cookie.Path,
- })
- }
+func (m *basicAuthAndSessionMiddleware) handleLogout(w http.ResponseWriter, r *http.Request) {
+ for _, cookie := range r.Cookies() {
+ // We iterate here since there may, historically, be multiple
+ // cookies with the same name but different path. We drop them
+ // all.
+ if cookie.Name == m.cookieName {
+ m.tokens.Delete(cookie.Value)
+
+ // Delete the cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: m.cookieName,
+ Value: "",
+ MaxAge: -1,
+ Secure: cookie.Secure,
+ Path: cookie.Path,
+ })
}
+ }
- w.WriteHeader(http.StatusNoContent)
- })
+ w.WriteHeader(http.StatusNoContent)
}
func auth(username string, password string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration) bool {
diff --git a/lib/api/api_auth_test.go b/lib/api/api_auth_test.go
index e4e207a09..a2d7146d9 100644
--- a/lib/api/api_auth_test.go
+++ b/lib/api/api_auth_test.go
@@ -8,8 +8,12 @@ package api
import (
"testing"
+ "time"
"github.com/syncthing/syncthing/lib/config"
+ "github.com/syncthing/syncthing/lib/db"
+ "github.com/syncthing/syncthing/lib/db/backend"
+ "github.com/syncthing/syncthing/lib/events"
)
var guiCfg config.GUIConfiguration
@@ -110,3 +114,76 @@ func TestEscapeForLDAPDN(t *testing.T) {
}
}
}
+
+type mockClock struct {
+ now time.Time
+}
+
+func (c *mockClock) Now() time.Time {
+ c.now = c.now.Add(1) // time always ticks by at least 1 ns
+ return c.now
+}
+
+func (c *mockClock) wind(t time.Duration) {
+ c.now = c.now.Add(t)
+}
+
+func TestTokenManager(t *testing.T) {
+ t.Parallel()
+
+ mdb, _ := db.NewLowlevel(backend.OpenMemory(), events.NoopLogger)
+ kdb := db.NewNamespacedKV(mdb, "test")
+ clock := &mockClock{now: time.Now()}
+
+ // Token manager keeps up to three tokens with a validity time of 24 hours.
+ tm := newTokenManager("testTokens", kdb, 24*time.Hour, 3)
+ tm.timeNow = clock.Now
+
+ // Create three tokens
+ t0 := tm.New()
+ t1 := tm.New()
+ t2 := tm.New()
+
+ // Check that the tokens are valid
+ if !tm.Check(t0) {
+ t.Errorf("token %q should be valid", t0)
+ }
+ if !tm.Check(t1) {
+ t.Errorf("token %q should be valid", t1)
+ }
+ if !tm.Check(t2) {
+ t.Errorf("token %q should be valid", t2)
+ }
+
+ // Create a fourth token
+ t3 := tm.New()
+ // It should be valid
+ if !tm.Check(t3) {
+ t.Errorf("token %q should be valid", t3)
+ }
+ // But the first token should have been removed
+ if tm.Check(t0) {
+ t.Errorf("token %q should be invalid", t0)
+ }
+
+ // Wind the clock by 12 hours
+ clock.wind(12 * time.Hour)
+ // The second token should still be valid (and checking it will give it more life)
+ if !tm.Check(t1) {
+ t.Errorf("token %q should be valid", t1)
+ }
+
+ // Wind the clock by 12 hours
+ clock.wind(12 * time.Hour)
+ // The second token should still be valid
+ if !tm.Check(t1) {
+ t.Errorf("token %q should be valid", t1)
+ }
+ // But the third and fourth tokens should have expired
+ if tm.Check(t2) {
+ t.Errorf("token %q should be invalid", t2)
+ }
+ if tm.Check(t3) {
+ t.Errorf("token %q should be invalid", t3)
+ }
+}
diff --git a/lib/api/api_csrf.go b/lib/api/api_csrf.go
index 2a309a4e8..e8f03418d 100644
--- a/lib/api/api_csrf.go
+++ b/lib/api/api_csrf.go
@@ -7,33 +7,24 @@
package api
import (
- "bufio"
- "fmt"
"net/http"
- "os"
"strings"
+ "time"
- "github.com/syncthing/syncthing/lib/osutil"
- "github.com/syncthing/syncthing/lib/rand"
- "github.com/syncthing/syncthing/lib/sync"
+ "github.com/syncthing/syncthing/lib/db"
)
-const maxCsrfTokens = 25
+const (
+ maxCSRFTokenLifetime = time.Hour
+ maxActiveCSRFTokens = 25
+)
type csrfManager struct {
- // tokens is a list of valid tokens. It is sorted so that the most
- // recently used token is first in the list. New tokens are added to the front
- // of the list (as it is the most recently used at that time). The list is
- // pruned to a maximum of maxCsrfTokens, throwing away the least recently used
- // tokens.
- tokens []string
- tokensMut sync.Mutex
-
unique string
prefix string
apiKeyValidator apiKeyValidator
next http.Handler
- saveLocation string
+ tokens *tokenManager
}
type apiKeyValidator interface {
@@ -43,17 +34,14 @@ type apiKeyValidator interface {
// Check for CSRF token on /rest/ URLs. If a correct one is not given, reject
// the request with 403. For / and /index.html, set a new CSRF cookie if none
// is currently set.
-func newCsrfManager(unique string, prefix string, apiKeyValidator apiKeyValidator, next http.Handler, saveLocation string) *csrfManager {
+func newCsrfManager(unique string, prefix string, apiKeyValidator apiKeyValidator, next http.Handler, miscDB *db.NamespacedKV) *csrfManager {
m := &csrfManager{
- tokensMut: sync.NewMutex(),
- tokens: make([]string, 0, maxCsrfTokens),
unique: unique,
prefix: prefix,
apiKeyValidator: apiKeyValidator,
next: next,
- saveLocation: saveLocation,
+ tokens: newTokenManager("csrfTokens", miscDB, maxCSRFTokenLifetime, maxActiveCSRFTokens),
}
- m.load()
return m
}
@@ -78,11 +66,11 @@ func (m *csrfManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// and set a CSRF cookie if there isn't already a valid one.
if !strings.HasPrefix(r.URL.Path, m.prefix) {
cookie, err := r.Cookie("CSRF-Token-" + m.unique)
- if err != nil || !m.validToken(cookie.Value) {
+ if err != nil || !m.tokens.Check(cookie.Value) {
l.Debugln("new CSRF cookie in response to request for", r.URL)
cookie = &http.Cookie{
Name: "CSRF-Token-" + m.unique,
- Value: m.newToken(),
+ Value: m.tokens.New(),
}
http.SetCookie(w, cookie)
}
@@ -99,7 +87,7 @@ func (m *csrfManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Verify the CSRF token
token := r.Header.Get("X-CSRF-Token-" + m.unique)
- if !m.validToken(token) {
+ if !m.tokens.Check(token) {
http.Error(w, "CSRF Error", http.StatusForbidden)
return
}
@@ -107,78 +95,6 @@ func (m *csrfManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.next.ServeHTTP(w, r)
}
-func (m *csrfManager) validToken(token string) bool {
- m.tokensMut.Lock()
- defer m.tokensMut.Unlock()
- for i, t := range m.tokens {
- if t == token {
- if i > 0 {
- // Move this token to the head of the list. Copy the tokens at
- // the front one step to the right and then replace the token
- // at the head.
- copy(m.tokens[1:], m.tokens[:i])
- m.tokens[0] = token
- }
- return true
- }
- }
- return false
-}
-
-func (m *csrfManager) newToken() string {
- token := rand.String(32)
-
- m.tokensMut.Lock()
- defer m.tokensMut.Unlock()
-
- if len(m.tokens) < maxCsrfTokens {
- m.tokens = append(m.tokens, "")
- }
- copy(m.tokens[1:], m.tokens)
- m.tokens[0] = token
-
- m.save()
-
- return token
-}
-
-func (m *csrfManager) save() {
- // We're ignoring errors in here. It's not super critical and there's
- // nothing relevant we can do about them anyway...
-
- if m.saveLocation == "" {
- return
- }
-
- f, err := osutil.CreateAtomic(m.saveLocation)
- if err != nil {
- return
- }
-
- for _, t := range m.tokens {
- fmt.Fprintln(f, t)
- }
-
- f.Close()
-}
-
-func (m *csrfManager) load() {
- if m.saveLocation == "" {
- return
- }
-
- f, err := os.Open(m.saveLocation)
- if err != nil {
- return
- }
- defer f.Close()
-
- s := bufio.NewScanner(f)
- for s.Scan() {
- m.tokens = append(m.tokens, s.Text())
- }
-}
-
func hasValidAPIKeyHeader(r *http.Request, validator apiKeyValidator) bool {
if key := r.Header.Get("X-API-Key"); validator.IsValidAPIKey(key) {
return true
diff --git a/lib/api/api_test.go b/lib/api/api_test.go
index 72c2ff381..facbb85e6 100644
--- a/lib/api/api_test.go
+++ b/lib/api/api_test.go
@@ -18,7 +18,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
- "reflect"
"strconv"
"strings"
"testing"
@@ -29,6 +28,8 @@ import (
"github.com/syncthing/syncthing/lib/build"
"github.com/syncthing/syncthing/lib/config"
connmocks "github.com/syncthing/syncthing/lib/connections/mocks"
+ "github.com/syncthing/syncthing/lib/db"
+ "github.com/syncthing/syncthing/lib/db/backend"
discovermocks "github.com/syncthing/syncthing/lib/discover/mocks"
"github.com/syncthing/syncthing/lib/events"
eventmocks "github.com/syncthing/syncthing/lib/events/mocks"
@@ -72,71 +73,6 @@ func TestMain(m *testing.M) {
os.Exit(exitCode)
}
-func TestCSRFToken(t *testing.T) {
- t.Parallel()
-
- max := 10 * maxCsrfTokens
- int := 5
- if testing.Short() {
- max = 1 + maxCsrfTokens
- int = 2
- }
-
- m := newCsrfManager("unique", "prefix", config.GUIConfiguration{}, nil, "")
-
- t1 := m.newToken()
- t2 := m.newToken()
-
- t3 := m.newToken()
- if !m.validToken(t3) {
- t.Fatal("t3 should be valid")
- }
-
- valid := make(map[string]struct{}, maxCsrfTokens)
- for _, token := range m.tokens {
- valid[token] = struct{}{}
- }
-
- for i := 0; i < max; i++ {
- if i%int == 0 {
- // t1 and t2 should remain valid by virtue of us checking them now
- // and then.
- if !m.validToken(t1) {
- t.Fatal("t1 should be valid at iteration", i)
- }
- if !m.validToken(t2) {
- t.Fatal("t2 should be valid at iteration", i)
- }
- }
-
- if len(m.tokens) == maxCsrfTokens {
- // We're about to add a token, which will remove the last token
- // from m.tokens.
- delete(valid, m.tokens[len(m.tokens)-1])
- }
-
- // The newly generated token is always valid
- t4 := m.newToken()
- if !m.validToken(t4) {
- t.Fatal("t4 should be valid at iteration", i)
- }
- valid[t4] = struct{}{}
-
- v := make(map[string]struct{}, maxCsrfTokens)
- for _, token := range m.tokens {
- v[token] = struct{}{}
- }
-
- if !reflect.DeepEqual(v, valid) {
- t.Fatalf("want valid tokens %v, got %v", valid, v)
- }
- }
-
- if m.validToken(t3) {
- t.Fatal("t3 should have expired by now")
- }
-}
-
func TestStopAfterBrokenConfig(t *testing.T) {
t.Parallel()
@@ -148,7 +84,9 @@ func TestStopAfterBrokenConfig(t *testing.T) {
}
w := config.Wrap("/dev/null", cfg, protocol.LocalDeviceID, events.NoopLogger)
- srv := New(protocol.LocalDeviceID, w, "", "syncthing", nil, nil, nil, events.NoopLogger, nil, nil, nil, nil, nil, nil, false).(*service)
+ mdb, _ := db.NewLowlevel(backend.OpenMemory(), events.NoopLogger)
+ kdb := db.NewMiscDataNamespace(mdb)
+ srv := New(protocol.LocalDeviceID, w, "", "syncthing", nil, nil, nil, events.NoopLogger, nil, nil, nil, nil, nil, nil, false, kdb).(*service)
defer os.Remove(token)
srv.started = make(chan string)
@@ -926,7 +864,9 @@ func startHTTP(cfg config.Wrapper) (string, context.CancelFunc, error) {
// Instantiate the API service
urService := ur.New(cfg, m, connections, false)
- svc := New(protocol.LocalDeviceID, cfg, assetDir, "syncthing", m, eventSub, diskEventSub, events.NoopLogger, discoverer, connections, urService, mockedSummary, errorLog, systemLog, false).(*service)
+ mdb, _ := db.NewLowlevel(backend.OpenMemory(), events.NoopLogger)
+ kdb := db.NewMiscDataNamespace(mdb)
+ svc := New(protocol.LocalDeviceID, cfg, assetDir, "syncthing", m, eventSub, diskEventSub, events.NoopLogger, discoverer, connections, urService, mockedSummary, errorLog, systemLog, false, kdb).(*service)
defer os.Remove(token)
svc.started = addrChan
@@ -1467,7 +1407,9 @@ func TestEventMasks(t *testing.T) {
cfg := newMockedConfig()
defSub := new(eventmocks.BufferedSubscription)
diskSub := new(eventmocks.BufferedSubscription)
- svc := New(protocol.LocalDeviceID, cfg, "", "syncthing", nil, defSub, diskSub, events.NoopLogger, nil, nil, nil, nil, nil, nil, false).(*service)
+ mdb, _ := db.NewLowlevel(backend.OpenMemory(), events.NoopLogger)
+ kdb := db.NewMiscDataNamespace(mdb)
+ svc := New(protocol.LocalDeviceID, cfg, "", "syncthing", nil, defSub, diskSub, events.NoopLogger, nil, nil, nil, nil, nil, nil, false, kdb).(*service)
defer os.Remove(token)
if mask := svc.getEventMask(""); mask != DefaultEventMask {
diff --git a/lib/api/tokenmanager.go b/lib/api/tokenmanager.go
new file mode 100644
index 000000000..d03c75923
--- /dev/null
+++ b/lib/api/tokenmanager.go
@@ -0,0 +1,137 @@
+// Copyright (C) 2024 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package api
+
+import (
+ "time"
+
+ "github.com/syncthing/syncthing/lib/db"
+ "github.com/syncthing/syncthing/lib/rand"
+ "github.com/syncthing/syncthing/lib/sync"
+ "golang.org/x/exp/slices"
+)
+
+type tokenManager struct {
+ key string
+ miscDB *db.NamespacedKV
+ lifetime time.Duration
+ maxItems int
+
+ timeNow func() time.Time // can be overridden for testing
+
+ mut sync.Mutex
+ tokens *TokenSet
+ saveTimer *time.Timer
+}
+
+func newTokenManager(key string, miscDB *db.NamespacedKV, lifetime time.Duration, maxItems int) *tokenManager {
+ tokens := &TokenSet{
+ Tokens: make(map[string]int64),
+ }
+ if bs, ok, _ := miscDB.Bytes(key); ok {
+ _ = tokens.Unmarshal(bs) // best effort
+ }
+ return &tokenManager{
+ key: key,
+ miscDB: miscDB,
+ lifetime: lifetime,
+ maxItems: maxItems,
+ timeNow: time.Now,
+ mut: sync.NewMutex(),
+ tokens: tokens,
+ }
+}
+
+// Check returns true if the token is valid, and updates the token's expiry
+// time. The token is removed if it is expired.
+func (m *tokenManager) Check(token string) bool {
+ m.mut.Lock()
+ defer m.mut.Unlock()
+
+ expires, ok := m.tokens.Tokens[token]
+ if ok {
+ if expires < m.timeNow().UnixNano() {
+ // The token is expired.
+ m.saveLocked() // removes expired tokens
+ return false
+ }
+
+ // Give the token further life.
+ m.tokens.Tokens[token] = m.timeNow().Add(m.lifetime).UnixNano()
+ m.saveLocked()
+ }
+ return ok
+}
+
+// New creates a new token and returns it.
+func (m *tokenManager) New() string {
+ token := rand.String(randomTokenLength)
+
+ m.mut.Lock()
+ defer m.mut.Unlock()
+
+ m.tokens.Tokens[token] = m.timeNow().Add(m.lifetime).UnixNano()
+ m.saveLocked()
+
+ return token
+}
+
+// Delete removes a token.
+func (m *tokenManager) Delete(token string) {
+ m.mut.Lock()
+ defer m.mut.Unlock()
+
+ delete(m.tokens.Tokens, token)
+ m.saveLocked()
+}
+
+func (m *tokenManager) saveLocked() {
+ // Remove expired tokens.
+ now := m.timeNow().UnixNano()
+ for token, expiry := range m.tokens.Tokens {
+ if expiry < now {
+ delete(m.tokens.Tokens, token)
+ }
+ }
+
+ // If we have a limit on the number of tokens, remove the oldest ones.
+ if m.maxItems > 0 && len(m.tokens.Tokens) > m.maxItems {
+ // Sort the tokens by expiry time, oldest first.
+ type tokenExpiry struct {
+ token string
+ expiry int64
+ }
+ var tokens []tokenExpiry
+ for token, expiry := range m.tokens.Tokens {
+ tokens = append(tokens, tokenExpiry{token, expiry})
+ }
+ slices.SortFunc(tokens, func(i, j tokenExpiry) int {
+ return int(i.expiry - j.expiry)
+ })
+ // Remove the oldest tokens.
+ for _, token := range tokens[:len(tokens)-m.maxItems] {
+ delete(m.tokens.Tokens, token.token)
+ }
+ }
+
+ // Postpone saving until one second of inactivity.
+ if m.saveTimer == nil {
+ m.saveTimer = time.AfterFunc(time.Second, m.scheduledSave)
+ } else {
+ m.saveTimer.Reset(time.Second)
+ }
+}
+
+func (m *tokenManager) scheduledSave() {
+ m.mut.Lock()
+ defer m.mut.Unlock()
+
+ m.saveTimer = nil
+
+ bs, _ := m.tokens.Marshal() // can't fail
+ _ = m.miscDB.PutBytes(m.key, bs) // can fail, but what are we going to do?
+}
diff --git a/lib/api/tokenset.pb.go b/lib/api/tokenset.pb.go
new file mode 100644
index 000000000..80883fab2
--- /dev/null
+++ b/lib/api/tokenset.pb.go
@@ -0,0 +1,411 @@
+// Code generated by protoc-gen-gogo. DO NOT EDIT.
+// source: lib/api/tokenset.proto
+
+package api
+
+import (
+ fmt "fmt"
+ proto "github.com/gogo/protobuf/proto"
+ io "io"
+ math "math"
+ math_bits "math/bits"
+)
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ = proto.Marshal
+var _ = fmt.Errorf
+var _ = math.Inf
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the proto package it is being compiled against.
+// A compilation error at this line likely means your copy of the
+// proto package needs to be updated.
+const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package
+
+type TokenSet struct {
+ // token -> expiry time (epoch nanoseconds)
+ Tokens map[string]int64 `protobuf:"bytes,1,rep,name=tokens,proto3" json:"tokens" xml:"token" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"`
+}
+
+func (m *TokenSet) Reset() { *m = TokenSet{} }
+func (m *TokenSet) String() string { return proto.CompactTextString(m) }
+func (*TokenSet) ProtoMessage() {}
+func (*TokenSet) Descriptor() ([]byte, []int) {
+ return fileDescriptor_9ea8707737c33b38, []int{0}
+}
+func (m *TokenSet) XXX_Unmarshal(b []byte) error {
+ return m.Unmarshal(b)
+}
+func (m *TokenSet) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+ if deterministic {
+ return xxx_messageInfo_TokenSet.Marshal(b, m, deterministic)
+ } else {
+ b = b[:cap(b)]
+ n, err := m.MarshalToSizedBuffer(b)
+ if err != nil {
+ return nil, err
+ }
+ return b[:n], nil
+ }
+}
+func (m *TokenSet) XXX_Merge(src proto.Message) {
+ xxx_messageInfo_TokenSet.Merge(m, src)
+}
+func (m *TokenSet) XXX_Size() int {
+ return m.ProtoSize()
+}
+func (m *TokenSet) XXX_DiscardUnknown() {
+ xxx_messageInfo_TokenSet.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_TokenSet proto.InternalMessageInfo
+
+func init() {
+ proto.RegisterType((*TokenSet)(nil), "api.TokenSet")
+ proto.RegisterMapType((map[string]int64)(nil), "api.TokenSet.TokensEntry")
+}
+
+func init() { proto.RegisterFile("lib/api/tokenset.proto", fileDescriptor_9ea8707737c33b38) }
+
+var fileDescriptor_9ea8707737c33b38 = []byte{
+ // 260 bytes of a gzipped FileDescriptorProto
+ 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0xcb, 0xc9, 0x4c, 0xd2,
+ 0x4f, 0x2c, 0xc8, 0xd4, 0x2f, 0xc9, 0xcf, 0x4e, 0xcd, 0x2b, 0x4e, 0x2d, 0xd1, 0x2b, 0x28, 0xca,
+ 0x2f, 0xc9, 0x17, 0x62, 0x4e, 0x2c, 0xc8, 0x54, 0x3a, 0xce, 0xc8, 0xc5, 0x11, 0x02, 0x12, 0x0f,
+ 0x4e, 0x2d, 0x11, 0x0a, 0xe0, 0x62, 0x83, 0xa8, 0x91, 0x60, 0x54, 0x60, 0xd6, 0xe0, 0x36, 0x92,
+ 0xd4, 0x4b, 0x2c, 0xc8, 0xd4, 0x83, 0x49, 0x43, 0x18, 0xc5, 0xae, 0x79, 0x25, 0x45, 0x95, 0x4e,
+ 0xb2, 0x27, 0xee, 0xc9, 0x33, 0xbc, 0xba, 0x27, 0x0f, 0xd5, 0xf0, 0xe9, 0x9e, 0x3c, 0x77, 0x45,
+ 0x6e, 0x8e, 0x95, 0x12, 0x98, 0xab, 0x14, 0x04, 0x15, 0x96, 0xca, 0xe4, 0xe2, 0x46, 0xd2, 0x25,
+ 0xa4, 0xc6, 0xc5, 0x9c, 0x9d, 0x5a, 0x29, 0xc1, 0xa8, 0xc0, 0xa8, 0xc1, 0xe9, 0x24, 0xf2, 0xea,
+ 0x9e, 0x3c, 0x88, 0xfb, 0xe9, 0x9e, 0x3c, 0x27, 0x58, 0x6f, 0x76, 0x6a, 0xa5, 0x52, 0x10, 0x48,
+ 0x44, 0x48, 0x8f, 0x8b, 0xb5, 0x2c, 0x31, 0xa7, 0x34, 0x55, 0x82, 0x49, 0x81, 0x51, 0x83, 0xd9,
+ 0x49, 0xe2, 0xd5, 0x3d, 0x79, 0x88, 0x00, 0xdc, 0x1e, 0x30, 0x4f, 0x29, 0x08, 0x22, 0x6a, 0xc5,
+ 0x64, 0xc1, 0xe8, 0xe4, 0x71, 0xe2, 0xa1, 0x1c, 0xc3, 0x85, 0x87, 0x72, 0x0c, 0x27, 0x1e, 0xc9,
+ 0x31, 0x5e, 0x78, 0x24, 0xc7, 0x38, 0xe1, 0xb1, 0x1c, 0xc3, 0x82, 0xc7, 0x72, 0x8c, 0x17, 0x1e,
+ 0xcb, 0x31, 0xdc, 0x78, 0x2c, 0xc7, 0x10, 0xa5, 0x96, 0x9e, 0x59, 0x92, 0x51, 0x9a, 0xa4, 0x97,
+ 0x9c, 0x9f, 0xab, 0x5f, 0x5c, 0x99, 0x97, 0x5c, 0x92, 0x91, 0x99, 0x97, 0x8e, 0xc4, 0x82, 0x86,
+ 0x53, 0x12, 0x1b, 0x38, 0x7c, 0x8c, 0x01, 0x01, 0x00, 0x00, 0xff, 0xff, 0xfe, 0x25, 0x31, 0x49,
+ 0x39, 0x01, 0x00, 0x00,
+}
+
+func (m *TokenSet) Marshal() (dAtA []byte, err error) {
+ size := m.ProtoSize()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBuffer(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *TokenSet) MarshalTo(dAtA []byte) (int, error) {
+ size := m.ProtoSize()
+ return m.MarshalToSizedBuffer(dAtA[:size])
+}
+
+func (m *TokenSet) MarshalToSizedBuffer(dAtA []byte) (int, error) {
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if len(m.Tokens) > 0 {
+ for k := range m.Tokens {
+ v := m.Tokens[k]
+ baseI := i
+ i = encodeVarintTokenset(dAtA, i, uint64(v))
+ i--
+ dAtA[i] = 0x10
+ i -= len(k)
+ copy(dAtA[i:], k)
+ i = encodeVarintTokenset(dAtA, i, uint64(len(k)))
+ i--
+ dAtA[i] = 0xa
+ i = encodeVarintTokenset(dAtA, i, uint64(baseI-i))
+ i--
+ dAtA[i] = 0xa
+ }
+ }
+ return len(dAtA) - i, nil
+}
+
+func encodeVarintTokenset(dAtA []byte, offset int, v uint64) int {
+ offset -= sovTokenset(v)
+ base := offset
+ for v >= 1<<7 {
+ dAtA[offset] = uint8(v&0x7f | 0x80)
+ v >>= 7
+ offset++
+ }
+ dAtA[offset] = uint8(v)
+ return base
+}
+func (m *TokenSet) ProtoSize() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ if len(m.Tokens) > 0 {
+ for k, v := range m.Tokens {
+ _ = k
+ _ = v
+ mapEntrySize := 1 + len(k) + sovTokenset(uint64(len(k))) + 1 + sovTokenset(uint64(v))
+ n += mapEntrySize + 1 + sovTokenset(uint64(mapEntrySize))
+ }
+ }
+ return n
+}
+
+func sovTokenset(x uint64) (n int) {
+ return (math_bits.Len64(x|1) + 6) / 7
+}
+func sozTokenset(x uint64) (n int) {
+ return sovTokenset(uint64((x << 1) ^ uint64((int64(x) >> 63))))
+}
+func (m *TokenSet) Unmarshal(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflowTokenset
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: TokenSet: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: TokenSet: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Tokens", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflowTokenset
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLengthTokenset
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLengthTokenset
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ if m.Tokens == nil {
+ m.Tokens = make(map[string]int64)
+ }
+ var mapkey string
+ var mapvalue int64
+ for iNdEx < postIndex {
+ entryPreIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflowTokenset
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ if fieldNum == 1 {
+ var stringLenmapkey uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflowTokenset
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLenmapkey |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLenmapkey := int(stringLenmapkey)
+ if intStringLenmapkey < 0 {
+ return ErrInvalidLengthTokenset
+ }
+ postStringIndexmapkey := iNdEx + intStringLenmapkey
+ if postStringIndexmapkey < 0 {
+ return ErrInvalidLengthTokenset
+ }
+ if postStringIndexmapkey > l {
+ return io.ErrUnexpectedEOF
+ }
+ mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
+ iNdEx = postStringIndexmapkey
+ } else if fieldNum == 2 {
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflowTokenset
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ mapvalue |= int64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ } else {
+ iNdEx = entryPreIndex
+ skippy, err := skipTokenset(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLengthTokenset
+ }
+ if (iNdEx + skippy) > postIndex {
+ return io.ErrUnexpectedEOF
+ }
+ iNdEx += skippy
+ }
+ }
+ m.Tokens[mapkey] = mapvalue
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skipTokenset(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLengthTokenset
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func skipTokenset(dAtA []byte) (n int, err error) {
+ l := len(dAtA)
+ iNdEx := 0
+ depth := 0
+ for iNdEx < l {
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return 0, ErrIntOverflowTokenset
+ }
+ if iNdEx >= l {
+ return 0, io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= (uint64(b) & 0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ wireType := int(wire & 0x7)
+ switch wireType {
+ case 0:
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return 0, ErrIntOverflowTokenset
+ }
+ if iNdEx >= l {
+ return 0, io.ErrUnexpectedEOF
+ }
+ iNdEx++
+ if dAtA[iNdEx-1] < 0x80 {
+ break
+ }
+ }
+ case 1:
+ iNdEx += 8
+ case 2:
+ var length int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return 0, ErrIntOverflowTokenset
+ }
+ if iNdEx >= l {
+ return 0, io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ length |= (int(b) & 0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if length < 0 {
+ return 0, ErrInvalidLengthTokenset
+ }
+ iNdEx += length
+ case 3:
+ depth++
+ case 4:
+ if depth == 0 {
+ return 0, ErrUnexpectedEndOfGroupTokenset
+ }
+ depth--
+ case 5:
+ iNdEx += 4
+ default:
+ return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
+ }
+ if iNdEx < 0 {
+ return 0, ErrInvalidLengthTokenset
+ }
+ if depth == 0 {
+ return iNdEx, nil
+ }
+ }
+ return 0, io.ErrUnexpectedEOF
+}
+
+var (
+ ErrInvalidLengthTokenset = fmt.Errorf("proto: negative length found during unmarshaling")
+ ErrIntOverflowTokenset = fmt.Errorf("proto: integer overflow")
+ ErrUnexpectedEndOfGroupTokenset = fmt.Errorf("proto: unexpected end of group")
+)
diff --git a/lib/locations/locations.go b/lib/locations/locations.go
index e384b897a..ad92124a6 100644
--- a/lib/locations/locations.go
+++ b/lib/locations/locations.go
@@ -29,7 +29,6 @@ const (
HTTPSKeyFile LocationEnum = "httpsKeyFile"
Database LocationEnum = "database"
LogFile LocationEnum = "logFile"
- CsrfTokens LocationEnum = "csrfTokens"
PanicLog LocationEnum = "panicLog"
AuditLog LocationEnum = "auditLog"
GUIAssets LocationEnum = "guiAssets"
@@ -121,7 +120,6 @@ var locationTemplates = map[LocationEnum]string{
HTTPSKeyFile: "${config}/https-key.pem",
Database: "${data}/" + LevelDBDir,
LogFile: "${data}/syncthing.log", // --logfile on Windows
- CsrfTokens: "${data}/csrftokens.txt",
PanicLog: "${data}/panic-%{timestamp}.log",
AuditLog: "${data}/audit-%{timestamp}.log",
GUIAssets: "${config}/gui",
@@ -170,7 +168,6 @@ func PrettyPaths() string {
fmt.Fprintf(&b, "Database location:\n\t%s\n\n", Get(Database))
fmt.Fprintf(&b, "Log file:\n\t%s\n\n", Get(LogFile))
fmt.Fprintf(&b, "GUI override directory:\n\t%s\n\n", Get(GUIAssets))
- fmt.Fprintf(&b, "CSRF tokens file:\n\t%s\n\n", Get(CsrfTokens))
fmt.Fprintf(&b, "Default sync folder directory:\n\t%s\n\n", Get(DefFolder))
return b.String()
}
diff --git a/lib/syncthing/syncthing.go b/lib/syncthing/syncthing.go
index 237131521..fd627b581 100644
--- a/lib/syncthing/syncthing.go
+++ b/lib/syncthing/syncthing.go
@@ -305,7 +305,7 @@ func (a *App) startup() error {
// GUI
- if err := a.setupGUI(m, defaultSub, diskSub, discoveryManager, connectionsService, usageReportingSvc, errors, systemLog); err != nil {
+ if err := a.setupGUI(m, defaultSub, diskSub, discoveryManager, connectionsService, usageReportingSvc, errors, systemLog, miscDB); err != nil {
l.Warnln("Failed starting API:", err)
return err
}
@@ -407,7 +407,7 @@ func (a *App) stopWithErr(stopReason svcutil.ExitStatus, err error) svcutil.Exit
return a.exitStatus
}
-func (a *App) setupGUI(m model.Model, defaultSub, diskSub events.BufferedSubscription, discoverer discover.Manager, connectionsService connections.Service, urService *ur.Service, errors, systemLog logger.Recorder) error {
+func (a *App) setupGUI(m model.Model, defaultSub, diskSub events.BufferedSubscription, discoverer discover.Manager, connectionsService connections.Service, urService *ur.Service, errors, systemLog logger.Recorder, miscDB *db.NamespacedKV) error {
guiCfg := a.cfg.GUI()
if !guiCfg.Enabled {
@@ -421,7 +421,7 @@ func (a *App) setupGUI(m model.Model, defaultSub, diskSub events.BufferedSubscri
summaryService := model.NewFolderSummaryService(a.cfg, m, a.myID, a.evLogger)
a.mainService.Add(summaryService)
- apiSvc := api.New(a.myID, a.cfg, locations.Get(locations.GUIAssets), tlsDefaultCommonName, m, defaultSub, diskSub, a.evLogger, discoverer, connectionsService, urService, summaryService, errors, systemLog, a.opts.NoUpgrade)
+ apiSvc := api.New(a.myID, a.cfg, locations.Get(locations.GUIAssets), tlsDefaultCommonName, m, defaultSub, diskSub, a.evLogger, discoverer, connectionsService, urService, summaryService, errors, systemLog, a.opts.NoUpgrade, miscDB)
a.mainService.Add(apiSvc)
if err := apiSvc.WaitForStart(); err != nil {
diff --git a/proto/generate.go b/proto/generate.go
index 03be4522e..c264868d8 100644
--- a/proto/generate.go
+++ b/proto/generate.go
@@ -26,7 +26,7 @@ import (
// Inception, go generate calls the script itself that then deals with generation.
// This is only done because go:generate does not support wildcards in paths.
-//go:generate go run generate.go lib/protocol lib/config lib/fs lib/db lib/discover
+//go:generate go run generate.go lib/protocol lib/config lib/fs lib/db lib/discover lib/api
func main() {
for _, path := range os.Args[1:] {
diff --git a/proto/lib/api/tokenset.proto b/proto/lib/api/tokenset.proto
new file mode 100644
index 000000000..bf3ff0842
--- /dev/null
+++ b/proto/lib/api/tokenset.proto
@@ -0,0 +1,8 @@
+syntax = "proto3";
+
+package api;
+
+message TokenSet {
+ // token -> expiry time (epoch nanoseconds)
+ map<string, int64> tokens = 1;
+}