Skip to content

Commit dd4e683

Browse files
robfrankclaude
andcommitted
fix(#10462): HA derives its cluster token from arcadedb.server.rootPasswordPath, and the StatefulSet mounts its own token
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 66e6cad commit dd4e683

6 files changed

Lines changed: 222 additions & 18 deletions

File tree

‎engine/src/main/java/com/arcadedb/GlobalConfiguration.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1682,7 +1682,8 @@ proved who it is, and the handshake timeout (arcadedb.network.socketTimeout) onl
16821682
String.class, null),
16831683

16841684
SERVER_ROOT_PASSWORD_PATH("arcadedb.server.rootPasswordPath", SCOPE.SERVER,
1685-
"Path to file with password for root user to use at first startup of the server. Set this to avoid asking the password to the user",
1685+
"Path to file with password for root user to use at first startup of the server. Set this to avoid asking the password to the user. "
1686+
+ "With HA enabled and no arcadedb.ha.clusterToken or arcadedb.ha.clusterTokenPath, the cluster token is derived from this password",
16861687
String.class, null),
16871688

16881689
SERVER_MODE("arcadedb.server.mode", SCOPE.SERVER, "Server mode between 'development', 'test' and 'production'", String.class,

‎ha-raft/src/main/java/com/arcadedb/server/ha/raft/ClusterTokenProvider.java‎

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import com.arcadedb.GlobalConfiguration;
2323
import com.arcadedb.exception.ConfigurationException;
2424
import com.arcadedb.log.LogManager;
25+
import com.arcadedb.server.security.ServerSecurity;
2526

2627
import javax.crypto.SecretKeyFactory;
2728
import javax.crypto.spec.PBEKeySpec;
@@ -36,7 +37,8 @@
3637
/**
3738
* Derives and caches the cluster token used for inter-node authentication.
3839
* The token is either explicitly configured or derived from the cluster name
39-
* and root password using PBKDF2-HMAC-SHA256.
40+
* and root password using PBKDF2-HMAC-SHA256. The root password is taken inline or,
41+
* failing that, from the file at {@code arcadedb.server.rootPasswordPath}.
4042
*/
4143
class ClusterTokenProvider {
4244

@@ -77,15 +79,13 @@ synchronized void initClusterToken() {
7779
if (clusterName == null || clusterName.isEmpty())
7880
throw new ConfigurationException(
7981
"Cannot derive cluster token: the cluster name is empty. Set arcadedb.ha.clusterName to a unique value or provide an explicit arcadedb.ha.clusterToken");
80-
// Check both the server's ContextConfiguration and the global default (system property)
81-
String rootPasswordStr = configuration.getValueAsString(GlobalConfiguration.SERVER_ROOT_PASSWORD);
82-
if (rootPasswordStr == null || rootPasswordStr.isEmpty())
83-
rootPasswordStr = GlobalConfiguration.SERVER_ROOT_PASSWORD.getValueAsString();
84-
if (rootPasswordStr == null || rootPasswordStr.isEmpty())
82+
final String rootPasswordStr = resolveRootPassword(configuration);
83+
if (rootPasswordStr == null)
8584
throw new ConfigurationException(
8685
"""
8786
Cannot start HA mode without authentication: the auto-derived cluster token requires a root password. \
88-
Set arcadedb.server.rootPassword or provide an explicit arcadedb.ha.clusterToken""");
87+
Set arcadedb.server.rootPassword or arcadedb.server.rootPasswordPath, or provide an explicit \
88+
arcadedb.ha.clusterToken or arcadedb.ha.clusterTokenPath""");
8989
if ("production".equals(configuration.getValueAsString(GlobalConfiguration.SERVER_MODE))
9090
&& "arcadedb".equalsIgnoreCase(clusterName))
9191
LogManager.instance().log(this, Level.WARNING,
@@ -120,12 +120,10 @@ static void initClusterTokenForTest(final ContextConfiguration config) {
120120
if (clusterName == null || clusterName.isEmpty())
121121
throw new ConfigurationException(
122122
"Cannot derive cluster token: the cluster name is empty. Set arcadedb.ha.clusterName to a unique value or provide an explicit arcadedb.ha.clusterToken");
123-
String rootPasswordStr = config.getValueAsString(GlobalConfiguration.SERVER_ROOT_PASSWORD);
124-
if (rootPasswordStr == null || rootPasswordStr.isEmpty())
125-
rootPasswordStr = GlobalConfiguration.SERVER_ROOT_PASSWORD.getValueAsString();
126-
if (rootPasswordStr == null || rootPasswordStr.isEmpty())
123+
final String rootPasswordStr = resolveRootPassword(config);
124+
if (rootPasswordStr == null)
127125
throw new ConfigurationException(
128-
"Cannot derive cluster token without a root password. Set arcadedb.server.rootPassword or arcadedb.ha.clusterToken");
126+
"Cannot derive cluster token without a root password. Set arcadedb.server.rootPassword, arcadedb.server.rootPasswordPath, arcadedb.ha.clusterToken or arcadedb.ha.clusterTokenPath");
129127

130128
config.setValue(GlobalConfiguration.HA_CLUSTER_TOKEN, deriveTokenInternal(clusterName, rootPasswordStr));
131129
}
@@ -166,6 +164,55 @@ private static String resolveExplicitToken(final ContextConfiguration config) {
166164
return token;
167165
}
168166

167+
/**
168+
* Resolves the root password the token is derived from, with the same precedence as
169+
* {@code ServerSecurity.askForRootPassword()} uses to create the root user:
170+
* <ol>
171+
* <li>{@link GlobalConfiguration#SERVER_ROOT_PASSWORD}, from the server's configuration, then from the global one;</li>
172+
* <li>otherwise the content of the file at {@link GlobalConfiguration#SERVER_ROOT_PASSWORD_PATH}, looked up the same
173+
* way, with one trailing line terminator dropped by {@link ServerSecurity#stripTrailingNewline(String)}.</li>
174+
* </ol>
175+
* The file is read with the very same normalization as the root user's password, so a node configured with the
176+
* password inline and a node reading it from a file derive the same token (issue #10462).
177+
*
178+
* @return the root password, or {@code null} when neither setting is configured
179+
*
180+
* @throws ConfigurationException if the password file is configured but cannot be read, or is empty.
181+
*/
182+
private static String resolveRootPassword(final ContextConfiguration config) {
183+
final String inline = valueOf(config, GlobalConfiguration.SERVER_ROOT_PASSWORD);
184+
if (inline != null)
185+
return inline;
186+
187+
final String passwordPath = valueOf(config, GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH);
188+
if (passwordPath == null)
189+
return null;
190+
191+
final Path path = Path.of(passwordPath);
192+
if (!Files.isReadable(path))
193+
throw new ConfigurationException("Error reading root password file at path '" + passwordPath + "'");
194+
final String password;
195+
try {
196+
password = ServerSecurity.stripTrailingNewline(Files.readString(path));
197+
} catch (final IOException e) {
198+
throw new ConfigurationException("Error reading root password file at path '" + passwordPath + "'", e);
199+
}
200+
if (password.isEmpty())
201+
throw new ConfigurationException("Root password file at path '" + passwordPath + "' is empty");
202+
return password;
203+
}
204+
205+
/**
206+
* Returns the setting from the server's configuration, falling back to the global default (system property), or
207+
* {@code null} when neither carries a non-empty value.
208+
*/
209+
private static String valueOf(final ContextConfiguration config, final GlobalConfiguration setting) {
210+
String value = config.getValueAsString(setting);
211+
if (value == null || value.isEmpty())
212+
value = setting.getValueAsString();
213+
return value == null || value.isEmpty() ? null : value;
214+
}
215+
169216
/**
170217
* Converts the root password String to a char[], delegates to
171218
* {@link #deriveTokenFromPassword(String, char[])}, and zeros the array before returning.

‎ha-raft/src/test/java/com/arcadedb/server/ha/raft/ClusterTokenProviderTest.java‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,120 @@ void emptyTokenFileThrows(@TempDir final Path tmp) throws Exception {
137137
assertThatThrownBy(() -> new ClusterTokenProvider(config).getClusterToken())
138138
.isInstanceOf(ConfigurationException.class);
139139
}
140+
141+
// Issue #10462: a server given its root password as a file (the shipped StatefulSet mounts a Secret and passes only
142+
// arcadedb.server.rootPasswordPath) has a root password, so HA must derive its cluster token from that file too.
143+
144+
@Test
145+
void tokenDerivedFromRootPasswordFile(@TempDir final Path tmp) throws Exception {
146+
final Path passwordFile = tmp.resolve("rootPassword");
147+
Files.writeString(passwordFile, "test-password");
148+
final ContextConfiguration fromFile = new ContextConfiguration();
149+
fromFile.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
150+
fromFile.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH, passwordFile.toString());
151+
152+
final ContextConfiguration inline = new ContextConfiguration();
153+
inline.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
154+
inline.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD, "test-password");
155+
156+
assertThat(new ClusterTokenProvider(fromFile).getClusterToken())
157+
.isEqualTo(new ClusterTokenProvider(inline).getClusterToken());
158+
}
159+
160+
@Test
161+
void rootPasswordFileDropsOnlyTheTrailingNewlineLikeServerSecurity(@TempDir final Path tmp) throws Exception {
162+
// `kubectl create secret --from-file`, a here-doc or an editor append a newline that is not part of the password:
163+
// the token must match the one a node given the same password inline derives. Trailing spaces are part of the
164+
// password, exactly as ServerSecurity reads the same file into the root user.
165+
final Path passwordFile = tmp.resolve("rootPassword");
166+
Files.writeString(passwordFile, "test-password \r\n");
167+
final ContextConfiguration fromFile = new ContextConfiguration();
168+
fromFile.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
169+
fromFile.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH, passwordFile.toString());
170+
171+
final ContextConfiguration inline = new ContextConfiguration();
172+
inline.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
173+
inline.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD, "test-password ");
174+
175+
assertThat(new ClusterTokenProvider(fromFile).getClusterToken())
176+
.isEqualTo(new ClusterTokenProvider(inline).getClusterToken());
177+
}
178+
179+
@Test
180+
void inlineRootPasswordTakesPrecedenceOverRootPasswordFile(@TempDir final Path tmp) throws Exception {
181+
// Same precedence as ServerSecurity.askForRootPassword(): the file is read only when no inline password is set.
182+
final Path passwordFile = tmp.resolve("rootPassword");
183+
Files.writeString(passwordFile, "file-password");
184+
final ContextConfiguration both = new ContextConfiguration();
185+
both.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
186+
both.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD, "inline-password");
187+
both.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH, passwordFile.toString());
188+
189+
final ContextConfiguration inline = new ContextConfiguration();
190+
inline.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
191+
inline.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD, "inline-password");
192+
193+
assertThat(new ClusterTokenProvider(both).getClusterToken())
194+
.isEqualTo(new ClusterTokenProvider(inline).getClusterToken());
195+
}
196+
197+
@Test
198+
void explicitTokenPathTakesPrecedenceOverRootPasswordFile(@TempDir final Path tmp) throws Exception {
199+
final Path tokenFile = tmp.resolve("clusterToken");
200+
Files.writeString(tokenFile, "file-token\n");
201+
final Path passwordFile = tmp.resolve("rootPassword");
202+
Files.writeString(passwordFile, "file-password");
203+
final ContextConfiguration config = new ContextConfiguration();
204+
config.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
205+
config.setValue(GlobalConfiguration.HA_CLUSTER_TOKEN_PATH, tokenFile.toString());
206+
config.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH, passwordFile.toString());
207+
assertThat(new ClusterTokenProvider(config).getClusterToken()).isEqualTo("file-token");
208+
}
209+
210+
@Test
211+
void unreadableRootPasswordFileThrowsNamingThePath() {
212+
final ContextConfiguration config = new ContextConfiguration();
213+
config.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
214+
config.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH, "/non/existent/rootPassword");
215+
assertThatThrownBy(() -> new ClusterTokenProvider(config).getClusterToken())
216+
.isInstanceOf(ConfigurationException.class).hasMessageContaining("/non/existent/rootPassword");
217+
}
218+
219+
@Test
220+
void emptyRootPasswordFileThrows(@TempDir final Path tmp) throws Exception {
221+
final Path passwordFile = tmp.resolve("rootPassword");
222+
Files.writeString(passwordFile, "\n");
223+
final ContextConfiguration config = new ContextConfiguration();
224+
config.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
225+
config.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH, passwordFile.toString());
226+
assertThatThrownBy(() -> new ClusterTokenProvider(config).getClusterToken())
227+
.isInstanceOf(ConfigurationException.class).hasMessageContaining("empty");
228+
}
229+
230+
@Test
231+
void missingRootPasswordErrorNamesThePasswordFileSetting() {
232+
final ContextConfiguration config = new ContextConfiguration();
233+
config.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
234+
assertThatThrownBy(() -> new ClusterTokenProvider(config).getClusterToken())
235+
.isInstanceOf(ConfigurationException.class)
236+
.hasMessageContaining(GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH.getKey())
237+
.hasMessageContaining(GlobalConfiguration.HA_CLUSTER_TOKEN_PATH.getKey());
238+
}
239+
240+
@Test
241+
void testHelperDerivesFromRootPasswordFile(@TempDir final Path tmp) throws Exception {
242+
final Path passwordFile = tmp.resolve("rootPassword");
243+
Files.writeString(passwordFile, "test-password\n");
244+
final ContextConfiguration fromFile = new ContextConfiguration();
245+
fromFile.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
246+
fromFile.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD_PATH, passwordFile.toString());
247+
ClusterTokenProvider.initClusterTokenForTest(fromFile);
248+
249+
final ContextConfiguration inline = new ContextConfiguration();
250+
inline.setValue(GlobalConfiguration.HA_CLUSTER_NAME, "test-cluster");
251+
inline.setValue(GlobalConfiguration.SERVER_ROOT_PASSWORD, "test-password");
252+
253+
assertThat(fromFile.getValueAsString(GlobalConfiguration.HA_CLUSTER_TOKEN))
254+
.isEqualTo(new ClusterTokenProvider(inline).getClusterToken());
255+
}
140256
}

‎package/src/main/config/arcadedb-statefulset.yaml‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
# Using ArcadeDB with k8s
1313
# - Create the namespace: kubectl create namespace arcadedb
1414
# - Create the secret : kubectl create secret generic arcadedb-credentials \
15-
# --namespace arcadedb --from-literal=rootPassword='<choose-a-strong-password>'
15+
# --namespace arcadedb --from-literal=rootPassword='<choose-a-strong-password>' \
16+
# --from-literal=clusterToken="$(openssl rand -hex 32)"
1617
# - Create cluster : kubectl apply -f arcadedb-statefulset.yaml
1718
# - List the pods : kubectl get pods --namespace arcadedb
1819
# - Run console : kubectl exec -it arcadedb-0 --namespace arcadedb -- bin/console.sh
@@ -28,6 +29,11 @@
2829
# sized for a real three-node cluster, and the Raft gRPC port is unauthenticated - restrict it to the
2930
# StatefulSet's own pods with a NetworkPolicy.
3031
#
32+
# The `clusterToken` key is the shared secret the nodes authenticate each other with (forwarded commands, Raft group
33+
# changes). Every pod mounts the same Secret, so they all read the same token. Without it the token would be derived
34+
# from the root password and the cluster name with a fixed public salt: a separate high-entropy value keeps a weak root
35+
# password from making the token guessable, and lets it be rotated without touching the root password.
36+
#
3137
# NOTE ON VARIABLE EXPANSION: Kubernetes expands only the `$(VAR)` form in `command`/`args`, and only
3238
# for variables declared in this container's `env`. The `${VAR}` form is NOT expanded, and since the
3339
# arguments are not run through a shell there is no POSIX expansion either: a `${VAR}` written below
@@ -115,9 +121,10 @@ spec:
115121
- arcadedb
116122
topologyKey: kubernetes.io/hostname
117123
volumes:
118-
# The root password is mounted as a file instead of being passed on the command line: an
119-
# argument is world-readable through /proc inside the pod, a mounted secret is not. ArcadeDB
120-
# reads it through `arcadedb.server.rootPasswordPath`.
124+
# The root password and the HA cluster token are mounted as files instead of being passed on
125+
# the command line: an argument is world-readable through /proc inside the pod, a mounted secret
126+
# is not. ArcadeDB reads them through `arcadedb.server.rootPasswordPath` and
127+
# `arcadedb.ha.clusterTokenPath`.
121128
- name: root-password
122129
secret:
123130
secretName: arcadedb-credentials
@@ -131,6 +138,8 @@ spec:
131138
items:
132139
- key: rootPassword
133140
path: rootPassword
141+
- key: clusterToken
142+
path: clusterToken
134143
initContainers:
135144
# Seeds the configuration directory on the PersistentVolume (see `configDirectory` below) with the image's
136145
# default files. Unlike a Docker named volume, a PersistentVolumeClaim is not populated from the image, and an
@@ -192,6 +201,8 @@ spec:
192201
# restart never drops the write quorum.
193202
- "-Darcadedb.server.readinessRequiresHA=true"
194203
- "-Darcadedb.ha.enabled=true"
204+
# The shared secret the nodes authenticate each other with, from the same mounted Secret (see the header).
205+
- "-Darcadedb.ha.clusterTokenPath=/etc/arcadedb/secrets/clusterToken"
195206
# arcadedb-0 lists itself, and must: this list IS the initial Raft group membership, so a
196207
# node absent from it is not a member. Only the first pod is listed because the remaining
197208
# pods are not members at boot - they synthesize their own peer entry from the pod name

‎server/src/main/java/com/arcadedb/server/security/ServerSecurity.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2325,7 +2325,7 @@ public String getApiTokensJsonPayload() {
23252325
* store a root password nobody can type. Only the final CR/LF is removed: interior characters and
23262326
* trailing spaces are left alone, since those can legitimately belong to the password.
23272327
*/
2328-
static String stripTrailingNewline(final String password) {
2328+
public static String stripTrailingNewline(final String password) {
23292329
if (password == null)
23302330
return null;
23312331
if (password.endsWith("\r\n"))

‎server/src/test/java/com/arcadedb/server/PackagingManifestsTest.java‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,35 @@ void statefulSetReadsTheRootPasswordFromTheMountedSecret() {
168168
.contains(directory);
169169
}
170170

171+
@Test
172+
void statefulSetNamesAnHaClusterTokenSource() {
173+
// Issue #10462: an HA node authenticates its peers with a cluster token. Without an explicit one it is derived from
174+
// the root password, and a manifest that keeps that password off the command line must not leave the cluster's
175+
// authentication hanging on that fallback alone: it names the token's own secret, mounted from the same Secret.
176+
final String manifest = codeOf(read(STATEFULSET));
177+
assertThat(manifest).contains("-D" + GlobalConfiguration.HA_ENABLED.getKey() + "=true");
178+
if (manifest.contains("-D" + GlobalConfiguration.SERVER_ROOT_PASSWORD.getKey() + "="))
179+
return;
180+
181+
assertThat(manifest).as("the cluster token must not be passed as a command-line argument")
182+
.doesNotContain("-D" + GlobalConfiguration.HA_CLUSTER_TOKEN.getKey() + "=");
183+
final Matcher path = Pattern.compile("-D" + Pattern.quote(GlobalConfiguration.HA_CLUSTER_TOKEN_PATH.getKey()) + "=([^\"\\s]+)")
184+
.matcher(manifest);
185+
assertThat(path.find()).as("an HA StatefulSet with no inline root password must set %s",
186+
GlobalConfiguration.HA_CLUSTER_TOKEN_PATH.getKey()).isTrue();
187+
188+
final String tokenPath = path.group(1);
189+
final String directory = tokenPath.substring(0, tokenPath.lastIndexOf('/'));
190+
final String file = tokenPath.substring(tokenPath.lastIndexOf('/') + 1);
191+
assertThat(matches(MOUNT_PATH, manifest)).as("no volume is mounted at the configured cluster token directory")
192+
.contains(directory);
193+
assertThat(manifest).as("the mounted Secret projects no '%s' item, so the token file never exists", file)
194+
.containsPattern("(?m)^\\s*- key: " + Pattern.quote(file) + "\\s*\\n\\s*path: " + Pattern.quote(file) + "\\s*$");
195+
// The instructions at the top of the manifest create that Secret: they have to fill the key the pods mount.
196+
assertThat(read(STATEFULSET)).as("the documented `kubectl create secret` does not set the '%s' key", file)
197+
.contains("--from-literal=" + file + "=");
198+
}
199+
171200
@Test
172201
void statefulSetProbesTheEndpointTheServerRegisters() {
173202
final String manifest = read(STATEFULSET);

0 commit comments

Comments
 (0)