From f796637919317786326375ee9373feeb42b8f57e Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Thu, 3 Sep 2026 11:21:25 +0530 Subject: [PATCH 1/6] fix(pd): validate REST credentials and return 401 on refusal The REST authentication interceptor had two defects (#3188): 1. Authentication.authenticate decoded the Basic credential but checked only the service name against the innerModules set, so any of the four public names with any password, including an empty one, was accepted, while the password was never read. 2. RestAuthentication.preHandle wrote an error body without calling setStatus, so success, refusal and missing credential all returned HTTP 200 and nothing keyed on a status code could see a refusal. Fix both together: - Compare the password of the Basic credential against the shared secret configured via auth.secret-key (constant-time comparison). A missing or empty secret refuses every request instead of falling back to name-only authentication. - Return 401 for a missing, malformed or refused credential. - Surface auth.secret-key in both shipped application.yml files with a change-in-production note, and let the Docker image set it through a new optional HG_PD_AUTH_SECRET_KEY env (never logged). - Wire the in-repo clients: wait-storage.sh now defaults its PD password to the shipped secret, and the Compose Hubble properties files carry a matching operations.pd.username/password pair. - Update the PD test credentials to the shipped secret and add REST tests asserting 401 for missing credential, wrong password, empty password and unknown service name. - Document the credential and the trusted-network requirement for port 8620 in the PD and Compose READMEs. Probes are unaffected: /v1/health, /actuator/* and /v1/prom/targets/* stay outside the interceptor. --- docker/README.md | 20 +++++- docker/conf/hubble/hstore-ha.properties | 4 ++ docker/conf/hubble/hstore.properties | 4 ++ hugegraph-pd/README.md | 14 ++++ .../hg-pd-dist/docker/docker-entrypoint.sh | 8 +++ .../src/assembly/static/conf/application.yml | 9 +++ .../rest/interceptor/RestAuthentication.java | 1 + .../service/interceptor/Authentication.java | 39 ++++++++--- .../src/main/resources/application.yml | 6 ++ .../org/apache/hugegraph/pd/BaseTest.java | 9 ++- .../hugegraph/pd/rest/BaseServerTest.java | 13 ++++ .../apache/hugegraph/pd/rest/RestApiTest.java | 67 ++++++++++++++++--- .../src/assembly/static/bin/wait-storage.sh | 4 +- 13 files changed, 177 insertions(+), 21 deletions(-) diff --git a/docker/README.md b/docker/README.md index 0bb74cf81f..e28afe0b05 100644 --- a/docker/README.md +++ b/docker/README.md @@ -66,6 +66,22 @@ For the verification commands below, set the password in your current shell: ADMIN_PASSWORD='the-same-password-used-in-.env' ``` +The PD REST API (port 8620, HStore topologies only) has its own credential: +requests other than health probes need HTTP Basic auth with an internal +service name (for example `hg`) and the PD secret as the password. PD ships +with a default secret in `conf/application.yml` (`auth.secret-key`), and the +Hubble files under `conf/hubble/` carry the matching `operations.pd.password`. +With the shipped default, list registered stores like this: + +```bash +curl -u hg:FXQXbJtbCLxODc6tGci732pkH1cyf8Qg http://localhost:8620/v1/stores +``` + +The default secret is public (it is in the source tree), so it only keeps +casual traffic out. On any shared network, change it: set +`HG_PD_AUTH_SECRET_KEY` on the PD services and put the same value in the +Hubble properties files, or do not publish port 8620 at all. + ### Standalone This is the recommended quickstart. @@ -312,7 +328,9 @@ docker compose -f docker-compose-hstore.yml up -d --wait ### Hubble configuration The three small files under `conf/hubble/` contain only topology-specific -discovery settings and container paths: +discovery settings, the PD REST credential (`operations.pd.username` and +`operations.pd.password`, which must match PD's `auth.secret-key`), and +container paths: - `conf/hubble/standalone.properties` uses direct Server mode. - `conf/hubble/hstore.properties` uses one PD and one Store REST target. diff --git a/docker/conf/hubble/hstore-ha.properties b/docker/conf/hubble/hstore-ha.properties index a50c52c96f..a9cb75d389 100644 --- a/docker/conf/hubble/hstore-ha.properties +++ b/docker/conf/hubble/hstore-ha.properties @@ -20,6 +20,10 @@ pd.enabled=true server.direct_url=http://server0:8080 pd.peers=pd0:8686,pd1:8686,pd2:8686 pd.server=pd0:8620 +# PD REST credential: the password must match PD's auth.secret-key (the +# value below is PD's shipped default). Change both together in production. +operations.pd.username=hubble +operations.pd.password=FXQXbJtbCLxODc6tGci732pkH1cyf8Qg operations.store.allowed_targets=[http://store0:8520,http://store1:8520,http://store2:8520] upload_file.location=/hubble/data/upload-files dashboard.address= diff --git a/docker/conf/hubble/hstore.properties b/docker/conf/hubble/hstore.properties index 5de43c212f..50541d9a0f 100644 --- a/docker/conf/hubble/hstore.properties +++ b/docker/conf/hubble/hstore.properties @@ -20,6 +20,10 @@ pd.enabled=true server.direct_url=http://server:8080 pd.peers=pd:8686 pd.server=pd:8620 +# PD REST credential: the password must match PD's auth.secret-key (the +# value below is PD's shipped default). Change both together in production. +operations.pd.username=hubble +operations.pd.password=FXQXbJtbCLxODc6tGci732pkH1cyf8Qg operations.store.allowed_targets=[http://store:8520] upload_file.location=/hubble/data/upload-files dashboard.address= diff --git a/hugegraph-pd/README.md b/hugegraph-pd/README.md index 794dba9b98..ac9e91bb39 100644 --- a/hugegraph-pd/README.md +++ b/hugegraph-pd/README.md @@ -100,6 +100,7 @@ Key configuration file: `conf/application.yml` | `raft.address` | `127.0.0.1:8610` | Raft service address for this PD node | | `raft.peers-list` | `127.0.0.1:8610` | Comma-separated list of all PD nodes in the Raft cluster | | `pd.data-path` | `./pd_data` | Directory for storing PD metadata and Raft logs | +| `auth.secret-key` | (public default) | Password required by the REST API with an internal service name (`hg`, `store`, `hubble`, `vermeer`) via HTTP Basic auth. Change it in production and configure every REST client (e.g. Hubble's `operations.pd.password`) with the same value | #### Single-Node Example @@ -280,6 +281,19 @@ docker/docker-compose-3pd-3store-3server.yml - Ensure low latency (<5ms) between PD nodes for Raft consensus - Open required ports: `8620` (REST), `8686` (gRPC), `8610` (Raft) +### Security + +- Keep all three ports on a trusted network. The REST API on `8620` includes + management endpoints that mutate the cluster (peer changes, store removal, + data movement), and the gRPC and Raft ports carry no authentication. +- REST requests need HTTP Basic auth: one of the internal service names + (`hg`, `store`, `hubble`, `vermeer`) with the `auth.secret-key` value as + the password. Health probes (`/v1/health`, `/actuator/*`, + `/v1/prom/targets/*`) stay unauthenticated. +- The shipped `auth.secret-key` default is public. Change it in production + (config file, or `HG_PD_AUTH_SECRET_KEY` for the Docker image) and update + every REST client with the same value. + ### Monitoring PD exposes metrics via REST API at: diff --git a/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh b/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh index 529936d06a..080c7e4260 100755 --- a/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh +++ b/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh @@ -57,8 +57,16 @@ require_env "HG_PD_INITIAL_STORE_LIST" : "${HG_PD_DATA_PATH:=/hugegraph-pd/pd_data}" : "${HG_PD_INITIAL_STORE_COUNT:=1}" +# Optional secret for REST Basic authentication (auth.secret-key). When unset, +# the value from conf/application.yml applies. Never logged. +AUTH_JSON="" +if [[ -n "${HG_PD_AUTH_SECRET_KEY:-}" ]]; then + AUTH_JSON="\"auth\": { \"secret-key\": \"$(json_escape "${HG_PD_AUTH_SECRET_KEY}")\" }," +fi + SPRING_APPLICATION_JSON="$(cat < - * WARNING: This class currently implements only basic internal authentication - * validation for internal modules (hg, store, hubble, vermeer). The authentication mechanism - * is designed for internal service-to-service communication only. + * WARNING: This class validates a Basic credential for internal modules + * (hg, store, hubble, vermeer): the service name must be one of the four and the + * password must match the shared secret configured via `auth.secret-key`. The + * mechanism is designed for internal service-to-service communication only. *

* *

Important SEC Considerations:

@@ -61,6 +65,9 @@ public class Authentication { private static final Set innerModules = Set.of("hg", "store", "hubble", "vermeer"); + @Autowired + private PDConfig pdConfig; + protected T authenticate(String authority, String token, Function tokenCall, Supplier call) { try { @@ -77,19 +84,33 @@ protected T authenticate(String authority, String token, Function } String name = info.substring(0, delim); - // TODO: password validation is skipped — only service name is checked against - // innerModules. Full credential validation should be added as part of the auth refactor. - //String pwd = info.substring(delim + 1); - if (innerModules.contains(name)) { - return call.get(); - } else { + String pwd = info.substring(delim + 1); + if (!innerModules.contains(name)) { throw new AccessDeniedException("invalid service name"); } + if (!verifySecret(pwd)) { + throw new BadCredentialsException("invalid credential"); + } + return call.get(); } catch (Exception e) { throw new RuntimeException(e); } } + /** + * Compare the password of the Basic credential with the shared secret + * configured via `auth.secret-key`. A missing or empty secret refuses every + * request instead of falling back to name-only authentication. + */ + private boolean verifySecret(String pwd) { + String secret = this.pdConfig == null ? null : this.pdConfig.getSecretKey(); + if (StringUtils.isEmpty(secret)) { + return false; + } + return MessageDigest.isEqual(pwd.getBytes(StandardCharsets.UTF_8), + secret.getBytes(StandardCharsets.UTF_8)); + } + public static String getTokenKey(String name) { return "PD/TOKEN/" + name; } diff --git a/hugegraph-pd/hg-pd-service/src/main/resources/application.yml b/hugegraph-pd/hg-pd-service/src/main/resources/application.yml index 5a03595f7b..d4e83e04e1 100644 --- a/hugegraph-pd/hg-pd-service/src/main/resources/application.yml +++ b/hugegraph-pd/hg-pd-service/src/main/resources/application.yml @@ -43,6 +43,12 @@ license: server: port: 8620 +auth: + # Shared secret checked against the password of the Basic credential on every + # authenticated REST request. Change it in production and keep it in sync + # with every client that calls the PD REST API. + secret-key: FXQXbJtbCLxODc6tGci732pkH1cyf8Qg + pd: # Periodically check whether the cluster is healthy at intervals, in seconds patrol-interval: 300 diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/BaseTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/BaseTest.java index 0836120c73..dc8ac1a36e 100644 --- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/BaseTest.java +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/BaseTest.java @@ -17,6 +17,9 @@ package org.apache.hugegraph.pd; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + import org.apache.hugegraph.pd.client.PDConfig; public class BaseTest { @@ -24,9 +27,11 @@ public class BaseTest { protected static String pdGrpcAddr = "127.0.0.1:8686"; protected static String pdRestAddr = "http://127.0.0.1:8620"; protected static String user = "store"; - protected static String pwd = ""; + // Matches the auth.secret-key default that the PD under test runs with + protected static String pwd = "FXQXbJtbCLxODc6tGci732pkH1cyf8Qg"; protected static String key = "Authorization"; - protected static String value = "Basic c3RvcmU6YWRtaW4="; + protected static String value = "Basic " + Base64.getEncoder().encodeToString( + (user + ":" + pwd).getBytes(StandardCharsets.UTF_8)); protected PDConfig getPdConfig() { return PDConfig.of(pdGrpcAddr).setAuthority(user, pwd); diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/BaseServerTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/BaseServerTest.java index 4aff85d1e9..8204eb5410 100644 --- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/BaseServerTest.java +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/BaseServerTest.java @@ -18,15 +18,28 @@ package org.apache.hugegraph.pd.rest; import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import org.junit.After; import org.junit.BeforeClass; public class BaseServerTest { + // Matches the auth.secret-key default that the PD under test runs with + protected static final String SECRET = "FXQXbJtbCLxODc6tGci732pkH1cyf8Qg"; + protected static final String AUTH_HEADER = "Authorization"; + protected static final String VALID_AUTH = basicAuth("store", SECRET); + protected static HttpClient client; protected static String pdRestAddr; + protected static String basicAuth(String name, String pwd) { + String credential = name + ":" + pwd; + return "Basic " + Base64.getEncoder() + .encodeToString(credential.getBytes(StandardCharsets.UTF_8)); + } + @BeforeClass public static void init() { client = HttpClient.newHttpClient(); diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/RestApiTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/RestApiTest.java index fb2b71d480..3c0b6e475b 100644 --- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/RestApiTest.java +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/RestApiTest.java @@ -35,7 +35,7 @@ public void testQueryIndexInfo() throws URISyntaxException, IOException, Interru String url = pdRestAddr + "/"; HttpRequest request = HttpRequest.newBuilder() .uri(new URI(url)) - .header("Authorization", "Basic c3RvcmU6MTIz") + .header(AUTH_HEADER, VALID_AUTH) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -54,7 +54,7 @@ public void testQueryClusterInfo() throws URISyntaxException, IOException, Inter String url = pdRestAddr + "/v1/cluster"; HttpRequest request = HttpRequest.newBuilder() .uri(new URI(url)) - .header("Authorization", "Basic c3RvcmU6MTIz") + .header(AUTH_HEADER, VALID_AUTH) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -68,7 +68,7 @@ public void testQueryClusterMembers() throws URISyntaxException, IOException, String url = pdRestAddr + "/v1/members"; HttpRequest request = HttpRequest.newBuilder() .uri(new URI(url)) - .header("Authorization", "Basic c3RvcmU6MTIz") + .header(AUTH_HEADER, VALID_AUTH) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -82,7 +82,7 @@ public void testQueryStoresInfo() throws URISyntaxException, IOException, Interr String url = pdRestAddr + "/v1/stores"; HttpRequest request = HttpRequest.newBuilder() .uri(new URI(url)) - .header("Authorization", "Basic c3RvcmU6MTIz") + .header(AUTH_HEADER, VALID_AUTH) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -96,7 +96,7 @@ public void testQueryGraphsInfo() throws IOException, InterruptedException, JSON String url = pdRestAddr + "/v1/graphs"; HttpRequest request = HttpRequest.newBuilder() .uri(new URI(url)) - .header("Authorization", "Basic c3RvcmU6MTIz") + .header(AUTH_HEADER, VALID_AUTH) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -110,7 +110,7 @@ public void testQueryPartitionsInfo() throws IOException, InterruptedException, String url = pdRestAddr + "/v1/highLevelPartitions"; HttpRequest request = HttpRequest.newBuilder() .uri(new URI(url)) - .header("Authorization", "Basic c3RvcmU6MTIz") + .header(AUTH_HEADER, VALID_AUTH) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -124,7 +124,7 @@ public void testQueryDebugPartitionsInfo() throws URISyntaxException, IOExceptio String url = pdRestAddr + "/v1/partitions"; HttpRequest request = HttpRequest.newBuilder() .uri(new URI(url)) - .header("Authorization", "Basic c3RvcmU6MTIz") + .header(AUTH_HEADER, VALID_AUTH) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -137,11 +137,62 @@ public void testQueryShards() throws URISyntaxException, IOException, Interrupte String url = pdRestAddr + "/v1/shards"; HttpRequest request = HttpRequest.newBuilder() .uri(new URI(url)) - .header("Authorization", "Basic c3RvcmU6MTIz") + .header(AUTH_HEADER, VALID_AUTH) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); JSONObject obj = new JSONObject(response.body()); assert obj.getInt("status") == 0; } + + @Test + public void testMissingCredentialGets401() throws URISyntaxException, IOException, + InterruptedException { + String url = pdRestAddr + "/v1/members"; + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(url)) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assert response.statusCode() == 401; + } + + @Test + public void testWrongPasswordGets401() throws URISyntaxException, IOException, + InterruptedException { + String url = pdRestAddr + "/v1/members"; + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(url)) + .header(AUTH_HEADER, basicAuth("store", "wrong-password")) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assert response.statusCode() == 401; + } + + @Test + public void testEmptyPasswordGets401() throws URISyntaxException, IOException, + InterruptedException { + String url = pdRestAddr + "/v1/members"; + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(url)) + .header(AUTH_HEADER, basicAuth("hg", "")) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assert response.statusCode() == 401; + } + + @Test + public void testUnknownServiceNameGets401() throws URISyntaxException, IOException, + InterruptedException { + String url = pdRestAddr + "/v1/members"; + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(url)) + .header(AUTH_HEADER, basicAuth("nobody", SECRET)) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assert response.statusCode() == 401; + } } diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/wait-storage.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/wait-storage.sh index 93c3a19dd2..f7a871bc44 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/wait-storage.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/wait-storage.sh @@ -39,7 +39,9 @@ log() { echo "[wait-storage] $1" } -PD_AUTH_ARGS="-u ${PD_AUTH_USER:-store}:${PD_AUTH_PASSWORD:-admin}" +# PD validates the password against its auth.secret-key; the default below +# matches PD's shipped default. Override both when the PD secret is changed. +PD_AUTH_ARGS="-u ${PD_AUTH_USER:-store}:${PD_AUTH_PASSWORD:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg}" function key_exists { local key=$1 From 1e3b616f731aaed7db52df6bdb6f2123e3533c4d Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Thu, 3 Sep 2026 18:22:09 +0530 Subject: [PATCH 2/6] fix(pd): fail closed when auth.secret-key is absent, wire every client Review follow-ups on the REST credential change. The blocker: PDConfig declared the key as `@Value("${auth.secret-key: 'FXQ...'}")`. Spring takes the text after the first colon as a literal default, so a PD whose conf/application.yml has no auth block resolved the secret to " 'FXQ...'", quotes and leading space included. That is neither empty nor anything a client sends, so the fail-closed branch never ran and PD rejected the secret shipped in both config files, wait-storage.sh and the Hubble properties. Since start-hugegraph-pd.sh passes -Dspring.config.location, which replaces the default locations rather than adding to them, an upgrade that keeps an existing config file hit this and Server startup aborted after the 300s wait-storage timeout. Drop the default so an absent key yields "", and log an error naming the parameter once so the refusal is diagnosable. Also from the review: - Pass the secret to every consumer from one variable. Both Compose files now set HG_PD_AUTH_SECRET_KEY on each PD service and PD_AUTH_PASSWORD on each Server, so rotating one .env value keeps the Server's wait-storage.sh probe working. Hubble still needs a manual edit of its mounted properties file, which the docs now say. - Narrow the actuator exposure from "*" to health,metrics,prometheus in both configs. /actuator/* is excluded from the interceptor, so anything exposed there is anonymous on the port this change is hardening. - Send WWW-Authenticate on the 401, per RFC 7235. Without it clients that authenticate reactively never retry with credentials. - Record in the GRpcServerConfig TODO that the secret check now lives in the shared base class, so enabling the gRPC interceptor also requires giving the Server, Store and CLI clients the secret. - Document the credential in the PD configuration and API references, and credential the balanceLeaders rebalancing procedure in the Store operations guide, where a 401 reads as a no-op during an incident. Verified against the packaged dist: with the auth block removed from conf/application.yml every authenticated request is refused and the error names auth.secret-key; with it present the matrix is unchanged, the 401 carries the challenge header, and /actuator/env, /beans and /configprops no longer serve data. --- docker/README.md | 23 +++++++++++++-- docker/docker-compose-3pd-3store-3server.yml | 5 ++++ docker/docker-compose-hstore.yml | 3 ++ hugegraph-pd/README.md | 10 ++++++- hugegraph-pd/docs/api-reference.md | 16 ++++++++++ hugegraph-pd/docs/configuration.md | 29 +++++++++++++++++-- .../apache/hugegraph/pd/config/PDConfig.java | 6 +++- .../src/assembly/static/conf/application.yml | 4 ++- .../rest/interceptor/RestAuthentication.java | 3 ++ .../service/interceptor/Authentication.java | 12 ++++++++ .../pd/util/grpc/GRpcServerConfig.java | 7 ++++- .../src/main/resources/application.yml | 4 ++- hugegraph-store/docs/operations-guide.md | 22 ++++++++++++-- 13 files changed, 131 insertions(+), 13 deletions(-) diff --git a/docker/README.md b/docker/README.md index e28afe0b05..6861091a39 100644 --- a/docker/README.md +++ b/docker/README.md @@ -78,9 +78,26 @@ curl -u hg:FXQXbJtbCLxODc6tGci732pkH1cyf8Qg http://localhost:8620/v1/stores ``` The default secret is public (it is in the source tree), so it only keeps -casual traffic out. On any shared network, change it: set -`HG_PD_AUTH_SECRET_KEY` on the PD services and put the same value in the -Hubble properties files, or do not publish port 8620 at all. +casual traffic out. On any shared network, change it, or do not publish port +8620 at all. Three consumers read this credential, and all three have to +agree or startup fails: + +- PD itself, through `HG_PD_AUTH_SECRET_KEY`. +- The Server, whose `bin/wait-storage.sh` polls `/v1/stores` before the + Server starts. Both Compose files pass `PD_AUTH_PASSWORD` to it from the + same variable, so setting `HG_PD_AUTH_SECRET_KEY` in `.env` covers it. If + the Server sends the wrong secret it retries until + `WAIT_STORAGE_TIMEOUT_S` (300s) expires and the container exits with + `ERROR: Timeout waiting for storage backend`. +- Hubble, through `operations.pd.password` in the file under `conf/hubble/`. + That file is mounted read-only and is not templated, so edit it by hand to + match. + +Set the secret once in `.env` before the first start: + +```bash +printf "HG_PD_AUTH_SECRET_KEY='%s'\n" "$(openssl rand -hex 24)" >> .env +``` ### Standalone diff --git a/docker/docker-compose-3pd-3store-3server.yml b/docker/docker-compose-3pd-3store-3server.yml index 6f599c6870..34f6fab9cc 100644 --- a/docker/docker-compose-3pd-3store-3server.yml +++ b/docker/docker-compose-3pd-3store-3server.yml @@ -70,6 +70,8 @@ x-server-environment: &server-environment HG_SERVER_REQUIRE_AUTH_TOKEN_SECRET: "true" HG_SERVER_AUTH_TOKEN_SECRET: ${HUGEGRAPH_AUTH_TOKEN_SECRET:-} PASSWORD: ${HUGEGRAPH_ADMIN_PASSWORD:-} + # bin/wait-storage.sh polls the PD REST API, so it needs the same secret + PD_AUTH_PASSWORD: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} x-server-common: &server-common image: hugegraph/server:${HUGEGRAPH_VERSION:-latest} @@ -107,6 +109,7 @@ services: HG_PD_INITIAL_STORE_LIST: store0:8500,store1:8500,store2:8500 HG_PD_DATA_PATH: /hugegraph-pd/pd_data HG_PD_INITIAL_STORE_COUNT: 3 + HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} ports: ["8620:8620", "8686:8686"] volumes: - hg-pd0-data:/hugegraph-pd/pd_data @@ -125,6 +128,7 @@ services: HG_PD_INITIAL_STORE_LIST: store0:8500,store1:8500,store2:8500 HG_PD_DATA_PATH: /hugegraph-pd/pd_data HG_PD_INITIAL_STORE_COUNT: 3 + HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} ports: ["8621:8620", "8687:8686"] volumes: - hg-pd1-data:/hugegraph-pd/pd_data @@ -143,6 +147,7 @@ services: HG_PD_INITIAL_STORE_LIST: store0:8500,store1:8500,store2:8500 HG_PD_DATA_PATH: /hugegraph-pd/pd_data HG_PD_INITIAL_STORE_COUNT: 3 + HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} ports: ["8622:8620", "8688:8686"] volumes: - hg-pd2-data:/hugegraph-pd/pd_data diff --git a/docker/docker-compose-hstore.yml b/docker/docker-compose-hstore.yml index d201430692..aaca6df421 100644 --- a/docker/docker-compose-hstore.yml +++ b/docker/docker-compose-hstore.yml @@ -39,6 +39,7 @@ services: HG_PD_RAFT_PEERS_LIST: pd:8610 HG_PD_INITIAL_STORE_LIST: store:8500 HG_PD_DATA_PATH: /hugegraph-pd/pd_data + HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} ports: - "8620:8620" volumes: @@ -94,6 +95,8 @@ services: HG_SERVER_INIT_STORE_ENABLED: "false" HG_SERVER_AUTH_TOKEN_SECRET: ${HUGEGRAPH_AUTH_TOKEN_SECRET:-} PASSWORD: ${HUGEGRAPH_ADMIN_PASSWORD:-} + # bin/wait-storage.sh polls the PD REST API, so it needs the same secret + PD_AUTH_PASSWORD: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} ports: - "8080:8080" healthcheck: diff --git a/hugegraph-pd/README.md b/hugegraph-pd/README.md index ac9e91bb39..324dbea389 100644 --- a/hugegraph-pd/README.md +++ b/hugegraph-pd/README.md @@ -292,7 +292,15 @@ docker/docker-compose-3pd-3store-3server.yml `/v1/prom/targets/*`) stay unauthenticated. - The shipped `auth.secret-key` default is public. Change it in production (config file, or `HG_PD_AUTH_SECRET_KEY` for the Docker image) and update - every REST client with the same value. + every REST client with the same value: the Server's `bin/wait-storage.sh` + reads `PD_AUTH_PASSWORD` (and `PD_AUTH_USER`, default `store`), and Hubble + reads `operations.pd.password`. A client left on the old secret gets 401, + and for `wait-storage.sh` that means Server startup aborts after + `WAIT_STORAGE_TIMEOUT_S`. +- An existing `conf/application.yml` carried over from an earlier release has + no `auth` block. PD then starts with an empty secret and refuses every + authenticated REST request, logging an error that names `auth.secret-key`. + Add the key to the file before upgrading. ### Monitoring diff --git a/hugegraph-pd/docs/api-reference.md b/hugegraph-pd/docs/api-reference.md index aa8cce8473..7535bc8f62 100644 --- a/hugegraph-pd/docs/api-reference.md +++ b/hugegraph-pd/docs/api-reference.md @@ -760,6 +760,22 @@ for (Map.Entry entry : results.entrySet()) { PD exposes a REST API for management and monitoring (default port: 8620). +### Authentication + +Every endpoint below except the probes needs HTTP Basic auth: one of the +internal service names (`hg`, `store`, `hubble`, `vermeer`) as the user, and +the `auth.secret-key` value from PD's `conf/application.yml` as the password. +A missing or wrong credential gets HTTP 401. The `curl` examples that follow +omit `-u` for readability; add it to every call except `/v1/health`, +`/actuator/*` and `/v1/prom/targets/*`, which stay unauthenticated for probes. + +```bash +curl -u hg: http://localhost:8620/v1/stores +``` + +Endpoints under `/v1` mutate the cluster (peer list changes, store removal, +partition balancing), so keep port 8620 on a trusted network regardless. + ### Health Check ```bash diff --git a/hugegraph-pd/docs/configuration.md b/hugegraph-pd/docs/configuration.md index e3ae4f6f25..ddc546fdbb 100644 --- a/hugegraph-pd/docs/configuration.md +++ b/hugegraph-pd/docs/configuration.md @@ -79,6 +79,31 @@ server: - Metrics: `http://:8620/actuator/metrics` - Prometheus: `http://:8620/actuator/prometheus` +### REST Authentication Settings + +Every REST request except the probes below must carry HTTP Basic auth: one of +the internal service names (`hg`, `store`, `hubble`, `vermeer`) as the user, +and the shared secret as the password. A missing or wrong credential gets +HTTP 401. Unauthenticated paths: `/v1/health`, `/actuator/*` and +`/v1/prom/targets/*`. + +```yaml +auth: + secret-key: FXQXbJtbCLxODc6tGci732pkH1cyf8Qg +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `auth.secret-key` | String | (public default in the shipped `conf/application.yml`) | Password checked against the Basic credential. The shipped value is public, so change it in production. If the key is absent PD starts with an empty secret and refuses every authenticated REST request, logging an error that names this parameter. | + +Every REST client needs the same value: the Server's `bin/wait-storage.sh` +reads it from `PD_AUTH_PASSWORD`, Hubble from `operations.pd.password`, and +the Docker image takes `HG_PD_AUTH_SECRET_KEY`. + +```bash +curl -u hg: http://:8620/v1/stores +``` + ### Raft Consensus Settings Controls Raft consensus for PD cluster coordination. @@ -253,13 +278,13 @@ management: endpoints: web: exposure: - include: "*" # Expose all actuator endpoints + include: "health,metrics,prometheus" # Allowlist; see note below ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `management.metrics.export.prometheus.enabled` | Boolean | `true` | Enable Prometheus-compatible metrics at `/actuator/prometheus`. | -| `management.endpoints.web.exposure.include` | String | `"*"` | Actuator endpoints to expose. `"*"` = all, or specify comma-separated list (e.g., `"health,metrics"`). | +| `management.endpoints.web.exposure.include` | String | `"health,metrics,prometheus"` | Actuator endpoints to expose. `/actuator/*` is excluded from the REST authentication interceptor, so every endpoint listed here is reachable without a credential on port 8620. Prefer an allowlist over `"*"`. | ## Deployment Scenarios diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java index acfc2ec290..3222f3e776 100644 --- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java +++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java @@ -69,7 +69,11 @@ public class PDConfig { @Autowired private ThreadPoolGrpc threadPoolGrpc; - @Value("${auth.secret-key: 'FXQXbJtbCLxODc6tGci732pkH1cyf8Qg'}") + // No default: Spring takes the text after the first ':' literally, so a + // quoted default would resolve to a value including the quotes and the + // leading space, and no client would ever match it. An absent key must + // yield "" so the REST interceptor can refuse every request and say why. + @Value("${auth.secret-key:}") @ToString.Exclude private String secretKey; diff --git a/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml b/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml index 5e4d59ca95..152eaa6eb7 100644 --- a/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml +++ b/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml @@ -27,7 +27,9 @@ management: endpoints: web: exposure: - include: "*" + # Allowlist, not "*": /actuator/* is excluded from the REST auth + # interceptor, so anything exposed here is anonymous on this port. + include: "health,metrics,prometheus" logging: config: 'file:./conf/log4j2.xml' diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/interceptor/RestAuthentication.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/interceptor/RestAuthentication.java index 80db94a9e6..d10d2e9da8 100644 --- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/interceptor/RestAuthentication.java +++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/interceptor/RestAuthentication.java @@ -60,6 +60,9 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return authenticate(authority, token, tokenCall, DEFAULT_HANDLE); } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + // RFC 7235 requires a challenge on a 401; without it clients that + // authenticate reactively never retry with credentials + response.setHeader("WWW-Authenticate", "Basic realm=\"hugegraph-pd\""); response.setContentType("application/json"); response.getWriter().println(new API().toJSON(e)); response.getWriter().flush(); diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/interceptor/Authentication.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/interceptor/Authentication.java index d609bb15e0..9774e7032f 100644 --- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/interceptor/Authentication.java +++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/interceptor/Authentication.java @@ -21,6 +21,7 @@ import java.security.MessageDigest; import java.util.Base64; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.function.Supplier; @@ -31,6 +32,8 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Component; +import lombok.extern.slf4j.Slf4j; + /** * Simple internal authentication component for PD service. *

@@ -61,10 +64,13 @@ * and regular security audits. *

*/ +@Slf4j @Component public class Authentication { private static final Set innerModules = Set.of("hg", "store", "hubble", "vermeer"); + private static final AtomicBoolean missingSecretLogged = new AtomicBoolean(); + @Autowired private PDConfig pdConfig; @@ -105,6 +111,12 @@ protected T authenticate(String authority, String token, Function private boolean verifySecret(String pwd) { String secret = this.pdConfig == null ? null : this.pdConfig.getSecretKey(); if (StringUtils.isEmpty(secret)) { + // Logged once: this path is reachable by unauthenticated callers + if (missingSecretLogged.compareAndSet(false, true)) { + log.error("auth.secret-key is not configured, so every authenticated REST " + + "request is refused. Add it to conf/application.yml (or set " + + "HG_PD_AUTH_SECRET_KEY) and give every REST client the same value."); + } return false; } return MessageDigest.isEqual(pwd.getBytes(StandardCharsets.UTF_8), diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/util/grpc/GRpcServerConfig.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/util/grpc/GRpcServerConfig.java index 2b1103739b..224d417cc4 100644 --- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/util/grpc/GRpcServerConfig.java +++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/util/grpc/GRpcServerConfig.java @@ -41,7 +41,12 @@ public void configure(ServerBuilder serverBuilder) { poolGrpc.getQueue())); serverBuilder.maxInboundMessageSize(MAX_INBOUND_MESSAGE_SIZE); // TODO: GrpcAuthentication is instantiated as a Spring bean but never registered - // here — add serverBuilder.intercept(grpcAuthentication) once auth is refactored. + // here - add serverBuilder.intercept(grpcAuthentication) once auth is refactored. + // It extends Authentication, which now also checks the Basic password against + // auth.secret-key. Registering it therefore requires giving that value to every + // gRPC client first: ServiceConstant.AUTHORITY (Server) and DefaultPdProvider + // .authority (Store) are "" and "default" today, and hg-pd-cli sends "". + // Otherwise no store can register once the interceptor is enabled. } } diff --git a/hugegraph-pd/hg-pd-service/src/main/resources/application.yml b/hugegraph-pd/hg-pd-service/src/main/resources/application.yml index d4e83e04e1..b0325e68a3 100644 --- a/hugegraph-pd/hg-pd-service/src/main/resources/application.yml +++ b/hugegraph-pd/hg-pd-service/src/main/resources/application.yml @@ -27,7 +27,9 @@ management: endpoints: web: exposure: - include: "*" + # Allowlist, not "*": /actuator/* is excluded from the REST auth + # interceptor, so anything exposed here is anonymous on this port. + include: "health,metrics,prometheus" grpc: port: 8686 diff --git a/hugegraph-store/docs/operations-guide.md b/hugegraph-store/docs/operations-guide.md index f46b5559d7..9b942683f0 100644 --- a/hugegraph-store/docs/operations-guide.md +++ b/hugegraph-store/docs/operations-guide.md @@ -2,6 +2,21 @@ This guide covers monitoring, troubleshooting, backup & recovery, and operational procedures for HugeGraph Store in production. +> **PD REST credential.** Calls to a PD REST endpoint on port 8620, other than +> `/v1/health`, `/actuator/*` and `/v1/prom/targets/*`, need HTTP Basic auth: +> one of the internal service names (`hg`, `store`, `hubble`, `vermeer`) and +> PD's `auth.secret-key` value as the password. A call without it gets HTTP +> 401, which for a mutating step such as `balanceLeaders` means the step did +> nothing. Export the secret before following a procedure that uses +> `${PD_SECRET}`: +> +> ```bash +> read -rs PD_SECRET && export PD_SECRET +> ``` +> +> Store endpoints on port 8520 are unaffected. Some PD examples in this guide +> still omit the credential; add `-u hg:"${PD_SECRET}"` when a call returns 401. + ## Table of Contents - [Monitoring and Metrics](#monitoring-and-metrics) @@ -604,19 +619,20 @@ curl http://192.168.1.10:8620/v1/partitionsAndStatus 2. **Verify Registration**: ```bash - curl http://192.168.1.10:8620/v1/stores + curl -u hg:"${PD_SECRET}" http://192.168.1.10:8620/v1/stores # New Store should appear ``` 3. **Trigger Rebalancing** (optional): ```bash - curl -X POST http://192.168.1.10:8620/v1/balanceLeaders + curl -u hg:"${PD_SECRET}" -X POST http://192.168.1.10:8620/v1/balanceLeaders ``` 4. **Monitor Rebalancing**: ```bash # Watch partition distribution - watch -n 10 'curl http://192.168.1.10:8620/v1/partitionsAndStatus' + watch -n 10 'curl -u hg:"${PD_SECRET}" \ + http://192.168.1.10:8620/v1/partitionsAndStatus' ``` 5. **Verify**: Wait for even distribution (may take hours) From 5c339c160356ad7683822826389953da542d31a6 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Fri, 4 Sep 2026 01:22:17 +0530 Subject: [PATCH 3/6] fix(pd): ship no REST secret and keep the credential out of argv Addresses the two remaining review points. A published default is not a secret. The REST credential shipped with a fixed value that lives in this repository, on a port the HStore Compose files publish to the host, so anyone who could read the source could authenticate as an internal service against endpoints that rewrite the raft peer list, remove stores and move data. Requiring a deployment-provided secret is the only version of this check that means anything. - Both application.yml files now ship auth.secret-key empty, with a comment saying why there is no default and how to generate one. PD already refuses every authenticated REST request while it is empty, naming the key in an error. - PD refuses to start when auth.secret-key is set to the value earlier revisions carried as a placeholder, so a deployment that copied it does not quietly keep a well-known credential. - The Docker image requires HG_PD_AUTH_SECRET_KEY, and both Compose files fail fast when it is unset rather than falling back to a shared value. The .env recipe generates one alongside the JWT secret. The Hubble properties files ship the password empty, with the manual step documented. - travis/start-pd.sh supplies a test-only secret through SPRING_APPLICATION_JSON, matching what the PD suites send, since the shipped configuration no longer authenticates anything. wait-storage.sh no longer interpolates the credential into the inner bash -c string, where a secret containing a space, a backtick or $(...) would have split the arguments or run, and no longer passes it in argv where anything able to read /proc could see it. The inner shell reads the value from the environment and hands it to curl on stdin as a config file. Its test now asserts the credential is absent from argv and present in that config. Verified against the packaged dist: startup is refused with the published placeholder, the shipped configuration starts but answers 401 to every credential, and a deployment-provided secret restores the matrix with the challenge header. PDRestSuiteTest 17/17, PDClientSuiteTest 45/45, test-wait-storage.sh 5/5, compose renders pass and refuse to render without the secret. --- docker/README.md | 29 +++++++++---------- docker/conf/hubble/hstore-ha.properties | 7 +++-- docker/conf/hubble/hstore.properties | 7 +++-- docker/docker-compose-3pd-3store-3server.yml | 8 ++--- docker/docker-compose-hstore.yml | 4 +-- docker/test-compose.sh | 2 ++ hugegraph-pd/README.md | 20 ++++++++----- hugegraph-pd/docs/configuration.md | 4 +-- .../apache/hugegraph/pd/config/PDConfig.java | 22 +++++++++++++- .../hg-pd-dist/docker/docker-entrypoint.sh | 12 ++++---- .../src/assembly/static/conf/application.yml | 19 ++++++++---- .../src/main/resources/application.yml | 8 +++-- .../org/apache/hugegraph/pd/BaseTest.java | 5 ++-- .../hugegraph/pd/rest/BaseServerTest.java | 5 ++-- .../src/assembly/static/bin/wait-storage.sh | 28 ++++++++++++++---- .../src/assembly/travis/start-pd.sh | 5 ++++ .../src/assembly/travis/test-wait-storage.sh | 19 ++++++++++-- 17 files changed, 140 insertions(+), 64 deletions(-) diff --git a/docker/README.md b/docker/README.md index 6861091a39..a47c2f7c4a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -39,8 +39,9 @@ contains a single quote or newline. echo ".env already exists; edit it instead of overwriting it" >&2 exit 1 } - printf "HUGEGRAPH_ADMIN_PASSWORD='%s'\nHUGEGRAPH_AUTH_TOKEN_SECRET='%s'\n" \ - 'replace-with-your-password' "${jwt_secret}" > .env + pd_secret="$(openssl rand -hex 24)" + printf "HUGEGRAPH_ADMIN_PASSWORD='%s'\nHUGEGRAPH_AUTH_TOKEN_SECRET='%s'\nHG_PD_AUTH_SECRET_KEY='%s'\n" \ + 'replace-with-your-password' "${jwt_secret}" "${pd_secret}" > .env ) ``` @@ -69,18 +70,16 @@ ADMIN_PASSWORD='the-same-password-used-in-.env' The PD REST API (port 8620, HStore topologies only) has its own credential: requests other than health probes need HTTP Basic auth with an internal service name (for example `hg`) and the PD secret as the password. PD ships -with a default secret in `conf/application.yml` (`auth.secret-key`), and the -Hubble files under `conf/hubble/` carry the matching `operations.pd.password`. -With the shipped default, list registered stores like this: +no default secret, so `HG_PD_AUTH_SECRET_KEY` is required and the HStore +Compose files refuse to start without it. The `.env` command above generates +one. To list registered stores: ```bash -curl -u hg:FXQXbJtbCLxODc6tGci732pkH1cyf8Qg http://localhost:8620/v1/stores +curl -u "hg:${HG_PD_AUTH_SECRET_KEY}" http://localhost:8620/v1/stores ``` -The default secret is public (it is in the source tree), so it only keeps -casual traffic out. On any shared network, change it, or do not publish port -8620 at all. Three consumers read this credential, and all three have to -agree or startup fails: +Three consumers read this credential, and all three have to agree or startup +fails: - PD itself, through `HG_PD_AUTH_SECRET_KEY`. - The Server, whose `bin/wait-storage.sh` polls `/v1/stores` before the @@ -90,13 +89,13 @@ agree or startup fails: `WAIT_STORAGE_TIMEOUT_S` (300s) expires and the container exits with `ERROR: Timeout waiting for storage backend`. - Hubble, through `operations.pd.password` in the file under `conf/hubble/`. - That file is mounted read-only and is not templated, so edit it by hand to - match. - -Set the secret once in `.env` before the first start: + That file is mounted read-only and is not templated, so write the same value + into it by hand. Until you do, Hubble's PD-backed views get 401 from PD; + everything else in Hubble works. ```bash -printf "HG_PD_AUTH_SECRET_KEY='%s'\n" "$(openssl rand -hex 24)" >> .env +sed -i.bak "s#^operations.pd.password=.*#operations.pd.password=${HG_PD_AUTH_SECRET_KEY}#" \ + conf/hubble/hstore.properties ``` ### Standalone diff --git a/docker/conf/hubble/hstore-ha.properties b/docker/conf/hubble/hstore-ha.properties index a9cb75d389..2a818d6fb2 100644 --- a/docker/conf/hubble/hstore-ha.properties +++ b/docker/conf/hubble/hstore-ha.properties @@ -20,10 +20,11 @@ pd.enabled=true server.direct_url=http://server0:8080 pd.peers=pd0:8686,pd1:8686,pd2:8686 pd.server=pd0:8620 -# PD REST credential: the password must match PD's auth.secret-key (the -# value below is PD's shipped default). Change both together in production. +# PD REST credential. The password must equal PD's auth.secret-key, which has +# no default: set it to the same value as HG_PD_AUTH_SECRET_KEY in .env. While +# it is empty, Hubble's PD-backed views get HTTP 401 from PD. operations.pd.username=hubble -operations.pd.password=FXQXbJtbCLxODc6tGci732pkH1cyf8Qg +operations.pd.password= operations.store.allowed_targets=[http://store0:8520,http://store1:8520,http://store2:8520] upload_file.location=/hubble/data/upload-files dashboard.address= diff --git a/docker/conf/hubble/hstore.properties b/docker/conf/hubble/hstore.properties index 50541d9a0f..55796d6e29 100644 --- a/docker/conf/hubble/hstore.properties +++ b/docker/conf/hubble/hstore.properties @@ -20,10 +20,11 @@ pd.enabled=true server.direct_url=http://server:8080 pd.peers=pd:8686 pd.server=pd:8620 -# PD REST credential: the password must match PD's auth.secret-key (the -# value below is PD's shipped default). Change both together in production. +# PD REST credential. The password must equal PD's auth.secret-key, which has +# no default: set it to the same value as HG_PD_AUTH_SECRET_KEY in .env. While +# it is empty, Hubble's PD-backed views get HTTP 401 from PD. operations.pd.username=hubble -operations.pd.password=FXQXbJtbCLxODc6tGci732pkH1cyf8Qg +operations.pd.password= operations.store.allowed_targets=[http://store:8520] upload_file.location=/hubble/data/upload-files dashboard.address= diff --git a/docker/docker-compose-3pd-3store-3server.yml b/docker/docker-compose-3pd-3store-3server.yml index 34f6fab9cc..ba02f27eb1 100644 --- a/docker/docker-compose-3pd-3store-3server.yml +++ b/docker/docker-compose-3pd-3store-3server.yml @@ -71,7 +71,7 @@ x-server-environment: &server-environment HG_SERVER_AUTH_TOKEN_SECRET: ${HUGEGRAPH_AUTH_TOKEN_SECRET:-} PASSWORD: ${HUGEGRAPH_ADMIN_PASSWORD:-} # bin/wait-storage.sh polls the PD REST API, so it needs the same secret - PD_AUTH_PASSWORD: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} + PD_AUTH_PASSWORD: ${HG_PD_AUTH_SECRET_KEY:?set HG_PD_AUTH_SECRET_KEY in .env; see docker/README.md} x-server-common: &server-common image: hugegraph/server:${HUGEGRAPH_VERSION:-latest} @@ -109,7 +109,7 @@ services: HG_PD_INITIAL_STORE_LIST: store0:8500,store1:8500,store2:8500 HG_PD_DATA_PATH: /hugegraph-pd/pd_data HG_PD_INITIAL_STORE_COUNT: 3 - HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} + HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:?set HG_PD_AUTH_SECRET_KEY in .env; see docker/README.md} ports: ["8620:8620", "8686:8686"] volumes: - hg-pd0-data:/hugegraph-pd/pd_data @@ -128,7 +128,7 @@ services: HG_PD_INITIAL_STORE_LIST: store0:8500,store1:8500,store2:8500 HG_PD_DATA_PATH: /hugegraph-pd/pd_data HG_PD_INITIAL_STORE_COUNT: 3 - HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} + HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:?set HG_PD_AUTH_SECRET_KEY in .env; see docker/README.md} ports: ["8621:8620", "8687:8686"] volumes: - hg-pd1-data:/hugegraph-pd/pd_data @@ -147,7 +147,7 @@ services: HG_PD_INITIAL_STORE_LIST: store0:8500,store1:8500,store2:8500 HG_PD_DATA_PATH: /hugegraph-pd/pd_data HG_PD_INITIAL_STORE_COUNT: 3 - HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} + HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:?set HG_PD_AUTH_SECRET_KEY in .env; see docker/README.md} ports: ["8622:8620", "8688:8686"] volumes: - hg-pd2-data:/hugegraph-pd/pd_data diff --git a/docker/docker-compose-hstore.yml b/docker/docker-compose-hstore.yml index aaca6df421..ba5a421c2c 100644 --- a/docker/docker-compose-hstore.yml +++ b/docker/docker-compose-hstore.yml @@ -39,7 +39,7 @@ services: HG_PD_RAFT_PEERS_LIST: pd:8610 HG_PD_INITIAL_STORE_LIST: store:8500 HG_PD_DATA_PATH: /hugegraph-pd/pd_data - HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} + HG_PD_AUTH_SECRET_KEY: ${HG_PD_AUTH_SECRET_KEY:?set HG_PD_AUTH_SECRET_KEY in .env; see docker/README.md} ports: - "8620:8620" volumes: @@ -96,7 +96,7 @@ services: HG_SERVER_AUTH_TOKEN_SECRET: ${HUGEGRAPH_AUTH_TOKEN_SECRET:-} PASSWORD: ${HUGEGRAPH_ADMIN_PASSWORD:-} # bin/wait-storage.sh polls the PD REST API, so it needs the same secret - PD_AUTH_PASSWORD: ${HG_PD_AUTH_SECRET_KEY:-FXQXbJtbCLxODc6tGci732pkH1cyf8Qg} + PD_AUTH_PASSWORD: ${HG_PD_AUTH_SECRET_KEY:?set HG_PD_AUTH_SECRET_KEY in .env; see docker/README.md} ports: - "8080:8080" healthcheck: diff --git a/docker/test-compose.sh b/docker/test-compose.sh index ecd5ab5b2d..0e9ddc76bb 100644 --- a/docker/test-compose.sh +++ b/docker/test-compose.sh @@ -21,6 +21,7 @@ set -Eeuo pipefail DOCKER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PASSWORD="ci-compose-password" SECRET="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +PD_SECRET="ci-compose-pd-secret" VERSION="ci-version" RENDER_HUBBLE_IMAGE="example.invalid/hugegraph/hubble:ci" DATASOURCE="jdbc:h2:file:/hubble/data/hubble;DB_CLOSE_ON_EXIT=FALSE" @@ -34,6 +35,7 @@ compose_auth() { HUBBLE_IMAGE="${RENDER_HUBBLE_IMAGE}" \ HUGEGRAPH_ADMIN_PASSWORD="${PASSWORD}" \ HUGEGRAPH_AUTH_TOKEN_SECRET="${SECRET}" \ + HG_PD_AUTH_SECRET_KEY="${PD_SECRET}" \ docker compose "$@" } diff --git a/hugegraph-pd/README.md b/hugegraph-pd/README.md index 324dbea389..7aa3ff9168 100644 --- a/hugegraph-pd/README.md +++ b/hugegraph-pd/README.md @@ -100,7 +100,7 @@ Key configuration file: `conf/application.yml` | `raft.address` | `127.0.0.1:8610` | Raft service address for this PD node | | `raft.peers-list` | `127.0.0.1:8610` | Comma-separated list of all PD nodes in the Raft cluster | | `pd.data-path` | `./pd_data` | Directory for storing PD metadata and Raft logs | -| `auth.secret-key` | (public default) | Password required by the REST API with an internal service name (`hg`, `store`, `hubble`, `vermeer`) via HTTP Basic auth. Change it in production and configure every REST client (e.g. Hubble's `operations.pd.password`) with the same value | +| `auth.secret-key` | none (required) | Password required by the REST API with an internal service name (`hg`, `store`, `hubble`, `vermeer`) via HTTP Basic auth. No default is shipped; generate one per deployment and configure every REST client (e.g. Hubble's `operations.pd.password`) with the same value | #### Single-Node Example @@ -242,6 +242,7 @@ docker run -d \ -p 8620:8620 \ -p 8686:8686 \ -p 8610:8610 \ + -e HG_PD_AUTH_SECRET_KEY="$(openssl rand -hex 24)" \ -e HG_PD_GRPC_HOST= \ -e HG_PD_RAFT_ADDRESS=:8610 \ -e HG_PD_RAFT_PEERS_LIST=:8610 \ @@ -290,17 +291,20 @@ docker/docker-compose-3pd-3store-3server.yml (`hg`, `store`, `hubble`, `vermeer`) with the `auth.secret-key` value as the password. Health probes (`/v1/health`, `/actuator/*`, `/v1/prom/targets/*`) stay unauthenticated. -- The shipped `auth.secret-key` default is public. Change it in production - (config file, or `HG_PD_AUTH_SECRET_KEY` for the Docker image) and update - every REST client with the same value: the Server's `bin/wait-storage.sh` - reads `PD_AUTH_PASSWORD` (and `PD_AUTH_USER`, default `store`), and Hubble - reads `operations.pd.password`. A client left on the old secret gets 401, - and for `wait-storage.sh` that means Server startup aborts after +- `auth.secret-key` has no shipped default, because a secret in the source + tree is published to everyone. Generate one per deployment (`openssl rand + -hex 24`) and set it in the config file, or through + `HG_PD_AUTH_SECRET_KEY`, which the Docker image requires. Give every REST + client the same value: the Server's `bin/wait-storage.sh` reads + `PD_AUTH_PASSWORD` (and `PD_AUTH_USER`, default `store`), and Hubble reads + `operations.pd.password`. A client left on a stale secret gets 401, and for + `wait-storage.sh` that means Server startup aborts after `WAIT_STORAGE_TIMEOUT_S`. - An existing `conf/application.yml` carried over from an earlier release has no `auth` block. PD then starts with an empty secret and refuses every authenticated REST request, logging an error that names `auth.secret-key`. - Add the key to the file before upgrading. + Add the key before upgrading. PD refuses to start if the key is set to the + placeholder value that earlier revisions of this repository carried. ### Monitoring diff --git a/hugegraph-pd/docs/configuration.md b/hugegraph-pd/docs/configuration.md index ddc546fdbb..e3cbd1ed3a 100644 --- a/hugegraph-pd/docs/configuration.md +++ b/hugegraph-pd/docs/configuration.md @@ -89,12 +89,12 @@ HTTP 401. Unauthenticated paths: `/v1/health`, `/actuator/*` and ```yaml auth: - secret-key: FXQXbJtbCLxODc6tGci732pkH1cyf8Qg + secret-key: ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `auth.secret-key` | String | (public default in the shipped `conf/application.yml`) | Password checked against the Basic credential. The shipped value is public, so change it in production. If the key is absent PD starts with an empty secret and refuses every authenticated REST request, logging an error that names this parameter. | +| `auth.secret-key` | String | none (required) | Password checked against the Basic credential. There is no default: a secret shipped in the source tree would be published to everyone. While it is empty PD refuses every authenticated REST request and logs an error naming this parameter, and PD refuses to start at all if it is set to the value that earlier revisions carried as a placeholder. | Every REST client needs the same value: the Server's `bin/wait-storage.sh` reads it from `PD_AUTH_PASSWORD`, Hubble from `operations.pd.password`, and diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java index 3222f3e776..1eece31655 100644 --- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java +++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java @@ -25,6 +25,7 @@ import org.apache.hugegraph.pd.ConfigService; import org.apache.hugegraph.pd.IdService; +import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; @@ -38,7 +39,15 @@ */ @Data @Component -public class PDConfig { +public class PDConfig implements InitializingBean { + + /** + * The secret that earlier revisions carried as the placeholder default for + * `auth.secret-key`. It is published in this repository, so a deployment + * still using it authenticates anyone who can read the source. Refuse to + * start rather than let a well-known string look like authentication. + */ + private static final String PUBLISHED_SECRET_KEY = "FXQXbJtbCLxODc6tGci732pkH1cyf8Qg"; // cluster ID @Value("${pd.cluster_id:1}") @@ -89,6 +98,17 @@ public class PDConfig { private ConfigService configService; private IdService idService; + @Override + public void afterPropertiesSet() { + if (PUBLISHED_SECRET_KEY.equals(this.secretKey)) { + throw new IllegalStateException( + "auth.secret-key is set to the value published in the HugeGraph source " + + "tree, which authenticates anyone who can read it. Set a " + + "deployment-specific secret in conf/application.yml, or through the " + + "HG_PD_AUTH_SECRET_KEY environment variable for the Docker image."); + } + } + public Map getInitialStoreMap() { if (initialStoreMap == null) { initialStoreMap = new HashMap<>(); diff --git a/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh b/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh index 080c7e4260..495be026eb 100755 --- a/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh +++ b/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh @@ -51,18 +51,18 @@ require_env "HG_PD_GRPC_HOST" require_env "HG_PD_RAFT_ADDRESS" require_env "HG_PD_RAFT_PEERS_LIST" require_env "HG_PD_INITIAL_STORE_LIST" +# The REST API refuses every authenticated request without this, and the image +# ships no default because a published secret is not a secret. +require_env "HG_PD_AUTH_SECRET_KEY" : "${HG_PD_GRPC_PORT:=8686}" : "${HG_PD_REST_PORT:=8620}" : "${HG_PD_DATA_PATH:=/hugegraph-pd/pd_data}" : "${HG_PD_INITIAL_STORE_COUNT:=1}" -# Optional secret for REST Basic authentication (auth.secret-key). When unset, -# the value from conf/application.yml applies. Never logged. -AUTH_JSON="" -if [[ -n "${HG_PD_AUTH_SECRET_KEY:-}" ]]; then - AUTH_JSON="\"auth\": { \"secret-key\": \"$(json_escape "${HG_PD_AUTH_SECRET_KEY}")\" }," -fi +# Secret for REST Basic authentication (auth.secret-key). Required above and +# never logged. +AUTH_JSON="\"auth\": { \"secret-key\": \"$(json_escape "${HG_PD_AUTH_SECRET_KEY}")\" }," SPRING_APPLICATION_JSON="$(cat < /dev/null; then check_any_pd_stores() { for peer in \$(echo \"\$PD_REST_LIST\" | tr ',' ' '); do - if curl ${PD_AUTH_ARGS} -f -s \ + if printf 'user = \"%s:%s\"\n' \ + \"\$PD_AUTH_CURL_USER\" \"\$PD_AUTH_CURL_PASSWORD\" | \ + curl -K - -f -s \ --connect-timeout ${WAIT_STORAGE_PD_CONNECT_TIMEOUT_S} \ --max-time ${WAIT_STORAGE_PD_MAX_TIMEOUT_S} \ - http://\${peer}/v1/stores 2>/dev/null | \ + \"http://\${peer}/v1/stores\" 2>/dev/null | \ grep -qi '\"state\"[[:space:]]*:[[:space:]]*\"Up\"'; then echo \"\$peer\" return 0 diff --git a/hugegraph-server/hugegraph-dist/src/assembly/travis/start-pd.sh b/hugegraph-server/hugegraph-dist/src/assembly/travis/start-pd.sh index 0c137489e1..7e3198bedb 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/travis/start-pd.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/travis/start-pd.sh @@ -31,6 +31,11 @@ fi PD_DIR=$HOME_DIR/hugegraph-pd/apache-hugegraph-pd-$VersionInBash +# conf/application.yml ships auth.secret-key empty on purpose, so PD would +# refuse every authenticated REST request. Supply a test-only secret; it must +# match the value the PD test suites send. +export SPRING_APPLICATION_JSON='{"auth":{"secret-key":"pd-ci-test-secret-not-for-production"}}' + pushd $PD_DIR . bin/start-hugegraph-pd.sh sleep 10 diff --git a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-wait-storage.sh b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-wait-storage.sh index da6a008f05..8764806519 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-wait-storage.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-wait-storage.sh @@ -25,6 +25,7 @@ DIST_ROOT="${TMP_DIR}/dist" MOCK_BIN="${TMP_DIR}/mock-bin" CALL_LOG="${TMP_DIR}/curl-calls" ARGS_LOG="${TMP_DIR}/curl-args" +CONFIG_LOG="${TMP_DIR}/curl-config" COUNT_FILE="${TMP_DIR}/store-call-count" TIMEOUT_LOG="${TMP_DIR}/timeout-arg" CASE_OUTPUT="" @@ -54,8 +55,12 @@ assert_contract() { ! grep -q '/v1/health' "${CALL_LOG}" || \ fail "/v1/health must not gate readiness" [[ -s "${ARGS_LOG}" ]] || fail "curl was not called" - if grep -Fv -- '-u test-user:test-password' "${ARGS_LOG}" | grep -q .; then - fail "authentication arguments were not preserved" + if grep -Fq -- 'test-password' "${ARGS_LOG}"; then + fail "credential leaked into curl argv" + fi + [[ -s "${CONFIG_LOG}" ]] || fail "curl was not given a credential config" + if grep -Fv -- 'user = "test-user:test-password"' "${CONFIG_LOG}" | grep -q .; then + fail "authentication credential was not preserved" fi if grep -Fv -- '--connect-timeout 2' "${ARGS_LOG}" | grep -q .; then fail "per-peer connect timeout was not preserved" @@ -70,6 +75,7 @@ run_case() { local scenario="$1" peers="$2" abort_after="$3" : > "${CALL_LOG}" : > "${ARGS_LOG}" + : > "${CONFIG_LOG}" : > "${COUNT_FILE}" : > "${TIMEOUT_LOG}" : > "${DIST_ROOT}/conf/graphs/hugegraph.properties" @@ -80,6 +86,7 @@ run_case() { MOCK_ABORT_AFTER="${abort_after}" \ MOCK_CALL_LOG="${CALL_LOG}" \ MOCK_ARGS_LOG="${ARGS_LOG}" \ + MOCK_CONFIG_LOG="${CONFIG_LOG}" \ MOCK_COUNT_FILE="${COUNT_FILE}" \ MOCK_TIMEOUT_LOG="${TIMEOUT_LOG}" \ HG_SERVER_PD_REST_ENDPOINT="${peers}" \ @@ -132,6 +139,14 @@ url="${!#}" printf '%s\n' "$*" >> "${MOCK_ARGS_LOG}" printf '%s\n' "${url}" >> "${MOCK_CALL_LOG}" +# The credential must arrive as a config file on stdin, never in argv +for arg in "$@"; do + if [[ "${arg}" == "-K" ]]; then + cat >> "${MOCK_CONFIG_LOG}" + break + fi +done + if [[ "${url}" == */v1/health ]]; then printf '{}\n' exit 0 From 3b3913229d9e408af5e2dba84f2fe6d969adc2c1 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Fri, 4 Sep 2026 10:09:14 +0530 Subject: [PATCH 4/6] fix(pd): unbreak the hstore smoke, decode credentials as UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups, including the CI failure the previous commit caused. The blocker was mine: requiring HG_PD_AUTH_SECRET_KEY in the Compose files without giving it to every caller of docker compose. Only compose_auth passed it, so the auth-on hstore smoke, which goes through compose_active, could not interpolate the file and failed before it started the stack. compose_active now passes it too. The Basic credential was decoded with the platform default charset while the secret it is compared against comes from the UTF-8 YAML, so a non-ASCII secret authenticated or not depending on the host locale. PD targets JDK 11, where the default charset still follows the locale, and the start script sets no -Dfile.encoding, so a PD started with LANG unset rejected a secret that worked from the operator's terminal, reporting only "invalid credential". Decode as UTF-8, which is what RFC 7617 specifies. Confirmed both ways: the old decode rejects `sécrèt-2026` under -Dfile.encoding=US-ASCII and the new one accepts it, and they agree under UTF-8. json_escape in the PD entrypoint escaped backslash and quote and dropped LF, leaving CR and TAB to produce invalid SPRING_APPLICATION_JSON and a container that failed before startup. It now escapes every C0 control character as \uXXXX. travis/test-pd-docker-entrypoint.sh covers the override: eight secrets including CR, TAB, quote, backslash and non-ASCII round-trip through the generated JSON, the missing-secret case is refused, and the secret never reaches the log. Wired into the pd CI job. Both README recipes were wrong in the same way, generating or expecting a secret the operator never has in their shell: - docker/README.md now loads .env before the commands that use the value, so the curl example stops sending an empty password and the Hubble sed stops rewriting the empty value to itself. It refuses to write an empty secret and names hstore-ha.properties for the HA topology. - hugegraph-pd/README.md generates the secret into a variable first, then passes it to docker run, instead of minting one inside the run line that only the container ever sees. --- .github/workflows/pd-store-ci.yml | 4 + docker/README.md | 19 ++- docker/test-compose.sh | 1 + hugegraph-pd/README.md | 7 +- .../hg-pd-dist/docker/docker-entrypoint.sh | 27 ++++- .../service/interceptor/Authentication.java | 5 +- .../travis/test-pd-docker-entrypoint.sh | 114 ++++++++++++++++++ 7 files changed, 169 insertions(+), 8 deletions(-) create mode 100755 hugegraph-server/hugegraph-dist/src/assembly/travis/test-pd-docker-entrypoint.sh diff --git a/.github/workflows/pd-store-ci.yml b/.github/workflows/pd-store-ci.yml index 1a6825e7e4..f0c2231a4f 100644 --- a/.github/workflows/pd-store-ci.yml +++ b/.github/workflows/pd-store-ci.yml @@ -134,6 +134,10 @@ jobs: done echo "can_run=true" >> "$GITHUB_OUTPUT" + - name: Run PD docker entrypoint secret override tests + run: | + $TRAVIS_DIR/test-pd-docker-entrypoint.sh + - name: Run start-hugegraph-pd.sh foreground mode tests if: steps.pd-preflight.outputs.can_run == 'true' run: | diff --git a/docker/README.md b/docker/README.md index a47c2f7c4a..b26d0b8060 100644 --- a/docker/README.md +++ b/docker/README.md @@ -61,12 +61,17 @@ behind an HTTPS reverse proxy and trusted network controls. first authenticated startup. Changing `.env` does not rotate an existing administrator password; use the HugeGraph user API for credential changes. -For the verification commands below, set the password in your current shell: +For the verification commands below, load `.env` into your current shell and +set the password: ```bash +set -a; . ./.env; set +a ADMIN_PASSWORD='the-same-password-used-in-.env' ``` +Compose reads `.env` on its own; the line above is so that the `curl` and +`sed` commands on this page can use `${HG_PD_AUTH_SECRET_KEY}` too. + The PD REST API (port 8620, HStore topologies only) has its own credential: requests other than health probes need HTTP Basic auth with an internal service name (for example `hg`) and the PD secret as the password. PD ships @@ -93,9 +98,17 @@ fails: into it by hand. Until you do, Hubble's PD-backed views get 401 from PD; everything else in Hubble works. +Write it in, after loading `.env` as above. Use `hstore.properties` for the +Minimal HStore topology and `hstore-ha.properties` for HA: + ```bash -sed -i.bak "s#^operations.pd.password=.*#operations.pd.password=${HG_PD_AUTH_SECRET_KEY}#" \ - conf/hubble/hstore.properties +if [ -n "${HG_PD_AUTH_SECRET_KEY:-}" ]; then + sed -i.bak \ + "s#^operations.pd.password=.*#operations.pd.password=${HG_PD_AUTH_SECRET_KEY}#" \ + conf/hubble/hstore.properties +else + echo 'HG_PD_AUTH_SECRET_KEY is empty; load .env first' >&2 +fi ``` ### Standalone diff --git a/docker/test-compose.sh b/docker/test-compose.sh index 0e9ddc76bb..aa74a88a30 100644 --- a/docker/test-compose.sh +++ b/docker/test-compose.sh @@ -273,6 +273,7 @@ compose_active() { HUBBLE_IMAGE="${HUBBLE_IMAGE:-hugegraph/hubble:latest}" \ HUGEGRAPH_ADMIN_PASSWORD="${PASSWORD}" \ HUGEGRAPH_AUTH_TOKEN_SECRET="${SECRET}" \ + HG_PD_AUTH_SECRET_KEY="${PD_SECRET}" \ COMPOSE_PROGRESS=plain \ docker compose -p "${ACTIVE_PROJECT}" "${ACTIVE_FILES[@]}" "$@" } diff --git a/hugegraph-pd/README.md b/hugegraph-pd/README.md index 7aa3ff9168..fa5d8de86c 100644 --- a/hugegraph-pd/README.md +++ b/hugegraph-pd/README.md @@ -237,12 +237,17 @@ Build PD Docker image: # From project root docker build -f hugegraph-pd/Dockerfile -t hugegraph/pd:latest . +# Generate the REST secret once and keep it: every PD REST client needs this +# same value, and a new one silently breaks the clients already using the old +# one. Store it somewhere durable rather than only in this shell. +export HG_PD_AUTH_SECRET_KEY="$(openssl rand -hex 24)" + # Run container docker run -d \ -p 8620:8620 \ -p 8686:8686 \ -p 8610:8610 \ - -e HG_PD_AUTH_SECRET_KEY="$(openssl rand -hex 24)" \ + -e HG_PD_AUTH_SECRET_KEY="${HG_PD_AUTH_SECRET_KEY}" \ -e HG_PD_GRPC_HOST= \ -e HG_PD_RAFT_ADDRESS=:8610 \ -e HG_PD_RAFT_PEERS_LIST=:8610 \ diff --git a/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh b/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh index 495be026eb..d38230b37d 100755 --- a/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh +++ b/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh @@ -26,10 +26,31 @@ require_env() { fi } +# Escape a value for use inside a JSON string: backslash and quote, then every +# remaining C0 control character as \uXXXX. Dropping only LF, as an earlier +# version did, left CR and TAB to produce invalid JSON and a container that +# failed before startup. json_escape() { - local s="$1" - s=${s//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\n'/} - printf "%s" "$s" + local s="$1" out="" i c + s=${s//\\/\\\\} + s=${s//\"/\\\"} + for (( i = 0; i < ${#s}; i++ )); do + c=${s:i:1} + case "$c" in + $'\n') out+='\n' ;; + $'\r') out+='\r' ;; + $'\t') out+='\t' ;; + $'\b') out+='\b' ;; + $'\f') out+='\f' ;; + *) + if [[ "$c" < $'\x20' || "$c" == $'\x7f' ]]; then + printf -v c '\\u%04x' "'$c" + fi + out+="$c" + ;; + esac + done + printf "%s" "$out" } migrate_env() { diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/interceptor/Authentication.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/interceptor/Authentication.java index 9774e7032f..4a8d73fa7e 100644 --- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/interceptor/Authentication.java +++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/interceptor/Authentication.java @@ -83,7 +83,10 @@ protected T authenticate(String authority, String token, Function } byte[] bytes = authority.getBytes(StandardCharsets.UTF_8); byte[] decode = Base64.getDecoder().decode(bytes); - String info = new String(decode); + // RFC 7617: Basic credentials are UTF-8. Decoding with the platform + // default would compare against a UTF-8 secret only when the host + // locale happens to agree. + String info = new String(decode, StandardCharsets.UTF_8); int delim = info.indexOf(':'); if (delim == -1) { throw new BadCredentialsException(invalidBasicInfo); diff --git a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-pd-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-pd-docker-entrypoint.sh new file mode 100755 index 0000000000..ea8874dc37 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-pd-docker-entrypoint.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Checks that the PD Docker entrypoint turns HG_PD_AUTH_SECRET_KEY into valid +# SPRING_APPLICATION_JSON, whatever the secret contains, and that the value +# Spring would read back is the secret that went in. + +set -euo pipefail + +ENTRYPOINT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh" +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/pd-entrypoint-test.XXXXXX") +trap 'rm -rf "${TMP_DIR}"' EXIT + +PASS=0 +FAIL=0 + +[[ -f "${ENTRYPOINT}" ]] || { echo "entrypoint not found at ${ENTRYPOINT}" >&2; exit 1; } +command -v python3 >/dev/null || { echo "python3 required" >&2; exit 1; } + +mkdir -p "${TMP_DIR}/bin" +cp "${ENTRYPOINT}" "${TMP_DIR}/docker-entrypoint.sh" +# Stand in for the launcher: record the generated config instead of starting PD +cat > "${TMP_DIR}/bin/start-hugegraph-pd.sh" <<'STUB' +#!/usr/bin/env bash +printf '%s' "${SPRING_APPLICATION_JSON}" > ./spring.json +STUB +chmod +x "${TMP_DIR}/bin/start-hugegraph-pd.sh" "${TMP_DIR}/docker-entrypoint.sh" + +run_case() { + local name="$1" secret="$2" + local out + if ! out=$(cd "${TMP_DIR}" && env \ + HG_PD_GRPC_HOST=pd0 \ + HG_PD_RAFT_ADDRESS=pd0:8610 \ + HG_PD_RAFT_PEERS_LIST=pd0:8610 \ + HG_PD_INITIAL_STORE_LIST=store0:8500 \ + HG_PD_AUTH_SECRET_KEY="${secret}" \ + ./docker-entrypoint.sh 2>&1); then + echo " FAIL ${name}: entrypoint exited non-zero" + printf '%s\n' "${out}" | tail -3 + FAIL=$((FAIL + 1)) + return + fi + + if ! SECRET="${secret}" python3 - "${TMP_DIR}/spring.json" <<'PY' +import json, os, sys +with open(sys.argv[1], encoding="utf-8") as fh: + doc = json.load(fh) +got = doc["auth"]["secret-key"] +want = os.environ["SECRET"] +if got != want: + print(" round-trip mismatch: %r != %r" % (got, want)) + sys.exit(1) +PY + then + echo " FAIL ${name}: invalid JSON or secret did not round-trip" + FAIL=$((FAIL + 1)) + return + fi + echo " PASS ${name}" + PASS=$((PASS + 1)) +} + +echo "PD docker-entrypoint secret override" +run_case "plain secret" 'aVerySecretValue123' +run_case "carriage return" "$(printf 'a\rb')" +run_case "tab" "$(printf 'a\tb')" +run_case "double quote" 'a"b' +run_case "backslash" 'a\b' +run_case "backslash and quote" 'a\"b' +run_case "non-ascii" 'sécrèt-2026' +run_case "spaces" 'two words' + +# The secret is required, and must never be echoed to the log +if (cd "${TMP_DIR}" && env \ + HG_PD_GRPC_HOST=pd0 HG_PD_RAFT_ADDRESS=pd0:8610 \ + HG_PD_RAFT_PEERS_LIST=pd0:8610 HG_PD_INITIAL_STORE_LIST=store0:8500 \ + ./docker-entrypoint.sh >/dev/null 2>&1); then + echo " FAIL missing secret: entrypoint started without HG_PD_AUTH_SECRET_KEY" + FAIL=$((FAIL + 1)) +else + echo " PASS missing secret is refused" + PASS=$((PASS + 1)) +fi + +log_out=$(cd "${TMP_DIR}" && env \ + HG_PD_GRPC_HOST=pd0 HG_PD_RAFT_ADDRESS=pd0:8610 \ + HG_PD_RAFT_PEERS_LIST=pd0:8610 HG_PD_INITIAL_STORE_LIST=store0:8500 \ + HG_PD_AUTH_SECRET_KEY='do-not-log-this-value' \ + ./docker-entrypoint.sh 2>&1) +if printf '%s' "${log_out}" | grep -q 'do-not-log-this-value'; then + echo " FAIL secret was written to the log" + FAIL=$((FAIL + 1)) +else + echo " PASS secret is not logged" + PASS=$((PASS + 1)) +fi + +echo "${PASS} passed, ${FAIL} failed" +[[ "${FAIL}" -eq 0 ]] From b352e8c105cf722a626a2b1e7b0861c4ca628eff Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Fri, 4 Sep 2026 10:18:43 +0530 Subject: [PATCH 5/6] chore(ci): retrigger build-commons after a port-collision flake ServerClientTest.testServiceProxy failed with "bind(..) failed: Address already in use" on port 8090 in hugegraph-commons/hugegraph-rpc, a module this branch does not touch. Empty commit, no source change. From ace8913a079810503810584cdc552bb710fe601a Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Fri, 4 Sep 2026 10:37:13 +0530 Subject: [PATCH 6/6] test(pd): cover the REST credential check in process The suites that exercise this talk to a PD in another JVM, so none of the new branches were attributed to the build and codecov/patch read 0%. AuthenticationTest runs the check directly: each inner module accepted with the secret, wrong and empty passwords refused, unknown names refused, a malformed credential refused, an unset secret refusing everyone rather than falling back to a name check, a non-ASCII secret that would fail if the credential were decoded with the platform charset, and PDConfig refusing to start on the published placeholder. Added to PDCoreSuiteTest, which needs no running PD. --- .../hugegraph/pd/core/PDCoreSuiteTest.java | 2 + .../interceptor/AuthenticationTest.java | 127 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/service/interceptor/AuthenticationTest.java diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java index 95b044c76b..a65b53e886 100644 --- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.pd.core; +import org.apache.hugegraph.pd.service.interceptor.AuthenticationTest; import org.apache.hugegraph.pd.core.meta.MetadataKeyHelperTest; import org.apache.hugegraph.pd.core.store.HgKVStoreImplTest; import org.apache.hugegraph.pd.raft.IpAuthHandlerTest; @@ -32,6 +33,7 @@ MetadataKeyHelperTest.class, HgKVStoreImplTest.class, PDConfigTest.class, + AuthenticationTest.class, ConfigServiceTest.class, IdServiceTest.class, KvServiceTest.class, diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/service/interceptor/AuthenticationTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/service/interceptor/AuthenticationTest.java new file mode 100644 index 0000000000..5bb801f6d1 --- /dev/null +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/service/interceptor/AuthenticationTest.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.pd.service.interceptor; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.apache.hugegraph.pd.config.PDConfig; +import org.junit.Assert; +import org.junit.Test; + +/** + * In-process cover for the REST credential check. The suites that exercise it + * over HTTP talk to a PD in another JVM, so nothing here is covered by them. + */ +public class AuthenticationTest { + + private static final String SECRET = "unit-test-secret"; + + private static Authentication authWithSecret(String secret) throws Exception { + Authentication auth = new Authentication(); + PDConfig config = new PDConfig(); + config.setSecretKey(secret); + Field field = Authentication.class.getDeclaredField("pdConfig"); + field.setAccessible(true); + field.set(auth, config); + return auth; + } + + private static String credential(String name, String pwd) { + return Base64.getEncoder().encodeToString( + (name + ":" + pwd).getBytes(StandardCharsets.UTF_8)); + } + + private static boolean accepts(Authentication auth, String authority) { + try { + return auth.authenticate(authority, null, t -> Boolean.TRUE, () -> Boolean.TRUE); + } catch (RuntimeException e) { + return false; + } + } + + @Test + public void testEveryInnerModuleIsAcceptedWithTheSecret() throws Exception { + Authentication auth = authWithSecret(SECRET); + for (String name : new String[]{"hg", "store", "hubble", "vermeer"}) { + Assert.assertTrue(name + " should be accepted with the right secret", + accepts(auth, credential(name, SECRET))); + } + } + + @Test + public void testPasswordIsActuallyChecked() throws Exception { + Authentication auth = authWithSecret(SECRET); + Assert.assertFalse("wrong password must be refused", + accepts(auth, credential("hg", "wrong-password"))); + Assert.assertFalse("empty password must be refused", + accepts(auth, credential("hg", ""))); + Assert.assertFalse("secret as the name must not help", + accepts(auth, credential(SECRET, SECRET))); + } + + @Test + public void testUnknownServiceNameIsRefused() throws Exception { + Authentication auth = authWithSecret(SECRET); + Assert.assertFalse(accepts(auth, credential("nobody", SECRET))); + Assert.assertFalse(accepts(auth, credential("admin", SECRET))); + } + + @Test + public void testMissingOrMalformedCredentialIsRefused() throws Exception { + Authentication auth = authWithSecret(SECRET); + Assert.assertFalse(accepts(auth, null)); + Assert.assertFalse(accepts(auth, "")); + // no colon + Assert.assertFalse(accepts(auth, Base64.getEncoder().encodeToString( + "hg".getBytes(StandardCharsets.UTF_8)))); + } + + @Test + public void testUnconfiguredSecretRefusesEveryone() throws Exception { + for (String secret : new String[]{null, ""}) { + Authentication auth = authWithSecret(secret); + Assert.assertFalse("an unset secret must not fall back to a name check", + accepts(auth, credential("hg", ""))); + Assert.assertFalse(accepts(auth, credential("hg", SECRET))); + } + } + + @Test + public void testNonAsciiSecretDoesNotDependOnTheDefaultCharset() throws Exception { + String secret = "sécrèt-2026"; + Authentication auth = authWithSecret(secret); + Assert.assertTrue(accepts(auth, credential("hg", secret))); + Assert.assertFalse(accepts(auth, credential("hg", "secret-2026"))); + } + + @Test + public void testPublishedSecretRefusesStartup() { + PDConfig config = new PDConfig(); + config.setSecretKey("FXQXbJtbCLxODc6tGci732pkH1cyf8Qg"); + Assert.assertThrows(IllegalStateException.class, config::afterPropertiesSet); + } + + @Test + public void testOwnSecretStarts() { + PDConfig config = new PDConfig(); + config.setSecretKey(SECRET); + config.afterPropertiesSet(); + } +}