Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package rqlite for openSUSE:Factory checked in at 2026-10-23 20:26:01 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/rqlite (Old) and /work/SRC/openSUSE:Factory/.rqlite.new.2252680 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "rqlite" Fri Oct 23 20:26:01 2026 rev:60 rq:1387163 version:10.6.4 Changes: -------- --- /work/SRC/openSUSE:Factory/rqlite/rqlite.changes 2026-10-21 20:51:53.362884044 +0200 +++ /work/SRC/openSUSE:Factory/.rqlite.new.2252680/rqlite.changes 2026-10-23 20:26:31.445081606 +0200 @@ -1,0 +2,13 @@ +Thu Oct 22 18:54:08 UTC 2026 - Andreas Stieger <[email protected]> + +- Update to version 10.6.4: + * BACKUP_STREAM authorization failures can disclose the database + and crash rqlited (boo#1286934) + * GET /leader returns a JSON error body when there is no leader + * Close the connection after rejecting an unauthorized inter-node + backup stream request, instead of streaming the database anyway + * Decide inter-node command authorization once, before dispatch, + and close the connection after rejecting any unauthorized + command + +------------------------------------------------------------------- Old: ---- rqlite-10.6.3.tar.xz New: ---- rqlite-10.6.4.tar.xz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ rqlite.spec ++++++ --- /var/tmp/diff_new_pack.rjPkha/_old 2026-10-23 20:26:32.139110659 +0200 +++ /var/tmp/diff_new_pack.rjPkha/_new 2026-10-23 20:26:32.142110785 +0200 @@ -18,7 +18,7 @@ Name: rqlite -Version: 10.6.3 +Version: 10.6.4 Release: 0 Summary: Distributed relational database built on SQLite License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.rjPkha/_old 2026-10-23 20:26:32.173112082 +0200 +++ /var/tmp/diff_new_pack.rjPkha/_new 2026-10-23 20:26:32.176112208 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/rqlite/rqlite.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">v10.6.3</param> + <param name="revision">v10.6.4</param> <param name="versionformat">@PARENT_TAG@</param> <param name="changesgenerate">enable</param> <param name="versionrewrite-pattern">v(.*)</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.rjPkha/_old 2026-10-23 20:26:32.195113003 +0200 +++ /var/tmp/diff_new_pack.rjPkha/_new 2026-10-23 20:26:32.197113087 +0200 @@ -1,7 +1,7 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/rqlite/rqlite.git</param> - <param name="changesrevision">d0e1d1777a9bbe5d933c5843814c442d43eb7475</param> + <param name="changesrevision">64145717224d620809174cb2235ac86ec604aac5</param> </service> </servicedata> (No newline at EOF) ++++++ rqlite-10.6.3.tar.xz -> rqlite-10.6.4.tar.xz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.6.3/CHANGELOG.md new/rqlite-10.6.4/CHANGELOG.md --- old/rqlite-10.6.3/CHANGELOG.md 2026-10-20 16:36:08.000000000 +0200 +++ new/rqlite-10.6.4/CHANGELOG.md 2026-10-22 04:39:23.000000000 +0200 @@ -1,8 +1,18 @@ +## v10.6.4 (October 21st 2026) +This release includes [a critical security fix](https://github.com/rqlite/rqlite/security/advisories/GHSA-f4f3-px5g-gm4x). Users should upgrade. + +### Implementation changes and bug fixes +- [PR #2977](https://github.com/rqlite/rqlite/pull/2977): `GET /leader` returns a JSON error body when there is no leader. Fixes issue [#2974](https://github.com/rqlite/rqlite/issues/2974). +- [PR #2980](https://github.com/rqlite/rqlite/pull/2980): Close the connection after rejecting an unauthorized inter-node backup stream request, instead of streaming the database anyway. Thanks @samuelemarro +- [PR #2981](https://github.com/rqlite/rqlite/pull/2981): Decide inter-node command authorization once, before dispatch, and close the connection after rejecting any unauthorized command. + ## v10.6.3 (October 20th 2026) +This release includes [an important security fix](https://github.com/rqlite/rqlite/security/advisories/GHSA-j62w-8865-2h7x). Users should upgrade. + ### Implementation changes and bug fixes - [PR #2968](https://github.com/rqlite/rqlite/pull/2968): Node probes honor request context and commit exactly one result. - [PR #2975](https://github.com/rqlite/rqlite/pull/2975): Make DB connections a first-class object. -- [PR #2976](https://github.com/rqlite/rqlite/pull/2976): Prevent `ATTACH` on the query database connection. +- [PR #2976](https://github.com/rqlite/rqlite/pull/2976): Prevent `ATTACH` on the query database connection. Thanks @Hama1cco ## v10.6.2 (October 18th 2026) ### Implementation changes and bug fixes @@ -167,7 +177,7 @@ - [PR #2787](https://github.com/rqlite/rqlite/pull/2787): Clean up cancelled snapshots. - [PR #2788](https://github.com/rqlite/rqlite/pull/2788): Reject incomplete snapshot headers. - [PR #2789](https://github.com/rqlite/rqlite/pull/2789): Validate restore database header. -- [PR #2790](https://github.com/rqlite/rqlite/pull/2790): Only reap a sole fullsnapshot if it has zero WALs. +- [PR #2790](https://github.com/rqlite/rqlite/pull/2790): Only reap a sole full snapshot if it has zero WALs. - [PR #2790](https://github.com/rqlite/rqlite/pull/2793): Keep database dumps consistent during writes to the database. - [PR #2792](https://github.com/rqlite/rqlite/pull/2792): Roll back DB Requests on statement-prepare errors. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.6.3/cluster/service.go new/rqlite-10.6.4/cluster/service.go --- old/rqlite-10.6.3/cluster/service.go 2026-10-20 16:36:08.000000000 +0200 +++ new/rqlite-10.6.4/cluster/service.go 2026-10-22 04:39:23.000000000 +0200 @@ -49,6 +49,7 @@ numBroadcastHWMRequest = "num_broadcast_hwm_req" numHWMUpdateDropped = "num_hwm_update_dropped" numConnRejected = "num_conn_rejected" + numUnauthorizedRequest = "num_unauthorized_req" numClientRetries = "num_client_retries" numGetNodeAPIRequestRetries = "num_get_node_api_req_retries" @@ -115,6 +116,7 @@ stats.Add(numClientWriteTimeouts, 0) stats.Add(numClientForceNewConn, 0) stats.Add(numConnRejected, 0) + stats.Add(numUnauthorizedRequest, 0) } // Dialer is the interface dialers must implement. @@ -375,6 +377,112 @@ return true } +const ( + // unauthorizedError is the error returned to a client whose credentials + // do not grant the permission its command requires. + unauthorizedError = "unauthorized" + + // unsupportedError is the error returned to a client whose command is + // one this node no longer acts on. + unsupportedError = "unsupported" +) + +// notSupported lists the command types a node no longer acts on. Such a +// command is answered with "unsupported", whatever credentials it carries, +// and its connection is closed. +var notSupported = map[proto.Command_Type]bool{ + proto.Command_COMMAND_TYPE_LOAD_CHUNK: true, +} + +// authorized reports whether the credentials carried by the command grant +// the permission its type requires. A command type not listed here is never +// authorized, so a new type must be added explicitly before a node will act +// on it. +func (s *Service) authorized(c *proto.Command) bool { + switch c.Type { + case proto.Command_COMMAND_TYPE_GET_NODE_META: + return true + case proto.Command_COMMAND_TYPE_EXECUTE: + return s.checkCommandPerm(c, auth.PermExecute) + case proto.Command_COMMAND_TYPE_QUERY: + return s.checkCommandPerm(c, auth.PermQuery) + case proto.Command_COMMAND_TYPE_REQUEST: + return s.checkCommandPermAll(c, auth.PermQuery, auth.PermExecute) + case proto.Command_COMMAND_TYPE_BACKUP, + proto.Command_COMMAND_TYPE_BACKUP_STREAM: + return s.checkCommandPerm(c, auth.PermBackup) + case proto.Command_COMMAND_TYPE_LOAD: + return s.checkCommandPerm(c, auth.PermLoad) + case proto.Command_COMMAND_TYPE_REMOVE_NODE: + return s.checkCommandPerm(c, auth.PermRemove) + case proto.Command_COMMAND_TYPE_DEMOTE_NODE: + return s.checkCommandPerm(c, auth.PermDemote) + case proto.Command_COMMAND_TYPE_PROMOTE_NODE: + return s.checkCommandPerm(c, auth.PermPromote) + case proto.Command_COMMAND_TYPE_NOTIFY: + return s.checkCommandPerm(c, auth.PermJoin) + case proto.Command_COMMAND_TYPE_JOIN: + if jr := c.GetJoinRequest(); jr != nil && !jr.Voter { + return s.checkCommandPerm(c, auth.PermJoinReadOnly) || + s.checkCommandPerm(c, auth.PermJoinReadReplica) + } + return s.checkCommandPerm(c, auth.PermJoin) + case proto.Command_COMMAND_TYPE_STEPDOWN: + return s.checkCommandPerm(c, auth.PermLeaderOps) + case proto.Command_COMMAND_TYPE_HIGHWATER_MARK_UPDATE: + return s.checkCommandPerm(c, auth.PermCDCHWMUpdate) + default: + return false + } +} + +// errorResponse returns the encoded response telling a client that its +// command of the given type was rejected, and why. The encoding matches +// what the client expects for that command type. +func errorResponse(t proto.Command_Type, errStr string) ([]byte, error) { + var m pb.Message + switch t { + case proto.Command_COMMAND_TYPE_EXECUTE: + m = &proto.CommandExecuteResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_QUERY: + m = &proto.CommandQueryResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_REQUEST: + m = &proto.CommandRequestResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_BACKUP, + proto.Command_COMMAND_TYPE_BACKUP_STREAM: + m = &proto.CommandBackupResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_LOAD: + m = &proto.CommandLoadResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_LOAD_CHUNK: + m = &proto.CommandLoadChunkResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_REMOVE_NODE: + m = &proto.CommandRemoveNodeResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_DEMOTE_NODE: + m = &proto.CommandDemoteNodeResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_PROMOTE_NODE: + m = &proto.CommandPromoteNodeResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_NOTIFY: + m = &proto.CommandNotifyResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_JOIN: + m = &proto.CommandJoinResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_STEPDOWN: + m = &proto.CommandStepdownResponse{Error: errStr} + case proto.Command_COMMAND_TYPE_HIGHWATER_MARK_UPDATE: + m = &proto.HighwaterMarkUpdateResponse{Error: errStr} + default: + return nil, fmt.Errorf("no response type for command %s", t) + } + p, err := pb.Marshal(m) + if err != nil { + return nil, err + } + if t == proto.Command_COMMAND_TYPE_BACKUP { + // Non-streaming backup responses are always compressed on the wire. + return gzCompress(p) + } + return p, nil +} + func (s *Service) handleConn(conn net.Conn) { defer conn.Close() @@ -409,6 +517,27 @@ return } + // A command this node no longer acts on is told so and, like any + // other rejected command, is the last one on its connection. + if notSupported[c.Type] { + if p, err := errorResponse(c.Type, unsupportedError); err == nil { + _ = writeBytesWithLength(conn, p) + } + return + } + + // Authorization is decided once, here, before any command is acted + // on. A rejected command is told so and is then the last command on + // its connection: returning closes the connection, so no handler + // below can act on the command by mistake. + if !s.authorized(c) { + stats.Add(numUnauthorizedRequest, 1) + if p, err := errorResponse(c.Type, unauthorizedError); err == nil { + _ = writeBytesWithLength(conn, p) + } + return + } + switch c.Type { case proto.Command_COMMAND_TYPE_GET_NODE_META: stats.Add(numGetNodeAPIRequest, 1) @@ -438,8 +567,6 @@ er := c.GetExecuteRequest() if er == nil { resp.Error = "ExecuteRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermExecute) { - resp.Error = "unauthorized" } else { res, idx, err := s.db.Execute(context.Background(), er) if err != nil { @@ -460,8 +587,6 @@ qr := c.GetQueryRequest() if qr == nil { resp.Error = "QueryRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermQuery) { - resp.Error = "unauthorized" } else { res, _, idx, err := s.db.Query(context.Background(), qr) if err != nil { @@ -482,8 +607,6 @@ rr := c.GetExecuteQueryRequest() if rr == nil { resp.Error = "RequestRequest is nil" - } else if !s.checkCommandPermAll(c, auth.PermQuery, auth.PermExecute) { - resp.Error = "unauthorized" } else { res, numRW, idx, err := s.db.Request(context.Background(), rr) if err != nil { @@ -505,8 +628,6 @@ br := c.GetBackupRequest() if br == nil { resp.Error = "BackupRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermBackup) { - resp.Error = "unauthorized" } else { buf := new(bytes.Buffer) if _, err := s.db.Backup(context.Background(), br, buf); err != nil { @@ -538,8 +659,6 @@ br := c.GetBackupRequest() if br == nil { resp.Error = "BackupRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermBackup) { - resp.Error = "unauthorized" } p, err = pb.Marshal(resp) if err != nil { @@ -549,6 +668,11 @@ if err := writeBytesWithLength(conn, p); err != nil { return } + if resp.Error != "" { + // There is nothing to stream, so end the connection + // so the client sees a clean end instead of waiting. + return + } // Now, start streaming the backup. Enable compressed mode // regardless of whether the client requested it, so the client @@ -567,8 +691,6 @@ lr := c.GetLoadRequest() if lr == nil { resp.Error = "LoadRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermLoad) { - resp.Error = "unauthorized" } else { if err := s.db.Load(context.Background(), lr); err != nil { resp.Error = fmt.Sprintf("remote node failed to load: %s", err.Error()) @@ -579,14 +701,6 @@ return } - case proto.Command_COMMAND_TYPE_LOAD_CHUNK: - resp := &proto.CommandLoadChunkResponse{ - Error: "unsupported", - } - if err := marshalAndWrite(conn, resp); err != nil { - return - } - case proto.Command_COMMAND_TYPE_REMOVE_NODE: stats.Add(numRemoveNodeRequest, 1) resp := &proto.CommandRemoveNodeResponse{} @@ -594,8 +708,6 @@ rn := c.GetRemoveNodeRequest() if rn == nil { resp.Error = "RemoveNodeRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermRemove) { - resp.Error = "unauthorized" } else { if err := s.mgr.Remove(context.Background(), rn); err != nil { resp.Error = err.Error() @@ -612,8 +724,6 @@ dn := c.GetDemoteNodeRequest() if dn == nil { resp.Error = "DemoteNodeRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermDemote) { - resp.Error = "unauthorized" } else { if err := s.mgr.Demote(context.Background(), dn); err != nil { resp.Error = err.Error() @@ -630,8 +740,6 @@ pn := c.GetPromoteNodeRequest() if pn == nil { resp.Error = "PromoteNodeRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermPromote) { - resp.Error = "unauthorized" } else { if err := s.mgr.Promote(context.Background(), pn); err != nil { resp.Error = err.Error() @@ -648,8 +756,6 @@ nr := c.GetNotifyRequest() if nr == nil { resp.Error = "NotifyRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermJoin) { - resp.Error = "unauthorized" } else { if err := s.mgr.Notify(nr); err != nil { resp.Error = err.Error() @@ -666,23 +772,15 @@ jr := c.GetJoinRequest() if jr == nil { resp.Error = "JoinRequest is nil" - } else { - if (jr.Voter && s.checkCommandPerm(c, auth.PermJoin)) || - (!jr.Voter && s.checkCommandPerm(c, auth.PermJoinReadOnly)) || - (!jr.Voter && s.checkCommandPerm(c, auth.PermJoinReadReplica)) { - if err := s.mgr.Join(jr); err != nil { + } else if err := s.mgr.Join(jr); err != nil { + resp.Error = err.Error() + if err.Error() == "not leader" { + laddr, err := s.mgr.LeaderAddr() + if err != nil { resp.Error = err.Error() - if err.Error() == "not leader" { - laddr, err := s.mgr.LeaderAddr() - if err != nil { - resp.Error = err.Error() - } else { - resp.Leader = laddr - } - } + } else { + resp.Leader = laddr } - } else { - resp.Error = "unauthorized" } } if err := marshalAndWrite(conn, resp); err != nil { @@ -696,8 +794,6 @@ sr := c.GetStepdownRequest() if sr == nil { resp.Error = "StepdownRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermLeaderOps) { - resp.Error = "unauthorized" } else { if err := s.mgr.Stepdown(sr.Wait, sr.Id); err != nil { resp.Error = err.Error() @@ -714,8 +810,6 @@ br := c.GetHighwaterMarkUpdateRequest() if br == nil { resp.Error = "HighwaterMarkUpdateRequest is nil" - } else if !s.checkCommandPerm(c, auth.PermCDCHWMUpdate) { - resp.Error = "unauthorized" } else { // Send to registered channel if available s.hwmMu.RLock() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.6.3/cluster/service_test.go new/rqlite-10.6.4/cluster/service_test.go --- old/rqlite-10.6.3/cluster/service_test.go 2026-10-20 16:36:08.000000000 +0200 +++ new/rqlite-10.6.4/cluster/service_test.go 2026-10-22 04:39:23.000000000 +0200 @@ -1,20 +1,26 @@ package cluster import ( + "bytes" + "compress/gzip" "context" "crypto/tls" "errors" "fmt" "io" "net" + "slices" "sync" + "sync/atomic" "testing" "time" + "github.com/rqlite/rqlite/v10/auth" "github.com/rqlite/rqlite/v10/cluster/proto" command "github.com/rqlite/rqlite/v10/command/proto" "github.com/rqlite/rqlite/v10/internal/fsutil" "github.com/rqlite/rqlite/v10/testdata/x509" + pb "google.golang.org/protobuf/proto" ) func Test_NewServiceOpenClose(t *testing.T) { @@ -364,6 +370,480 @@ } } +// errResponse is any cluster response message that carries an error string. +type errResponse interface { + pb.Message + GetError() string +} + +// unauthorizedCommand describes one command that must be rejected when the +// credential store does not grant the permission it needs. +type unauthorizedCommand struct { + name string + cmd *proto.Command + perms []string // Permissions the store must be asked for, in order. + resp errResponse // Response type the client decodes for this command. +} + +// unauthorizedCommands lists every command type that requires a permission, +// with the permissions the service must ask the credential store for. Join +// appears three times because its permission depends on the request. +func unauthorizedCommands() []unauthorizedCommand { + return []unauthorizedCommand{ + { + name: "EXECUTE", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_EXECUTE, + Request: &proto.Command_ExecuteRequest{ExecuteRequest: executeRequestFromString("some SQL")}, + }, + perms: []string{auth.PermExecute}, + resp: &proto.CommandExecuteResponse{}, + }, + { + name: "QUERY", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_QUERY, + Request: &proto.Command_QueryRequest{QueryRequest: queryRequestFromString("some SQL")}, + }, + perms: []string{auth.PermQuery}, + resp: &proto.CommandQueryResponse{}, + }, + { + name: "REQUEST", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_REQUEST, + Request: &proto.Command_ExecuteQueryRequest{ExecuteQueryRequest: executeQueryRequestFromString("some SQL")}, + }, + // The check stops at the first permission the store refuses. + perms: []string{auth.PermQuery}, + resp: &proto.CommandRequestResponse{}, + }, + { + name: "BACKUP", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_BACKUP, + Request: &proto.Command_BackupRequest{BackupRequest: backupRequestBinary(true)}, + }, + perms: []string{auth.PermBackup}, + resp: &proto.CommandBackupResponse{}, + }, + { + name: "BACKUP_STREAM", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_BACKUP_STREAM, + Request: &proto.Command_BackupRequest{BackupRequest: backupRequestBinary(true)}, + }, + perms: []string{auth.PermBackup}, + resp: &proto.CommandBackupResponse{}, + }, + { + name: "LOAD", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_LOAD, + Request: &proto.Command_LoadRequest{LoadRequest: &command.LoadRequest{Data: []byte("sqlite")}}, + }, + perms: []string{auth.PermLoad}, + resp: &proto.CommandLoadResponse{}, + }, + { + name: "REMOVE_NODE", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_REMOVE_NODE, + Request: &proto.Command_RemoveNodeRequest{RemoveNodeRequest: &command.RemoveNodeRequest{Id: "node1"}}, + }, + perms: []string{auth.PermRemove}, + resp: &proto.CommandRemoveNodeResponse{}, + }, + { + name: "DEMOTE_NODE", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_DEMOTE_NODE, + Request: &proto.Command_DemoteNodeRequest{DemoteNodeRequest: &command.DemoteNodeRequest{Id: "node1"}}, + }, + perms: []string{auth.PermDemote}, + resp: &proto.CommandDemoteNodeResponse{}, + }, + { + name: "PROMOTE_NODE", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_PROMOTE_NODE, + Request: &proto.Command_PromoteNodeRequest{PromoteNodeRequest: &command.PromoteNodeRequest{Id: "node1"}}, + }, + perms: []string{auth.PermPromote}, + resp: &proto.CommandPromoteNodeResponse{}, + }, + { + name: "NOTIFY", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_NOTIFY, + Request: &proto.Command_NotifyRequest{NotifyRequest: &command.NotifyRequest{Id: "node1", Address: "localhost"}}, + }, + perms: []string{auth.PermJoin}, + resp: &proto.CommandNotifyResponse{}, + }, + { + name: "JOIN_voter", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_JOIN, + Request: &proto.Command_JoinRequest{JoinRequest: &command.JoinRequest{Id: "node1", Address: "localhost", Voter: true}}, + }, + perms: []string{auth.PermJoin}, + resp: &proto.CommandJoinResponse{}, + }, + { + name: "JOIN_non_voter", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_JOIN, + Request: &proto.Command_JoinRequest{JoinRequest: &command.JoinRequest{Id: "node1", Address: "localhost", Voter: false}}, + }, + perms: []string{auth.PermJoinReadOnly, auth.PermJoinReadReplica}, + resp: &proto.CommandJoinResponse{}, + }, + { + name: "JOIN_nil_request", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_JOIN, + }, + perms: []string{auth.PermJoin}, + resp: &proto.CommandJoinResponse{}, + }, + { + name: "STEPDOWN", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_STEPDOWN, + Request: &proto.Command_StepdownRequest{StepdownRequest: &command.StepdownRequest{Wait: true}}, + }, + perms: []string{auth.PermLeaderOps}, + resp: &proto.CommandStepdownResponse{}, + }, + { + name: "HIGHWATER_MARK_UPDATE", + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_HIGHWATER_MARK_UPDATE, + Request: &proto.Command_HighwaterMarkUpdateRequest{HighwaterMarkUpdateRequest: &proto.HighwaterMarkUpdateRequest{HighwaterMark: 123}}, + }, + perms: []string{auth.PermCDCHWMUpdate}, + resp: &proto.HighwaterMarkUpdateResponse{}, + }, + } +} + +// openCommands lists the command types which need no permission. Node meta +// is how a client finds nodes before it has anything to authenticate against. +var openCommands = []proto.Command_Type{ + proto.Command_COMMAND_TYPE_GET_NODE_META, +} + +// mustReadClosed checks that nothing further arrives on conn and that the +// remote end has closed it. Since the service closes a connection only by +// returning from its handler, seeing EOF also proves the handler finished. +func mustReadClosed(t *testing.T, conn net.Conn) { + t.Helper() + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatalf("failed to set read deadline: %s", err) + } + buf := make([]byte, 1) + n, err := conn.Read(buf) + if n != 0 { + t.Fatalf("node sent %d byte(s) after rejecting the command", n) + } + if err != io.EOF { + t.Fatalf("expected EOF after rejected command, got %v", err) + } +} + +// Test_ServiceUnauthorizedClosesConnection sends every command type that +// requires a permission with credentials the store rejects, and checks that +// the node answers "unauthorized", asks the store for the right permission, +// touches neither the database nor the cluster manager, and then closes the +// connection without sending another byte. Each command is also sent with +// its request payload missing, which must not bypass the check. +func Test_ServiceUnauthorizedClosesConnection(t *testing.T) { + for _, tc := range unauthorizedCommands() { + for _, variant := range []struct { + name string + cmd *proto.Command + }{ + {"request", tc.cmd}, + {"no_request", &proto.Command{Type: tc.cmd.Type}}, + } { + t.Run(tc.name+"/"+variant.name, func(t *testing.T) { + // Every path into the database or cluster manager bumps this + // counter, so any work done on a rejected command is caught. + var touched atomic.Int64 + touch := func() { touched.Add(1) } + db := &mockDatabase{ + executeFn: func(*command.ExecuteRequest) ([]*command.ExecuteQueryResponse, uint64, error) { + touch() + return nil, 0, nil + }, + queryFn: func(*command.QueryRequest) ([]*command.QueryRows, uint64, error) { + touch() + return nil, 0, nil + }, + requestFn: func(*command.ExecuteQueryRequest) ([]*command.ExecuteQueryResponse, uint64, uint64, error) { + touch() + return nil, 0, 0, nil + }, + backupFn: func(_ *command.BackupRequest, dst io.Writer) (int, error) { + touch() + return dst.Write(mustGZIPCompress([]byte("this is SQLite data"))) + }, + loadFn: func(*command.LoadRequest) error { + touch() + return nil + }, + } + mgr := &MockManager{ + removeNodeFn: func(*command.RemoveNodeRequest) error { touch(); return nil }, + demoteNodeFn: func(*command.DemoteNodeRequest) error { touch(); return nil }, + promoteNodeFn: func(*command.PromoteNodeRequest) error { touch(); return nil }, + notifyFn: func(*command.NotifyRequest) error { touch(); return nil }, + joinFn: func(*command.JoinRequest) error { touch(); return nil }, + stepdownFn: func(bool, string) error { touch(); return nil }, + } + + var mu sync.Mutex + var asked []string + credStr := &mockCredentialStore{aaFunc: func(username, password, perm string) bool { + mu.Lock() + defer mu.Unlock() + asked = append(asked, perm) + return false + }} + + ml := mustNewMockTransport() + s := New(ml, db, mgr, credStr) + if err := s.Open(); err != nil { + t.Fatalf("failed to open cluster service: %s", err) + } + defer s.Close() + hwmCh := make(chan uint64, 1) + s.RegisterHWMUpdate(hwmCh) + + conn, err := ml.Dial(s.Addr(), 5*time.Second) + if err != nil { + t.Fatalf("failed to dial service: %s", err) + } + defer conn.Close() + + cmd := pb.Clone(variant.cmd).(*proto.Command) + cmd.Credentials = makeCredentials("user", "wrong") + if err := writeCommand(conn, cmd, 5*time.Second); err != nil { + t.Fatalf("failed to write command: %s", err) + } + p, err := readResponse(conn, 5*time.Second) + if err != nil { + t.Fatalf("failed to read response: %s", err) + } + if cmd.Type == proto.Command_COMMAND_TYPE_BACKUP { + // Non-streaming backup responses are compressed on the wire. + if p, err = mustGZIPUncompress(p); err != nil { + t.Fatalf("failed to decompress response: %s", err) + } + } + resp := pb.Clone(tc.resp).(errResponse) + if err := pb.Unmarshal(p, resp); err != nil { + t.Fatalf("failed to unmarshal response: %s", err) + } + if resp.GetError() != "unauthorized" { + t.Fatalf("failed to get correct error, exp %s, got %q", "unauthorized", resp.GetError()) + } + + // The node must now close the connection. Once it has, the + // handler has returned, so any work it did is visible below. + mustReadClosed(t, conn) + + if n := touched.Load(); n != 0 { + t.Fatalf("database or cluster manager was called %d time(s) on a rejected command", n) + } + if len(hwmCh) != 0 { + t.Fatalf("highwater mark was delivered on a rejected command") + } + if variant.name == "request" { + mu.Lock() + defer mu.Unlock() + if !slices.Equal(asked, tc.perms) { + t.Fatalf("credential store asked for %v, exp %v", asked, tc.perms) + } + } + }) + } + } +} + +// Test_ServiceOpenCommandsNeedNoPermission checks that the command types +// which need no permission are still served, on a connection that stays +// open, even when the credential store rejects everything. +func Test_ServiceOpenCommandsNeedNoPermission(t *testing.T) { + var asked atomic.Int64 + credStr := &mockCredentialStore{aaFunc: func(string, string, string) bool { + asked.Add(1) + return false + }} + ml := mustNewMockTransport() + s := New(ml, mustNewMockDatabase(), mustNewMockManager(), credStr) + if err := s.Open(); err != nil { + t.Fatalf("failed to open cluster service: %s", err) + } + defer s.Close() + + conn, err := ml.Dial(s.Addr(), 5*time.Second) + if err != nil { + t.Fatalf("failed to dial service: %s", err) + } + defer conn.Close() + + // Send each open command twice on the same connection, to prove the + // connection is left open after each one. + for i := 0; i < 2; i++ { + for _, typ := range openCommands { + cmd := &proto.Command{Type: typ} + if err := writeCommand(conn, cmd, 5*time.Second); err != nil { + t.Fatalf("failed to write %s command: %s", typ, err) + } + p, err := readResponse(conn, 5*time.Second) + if err != nil { + t.Fatalf("failed to read %s response: %s", typ, err) + } + if typ == proto.Command_COMMAND_TYPE_GET_NODE_META { + if err := pb.Unmarshal(p, &proto.NodeMeta{}); err != nil { + t.Fatalf("failed to unmarshal node meta response: %s", err) + } + } + } + } + if n := asked.Load(); n != 0 { + t.Fatalf("credential store was asked %d time(s) for commands that need no permission", n) + } +} + +// unsupportedCommand describes one command the node no longer acts on. +type unsupportedCommand struct { + cmd *proto.Command + resp errResponse // Response type the client decodes for this command. +} + +// unsupportedCommands lists every command type the node no longer acts on, +// keyed by type. It must match the service's notSupported set. +func unsupportedCommands() map[proto.Command_Type]unsupportedCommand { + return map[proto.Command_Type]unsupportedCommand{ + proto.Command_COMMAND_TYPE_LOAD_CHUNK: { + cmd: &proto.Command{ + Type: proto.Command_COMMAND_TYPE_LOAD_CHUNK, + Request: &proto.Command_LoadChunkRequest{LoadChunkRequest: &command.LoadChunkRequest{}}, + }, + resp: &proto.CommandLoadChunkResponse{}, + }, + } +} + +// Test_ServiceUnsupportedCommandClosesConnection checks that every command +// type the node no longer acts on is answered with "unsupported" and then +// has its connection closed, without the credential store being consulted. +func Test_ServiceUnsupportedCommandClosesConnection(t *testing.T) { + cmds := unsupportedCommands() + for typ := range notSupported { + if _, ok := cmds[typ]; !ok { + t.Errorf("unsupported command %s is missing from unsupportedCommands", typ) + } + } + for typ, tc := range cmds { + t.Run(typ.String(), func(t *testing.T) { + if !notSupported[typ] { + t.Fatalf("command %s is not in the service's notSupported set", typ) + } + + var asked atomic.Int64 + credStr := &mockCredentialStore{aaFunc: func(string, string, string) bool { + asked.Add(1) + return false + }} + ml := mustNewMockTransport() + s := New(ml, mustNewMockDatabase(), mustNewMockManager(), credStr) + if err := s.Open(); err != nil { + t.Fatalf("failed to open cluster service: %s", err) + } + defer s.Close() + + conn, err := ml.Dial(s.Addr(), 5*time.Second) + if err != nil { + t.Fatalf("failed to dial service: %s", err) + } + defer conn.Close() + + cmd := pb.Clone(tc.cmd).(*proto.Command) + cmd.Credentials = makeCredentials("user", "wrong") + if err := writeCommand(conn, cmd, 5*time.Second); err != nil { + t.Fatalf("failed to write command: %s", err) + } + p, err := readResponse(conn, 5*time.Second) + if err != nil { + t.Fatalf("failed to read response: %s", err) + } + resp := pb.Clone(tc.resp).(errResponse) + if err := pb.Unmarshal(p, resp); err != nil { + t.Fatalf("failed to unmarshal response: %s", err) + } + if resp.GetError() != "unsupported" { + t.Fatalf("failed to get correct error, exp %s, got %q", "unsupported", resp.GetError()) + } + mustReadClosed(t, conn) + if n := asked.Load(); n != 0 { + t.Fatalf("credential store was asked %d time(s) for an unsupported command", n) + } + }) + } +} + +// Test_ServiceUnknownCommandClosesConnection checks that a command type the +// node does not recognise gets no response and closes the connection, even +// when no credential store is configured. +func Test_ServiceUnknownCommandClosesConnection(t *testing.T) { + ml := mustNewMockTransport() + s := New(ml, mustNewMockDatabase(), mustNewMockManager(), nil) + if err := s.Open(); err != nil { + t.Fatalf("failed to open cluster service: %s", err) + } + defer s.Close() + + conn, err := ml.Dial(s.Addr(), 5*time.Second) + if err != nil { + t.Fatalf("failed to dial service: %s", err) + } + defer conn.Close() + + cmd := &proto.Command{Type: proto.Command_COMMAND_TYPE_UNKNOWN} + if err := writeCommand(conn, cmd, 5*time.Second); err != nil { + t.Fatalf("failed to write command: %s", err) + } + mustReadClosed(t, conn) +} + +// Test_ServiceEveryCommandTypeIsCovered fails when a command type exists +// that none of the unauthorized, unsupported or open-command tables knows +// about. Adding a command type means deciding, here, whether it needs a +// permission, and adding it to the matching table. +func Test_ServiceEveryCommandTypeIsCovered(t *testing.T) { + covered := map[proto.Command_Type]bool{ + proto.Command_COMMAND_TYPE_UNKNOWN: true, // Test_ServiceUnknownCommandClosesConnection + } + for _, tc := range unauthorizedCommands() { + covered[tc.cmd.Type] = true + } + for typ := range unsupportedCommands() { + covered[typ] = true + } + for _, typ := range openCommands { + covered[typ] = true + } + for v, name := range proto.Command_Type_name { + if !covered[proto.Command_Type(v)] { + t.Errorf("command type %s has no authorization test; add it to unauthorizedCommands, unsupportedCommands or openCommands", name) + } + } +} + func Test_NewServiceJoin(t *testing.T) { ml := mustNewMockTransport() mm := mustNewMockManager() @@ -900,6 +1380,16 @@ return &mockCredentialStore{HasPermOK: true} } +// mustGZIPUncompress returns the decompressed form of gzip data. +func mustGZIPUncompress(b []byte) ([]byte, error) { + gzr, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer gzr.Close() + return io.ReadAll(gzr) +} + func makeCredentials(username, password string) *proto.Credentials { return &proto.Credentials{ Username: username, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.6.3/http/service.go new/rqlite-10.6.4/http/service.go --- old/rqlite-10.6.3/http/service.go 2026-10-20 16:36:08.000000000 +0200 +++ new/rqlite-10.6.4/http/service.go 2026-10-22 04:39:23.000000000 +0200 @@ -1202,15 +1202,19 @@ switch r.Method { case "GET": - // Return leader information + // Return leader information. Errors are returned as a JSON body + // so callers can always parse the response. laddr := "" ldr, err := s.store.Leader() if err != nil { if err == store.ErrLeaderNotFound || err == store.ErrNotOpen { - http.Error(w, store.ErrLeaderNotFound.Error(), http.StatusServiceUnavailable) + stats.Add(numLeaderNotFound, 1) + w.WriteHeader(http.StatusServiceUnavailable) + err = store.ErrLeaderNotFound } else { - http.Error(w, fmt.Sprintf("leader: %s", err.Error()), http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) } + s.writeResponse(w, qp, &Response{Error: err.Error()}) return } if ldr != nil { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rqlite-10.6.3/http/service_test.go new/rqlite-10.6.4/http/service_test.go --- old/rqlite-10.6.3/http/service_test.go 2026-10-20 16:36:08.000000000 +0200 +++ new/rqlite-10.6.4/http/service_test.go 2026-10-22 04:39:23.000000000 +0200 @@ -1650,6 +1650,39 @@ } } +func Test_Leader_GET_NoLeader(t *testing.T) { + store := &MockStore{} + cluster := &mockClusterService{} + cred := &mockCredentialStore{HasPermOK: true} + + s := New("127.0.0.1:4001", store, cluster, proxy.New(store, cluster), cred) + + for _, url := range []string{"/leader", "/leader?pretty"} { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + t.Fatalf("failed to create GET request: %s", err.Error()) + } + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("%s: expected 503, got %d", url, rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { + t.Fatalf("%s: unexpected Content-Type: %s", url, ct) + } + + // The body must be JSON carrying the error, so clients can parse it. + var resp Response + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("%s: failed to unmarshal response body %q: %s", url, rr.Body.String(), err.Error()) + } + if resp.Error != "leader not found" { + t.Fatalf("%s: unexpected error in response: %q", url, resp.Error) + } + } +} + func Test_DBTimeoutQueryParam(t *testing.T) { tests := []struct { name string ++++++ vendor.tar.xz ++++++
