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 ++++++

Reply via email to