From a7b81cee0dd791a19abe79c81433963eb68eacf3 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 26 Jul 2026 17:49:36 +0530 Subject: [PATCH 01/19] fix(hugegraph-dist): skip init-store when graph.load_from_local_config is false Honor the existing ServerOptions flag in InitStore so distributed/HStore can opt out of local backend and admin init. Unset keeps full init for standalone. Docker maps HG_SERVER_LOAD_FROM_LOCAL_CONFIG to rest-server.properties. Fixes #3118 --- .../docker/docker-entrypoint.sh | 2 + .../org/apache/hugegraph/cmd/InitStore.java | 30 +++++++++ .../apache/hugegraph/unit/UnitTestSuite.java | 2 + .../unit/cmd/InitStoreConfigTest.java | 65 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 2c76a54435..f0047b39fe 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -20,6 +20,7 @@ set -euo pipefail DOCKER_FOLDER="./docker" INIT_FLAG_FILE="init_complete" GRAPH_CONF="./conf/graphs/hugegraph.properties" +REST_SERVER_CONF="./conf/rest-server.properties" mkdir -p "${DOCKER_FOLDER}" @@ -54,6 +55,7 @@ migrate_env "PD_PEERS" "HG_SERVER_PD_PEERS" # ── Map env → properties file ───────────────────────────────────────── [[ -n "${HG_SERVER_BACKEND:-}" ]] && set_prop "backend" "${HG_SERVER_BACKEND}" "${GRAPH_CONF}" [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && set_prop "pd.peers" "${HG_SERVER_PD_PEERS}" "${GRAPH_CONF}" +[[ -n "${HG_SERVER_LOAD_FROM_LOCAL_CONFIG:-}" ]] && set_prop "graph.load_from_local_config" "${HG_SERVER_LOAD_FROM_LOCAL_CONFIG}" "${REST_SERVER_CONF}" # ── Build wait-storage env ───────────────────────────────────────────── WAIT_ENV=() diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 9dc5e89a73..44a8b8496a 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -74,6 +74,20 @@ public static void main(String[] args) throws Exception { RegisterUtil.registerServer(); HugeConfig restServerConfig = new HugeConfig(restConf); + + // Skip local init only when the flag is *explicitly* false (Helm / + // HStore). Unset keeps master behavior: full standalone init-store. + // ServerOptions default is false for GraphManager; we do not treat + // "missing key" as skip so existing tarball users are not broken. + if (shouldSkipLocalInit(restServerConfig)) { + LOG.warn("Skipping init-store: '{}' is false in {}. " + + "Unset the property (or set true) to run local " + + "backend/admin init; distributed/Helm sets false.", + ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG.name(), + restConf); + return; + } + PDAuthConfig.setAuthority( ServiceConstant.SERVICE_NAME, ServiceConstant.AUTHORITY); @@ -101,6 +115,22 @@ public static void main(String[] args) throws Exception { } } + /** + * Whether init-store should no-op. + * + */ + public static boolean shouldSkipLocalInit(HugeConfig conf) { + String key = ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG.name(); + if (!conf.containsKey(key)) { + return false; + } + return !conf.get(ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG); + } + private static HugeGraph initGraph(String configPath) throws Exception { LOG.info("Init graph with config file: {}", configPath); HugeConfig config = new HugeConfig(configPath); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index 1a4f0c2c9b..ab2d3cc418 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -46,6 +46,7 @@ import org.apache.hugegraph.unit.core.DataTypeTest; import org.apache.hugegraph.unit.core.DirectionsTest; import org.apache.hugegraph.unit.core.ExceptionTest; +import org.apache.hugegraph.unit.cmd.InitStoreConfigTest; import org.apache.hugegraph.unit.core.GraphManagerConfigTest; import org.apache.hugegraph.unit.core.LocksTableTest; import org.apache.hugegraph.unit.core.PageStateTest; @@ -146,6 +147,7 @@ SecurityManagerTest.class, RolePermissionTest.class, ExceptionTest.class, + InitStoreConfigTest.class, GraphManagerConfigTest.class, BackendStoreInfoTest.class, TraversalUtilTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java new file mode 100644 index 0000000000..571a62897a --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -0,0 +1,65 @@ +/* + * 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.unit.cmd; + +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.hugegraph.cmd.InitStore; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.config.ServerOptions; +import org.apache.hugegraph.dist.RegisterUtil; +import org.apache.hugegraph.testutil.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * InitStore skips local init only when {@code graph.load_from_local_config} + * is explicitly false (Helm / HStore). Unset keeps master standalone init. + */ +public class InitStoreConfigTest { + + @BeforeClass + public static void init() { + RegisterUtil.registerServer(); + } + + @Test + public void testUnsetDoesNotSkipInit() { + PropertiesConfiguration conf = new PropertiesConfiguration(); + HugeConfig config = new HugeConfig(conf); + // ServerOptions default is false, but InitStore must not treat missing + // key as skip — same as master bare init-store for standalone users. + Assert.assertFalse(config.get(ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG)); + Assert.assertFalse(InitStore.shouldSkipLocalInit(config)); + } + + @Test + public void testExplicitFalseSkipsInit() { + PropertiesConfiguration conf = new PropertiesConfiguration(); + conf.setProperty(ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG.name(), "false"); + HugeConfig config = new HugeConfig(conf); + Assert.assertTrue(InitStore.shouldSkipLocalInit(config)); + } + + @Test + public void testExplicitTrueDoesNotSkipInit() { + PropertiesConfiguration conf = new PropertiesConfiguration(); + conf.setProperty(ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG.name(), "true"); + HugeConfig config = new HugeConfig(conf); + Assert.assertFalse(InitStore.shouldSkipLocalInit(config)); + } +} From 69ac25e1cd41d7fdb7cbf877bbe431c29ec0f970 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Mon, 27 Jul 2026 02:23:33 +0530 Subject: [PATCH 02/19] fix(hugegraph-dist): gate init-store on a dedicated init_store.enabled option Replace the graph.load_from_local_config gate with a dedicated init_store.enabled option defaulting to true. That property already means whether GraphManager loads graph definitions from the local directory, and existing configs may materialize its declared false default, so reusing it made an explicit false skip backend and admin initialization for standalone RocksDB/HBase installs. The early return also skipped StandardAuthenticator.initAdminUserIfNeeded, so a Docker PASSWORD was piped into init-store.sh and discarded without creating the admin, leaving the server on the auth.admin_pa default. In skip mode the entrypoint now writes PASSWORD to auth.admin_pa instead, and does not create docker/init_complete since nothing was initialized. The gate is read back from rest-server.properties so a config mounted without the env var behaves the same way. Cover the lifecycle with a main-level test whose graphs directory would fail if the early exit were missed, and entrypoint smoke tests for default init, PASSWORD, skip, skip with PASSWORD, mounted property, and false to true restart. Run those in Docker CI and extend its path filter to the dist docker directory. --- .github/workflows/docker-build-ci.yml | 11 + docker/README.md | 13 + .../hugegraph/config/ServerOptions.java | 12 + .../docker/docker-entrypoint.sh | 55 +++- .../docker/test/test-docker-entrypoint.sh | 247 ++++++++++++++++++ .../org/apache/hugegraph/cmd/InitStore.java | 47 ++-- .../unit/cmd/InitStoreConfigTest.java | 106 ++++++-- 7 files changed, 442 insertions(+), 49 deletions(-) create mode 100755 hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index 8d31ff266f..3a436c6826 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -26,8 +26,19 @@ on: paths: - '**/Dockerfile*' - '.dockerignore' + - 'hugegraph-server/hugegraph-dist/docker/**' + - '**/docker/docker-entrypoint.sh' jobs: + entrypoint-test: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Server entrypoint smoke tests + run: hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh + docker-build: runs-on: ubuntu-24.04 strategy: diff --git a/docker/README.md b/docker/README.md index 9bc21b1ba7..bed6076b68 100644 --- a/docker/README.md +++ b/docker/README.md @@ -161,6 +161,19 @@ Configuration is injected via environment variables. The old `docker/configs/app | `HG_SERVER_PD_PEERS` | Yes | — | `pd.peers` | PD cluster addresses (e.g. `pd0:8686,pd1:8686,pd2:8686`) | | `STORE_REST` | No | — | Used by `wait-partition.sh` | Store REST endpoint for partition verification (e.g. `store0:8520`) | | `PASSWORD` | No | — | Enables auth mode | Optional authentication password | +| `HG_SERVER_INIT_STORE_ENABLED` | No | `true` | `init_store.enabled` in `rest-server.properties` | Set `false` in PD/HStore deployments so init-store skips local backend and admin initialization | + +> When `HG_SERVER_INIT_STORE_ENABLED=false`, a `PASSWORD` is written to +> `auth.admin_pa` instead of being passed to `init-store.sh`, because the admin +> account is created on server startup rather than by init-store. Two +> consequences worth knowing: +> +> - The password is stored in `conf/rest-server.properties`, so anyone who can +> read that config volume can read it. With init-store enabled the password +> only travels over stdin and is never written to disk. +> - `auth.admin_pa` takes effect only when the admin account is first created. +> Changing `PASSWORD` on a later restart does not rotate an existing admin +> password, and does not report an error. **Deprecated aliases** (still work but log a warning): diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java index 7d706de8f5..955d2ab8a2 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java @@ -368,6 +368,18 @@ public class ServerOptions extends OptionHolder { "./conf/graphs" ); + public static final ConfigOption INIT_STORE_ENABLED = + new ConfigOption<>( + "init_store.enabled", + "Whether init-store initializes the local backend stores " + + "and the built-in admin account. Set false in distributed " + + "deployments (PD/HStore) where the storage side already " + + "owns metadata and the admin account is created on server " + + "startup from 'auth.admin_pa'.", + disallowEmpty(), + true + ); + public static final ConfigOption SERVER_START_IGNORE_SINGLE_GRAPH_ERROR = new ConfigOption<>( "server.start_ignore_single_graph_error", diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index f0047b39fe..726bab2b5c 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -40,6 +40,19 @@ set_prop() { fi } +# Echoes the value of a property, or nothing when the key or the file is +# absent, so callers apply their own default. On duplicate keys the last one +# wins, matching how java.util.Properties reads the same file. +get_prop() { + local key="$1" file="$2" + local esc_key + + [[ -f "${file}" ]] || return 0 + esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') + sed -rn "s|^[[:space:]]*${esc_key}[[:space:]]*=[[:space:]]*(.*)$|\\1|p" \ + "${file}" | tail -n 1 | tr -d '[:space:]' +} + migrate_env() { local old_name="$1" new_name="$2" @@ -55,20 +68,52 @@ migrate_env "PD_PEERS" "HG_SERVER_PD_PEERS" # ── Map env → properties file ───────────────────────────────────────── [[ -n "${HG_SERVER_BACKEND:-}" ]] && set_prop "backend" "${HG_SERVER_BACKEND}" "${GRAPH_CONF}" [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && set_prop "pd.peers" "${HG_SERVER_PD_PEERS}" "${GRAPH_CONF}" -[[ -n "${HG_SERVER_LOAD_FROM_LOCAL_CONFIG:-}" ]] && set_prop "graph.load_from_local_config" "${HG_SERVER_LOAD_FROM_LOCAL_CONFIG}" "${REST_SERVER_CONF}" +[[ -n "${HG_SERVER_INIT_STORE_ENABLED:-}" ]] && set_prop "init_store.enabled" "${HG_SERVER_INIT_STORE_ENABLED}" "${REST_SERVER_CONF}" # ── Build wait-storage env ───────────────────────────────────────────── WAIT_ENV=() [[ -n "${HG_SERVER_BACKEND:-}" ]] && WAIT_ENV+=("hugegraph.backend=${HG_SERVER_BACKEND}") [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && WAIT_ENV+=("hugegraph.pd.peers=${HG_SERVER_PD_PEERS}") -# ── Init store (once) ───────────────────────────────────────────────── -if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then +wait_storage() { if (( ${#WAIT_ENV[@]} > 0 )); then env "${WAIT_ENV[@]}" ./bin/wait-storage.sh else ./bin/wait-storage.sh fi +} + +# ── Init store (once) ───────────────────────────────────────────────── +# With `init_store.enabled=false` (distributed PD/HStore) init-store is a no-op: +# storage owns the metadata and the admin account is created on server startup +# from `auth.admin_pa`. A requested PASSWORD is therefore written to that +# property rather than piped into init-store.sh, where it would be read and +# discarded without creating the account. +# +# The value is read back from the config file rather than from the env var, so +# that a rest-server.properties mounted with the property already set behaves +# the same as `HG_SERVER_INIT_STORE_ENABLED` (the env mapping above has already +# been applied, so env still wins). +INIT_STORE_ENABLED=$(get_prop "init_store.enabled" "${REST_SERVER_CONF}") +if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then + log "init-store disabled, skipping local backend/admin init" + # Still wait: the server needs the storage side reachable at startup even + # though nothing is initialized here + wait_storage + + if [[ -n "${PASSWORD:-}" ]]; then + log "enabling auth mode, admin password applied via auth.admin_pa" + ./bin/enable-auth.sh + # TODO: auth.admin_pa only applies when the admin account is first + # created, so changing PASSWORD on a later restart silently keeps the + # old one. It also leaves the password at rest in rest-server.properties, + # unlike the enabled path where it only travels over stdin. + set_prop "auth.admin_pa" "${PASSWORD}" "${REST_SERVER_CONF}" + fi + # No init flag is written here: nothing was initialized, so a later run + # with init-store enabled must still perform the real initialization. +elif [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then + wait_storage if [[ -z "${PASSWORD:-}" ]]; then log "init hugegraph with non-auth mode" @@ -78,6 +123,10 @@ if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then ./bin/enable-auth.sh echo "${PASSWORD}" | ./bin/init-store.sh fi + # TODO: this flag only tracks "init has run", not what it ran with. On a + # persisted volume it survives env changes, so flipping HG_SERVER_* on a + # later start will not re-init. It also does not survive a Kubernetes pod + # restart, which is why init_store.enabled exists rather than a flag file. touch "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" else log "HugeGraph initialization already done. Skipping re-init..." diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh new file mode 100755 index 0000000000..64bf0f329d --- /dev/null +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -0,0 +1,247 @@ +#!/bin/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. +# +# Smoke tests for docker-entrypoint.sh init-store lifecycle. +# +# The entrypoint is run against a throwaway install tree whose ./bin scripts are +# stubs recording their own invocation, so the tests assert on which scripts ran +# and on the resulting config, without needing a JVM, a backend or Docker. +# +# Usage: hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh + +set -uo pipefail + +SELF_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRYPOINT="${SELF_DIR}/../docker-entrypoint.sh" + +PASS=0 +FAIL=0 +SKIP=0 + +# docker-entrypoint.sh rewrites an existing property with GNU `sed -ri`, which +# BSD sed rejects. The image is Linux, so the cases that exercise that branch +# are skipped rather than failed when running locally on macOS. +if sed --version >/dev/null 2>&1; then + GNU_SED=1 +else + GNU_SED=0 +fi + +skip_without_gnu_sed() { + if [[ ${GNU_SED} -eq 1 ]]; then + return 1 + fi + echo " SKIP: needs GNU sed (set_prop rewrites an existing property)" + SKIP=$((SKIP + 1)) + return 0 +} + +fail() { + echo " FAIL: $*" + FAIL=$((FAIL + 1)) +} + +ok() { + PASS=$((PASS + 1)) +} + +assert_ran() { + if grep -qxF "$1" "${INSTALL}/calls.log" 2>/dev/null; then + ok + else + fail "expected '$1' to run; calls were: $(tr '\n' ' ' < "${INSTALL}/calls.log")" + fi +} + +assert_not_ran() { + if grep -qxF "$1" "${INSTALL}/calls.log" 2>/dev/null; then + fail "expected '$1' NOT to run" + else + ok + fi +} + +assert_file() { + if [[ -f "${INSTALL}/$1" ]]; then ok; else fail "expected file '$1' to exist"; fi +} + +assert_no_file() { + if [[ -f "${INSTALL}/$1" ]]; then fail "expected file '$1' NOT to exist"; else ok; fi +} + +assert_prop() { + local expected="$1=$2" + if grep -qxF "${expected}" "${INSTALL}/conf/rest-server.properties" 2>/dev/null; then + ok + else + fail "expected property '${expected}' in rest-server.properties" + fi +} + +assert_no_prop_key() { + if grep -qE "^[[:space:]]*$1[[:space:]]*=" \ + "${INSTALL}/conf/rest-server.properties" 2>/dev/null; then + fail "expected no '$1' property" + else + ok + fi +} + +# Build a throwaway install tree with stubbed bin scripts +new_install() { + INSTALL=$(mktemp -d "${TMPDIR:-/tmp}/hg-entrypoint-test.XXXXXX") + mkdir -p "${INSTALL}/bin" "${INSTALL}/conf/graphs" + + # Mirrors the shipped conf: the auth properties are present but commented + cat > "${INSTALL}/conf/rest-server.properties" <<'EOF' +restserver.url=http://0.0.0.0:8080 +graphs=./conf/graphs +#auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator +#auth.admin_pa=pa +EOF + echo "backend=rocksdb" > "${INSTALL}/conf/graphs/hugegraph.properties" + + local script + for script in wait-storage start-hugegraph wait-partition; do + cat > "${INSTALL}/bin/${script}.sh" <> "${INSTALL}/calls.log" +EOF + chmod +x "${INSTALL}/bin/${script}.sh" + done + + # Records whether a password was piped in, which is how the entrypoint + # passes a Docker PASSWORD to the admin bootstrap + cat > "${INSTALL}/bin/init-store.sh" <> "${INSTALL}/calls.log" +if [[ ! -t 0 ]]; then + stdin=\$(cat) + [[ -n "\${stdin}" ]] && echo "init-store.sh:stdin=\${stdin}" >> "${INSTALL}/calls.log" +fi +exit 0 +EOF + chmod +x "${INSTALL}/bin/init-store.sh" + + cat > "${INSTALL}/bin/enable-auth.sh" <> "${INSTALL}/calls.log" +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +EOF + chmod +x "${INSTALL}/bin/enable-auth.sh" + + : > "${INSTALL}/calls.log" +} + +# Run the entrypoint inside the throwaway tree. No ./bin/pid is ever written by +# the stubs, so the entrypoint's tail-on-pid block is skipped and it returns. +run_entrypoint() { + ( cd "${INSTALL}" && env "$@" bash "${ENTRYPOINT}" ) > "${INSTALL}/out.log" 2>&1 + local rc=$? + if [[ ${rc} -ne 0 ]]; then + fail "entrypoint exited ${rc}; output: $(cat "${INSTALL}/out.log")" + fi + return 0 +} + +cleanup() { [[ -n "${INSTALL:-}" ]] && rm -rf "${INSTALL}"; } +trap cleanup EXIT + +echo "==> default: no flag set, full init runs" +new_install +run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD +assert_ran "wait-storage.sh" +assert_ran "init-store.sh" +assert_not_ran "enable-auth.sh" +assert_file "docker/init_complete" +cleanup + +echo "==> default + PASSWORD: auth enabled, password piped to init-store" +new_install +run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret +assert_ran "enable-auth.sh" +assert_ran "init-store.sh" +assert_ran "init-store.sh:stdin=s3cret" +assert_file "docker/init_complete" +cleanup + +echo "==> skip via env: init-store never runs and no init flag is written" +new_install +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_ran "wait-storage.sh" +assert_not_ran "init-store.sh" +assert_prop "init_store.enabled" "false" +assert_no_file "docker/init_complete" +cleanup + +echo "==> skip + PASSWORD: password reaches auth.admin_pa, not init-store stdin" +new_install +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_ran "enable-auth.sh" +assert_not_ran "init-store.sh" +assert_not_ran "init-store.sh:stdin=s3cret" +assert_prop "auth.admin_pa" "s3cret" +assert_no_file "docker/init_complete" +cleanup + +echo "==> skip via mounted property only: env var absent behaves the same" +new_install +echo "init_store.enabled=false" >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret +assert_not_ran "init-store.sh" +assert_not_ran "init-store.sh:stdin=s3cret" +assert_prop "auth.admin_pa" "s3cret" +assert_no_file "docker/init_complete" +cleanup + +echo "==> env wins over a conflicting mounted property" +if ! skip_without_gnu_sed; then + new_install + echo "init_store.enabled=false" >> "${INSTALL}/conf/rest-server.properties" + run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true + assert_ran "init-store.sh" + assert_file "docker/init_complete" + cleanup +fi + +echo "==> false then true: a restart with init enabled still initializes" +if ! skip_without_gnu_sed; then + new_install + run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false + assert_not_ran "init-store.sh" + assert_no_file "docker/init_complete" + : > "${INSTALL}/calls.log" + run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true + assert_ran "init-store.sh" + assert_file "docker/init_complete" + cleanup +fi + +echo "==> restart with init enabled: the flag file suppresses re-init" +new_install +run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD +assert_ran "init-store.sh" +: > "${INSTALL}/calls.log" +run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD +assert_not_ran "init-store.sh" +assert_file "docker/init_complete" +cleanup + +echo +echo "passed: ${PASS}, failed: ${FAIL}, skipped cases: ${SKIP}" +[[ ${FAIL} -eq 0 ]] diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 44a8b8496a..062e627f98 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -75,16 +75,27 @@ public static void main(String[] args) throws Exception { HugeConfig restServerConfig = new HugeConfig(restConf); - // Skip local init only when the flag is *explicitly* false (Helm / - // HStore). Unset keeps master behavior: full standalone init-store. - // ServerOptions default is false for GraphManager; we do not treat - // "missing key" as skip so existing tarball users are not broken. - if (shouldSkipLocalInit(restServerConfig)) { - LOG.warn("Skipping init-store: '{}' is false in {}. " - + "Unset the property (or set true) to run local " - + "backend/admin init; distributed/Helm sets false.", - ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG.name(), - restConf); + /* + * Distributed deployments (PD/HStore) let the storage side own the + * metadata, and create the admin account on server startup from + * 'auth.admin_pa', so there is nothing for init-store to do. The + * option defaults to true, keeping standalone/tarball installs on the + * full init path. + * + * The loop below already skips hstore backends, so what this gate + * additionally avoids is scanning the graphs directory (which must + * otherwise exist), and — when auth is configured — opening the auth + * graph store in initAdminUserIfNeeded(). On Kubernetes that ran on + * every Server pod restart, since the entrypoint's init flag file does + * not survive one. + */ + if (!restServerConfig.get(ServerOptions.INIT_STORE_ENABLED)) { + LOG.warn("Skipping init-store: '{}' is false in '{}'. Local " + + "backend and admin initialization are not performed; " + + "the admin account is created on server startup from " + + "'{}'.", + ServerOptions.INIT_STORE_ENABLED.name(), restConf, + ServerOptions.ADMIN_PA.name()); return; } @@ -115,22 +126,6 @@ public static void main(String[] args) throws Exception { } } - /** - * Whether init-store should no-op. - *
    - *
  • Property unset → run init (standalone / master-compatible)
  • - *
  • Explicit {@code false} → skip (Helm / distributed HStore)
  • - *
  • Explicit {@code true} → run init
  • - *
- */ - public static boolean shouldSkipLocalInit(HugeConfig conf) { - String key = ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG.name(); - if (!conf.containsKey(key)) { - return false; - } - return !conf.get(ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG); - } - private static HugeGraph initGraph(String configPath) throws Exception { LOG.info("Init graph with config file: {}", configPath); HugeConfig config = new HugeConfig(configPath); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 571a62897a..25d36c6c71 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -17,49 +17,115 @@ package org.apache.hugegraph.unit.cmd; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.stream.Stream; + import org.apache.commons.configuration2.PropertiesConfiguration; import org.apache.hugegraph.cmd.InitStore; import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.config.ServerOptions; import org.apache.hugegraph.dist.RegisterUtil; import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.util.ConfigUtil; +import org.junit.After; +import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; /** - * InitStore skips local init only when {@code graph.load_from_local_config} - * is explicitly false (Helm / HStore). Unset keeps master standalone init. + * {@code init_store.enabled} controls whether init-store performs local + * backend and admin initialization. It defaults to true, so standalone and + * tarball installs keep the full init path; distributed (PD/HStore) + * deployments set it to false. */ public class InitStoreConfigTest { + private static final String MISSING_GRAPHS_DIR = "no-such-graphs-dir"; + + private Path workDir; + @BeforeClass - public static void init() { + public static void registerOptions() { RegisterUtil.registerServer(); } + @Before + public void setup() throws IOException { + this.workDir = Files.createTempDirectory("init-store-config-test"); + } + + @After + public void teardown() throws IOException { + try (Stream paths = Files.walk(this.workDir)) { + for (Path path : paths.sorted(Comparator.reverseOrder()) + .toArray(Path[]::new)) { + Files.deleteIfExists(path); + } + } + } + @Test - public void testUnsetDoesNotSkipInit() { - PropertiesConfiguration conf = new PropertiesConfiguration(); - HugeConfig config = new HugeConfig(conf); - // ServerOptions default is false, but InitStore must not treat missing - // key as skip — same as master bare init-store for standalone users. - Assert.assertFalse(config.get(ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG)); - Assert.assertFalse(InitStore.shouldSkipLocalInit(config)); + public void testInitStoreEnabledByDefault() { + HugeConfig config = new HugeConfig(new PropertiesConfiguration()); + Assert.assertTrue(config.get(ServerOptions.INIT_STORE_ENABLED)); } @Test - public void testExplicitFalseSkipsInit() { - PropertiesConfiguration conf = new PropertiesConfiguration(); - conf.setProperty(ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG.name(), "false"); - HugeConfig config = new HugeConfig(conf); - Assert.assertTrue(InitStore.shouldSkipLocalInit(config)); + public void testExplicitTrueKeepsInitStoreEnabled() { + PropertiesConfiguration properties = new PropertiesConfiguration(); + properties.setProperty(ServerOptions.INIT_STORE_ENABLED.name(), "true"); + HugeConfig config = new HugeConfig(properties); + Assert.assertTrue(config.get(ServerOptions.INIT_STORE_ENABLED)); } @Test - public void testExplicitTrueDoesNotSkipInit() { - PropertiesConfiguration conf = new PropertiesConfiguration(); - conf.setProperty(ServerOptions.GRAPH_LOAD_FROM_LOCAL_CONFIG.name(), "true"); - HugeConfig config = new HugeConfig(conf); - Assert.assertFalse(InitStore.shouldSkipLocalInit(config)); + public void testExplicitFalseDisablesInitStore() { + PropertiesConfiguration properties = new PropertiesConfiguration(); + properties.setProperty(ServerOptions.INIT_STORE_ENABLED.name(), "false"); + HugeConfig config = new HugeConfig(properties); + Assert.assertFalse(config.get(ServerOptions.INIT_STORE_ENABLED)); + } + + /** + * The graphs directory referenced by the temporary config does not exist, + * so every code path that reaches graph scanning fails. That is what makes + * {@link #testDisabledInitStoreExitsBeforeGraphInit()} a real assertion + * rather than a tautology. + */ + @Test + public void testMissingGraphsDirFailsScan() { + String graphsDir = this.workDir.resolve(MISSING_GRAPHS_DIR).toString(); + Assert.assertThrows(IllegalArgumentException.class, () -> { + ConfigUtil.scanGraphsDir(graphsDir); + }); + } + + /* + * NOTE: only one test may call InitStore.main(), because it calls + * RegisterUtil.registerBackends() and a backend provider can be + * registered only once per JVM. + */ + @Test + public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { + String restConf = this.writeDisabledRestServerConf(); + // Completes instead of failing on the missing graphs directory, which + // proves the gate is applied before any graph or admin initialization + InitStore.main(new String[]{restConf}); + } + + private String writeDisabledRestServerConf() throws IOException { + Path restConf = this.workDir.resolve("rest-server.properties"); + String graphsDir = this.workDir.resolve(MISSING_GRAPHS_DIR).toString(); + Files.write(restConf, + Arrays.asList(ServerOptions.GRAPHS.name() + "=" + graphsDir, + ServerOptions.INIT_STORE_ENABLED.name() + + "=false"), + StandardCharsets.UTF_8); + return restConf.toString(); } } From e69820285c83d6aa52e9ddef849760cce3048a29 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Mon, 27 Jul 2026 02:35:03 +0530 Subject: [PATCH 03/19] style(hugegraph-dist): avoid em dashes in InitStore comment --- .../src/main/java/org/apache/hugegraph/cmd/InitStore.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 062e627f98..aefc4bf01b 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -84,7 +84,7 @@ public static void main(String[] args) throws Exception { * * The loop below already skips hstore backends, so what this gate * additionally avoids is scanning the graphs directory (which must - * otherwise exist), and — when auth is configured — opening the auth + * otherwise exist), and, when auth is configured, opening the auth * graph store in initAdminUserIfNeeded(). On Kubernetes that ran on * every Server pod restart, since the entrypoint's init flag file does * not survive one. From a62f6ffe74b14c48d0c793743d9909079c0d405d Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Mon, 27 Jul 2026 14:31:28 +0530 Subject: [PATCH 04/19] fix(hugegraph-dist): refuse auth with init-store skipped unless usePD is true With init_store.enabled=false the built-in admin account is not created by init-store, and the only other component that creates it is GraphManager.initAdminUserIfNeeded(), reached from loadMetaFromPD() and so gated on usePD. That option defaults to false and the shipped HStore compose files do not set it, so enabling auth in skip mode would start a server that enforces authentication with no account to authenticate against. The entrypoint now refuses that combination and exits with an explanation, and InitStore logs the same condition for non-Docker installs. Skipping without auth is unaffected. usePD=true is already the documented pairing for distributed deployments in the cluster-test rest-server.properties template. Also align the shell with the server's boolean parsing, which is case-insensitive, so HG_SERVER_INIT_STORE_ENABLED=FALSE no longer means skip to Java and run to the entrypoint; read the property through the separators a properties file allows; escape the password for properties serialization so a backslash survives; and register backends and plugins only on the enabled path, since plugin registration runs every plugin's register() and propagates its failures. Add entrypoint coverage for these plus a test that disabled mode leaves backend options unregistered. Note a pre-existing arthas config key mismatch found while checking the option table. --- docker/README.md | 14 ++- .../hugegraph/config/ServerOptions.java | 14 ++- .../docker/docker-entrypoint.sh | 70 +++++++++++++- .../docker/test/test-docker-entrypoint.sh | 91 +++++++++++++++++++ .../org/apache/hugegraph/cmd/InitStore.java | 43 ++++++--- .../unit/cmd/InitStoreConfigTest.java | 28 +++++- 6 files changed, 233 insertions(+), 27 deletions(-) diff --git a/docker/README.md b/docker/README.md index bed6076b68..89421cdc12 100644 --- a/docker/README.md +++ b/docker/README.md @@ -163,10 +163,16 @@ Configuration is injected via environment variables. The old `docker/configs/app | `PASSWORD` | No | — | Enables auth mode | Optional authentication password | | `HG_SERVER_INIT_STORE_ENABLED` | No | `true` | `init_store.enabled` in `rest-server.properties` | Set `false` in PD/HStore deployments so init-store skips local backend and admin initialization | -> When `HG_SERVER_INIT_STORE_ENABLED=false`, a `PASSWORD` is written to -> `auth.admin_pa` instead of being passed to `init-store.sh`, because the admin -> account is created on server startup rather than by init-store. Two -> consequences worth knowing: +> **Auth with `HG_SERVER_INIT_STORE_ENABLED=false` requires `usePD=true`.** +> With init-store skipped, the only component that creates the built-in admin +> account is the PD metadata path, which the server takes only when `usePD` is +> true. Enabling auth without it would start a server that enforces +> authentication while no account exists, so the entrypoint refuses that +> combination and exits with an error rather than starting. +> +> When the combination is valid, a `PASSWORD` is written to `auth.admin_pa` +> instead of being passed to `init-store.sh`, because the admin account is then +> created on server startup. Two consequences worth knowing: > > - The password is stored in `conf/rest-server.properties`, so anyone who can > read that config volume can read it. With init-store enabled the password diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java index 955d2ab8a2..513a45f3b7 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java @@ -374,8 +374,11 @@ public class ServerOptions extends OptionHolder { "Whether init-store initializes the local backend stores " + "and the built-in admin account. Set false in distributed " + "deployments (PD/HStore) where the storage side already " + - "owns metadata and the admin account is created on server " + - "startup from 'auth.admin_pa'.", + "owns the metadata. Note that setting it false also means " + + "the admin account is not created here, and the only " + + "other component that creates it requires 'usePD' to be " + + "true, so enabling auth without that would leave no " + + "account to authenticate against.", disallowEmpty(), true ); @@ -430,6 +433,13 @@ public class ServerOptions extends OptionHolder { nonNegativeInt(), 0); + // TODO: these keys are camelCase but conf/rest-server.properties ships them + // as arthas.telnet_port / arthas.http_port / arthas.disabled_commands, so + // nothing matches and operator values are silently replaced by the defaults + // below. Only arthas.ip works. Unnoticed so far because each shipped value + // equals its default, but e.g. arthas.disabled_commands=jad,exec is dropped + // and exec stays enabled. Rename these to snake_case (pre-existing, tracked + // separately from this change). public static final ConfigOption ARTHAS_TELNET_PORT = new ConfigOption<>( "arthas.telnetPort", diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 726bab2b5c..abd22eac01 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -40,19 +40,42 @@ set_prop() { fi } +# Escapes a value for Java-properties serialization. Backslashes must be +# doubled or the parser consumes them, so a password like `abc\def` would +# otherwise be read back as `abcdef`; a leading space would be stripped. +props_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/^ /\\ /' +} + # Echoes the value of a property, or nothing when the key or the file is -# absent, so callers apply their own default. On duplicate keys the last one -# wins, matching how java.util.Properties reads the same file. +# absent, so callers apply their own default. Accepts the `=`, `:` and +# whitespace separators that properties files allow. On duplicate keys the last +# one wins, matching how the properties parser reads the same file. get_prop() { local key="$1" file="$2" local esc_key [[ -f "${file}" ]] || return 0 esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') - sed -rn "s|^[[:space:]]*${esc_key}[[:space:]]*=[[:space:]]*(.*)$|\\1|p" \ + # '#' delimits the s command because the pattern itself contains '|' + sed -rn "s#^[[:space:]]*${esc_key}([[:space:]]*[=:]|[[:space:]]+)[[:space:]]*(.*)\$#\\2#p" \ "${file}" | tail -n 1 | tr -d '[:space:]' } +# Canonicalizes a boolean the way the server does. HugeConfig parses these +# options through commons-configuration2 PropertyConverter.toBoolean, i.e. +# BooleanUtils, which is case-insensitive and accepts y/t/on/yes/true and +# n/f/no/off/false. The shell must agree with it, or the two layers can +# disagree about whether to skip: `FALSE` once meant "skip" to Java and "run" +# to this script. Unrecognized values fail here, as they do in the server. +to_bool() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + y|t|on|yes|true) echo "true" ;; + n|f|no|off|false) echo "false" ;; + *) return 1 ;; + esac +} + migrate_env() { local old_name="$1" new_name="$2" @@ -68,7 +91,15 @@ migrate_env "PD_PEERS" "HG_SERVER_PD_PEERS" # ── Map env → properties file ───────────────────────────────────────── [[ -n "${HG_SERVER_BACKEND:-}" ]] && set_prop "backend" "${HG_SERVER_BACKEND}" "${GRAPH_CONF}" [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && set_prop "pd.peers" "${HG_SERVER_PD_PEERS}" "${GRAPH_CONF}" -[[ -n "${HG_SERVER_INIT_STORE_ENABLED:-}" ]] && set_prop "init_store.enabled" "${HG_SERVER_INIT_STORE_ENABLED}" "${REST_SERVER_CONF}" +if [[ -n "${HG_SERVER_INIT_STORE_ENABLED:-}" ]]; then + # Canonicalize before writing, so the property file only ever holds `true` + # or `false` and cannot be read differently by the shell and the server + if ! HG_SERVER_INIT_STORE_ENABLED=$(to_bool "${HG_SERVER_INIT_STORE_ENABLED}"); then + log "ERROR: HG_SERVER_INIT_STORE_ENABLED must be a boolean, got '${HG_SERVER_INIT_STORE_ENABLED}'" + exit 1 + fi + set_prop "init_store.enabled" "${HG_SERVER_INIT_STORE_ENABLED}" "${REST_SERVER_CONF}" +fi # ── Build wait-storage env ───────────────────────────────────────────── WAIT_ENV=() @@ -95,8 +126,37 @@ wait_storage() { # the same as `HG_SERVER_INIT_STORE_ENABLED` (the env mapping above has already # been applied, so env still wins). INIT_STORE_ENABLED=$(get_prop "init_store.enabled" "${REST_SERVER_CONF}") +if [[ -n "${INIT_STORE_ENABLED}" ]]; then + if ! INIT_STORE_ENABLED=$(to_bool "${INIT_STORE_ENABLED}"); then + log "ERROR: init_store.enabled in ${REST_SERVER_CONF} must be a boolean," \ + "got '${INIT_STORE_ENABLED}'" + exit 1 + fi +fi if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then log "init-store disabled, skipping local backend/admin init" + + # With init-store skipped, nothing creates the built-in admin account + # unless the server takes the PD metadata path, which it only does when + # `usePD=true`. Enabling auth without that combination starts a server + # that enforces authentication while no account exists, so refuse it here + # rather than fail every request later. + AUTH_REQUESTED="" + [[ -n "${PASSWORD:-}" ]] && AUTH_REQUESTED=1 + [[ -n "$(get_prop "auth.authenticator" "${REST_SERVER_CONF}")" ]] && AUTH_REQUESTED=1 + if [[ -n "${AUTH_REQUESTED}" ]]; then + USE_PD=$(to_bool "$(get_prop "usePD" "${REST_SERVER_CONF}")" 2>/dev/null || echo "false") + if [[ "${USE_PD}" != "true" ]]; then + log "ERROR: auth is enabled and init_store.enabled=false, but usePD is not true." + log "ERROR: With init-store skipped the admin account is only created on the PD" + log "ERROR: metadata path, so this combination would start a server that nobody" + log "ERROR: can authenticate against." + log "ERROR: Set usePD=true in ${REST_SERVER_CONF}, or leave init-store enabled" + log "ERROR: so that it can create the admin account locally." + exit 1 + fi + fi + # Still wait: the server needs the storage side reachable at startup even # though nothing is initialized here wait_storage @@ -108,7 +168,7 @@ if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then # created, so changing PASSWORD on a later restart silently keeps the # old one. It also leaves the password at rest in rest-server.properties, # unlike the enabled path where it only travels over stdin. - set_prop "auth.admin_pa" "${PASSWORD}" "${REST_SERVER_CONF}" + set_prop "auth.admin_pa" "$(props_escape "${PASSWORD}")" "${REST_SERVER_CONF}" fi # No init flag is written here: nothing was initialized, so a later run # with init-store enabled must still perform the real initialization. diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index 64bf0f329d..5351d76985 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -148,6 +148,12 @@ EOF : > "${INSTALL}/calls.log" } +# Auth plus skipped init-store is only supported alongside the PD metadata +# path, which is what actually creates the admin account in that mode +enable_pd() { + echo "usePD=true" >> "${INSTALL}/conf/rest-server.properties" +} + # Run the entrypoint inside the throwaway tree. No ./bin/pid is ever written by # the stubs, so the entrypoint's tail-on-pid block is skipped and it returns. run_entrypoint() { @@ -191,6 +197,7 @@ cleanup echo "==> skip + PASSWORD: password reaches auth.admin_pa, not init-store stdin" new_install +enable_pd run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret assert_ran "enable-auth.sh" assert_not_ran "init-store.sh" @@ -201,6 +208,7 @@ cleanup echo "==> skip via mounted property only: env var absent behaves the same" new_install +enable_pd echo "init_store.enabled=false" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret assert_not_ran "init-store.sh" @@ -242,6 +250,89 @@ assert_not_ran "init-store.sh" assert_file "docker/init_complete" cleanup +echo "==> uppercase FALSE is honoured, matching the server's boolean parsing" +new_install +enable_pd +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=FALSE PASSWORD=s3cret +assert_not_ran "init-store.sh" +assert_not_ran "init-store.sh:stdin=s3cret" +assert_prop "init_store.enabled" "false" +assert_prop "auth.admin_pa" "s3cret" +assert_no_file "docker/init_complete" +cleanup + +echo "==> 'off' and 'no' are honoured too" +for value in off no; do + new_install + run_entrypoint -u PASSWORD "HG_SERVER_INIT_STORE_ENABLED=${value}" + assert_not_ran "init-store.sh" + assert_no_file "docker/init_complete" + cleanup +done + +echo "==> a non-boolean value fails fast instead of diverging" +new_install +if ( cd "${INSTALL}" && env -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=maybe \ + bash "${ENTRYPOINT}" ) >/dev/null 2>&1; then + fail "expected a non-boolean HG_SERVER_INIT_STORE_ENABLED to fail" +else + ok +fi +assert_not_ran "start-hugegraph.sh" +cleanup + +echo "==> mounted property with a ':' separator is honoured" +new_install +echo "init_store.enabled:false" >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD +assert_not_ran "init-store.sh" +assert_no_file "docker/init_complete" +cleanup + +echo "==> password with backslashes survives the properties round trip" +new_install +enable_pd +run_entrypoint 'PASSWORD=abc\def' HG_SERVER_INIT_STORE_ENABLED=false +assert_not_ran "init-store.sh" +# Written escaped, so the properties parser reads back the original `abc\def` +assert_prop "auth.admin_pa" 'abc\\def' +cleanup + +echo "==> skip + PASSWORD without usePD is refused, not silently started" +new_install +if ( cd "${INSTALL}" && env -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret \ + HG_SERVER_INIT_STORE_ENABLED=false bash "${ENTRYPOINT}" ) >/dev/null 2>&1; then + fail "expected auth + skip without usePD to be refused" +else + ok +fi +# Refused before starting the server, and without leaving auth half-enabled +assert_not_ran "start-hugegraph.sh" +assert_not_ran "init-store.sh" +assert_no_file "docker/init_complete" +cleanup + +echo "==> skip with auth already in a mounted config, no usePD, is refused too" +new_install +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +if ( cd "${INSTALL}" && env -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ + bash "${ENTRYPOINT}" ) >/dev/null 2>&1; then + fail "expected mounted auth + skip without usePD to be refused" +else + ok +fi +assert_not_ran "start-hugegraph.sh" +cleanup + +echo "==> skip without auth is unaffected by the usePD requirement" +new_install +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_ran "start-hugegraph.sh" +assert_not_ran "init-store.sh" +assert_no_file "docker/init_complete" +cleanup + echo echo "passed: ${PASS}, failed: ${FAIL}, skipped cases: ${SKIP}" [[ ${FAIL} -eq 0 ]] diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index aefc4bf01b..9ce612ee62 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -69,18 +69,22 @@ public static void main(String[] args) throws Exception { String restConf = args[0]; - RegisterUtil.registerBackends(); - RegisterUtil.registerPlugins(); + /* + * Only the server options are needed to read the gate below. Backend + * and plugin registration is deferred to the enabled path: + * registerPlugins() invokes every discovered plugin's register() and + * propagates their failures, which must not happen on a path that is + * documented to be a no-op. + */ RegisterUtil.registerServer(); HugeConfig restServerConfig = new HugeConfig(restConf); /* * Distributed deployments (PD/HStore) let the storage side own the - * metadata, and create the admin account on server startup from - * 'auth.admin_pa', so there is nothing for init-store to do. The - * option defaults to true, keeping standalone/tarball installs on the - * full init path. + * metadata, so there is nothing for init-store to do. The option + * defaults to true, keeping standalone/tarball installs on the full + * init path. * * The loop below already skips hstore backends, so what this gate * additionally avoids is scanning the graphs directory (which must @@ -88,17 +92,34 @@ public static void main(String[] args) throws Exception { * graph store in initAdminUserIfNeeded(). On Kubernetes that ran on * every Server pod restart, since the entrypoint's init flag file does * not survive one. + * + * NOTE: skipping also means the built-in admin account is not created + * here. The only other code path that creates it is + * GraphManager.initAdminUserIfNeeded(), reached from loadMetaFromPD(), + * which runs only when 'usePD' is true. Enabling auth with this option + * false and 'usePD' false therefore yields a server that enforces + * authentication with no account to authenticate against. */ if (!restServerConfig.get(ServerOptions.INIT_STORE_ENABLED)) { LOG.warn("Skipping init-store: '{}' is false in '{}'. Local " + - "backend and admin initialization are not performed; " + - "the admin account is created on server startup from " + - "'{}'.", - ServerOptions.INIT_STORE_ENABLED.name(), restConf, - ServerOptions.ADMIN_PA.name()); + "backend and admin initialization are not performed.", + ServerOptions.INIT_STORE_ENABLED.name(), restConf); + if (!restServerConfig.get(ServerOptions.AUTHENTICATOR).isEmpty() && + !restServerConfig.get(ServerOptions.USE_PD)) { + LOG.warn("'{}' is set but '{}' is false: no component will " + + "create the built-in admin account. Set '{}' to true, " + + "or leave '{}' enabled so it can create the account.", + ServerOptions.AUTHENTICATOR.name(), + ServerOptions.USE_PD.name(), + ServerOptions.USE_PD.name(), + ServerOptions.INIT_STORE_ENABLED.name()); + } return; } + RegisterUtil.registerBackends(); + RegisterUtil.registerPlugins(); + PDAuthConfig.setAuthority( ServiceConstant.SERVICE_NAME, ServiceConstant.AUTHORITY); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 25d36c6c71..14b1b84a41 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -28,6 +28,7 @@ import org.apache.commons.configuration2.PropertiesConfiguration; import org.apache.hugegraph.cmd.InitStore; import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.config.OptionSpace; import org.apache.hugegraph.config.ServerOptions; import org.apache.hugegraph.dist.RegisterUtil; import org.apache.hugegraph.testutil.Assert; @@ -46,6 +47,8 @@ public class InitStoreConfigTest { private static final String MISSING_GRAPHS_DIR = "no-such-graphs-dir"; + // Registered by RegisterUtil.registerBackends(), not by registerServer() + private static final String ROCKSDB_OPTION = "rocksdb.data_path"; private Path workDir; @@ -105,11 +108,6 @@ public void testMissingGraphsDirFailsScan() { }); } - /* - * NOTE: only one test may call InitStore.main(), because it calls - * RegisterUtil.registerBackends() and a backend provider can be - * registered only once per JVM. - */ @Test public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { String restConf = this.writeDisabledRestServerConf(); @@ -118,6 +116,26 @@ public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { InitStore.main(new String[]{restConf}); } + /** + * Disabled mode must not reach RegisterUtil.registerBackends() or + * registerPlugins(): plugin registration runs every discovered plugin's + * register() and propagates its failures, which a documented no-op path + * must not do. Backend options land in OptionSpace only via + * registerBackends(), so their absence shows it was never called. + */ + @Test + public void testDisabledInitStoreSkipsBackendAndPluginRegistration() + throws Exception { + Assert.assertFalse(OptionSpace.containKey(ROCKSDB_OPTION)); + + InitStore.main(new String[]{this.writeDisabledRestServerConf()}); + + Assert.assertFalse(OptionSpace.containKey(ROCKSDB_OPTION)); + // Server options are still registered, since the gate is read from them + Assert.assertTrue(OptionSpace.containKey( + ServerOptions.INIT_STORE_ENABLED.name())); + } + private String writeDisabledRestServerConf() throws IOException { Path restConf = this.workDir.resolve("rest-server.properties"); String graphsDir = this.workDir.resolve(MISSING_GRAPHS_DIR).toString(); From 3cb4fda2a247184cda1b530d5be384a3e5eefd91 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Mon, 27 Jul 2026 20:47:25 +0530 Subject: [PATCH 05/19] fix(hugegraph-dist): make set_prop match the separators get_prop accepts get_prop was widened to read the `=`, `:` and whitespace separators a properties file allows, but set_prop still matched only `key=value`. A mounted `init_store.enabled:false` overridden by HG_SERVER_INIT_STORE_ENABLED therefore gained a second definition instead of being replaced, and a key defined twice collects both values into a list that fails the scalar type check while the config is still loading. Both init-store and server startup would fail. The same applied to a colon-form auth.admin_pa overridden by PASSWORD. set_prop now matches the same separators and collapses every existing definition into one canonical `key=value` line, leaving comments alone. It is written in awk, so the key and value are matched and emitted literally and no regex escaping is involved. Cover colon-form and whitespace-form overrides asserting a single remaining definition, a colon-form auth.admin_pa override, and that commented defaults are not treated as definitions. Add a test loading a duplicated key through HugeConfig to pin why the collapse is required. --- .../docker/docker-entrypoint.sh | 32 +++++++++---- .../docker/test/test-docker-entrypoint.sh | 48 +++++++++++++++++++ .../unit/cmd/InitStoreConfigTest.java | 20 ++++++++ 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index abd22eac01..ac7e80ae33 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -26,18 +26,32 @@ mkdir -p "${DOCKER_FOLDER}" log() { echo "[hugegraph-server-entrypoint] $*"; } +# Sets a property to exactly one canonical `key=value` line. Existing +# definitions are matched on any separator a properties file allows (`=`, `:` +# or whitespace) and collapsed into that single line, because leaving a second +# definition behind would make the parser expose the key as a list and a scalar +# read of it would then fail. Comment lines are left alone. Matching is literal, +# so no regex escaping of the key or value is needed. set_prop() { local key="$1" val="$2" file="$3" - local esc_key esc_val - esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') - esc_val=$(printf '%s' "$val" | sed -e 's/[&|\\]/\\&/g') - - if grep -qE "^[[:space:]]*${esc_key}[[:space:]]*=" "${file}"; then - sed -ri "s|^([[:space:]]*${esc_key}[[:space:]]*=).*|\\1${esc_val}|" "${file}" - else - printf '%s=%s\n' "$key" "$val" >> "${file}" - fi + SET_PROP_KEY="$key" SET_PROP_VAL="$val" awk ' + BEGIN { key = ENVIRON["SET_PROP_KEY"]; val = ENVIRON["SET_PROP_VAL"] } + { + line = $0 + probe = line + sub(/^[[:space:]]+/, "", probe) + if (index(probe, key) == 1) { + rest = substr(probe, length(key) + 1) + if (rest ~ /^[[:space:]]*[=:]/ || rest ~ /^[[:space:]]+/) { + if (!done) { print key "=" val; done = 1 } + next + } + } + print line + } + END { if (!done) print key "=" val } + ' "${file}" > "${file}.tmp" && mv "${file}.tmp" "${file}" } # Escapes a value for Java-properties serialization. Backslashes must be diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index 5351d76985..bcf9eebfe3 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -92,6 +92,15 @@ assert_prop() { fi } +# A scalar option must end up defined exactly once, on any separator, or the +# properties parser exposes it as a list and a scalar read of it fails +assert_prop_defined_once() { + local n + n=$(grep -cE "^[[:space:]]*$1([[:space:]]*[=:]|[[:space:]])" \ + "${INSTALL}/conf/rest-server.properties" 2>/dev/null || true) + if [[ "${n}" == "1" ]]; then ok; else fail "expected '$1' defined once, found ${n}"; fi +} + assert_no_prop_key() { if grep -qE "^[[:space:]]*$1[[:space:]]*=" \ "${INSTALL}/conf/rest-server.properties" 2>/dev/null; then @@ -333,6 +342,45 @@ assert_not_ran "init-store.sh" assert_no_file "docker/init_complete" cleanup +echo "==> env override of a colon-form property leaves one canonical key" +new_install +echo "init_store.enabled:false" >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true +assert_prop_defined_once "init_store.enabled" +assert_prop "init_store.enabled" "true" +assert_ran "init-store.sh" +cleanup + +echo "==> env override of a whitespace-form property leaves one canonical key" +new_install +echo "init_store.enabled false" >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true +assert_prop_defined_once "init_store.enabled" +assert_prop "init_store.enabled" "true" +assert_ran "init-store.sh" +cleanup + +echo "==> PASSWORD override of a colon-form auth.admin_pa leaves one key" +new_install +enable_pd +echo "auth.admin_pa:old" >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_prop_defined_once "auth.admin_pa" +assert_prop "auth.admin_pa" "s3cret" +cleanup + +echo "==> commented-out defaults are not treated as definitions" +new_install +run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret +# The shipped file ships '#auth.admin_pa=pa' commented; it must stay commented +# and must not count as an existing definition +if grep -qxF "#auth.admin_pa=pa" "${INSTALL}/conf/rest-server.properties"; then + ok +else + fail "expected the commented '#auth.admin_pa=pa' line to be preserved" +fi +cleanup + echo echo "passed: ${PASS}, failed: ${FAIL}, skipped cases: ${SKIP}" [[ ${FAIL} -eq 0 ]] diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 14b1b84a41..512e39de73 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -94,6 +94,26 @@ public void testExplicitFalseDisablesInitStore() { Assert.assertFalse(config.get(ServerOptions.INIT_STORE_ENABLED)); } + /** + * A key defined twice collects both values into a list, which fails the + * scalar type check while the config is still being loaded. Both + * init-store and server startup would therefore fail outright. That is why + * docker-entrypoint.sh collapses any existing definition into one + * canonical line instead of appending a second one when it maps an + * environment variable onto the property. + */ + @Test + public void testDuplicateDefinitionFailsToLoad() throws IOException { + Path conf = this.workDir.resolve("duplicate.properties"); + String key = ServerOptions.INIT_STORE_ENABLED.name(); + Files.write(conf, Arrays.asList(key + "=false", key + "=true"), + StandardCharsets.UTF_8); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + new HugeConfig(conf.toString()); + }, e -> Assert.assertContains("[false, true]", e.getMessage())); + } + /** * The graphs directory referenced by the temporary config does not exist, * so every code path that reaches graph scanning fails. That is what makes From 99d7de87154c44f557f2ac4695407c2a5f2ac426 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Tue, 28 Jul 2026 06:39:03 +0530 Subject: [PATCH 06/19] fix(hugegraph-dist): fail non-zero on unusable skip config, rewrite conf in place Four fixes to the skip path. InitStore returned zero for a configuration it had just reported as unusable: init_store.enabled=false with an authenticator set and usePD false means no component creates the built-in admin. Tarball and init-job callers see only the exit status, so they continued into an auth-enabled server nobody could reach. It now fails. Remote auth is exempt, since the auth manager is then an RPC client and StandardAuthenticator only bootstraps a local one; the entrypoint guard gains the same exemption, which it was missing. set_prop replaced the config by rename. A single-file bind mount cannot be replaced that way, so startup aborted under set -e, and the rename also discarded the original mode, which matters where auth.admin_pa is written. It now truncates and rewrites in place, and creates its scratch file under umask 077. On the skip path enable-auth.sh ran unconditionally, and it appends its keys without checking, so a mounted config that already enabled auth ended up with auth.authenticator and auth.graph_store defined twice, which the config parser rejects. It now runs only when auth is not configured yet, and both keys are collapsed to single definitions afterwards. The test guard that skipped two cases without GNU sed was stale once set_prop moved to awk. Removed, so those cases run everywhere. Cover the exit status and the remote-auth exemption in the CLI test, and add entrypoint cases for a mounted auth config, the remote-auth exemption, inode and mode preservation, and scratch file cleanup. --- docker/README.md | 3 +- .../docker/docker-entrypoint.sh | 38 ++++++- .../docker/test/test-docker-entrypoint.sh | 104 +++++++++++------- .../org/apache/hugegraph/cmd/InitStore.java | 41 +++++-- .../unit/cmd/InitStoreConfigTest.java | 52 +++++++-- 5 files changed, 178 insertions(+), 60 deletions(-) diff --git a/docker/README.md b/docker/README.md index 89421cdc12..24e5d89cc7 100644 --- a/docker/README.md +++ b/docker/README.md @@ -163,7 +163,8 @@ Configuration is injected via environment variables. The old `docker/configs/app | `PASSWORD` | No | — | Enables auth mode | Optional authentication password | | `HG_SERVER_INIT_STORE_ENABLED` | No | `true` | `init_store.enabled` in `rest-server.properties` | Set `false` in PD/HStore deployments so init-store skips local backend and admin initialization | -> **Auth with `HG_SERVER_INIT_STORE_ENABLED=false` requires `usePD=true`.** +> **Auth with `HG_SERVER_INIT_STORE_ENABLED=false` requires `usePD=true`, +> unless `auth.remote_url` delegates auth elsewhere.** > With init-store skipped, the only component that creates the built-in admin > account is the PD metadata path, which the server takes only when `usePD` is > true. Enabling auth without it would start a server that enforces diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index ac7e80ae33..00003c8380 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -33,7 +33,11 @@ log() { echo "[hugegraph-server-entrypoint] $*"; } # read of it would then fail. Comment lines are left alone. Matching is literal, # so no regex escaping of the key or value is needed. set_prop() { - local key="$1" val="$2" file="$3" + local key="$1" val="$2" file="$3" tmp + tmp="${file}.tmp.$$" + + # The scratch file holds auth.admin_pa, so keep it off the process umask + ( umask 077; : > "${tmp}" ) SET_PROP_KEY="$key" SET_PROP_VAL="$val" awk ' BEGIN { key = ENVIRON["SET_PROP_KEY"]; val = ENVIRON["SET_PROP_VAL"] } @@ -51,7 +55,23 @@ set_prop() { print line } END { if (!done) print key "=" val } - ' "${file}" > "${file}.tmp" && mv "${file}.tmp" "${file}" + ' "${file}" > "${tmp}" + + # Truncate and rewrite in place rather than rename: a single-file bind + # mount cannot be replaced by rename, and a rename would also discard the + # original ownership and mode, which matters where auth.admin_pa is written + cat "${tmp}" > "${file}" + rm -f "${tmp}" +} + +# Rewrites a key that is already present as a single canonical line, dropping +# any duplicate definitions. No-op when the key is absent. +canonicalize_prop() { + local key="$1" file="$2" cur + cur=$(get_prop "${key}" "${file}") + if [[ -n "${cur}" ]]; then + set_prop "${key}" "${cur}" "${file}" + fi } # Escapes a value for Java-properties serialization. Backslashes must be @@ -158,6 +178,9 @@ if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then AUTH_REQUESTED="" [[ -n "${PASSWORD:-}" ]] && AUTH_REQUESTED=1 [[ -n "$(get_prop "auth.authenticator" "${REST_SERVER_CONF}")" ]] && AUTH_REQUESTED=1 + # Remote auth delegates to another service and has no local admin to + # create, so it is exempt from the requirement below + [[ -n "$(get_prop "auth.remote_url" "${REST_SERVER_CONF}")" ]] && AUTH_REQUESTED="" if [[ -n "${AUTH_REQUESTED}" ]]; then USE_PD=$(to_bool "$(get_prop "usePD" "${REST_SERVER_CONF}")" 2>/dev/null || echo "false") if [[ "${USE_PD}" != "true" ]]; then @@ -177,7 +200,16 @@ if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then if [[ -n "${PASSWORD:-}" ]]; then log "enabling auth mode, admin password applied via auth.admin_pa" - ./bin/enable-auth.sh + # enable-auth.sh appends its keys unconditionally on its first run, so + # running it against a mounted config that already enables auth would + # leave those scalar keys defined twice, which the config parser + # rejects. Only run it when auth is not configured yet, then collapse + # whatever it appended into single definitions. + if [[ -z "$(get_prop "auth.authenticator" "${REST_SERVER_CONF}")" ]]; then + ./bin/enable-auth.sh + fi + canonicalize_prop "auth.authenticator" "${REST_SERVER_CONF}" + canonicalize_prop "auth.graph_store" "${REST_SERVER_CONF}" # TODO: auth.admin_pa only applies when the admin account is first # created, so changing PASSWORD on a later restart silently keeps the # old one. It also leaves the password at rest in rest-server.properties, diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index bcf9eebfe3..378b9cf516 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -30,25 +30,6 @@ ENTRYPOINT="${SELF_DIR}/../docker-entrypoint.sh" PASS=0 FAIL=0 -SKIP=0 - -# docker-entrypoint.sh rewrites an existing property with GNU `sed -ri`, which -# BSD sed rejects. The image is Linux, so the cases that exercise that branch -# are skipped rather than failed when running locally on macOS. -if sed --version >/dev/null 2>&1; then - GNU_SED=1 -else - GNU_SED=0 -fi - -skip_without_gnu_sed() { - if [[ ${GNU_SED} -eq 1 ]]; then - return 1 - fi - echo " SKIP: needs GNU sed (set_prop rewrites an existing property)" - SKIP=$((SKIP + 1)) - return 0 -} fail() { echo " FAIL: $*" @@ -227,27 +208,23 @@ assert_no_file "docker/init_complete" cleanup echo "==> env wins over a conflicting mounted property" -if ! skip_without_gnu_sed; then - new_install - echo "init_store.enabled=false" >> "${INSTALL}/conf/rest-server.properties" - run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true - assert_ran "init-store.sh" - assert_file "docker/init_complete" - cleanup -fi +new_install +echo "init_store.enabled=false" >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true +assert_ran "init-store.sh" +assert_file "docker/init_complete" +cleanup echo "==> false then true: a restart with init enabled still initializes" -if ! skip_without_gnu_sed; then - new_install - run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false - assert_not_ran "init-store.sh" - assert_no_file "docker/init_complete" - : > "${INSTALL}/calls.log" - run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true - assert_ran "init-store.sh" - assert_file "docker/init_complete" - cleanup -fi +new_install +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_not_ran "init-store.sh" +assert_no_file "docker/init_complete" +: > "${INSTALL}/calls.log" +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true +assert_ran "init-store.sh" +assert_file "docker/init_complete" +cleanup echo "==> restart with init enabled: the flag file suppresses re-init" new_install @@ -381,6 +358,55 @@ else fi cleanup +echo "==> mounted config that already enables auth is not duplicated" +new_install +enable_pd +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +echo "auth.graph_store=hugegraph" >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_not_ran "enable-auth.sh" +assert_prop_defined_once "auth.authenticator" +assert_prop_defined_once "auth.graph_store" +assert_prop_defined_once "auth.admin_pa" +assert_prop "auth.admin_pa" "s3cret" +cleanup + +echo "==> remote auth is exempt from the usePD requirement" +new_install +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +echo "auth.remote_url=127.0.0.1:8899" >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_ran "start-hugegraph.sh" +assert_not_ran "init-store.sh" +cleanup + +echo "==> set_prop preserves the config file's inode and mode" +new_install +chmod 600 "${INSTALL}/conf/rest-server.properties" +before_inode=$(ls -i "${INSTALL}/conf/rest-server.properties" | awk '{print $1}') +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +after_inode=$(ls -i "${INSTALL}/conf/rest-server.properties" | awk '{print $1}') +after_mode=$(stat -c '%a' "${INSTALL}/conf/rest-server.properties" 2>/dev/null \ + || stat -f '%Lp' "${INSTALL}/conf/rest-server.properties") +# Rewriting in place matters: a single-file bind mount cannot be replaced by +# rename, and a rename would drop the mode protecting auth.admin_pa +if [[ "${before_inode}" == "${after_inode}" ]]; then ok; else fail "inode changed"; fi +if [[ "${after_mode}" == "600" ]]; then ok; else fail "mode became ${after_mode}, expected 600"; fi +assert_prop "init_store.enabled" "false" +cleanup + +echo "==> no scratch file is left behind" +new_install +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +if compgen -G "${INSTALL}/conf/rest-server.properties.tmp*" >/dev/null; then + fail "a set_prop scratch file was left behind" +else + ok +fi +cleanup + echo -echo "passed: ${PASS}, failed: ${FAIL}, skipped cases: ${SKIP}" +echo "passed: ${PASS}, failed: ${FAIL}" [[ ${FAIL} -eq 0 ]] diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 9ce612ee62..816bb3e377 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -104,16 +104,7 @@ public static void main(String[] args) throws Exception { LOG.warn("Skipping init-store: '{}' is false in '{}'. Local " + "backend and admin initialization are not performed.", ServerOptions.INIT_STORE_ENABLED.name(), restConf); - if (!restServerConfig.get(ServerOptions.AUTHENTICATOR).isEmpty() && - !restServerConfig.get(ServerOptions.USE_PD)) { - LOG.warn("'{}' is set but '{}' is false: no component will " + - "create the built-in admin account. Set '{}' to true, " + - "or leave '{}' enabled so it can create the account.", - ServerOptions.AUTHENTICATOR.name(), - ServerOptions.USE_PD.name(), - ServerOptions.USE_PD.name(), - ServerOptions.INIT_STORE_ENABLED.name()); - } + checkAdminBootstrapReachable(restServerConfig, restConf); return; } @@ -147,6 +138,36 @@ public static void main(String[] args) throws Exception { } } + /** + * Skipping means init-store does not create the built-in admin account, + * and the only other component that creates it is + * GraphManager.initAdminUserIfNeeded(), reached from loadMetaFromPD() and + * so gated on 'usePD'. Failing here rather than returning zero keeps + * tarball and init-job callers, which see only the exit status, from + * continuing into a server that enforces authentication with no account + * to authenticate against. + *

+ * Remote auth is exempt: the auth manager is then an RPC client, and + * StandardAuthenticator only bootstraps an admin for a local one. + */ + private static void checkAdminBootstrapReachable(HugeConfig conf, + String restConf) { + if (conf.get(ServerOptions.AUTHENTICATOR).isEmpty() || + conf.get(ServerOptions.USE_PD) || + !conf.get(ServerOptions.AUTH_REMOTE_URL).isEmpty()) { + return; + } + throw new IllegalStateException(String.format( + "Refusing to skip init-store: '%s' is set in '%s' but '%s' is " + + "false, so no component would create the built-in admin " + + "account. Set '%s' to true, set '%s' to delegate auth, or " + + "leave '%s' enabled so init-store creates the account.", + ServerOptions.AUTHENTICATOR.name(), restConf, + ServerOptions.USE_PD.name(), ServerOptions.USE_PD.name(), + ServerOptions.AUTH_REMOTE_URL.name(), + ServerOptions.INIT_STORE_ENABLED.name())); + } + private static HugeGraph initGraph(String configPath) throws Exception { LOG.info("Init graph with config file: {}", configPath); HugeConfig config = new HugeConfig(configPath); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 512e39de73..382a7e9b11 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -21,8 +21,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.List; import java.util.stream.Stream; import org.apache.commons.configuration2.PropertiesConfiguration; @@ -156,14 +158,50 @@ public void testDisabledInitStoreSkipsBackendAndPluginRegistration() ServerOptions.INIT_STORE_ENABLED.name())); } - private String writeDisabledRestServerConf() throws IOException { - Path restConf = this.workDir.resolve("rest-server.properties"); + /** + * The CLI must not report success for a configuration that would start an + * auth-enabled server with no admin account, since tarball and init-job + * callers only see the exit status. + */ + @Test + public void testDisabledInitStoreFailsWhenAdminCannotBeCreated() + throws IOException { + String restConf = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + + "=org.apache.hugegraph.auth.StandardAuthenticator"); + + Assert.assertThrows(IllegalStateException.class, () -> { + InitStore.main(new String[]{restConf}); + }, e -> Assert.assertContains("Refusing to skip init-store", + e.getMessage())); + } + + /** + * Remote auth delegates to another service, so there is no local admin to + * create and the check above must not fire. + */ + @Test + public void testDisabledInitStoreAllowsRemoteAuth() throws Exception { + String restConf = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + + "=org.apache.hugegraph.auth.StandardAuthenticator", + ServerOptions.AUTH_REMOTE_URL.name() + "=127.0.0.1:8899"); + + InitStore.main(new String[]{restConf}); + } + + private String writeDisabledRestServerConf(String... extraLines) + throws IOException { + // A distinct file per call, so the tests that write extra lines do not + // collide with each other inside one temporary directory + Path restConf = this.workDir.resolve( + "rest-server-" + extraLines.length + ".properties"); String graphsDir = this.workDir.resolve(MISSING_GRAPHS_DIR).toString(); - Files.write(restConf, - Arrays.asList(ServerOptions.GRAPHS.name() + "=" + graphsDir, - ServerOptions.INIT_STORE_ENABLED.name() + - "=false"), - StandardCharsets.UTF_8); + List lines = new ArrayList<>(); + lines.add(ServerOptions.GRAPHS.name() + "=" + graphsDir); + lines.add(ServerOptions.INIT_STORE_ENABLED.name() + "=false"); + lines.addAll(Arrays.asList(extraLines)); + Files.write(restConf, lines, StandardCharsets.UTF_8); return restConf.toString(); } } From 0dd5a7a1cbc4b0f3691f50e929448f3943ca6089 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Tue, 28 Jul 2026 13:50:22 +0530 Subject: [PATCH 07/19] fix(hugegraph-dist): keep auth enablement complete and limit the admin check enable-auth.sh also adds the authentication block to gremlin-server.yaml and switches hugegraph.properties to the auth proxy. Skipping it whenever auth.authenticator already existed left both undone, so a mounted config that set only that key could start with REST auth on and Gremlin open. Apply each of the three changes only where it is missing instead, and point the Gremlin block at whichever authenticator the REST config names. The admin-account requirement now applies only to the built-in authenticator and its subclasses. auth.authenticator takes any implementation class, and a custom LDAP/OIDC/plugin one manages identities elsewhere, so requiring usePD for it rejected a deployment that works. get_prop no longer deletes whitespace inside a value, only around it, as the properties parser does. --- .../docker/docker-entrypoint.sh | 90 +++++++++++--- .../docker/test/test-docker-entrypoint.sh | 110 +++++++++++++++++- .../org/apache/hugegraph/cmd/InitStore.java | 29 ++++- .../unit/cmd/InitStoreConfigTest.java | 49 +++++++- 4 files changed, 250 insertions(+), 28 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 00003c8380..15ce5ff40f 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -21,6 +21,13 @@ DOCKER_FOLDER="./docker" INIT_FLAG_FILE="init_complete" GRAPH_CONF="./conf/graphs/hugegraph.properties" REST_SERVER_CONF="./conf/rest-server.properties" +GREMLIN_SERVER_CONF="./conf/gremlin-server.yaml" + +# The only in-tree HugeAuthenticator that bootstraps HugeGraph's built-in admin +# account. auth.authenticator accepts any implementation class, and a custom one +# (LDAP, OIDC, a plugin) manages its identities elsewhere, so the admin-account +# requirement below must not be applied to it. +BUILTIN_AUTHENTICATOR="org.apache.hugegraph.auth.StandardAuthenticator" mkdir -p "${DOCKER_FOLDER}" @@ -84,7 +91,9 @@ props_escape() { # Echoes the value of a property, or nothing when the key or the file is # absent, so callers apply their own default. Accepts the `=`, `:` and # whitespace separators that properties files allow. On duplicate keys the last -# one wins, matching how the properties parser reads the same file. +# one wins, matching how the properties parser reads the same file. Only +# surrounding whitespace is trimmed, as the parser does; whitespace inside a +# value is part of the value and deleting it would corrupt one. get_prop() { local key="$1" file="$2" local esc_key @@ -93,7 +102,7 @@ get_prop() { esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') # '#' delimits the s command because the pattern itself contains '|' sed -rn "s#^[[:space:]]*${esc_key}([[:space:]]*[=:]|[[:space:]]+)[[:space:]]*(.*)\$#\\2#p" \ - "${file}" | tail -n 1 | tr -d '[:space:]' + "${file}" | tail -n 1 | sed -e 's/[[:space:]]*$//' } # Canonicalizes a boolean the way the server does. HugeConfig parses these @@ -110,6 +119,64 @@ to_bool() { esac } +gremlin_auth_configured() { + grep -qE '^[[:space:]]*authentication:' "${GREMLIN_SERVER_CONF}" 2>/dev/null +} + +graph_auth_proxy_configured() { + [[ "$(get_prop "gremlin.graph" "${GRAPH_CONF}")" == \ + "org.apache.hugegraph.auth.HugeFactoryAuthProxy" ]] +} + +# Enables auth across all three configs. bin/enable-auth.sh does the same, but +# it appends unconditionally and guards itself only with conf-bak, which a +# mounted conf directory does not carry. Running it over a config that already +# enables auth would define the REST keys twice, which the parser rejects, and +# append a second `authentication:` block to the YAML. Skipping it instead would +# leave Gremlin unauthenticated and the graph outside the auth proxy whenever a +# mounted config sets only auth.authenticator. So run it for the untouched +# shipped config, and otherwise apply exactly the parts that are missing. +ensure_auth_enabled() { + local authenticator + authenticator=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") + + if [[ -z "${authenticator}" ]] && ! gremlin_auth_configured && \ + ! graph_auth_proxy_configured; then + ./bin/enable-auth.sh + else + log "auth is already partly configured; applying only what is missing" + if [[ -z "${authenticator}" ]]; then + set_prop "auth.authenticator" "${BUILTIN_AUTHENTICATOR}" \ + "${REST_SERVER_CONF}" + fi + if [[ -z "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")" ]]; then + set_prop "auth.graph_store" "hugegraph" "${REST_SERVER_CONF}" + fi + if ! gremlin_auth_configured; then + # Kept in step with bin/enable-auth.sh, which owns this block, + # except that Gremlin is pointed at whichever authenticator the + # REST config names rather than always at the built-in one + cat >> "${GREMLIN_SERVER_CONF}" </dev/null; then fail "expected no '$1' property" else @@ -91,6 +93,27 @@ assert_no_prop_key() { fi } +# Auth is only fully enabled when all three configs agree: the REST properties, +# the gremlin-server.yaml authentication block and the graph's auth proxy +assert_auth_fully_enabled() { + local n + n=$(grep -cE '^[[:space:]]*authentication:' \ + "${INSTALL}/conf/gremlin-server.yaml" 2>/dev/null || true) + if [[ "${n}" == "1" ]]; then + ok + else + fail "expected one gremlin-server.yaml authentication block, found ${n}" + fi + if grep -qxF "gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy" \ + "${INSTALL}/conf/graphs/hugegraph.properties" 2>/dev/null; then + ok + else + fail "expected hugegraph.properties to use HugeFactoryAuthProxy" + fi + assert_prop_defined_once "auth.authenticator" + assert_prop_defined_once "auth.graph_store" +} + # Build a throwaway install tree with stubbed bin scripts new_install() { INSTALL=$(mktemp -d "${TMPDIR:-/tmp}/hg-entrypoint-test.XXXXXX") @@ -103,7 +126,12 @@ graphs=./conf/graphs #auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator #auth.admin_pa=pa EOF - echo "backend=rocksdb" > "${INSTALL}/conf/graphs/hugegraph.properties" + cat > "${INSTALL}/conf/graphs/hugegraph.properties" <<'EOF' +backend=rocksdb +gremlin.graph=org.apache.hugegraph.HugeFactory +EOF + # Shipped without an authentication block, which is what enable-auth.sh adds + echo "host: 0.0.0.0" > "${INSTALL}/conf/gremlin-server.yaml" local script for script in wait-storage start-hugegraph wait-partition; do @@ -127,11 +155,25 @@ exit 0 EOF chmod +x "${INSTALL}/bin/init-store.sh" + # Mirrors bin/enable-auth.sh: appends the REST keys and the YAML + # authentication block, and switches the graph to the auth proxy cat > "${INSTALL}/bin/enable-auth.sh" <> "${INSTALL}/calls.log" -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" +{ + echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" + echo "auth.graph_store=hugegraph" +} >> "${INSTALL}/conf/rest-server.properties" +cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' +authentication: { + authenticator: org.apache.hugegraph.auth.StandardAuthenticator, + authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, + config: {tokens: conf/rest-server.properties} +} +YAML +sed -i.bak 's/gremlin.graph=org.apache.hugegraph.HugeFactory/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/g' \ + "${INSTALL}/conf/graphs/hugegraph.properties" +rm -f "${INSTALL}/conf/graphs/hugegraph.properties.bak" EOF chmod +x "${INSTALL}/bin/enable-auth.sh" @@ -174,6 +216,7 @@ assert_ran "enable-auth.sh" assert_ran "init-store.sh" assert_ran "init-store.sh:stdin=s3cret" assert_file "docker/init_complete" +assert_auth_fully_enabled cleanup echo "==> skip via env: init-store never runs and no init flag is written" @@ -366,10 +409,65 @@ echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ echo "auth.graph_store=hugegraph" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret assert_not_ran "enable-auth.sh" -assert_prop_defined_once "auth.authenticator" -assert_prop_defined_once "auth.graph_store" assert_prop_defined_once "auth.admin_pa" assert_prop "auth.admin_pa" "s3cret" +# The mounted config carried only the REST keys, so the YAML block and the auth +# proxy still have to be applied, or Gremlin would stay unauthenticated +assert_auth_fully_enabled +cleanup + +echo "==> mounted config with only auth.authenticator still authenticates Gremlin" +new_install +enable_pd +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_not_ran "enable-auth.sh" +assert_auth_fully_enabled +assert_prop "auth.graph_store" "hugegraph" +cleanup + +echo "==> a pre-authenticated mount is completed, not duplicated" +new_install +enable_pd +# Everything already in place, as after a restart with the conf dir mounted +"${INSTALL}/bin/enable-auth.sh" +: > "${INSTALL}/calls.log" +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_not_ran "enable-auth.sh" +assert_auth_fully_enabled +cleanup + +echo "==> a custom authenticator is not held to the usePD requirement" +new_install +echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_ran "start-hugegraph.sh" +assert_not_ran "init-store.sh" +cleanup + +echo "==> surrounding whitespace is trimmed, inner whitespace is kept" +new_install +printf 'auth.authenticator = %s \n' \ + "org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +# Trailing spaces must not stop the value matching the built-in class, or the +# admin-account requirement below would be skipped for a config that needs it +if ( cd "${INSTALL}" && env -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ + bash "${ENTRYPOINT}" ) >/dev/null 2>&1; then + fail "expected a padded auth.authenticator to still be recognized" +else + ok +fi +cleanup + +echo "==> a value containing spaces survives the round trip" +new_install +enable_pd +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false 'PASSWORD=two words' +assert_prop "auth.admin_pa" "two words" +assert_prop_defined_once "auth.admin_pa" cleanup echo "==> remote auth is exempt from the usePD requirement" diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 816bb3e377..6cde4bde2c 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -148,11 +148,13 @@ public static void main(String[] args) throws Exception { * to authenticate against. *

* Remote auth is exempt: the auth manager is then an RPC client, and - * StandardAuthenticator only bootstraps an admin for a local one. + * StandardAuthenticator only bootstraps an admin for a local one. So is + * any authenticator other than the built-in one, which keeps its + * identities outside HugeGraph and needs no such account. */ private static void checkAdminBootstrapReachable(HugeConfig conf, String restConf) { - if (conf.get(ServerOptions.AUTHENTICATOR).isEmpty() || + if (!requiresBuiltinAdmin(conf.get(ServerOptions.AUTHENTICATOR)) || conf.get(ServerOptions.USE_PD) || !conf.get(ServerOptions.AUTH_REMOTE_URL).isEmpty()) { return; @@ -168,6 +170,29 @@ private static void checkAdminBootstrapReachable(HugeConfig conf, ServerOptions.INIT_STORE_ENABLED.name())); } + /** + * HugeAuthenticator.loadAuthenticator() accepts any implementation class, + * and only StandardAuthenticator bootstraps HugeGraph's built-in admin + * account. A custom one (LDAP, OIDC, a plugin) manages its identities + * elsewhere, so it must not be held to the requirement above. The class is + * resolved without initializing it, and one that is not on the init-store + * classpath is by definition not the built-in authenticator. + */ + private static boolean requiresBuiltinAdmin(String authClass) { + if (authClass.isEmpty()) { + return false; + } + try { + Class clazz = Class.forName(authClass, false, + InitStore.class.getClassLoader()); + return StandardAuthenticator.class.isAssignableFrom(clazz); + } catch (ClassNotFoundException | LinkageError e) { + LOG.info("Authenticator '{}' is not resolvable here, assuming it " + + "does not need the built-in admin account", authClass); + return false; + } + } + private static HugeGraph initGraph(String configPath) throws Exception { LOG.info("Init graph with config file: {}", configPath); HugeConfig config = new HugeConfig(configPath); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 382a7e9b11..4f1dd82955 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -28,6 +28,7 @@ import java.util.stream.Stream; import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.hugegraph.auth.StandardAuthenticator; import org.apache.hugegraph.cmd.InitStore; import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.config.OptionSpace; @@ -53,6 +54,7 @@ public class InitStoreConfigTest { private static final String ROCKSDB_OPTION = "rocksdb.data_path"; private Path workDir; + private int confSeq; @BeforeClass public static void registerOptions() { @@ -143,16 +145,19 @@ public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { * registerPlugins(): plugin registration runs every discovered plugin's * register() and propagates its failures, which a documented no-op path * must not do. Backend options land in OptionSpace only via - * registerBackends(), so their absence shows it was never called. + * registerBackends(), so their registration state shows whether it ran. + * OptionSpace is process-wide, so the assertion is that main() leaves that + * state unchanged rather than that it starts out empty. */ @Test public void testDisabledInitStoreSkipsBackendAndPluginRegistration() throws Exception { - Assert.assertFalse(OptionSpace.containKey(ROCKSDB_OPTION)); + boolean backendsRegistered = OptionSpace.containKey(ROCKSDB_OPTION); InitStore.main(new String[]{this.writeDisabledRestServerConf()}); - Assert.assertFalse(OptionSpace.containKey(ROCKSDB_OPTION)); + Assert.assertEquals(backendsRegistered, + OptionSpace.containKey(ROCKSDB_OPTION)); // Server options are still registered, since the gate is read from them Assert.assertTrue(OptionSpace.containKey( ServerOptions.INIT_STORE_ENABLED.name())); @@ -190,12 +195,37 @@ public void testDisabledInitStoreAllowsRemoteAuth() throws Exception { InitStore.main(new String[]{restConf}); } + /** + * auth.authenticator takes any implementation class, and only the built-in + * one bootstraps HugeGraph's admin account. A custom authenticator keeps + * its identities elsewhere, so the check must not reject it; a subclass of + * the built-in one still relies on the same bootstrap. + */ + @Test + public void testDisabledInitStoreAllowsCustomAuthenticator() + throws Exception { + String restConf = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + + "=org.example.auth.LdapAuthenticator"); + + InitStore.main(new String[]{restConf}); + + String subclassConf = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + "=" + + DerivedAuthenticator.class.getName()); + + Assert.assertThrows(IllegalStateException.class, () -> { + InitStore.main(new String[]{subclassConf}); + }, e -> Assert.assertContains("Refusing to skip init-store", + e.getMessage())); + } + private String writeDisabledRestServerConf(String... extraLines) throws IOException { - // A distinct file per call, so the tests that write extra lines do not - // collide with each other inside one temporary directory + // A distinct file per call, so two configs written by one test do not + // collide inside its temporary directory Path restConf = this.workDir.resolve( - "rest-server-" + extraLines.length + ".properties"); + "rest-server-" + this.confSeq++ + ".properties"); String graphsDir = this.workDir.resolve(MISSING_GRAPHS_DIR).toString(); List lines = new ArrayList<>(); lines.add(ServerOptions.GRAPHS.name() + "=" + graphsDir); @@ -204,4 +234,11 @@ private String writeDisabledRestServerConf(String... extraLines) Files.write(restConf, lines, StandardCharsets.UTF_8); return restConf.toString(); } + + /** + * Stands in for a deployment that subclasses the built-in authenticator, + * which still depends on the admin account it creates. + */ + public static class DerivedAuthenticator extends StandardAuthenticator { + } } From 090763a68f0eed244be37e04b82096bcf2376171 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Tue, 28 Jul 2026 14:04:14 +0530 Subject: [PATCH 08/19] fix(hugegraph-dist): self-review follow-ups on the init-store skip path - get_prop uses `sed -E` rather than `-r`, which only GNU sed documents - the appended gremlin-server.yaml block no longer glues itself onto a last line that has no trailing newline - the option description, the InitStore comment and docker/README no longer claim every authenticator needs the built-in admin account, which stopped being true when the check was narrowed - InitStoreConfigTest is imported in suite order in UnitTestSuite - a test asserts the entrypoint's inlined auth block still names everything bin/enable-auth.sh does, so the two cannot drift unnoticed --- PR_DESCRIPTION.md | 152 ++++++++++++++++++ docker/README.md | 8 +- .../hugegraph/config/ServerOptions.java | 4 +- .../docker/docker-entrypoint.sh | 13 +- .../docker/test/test-docker-entrypoint.sh | 35 ++++ .../org/apache/hugegraph/cmd/InitStore.java | 7 +- .../apache/hugegraph/unit/UnitTestSuite.java | 2 +- 7 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000000..fca13fde53 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,152 @@ + + +# Add `init_store.enabled` to skip init-store in distributed deployments + +## Problem + +`InitStore` always runs full local initialization: scan `./conf/graphs`, init +non-hstore backends, create the built-in admin account. That is correct for +**standalone / tarball** users and must remain the default. + +In **PD / HStore** deployments the storage side already owns metadata, so there +is nothing for init-store to do. + +### Why the Docker guard is not enough + +`docker-entrypoint.sh` guards init with a flag file, `docker/init_complete`. +That file lives in the container's writable layer at +`/hugegraph-server/docker/init_complete`. No compose file mounts a volume over +it, and the server services declare no volumes at all. + +That guard works for Docker and does not work for Kubernetes: + +| | Writable layer after a restart | Effect | +|---|---|---| +| `docker restart` | preserved | flag survives, init-store runs once ever | +| K8s container restart / pod reschedule | new container from the image | flag is gone, **full init-store runs on every restart** | + +So on Helm the flag file never accumulates: every Server pod restart re-runs +the complete local init against a PD/HStore cluster that already owns its +metadata. Working around that is what pushed the chart toward one-shot init +Jobs and `HG_SERVER_SKIP_INIT`-style switches. A file-based guard cannot fix +this, because the problem is precisely that the file does not persist. The +decision has to come from configuration instead. + +This is the whole motivation for the change: it is a distributed-deployment +concern, not a Docker one. + +## Solution + +A dedicated option, `init_store.enabled`, defaulting to `true`: + +| `init_store.enabled` in `rest-server.properties` | `init-store` | +|--------------------------------------------------|--------------| +| **Unset** (shipped defaults) | **Full init**, same as master, standalone safe | +| `true` | Full init | +| `false` | No-op, WARN, exit 0, the distributed opt-out | + +A dedicated option rather than reusing `graph.load_from_local_config`: that +property already means "whether `GraphManager` loads graph definitions from the +local directory", its declared default is `false`, and existing configs may +materialize that default explicitly. Giving it a second contract would make an +explicit `false` silently skip backend and admin init for standalone +RocksDB/HBase installs. + +Docker maps `HG_SERVER_INIT_STORE_ENABLED` onto the property in +`rest-server.properties`, the file `init-store.sh` passes to `InitStore`. + +### Auth bootstrap in skip mode + +When init-store is skipped, `StandardAuthenticator.initAdminUserIfNeeded()` +does not run, so a Docker `PASSWORD` piped into `init-store.sh` would be read +and discarded, so the server would then come up with the `auth.admin_pa` default +of `pa` instead of the requested credential. + +The entrypoint therefore writes `PASSWORD` to `auth.admin_pa` in skip mode +instead of piping it to `init-store.sh`, so the account the server creates on +startup uses the requested password. + +That startup path has a precondition. `GraphManager.initAdminUserIfNeeded()` is +reached only from `loadMetaFromPD()`, which runs only when `usePD` is true, and +`usePD` defaults to false. With init-store skipped and `usePD` false, no +component creates the built-in admin at all, so enabling auth would start a +server that enforces authentication against an empty account list. The +entrypoint refuses that combination and exits with an explanation, and +`InitStore` logs the same condition for non-Docker installs. + +`usePD=true` is the intended setting for distributed deployments: the +cluster-test `rest-server.properties.template` already pairs it with +`auth.authenticator` and `auth.admin_pa`. The shipped compose files not setting +it is a pre-existing gap, and is left to a follow-up because enabling it also +switches graph loading from local config to PD metadata. + +The entrypoint also does not write `docker/init_complete` in skip mode: nothing +was initialized, so a later run with init-store enabled must still be able to +perform the real initialization. + +## Files changed + +| File | Change | +|------|--------| +| `.../config/ServerOptions.java` | New `INIT_STORE_ENABLED` option, default `true` | +| `.../cmd/InitStore.java` | Early exit when `init_store.enabled=false` | +| `.../docker/docker-entrypoint.sh` | Env → property; `PASSWORD` → `auth.admin_pa` in skip mode; refuse auth + skip without `usePD`; no init flag when nothing was initialized | +| `.../unit/cmd/InitStoreConfigTest.java` | Option defaults + `main()`-level proof of the early exit | +| `.../unit/UnitTestSuite.java` | Suite entry | +| `.../docker/test/test-docker-entrypoint.sh` | Entrypoint lifecycle smoke tests | +| `.github/workflows/docker-build-ci.yml` | Run the entrypoint tests, and Docker CI on `hugegraph-dist/docker/**` | +| `docker/README.md` | Server env var reference | + +## Test plan + +```bash +mvn test -pl hugegraph-server/hugegraph-test -am -P unit-test -Dtest=InitStoreConfigTest +mvn test -pl hugegraph-server/hugegraph-test -am -P unit-test +``` + +`InitStoreConfigTest` points the temporary `rest-server.properties` at a +`graphs` directory that does not exist, then calls `InitStore.main()` with +`init_store.enabled=false`. A companion test asserts that the same directory +does make `ConfigUtil.scanGraphsDir` fail, so the skip test proves the gate is +applied before any graph or admin work rather than being a tautology. + +The entrypoint lifecycle is covered separately, with no JVM, backend or Docker +needed. The entrypoint runs against a throwaway install tree whose `./bin` +scripts are stubs that record their own invocation: + +```bash +hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +``` + +Cases: default init, default + `PASSWORD`, skip via env, **skip + `PASSWORD`** +(asserts the password reaches `auth.admin_pa` and never `init-store.sh` stdin), +skip via a mounted property with no env var, env overriding a conflicting +property, `false → true` restart, flag-file suppression of re-init, boolean +parsing parity with the server (`FALSE`, `off`, `no`, and fail-fast on +non-booleans), the `:` property separator, a backslash password round trip, and +refusal of auth plus skip without `usePD`. + +**Manual smoke (optional):** + +1. Shipped `conf/rest-server.properties` (no flag): `bin/init-store.sh` → full + init, same as master. +2. Set `init_store.enabled=false`, run again → WARN and exit 0 without backend + init. + +## Out of scope + +- `util.sh` / `check_port` / lsof +- Helm chart Job / `SKIP_INIT` removal (the chart sets the env, and `usePD=true` when it enables auth) +- Setting `usePD=true` in the shipped compose files, which also switches graph + loading from local config to PD metadata +- `GraphManager` behavior and the `graph.load_from_local_config` default + +## Compatibility + +**No breaking change for tarball / bare init-store.** The option defaults to +`true`, so unset config keeps the full init path. + +Distributed deployments set `init_store.enabled=false` (or +`HG_SERVER_INIT_STORE_ENABLED=false`). diff --git a/docker/README.md b/docker/README.md index 24e5d89cc7..e956f007c9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -163,13 +163,15 @@ Configuration is injected via environment variables. The old `docker/configs/app | `PASSWORD` | No | — | Enables auth mode | Optional authentication password | | `HG_SERVER_INIT_STORE_ENABLED` | No | `true` | `init_store.enabled` in `rest-server.properties` | Set `false` in PD/HStore deployments so init-store skips local backend and admin initialization | -> **Auth with `HG_SERVER_INIT_STORE_ENABLED=false` requires `usePD=true`, -> unless `auth.remote_url` delegates auth elsewhere.** +> **The built-in authenticator with `HG_SERVER_INIT_STORE_ENABLED=false` +> requires `usePD=true`, unless `auth.remote_url` delegates auth elsewhere.** > With init-store skipped, the only component that creates the built-in admin > account is the PD metadata path, which the server takes only when `usePD` is > true. Enabling auth without it would start a server that enforces > authentication while no account exists, so the entrypoint refuses that -> combination and exits with an error rather than starting. +> combination and exits with an error rather than starting. A custom +> `auth.authenticator` is exempt, since it keeps its identities outside +> HugeGraph and needs no such account. > > When the combination is valid, a `PASSWORD` is written to `auth.admin_pa` > instead of being passed to `init-store.sh`, because the admin account is then diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java index 513a45f3b7..d3549fd0ca 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java @@ -377,8 +377,8 @@ public class ServerOptions extends OptionHolder { "owns the metadata. Note that setting it false also means " + "the admin account is not created here, and the only " + "other component that creates it requires 'usePD' to be " + - "true, so enabling auth without that would leave no " + - "account to authenticate against.", + "true, so configuring the built-in authenticator without " + + "that would leave no account to authenticate against.", disallowEmpty(), true ); diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 15ce5ff40f..ff0f63b5d0 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -100,8 +100,9 @@ get_prop() { [[ -f "${file}" ]] || return 0 esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') - # '#' delimits the s command because the pattern itself contains '|' - sed -rn "s#^[[:space:]]*${esc_key}([[:space:]]*[=:]|[[:space:]]+)[[:space:]]*(.*)\$#\\2#p" \ + # '#' delimits the s command because the pattern itself contains '|'. + # '-E' rather than '-r': both GNU and BSD sed accept it + sed -En "s#^[[:space:]]*${esc_key}([[:space:]]*[=:]|[[:space:]]+)[[:space:]]*(.*)\$#\\2#p" \ "${file}" | tail -n 1 | sed -e 's/[[:space:]]*$//' } @@ -144,7 +145,7 @@ ensure_auth_enabled() { ! graph_auth_proxy_configured; then ./bin/enable-auth.sh else - log "auth is already partly configured; applying only what is missing" + log "auth is already configured in part; applying anything still missing" if [[ -z "${authenticator}" ]]; then set_prop "auth.authenticator" "${BUILTIN_AUTHENTICATOR}" \ "${REST_SERVER_CONF}" @@ -153,6 +154,12 @@ ensure_auth_enabled() { set_prop "auth.graph_store" "hugegraph" "${REST_SERVER_CONF}" fi if ! gremlin_auth_configured; then + # A file whose last line has no newline would otherwise absorb the + # first line of the block + if [[ -s "${GREMLIN_SERVER_CONF}" && \ + -n "$(tail -c 1 "${GREMLIN_SERVER_CONF}")" ]]; then + echo >> "${GREMLIN_SERVER_CONF}" + fi # Kept in step with bin/enable-auth.sh, which owns this block, # except that Gremlin is pointed at whichever authenticator the # REST config names rather than always at the built-in one diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index 2c8c90dea9..5ac717208a 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -427,6 +427,22 @@ assert_auth_fully_enabled assert_prop "auth.graph_store" "hugegraph" cleanup +echo "==> a gremlin-server.yaml without a trailing newline is still valid" +new_install +enable_pd +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +printf 'host: 0.0.0.0' > "${INSTALL}/conf/gremlin-server.yaml" +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_auth_fully_enabled +# The appended block must start on its own line, not glued onto 'host: 0.0.0.0' +if grep -qxF "host: 0.0.0.0" "${INSTALL}/conf/gremlin-server.yaml"; then + ok +else + fail "the last pre-existing line was absorbed by the appended block" +fi +cleanup + echo "==> a pre-authenticated mount is completed, not duplicated" new_install enable_pd @@ -505,6 +521,25 @@ else fi cleanup +echo "==> the entrypoint's auth block has not drifted from enable-auth.sh" +# The entrypoint reapplies what bin/enable-auth.sh would have done when it +# cannot run the script itself, so the two must name the same classes and keys +ENABLE_AUTH="${SELF_DIR}/../../src/assembly/static/bin/enable-auth.sh" +if [[ -f "${ENABLE_AUTH}" ]]; then + for token in "authentication: {" "WsAndHttpBasicAuthHandler" \ + "tokens: conf/rest-server.properties" "auth.graph_store" \ + "HugeFactoryAuthProxy"; do + if grep -qF "${token}" "${ENABLE_AUTH}" && \ + ! grep -qF "${token}" "${ENTRYPOINT}"; then + fail "enable-auth.sh has '${token}' but the entrypoint does not" + else + ok + fi + done +else + fail "enable-auth.sh not found at ${ENABLE_AUTH}" +fi + echo echo "passed: ${PASS}, failed: ${FAIL}" [[ ${FAIL} -eq 0 ]] diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 6cde4bde2c..f97c6e1cab 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -96,9 +96,10 @@ public static void main(String[] args) throws Exception { * NOTE: skipping also means the built-in admin account is not created * here. The only other code path that creates it is * GraphManager.initAdminUserIfNeeded(), reached from loadMetaFromPD(), - * which runs only when 'usePD' is true. Enabling auth with this option - * false and 'usePD' false therefore yields a server that enforces - * authentication with no account to authenticate against. + * which runs only when 'usePD' is true. Configuring the built-in + * authenticator with this option false and 'usePD' false therefore + * yields a server that enforces authentication with no account to + * authenticate against, which checkAdminBootstrapReachable() refuses. */ if (!restServerConfig.get(ServerOptions.INIT_STORE_ENABLED)) { LOG.warn("Skipping init-store: '{}' is false in '{}'. Local " + diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index ab2d3cc418..9650aaa0f3 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -38,6 +38,7 @@ import org.apache.hugegraph.unit.cache.CachedSchemaTransactionTest; import org.apache.hugegraph.unit.cache.RamTableTest; import org.apache.hugegraph.unit.cassandra.CassandraTest; +import org.apache.hugegraph.unit.cmd.InitStoreConfigTest; import org.apache.hugegraph.unit.core.AnalyzerTest; import org.apache.hugegraph.unit.core.BackendMutationTest; import org.apache.hugegraph.unit.core.BackendStoreInfoTest; @@ -46,7 +47,6 @@ import org.apache.hugegraph.unit.core.DataTypeTest; import org.apache.hugegraph.unit.core.DirectionsTest; import org.apache.hugegraph.unit.core.ExceptionTest; -import org.apache.hugegraph.unit.cmd.InitStoreConfigTest; import org.apache.hugegraph.unit.core.GraphManagerConfigTest; import org.apache.hugegraph.unit.core.LocksTableTest; import org.apache.hugegraph.unit.core.PageStateTest; From 95bfe4517e9a56d53a89d5f47880dfb5f7a72cbd Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Wed, 29 Jul 2026 21:27:16 +0530 Subject: [PATCH 09/19] fix(hugegraph-dist): harden skipped init authentication --- .github/workflows/docker-build-ci.yml | 12 + PR_DESCRIPTION.md | 152 -------- docker/README.md | 22 +- .../hugegraph/auth/StandardAuthenticator.java | 20 +- .../hugegraph/config/ServerOptions.java | 13 +- .../docker/docker-entrypoint.sh | 232 +++++++----- .../docker/test/JavaPropertiesReader.java | 49 +++ .../docker/test/test-docker-entrypoint.sh | 337 +++++++++++++++--- .../src/assembly/static/bin/init-store.sh | 17 +- .../org/apache/hugegraph/cmd/InitStore.java | 97 +++-- .../unit/cmd/InitStoreConfigTest.java | 85 ++++- 11 files changed, 694 insertions(+), 342 deletions(-) delete mode 100644 PR_DESCRIPTION.md create mode 100644 hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesReader.java diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index 3a436c6826..1b42f528b7 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -27,14 +27,26 @@ on: - '**/Dockerfile*' - '.dockerignore' - 'hugegraph-server/hugegraph-dist/docker/**' + - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh' + - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh' - '**/docker/docker-entrypoint.sh' jobs: entrypoint-test: + permissions: + contents: read runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '11' - name: Server entrypoint smoke tests run: hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md deleted file mode 100644 index fca13fde53..0000000000 --- a/PR_DESCRIPTION.md +++ /dev/null @@ -1,152 +0,0 @@ - - -# Add `init_store.enabled` to skip init-store in distributed deployments - -## Problem - -`InitStore` always runs full local initialization: scan `./conf/graphs`, init -non-hstore backends, create the built-in admin account. That is correct for -**standalone / tarball** users and must remain the default. - -In **PD / HStore** deployments the storage side already owns metadata, so there -is nothing for init-store to do. - -### Why the Docker guard is not enough - -`docker-entrypoint.sh` guards init with a flag file, `docker/init_complete`. -That file lives in the container's writable layer at -`/hugegraph-server/docker/init_complete`. No compose file mounts a volume over -it, and the server services declare no volumes at all. - -That guard works for Docker and does not work for Kubernetes: - -| | Writable layer after a restart | Effect | -|---|---|---| -| `docker restart` | preserved | flag survives, init-store runs once ever | -| K8s container restart / pod reschedule | new container from the image | flag is gone, **full init-store runs on every restart** | - -So on Helm the flag file never accumulates: every Server pod restart re-runs -the complete local init against a PD/HStore cluster that already owns its -metadata. Working around that is what pushed the chart toward one-shot init -Jobs and `HG_SERVER_SKIP_INIT`-style switches. A file-based guard cannot fix -this, because the problem is precisely that the file does not persist. The -decision has to come from configuration instead. - -This is the whole motivation for the change: it is a distributed-deployment -concern, not a Docker one. - -## Solution - -A dedicated option, `init_store.enabled`, defaulting to `true`: - -| `init_store.enabled` in `rest-server.properties` | `init-store` | -|--------------------------------------------------|--------------| -| **Unset** (shipped defaults) | **Full init**, same as master, standalone safe | -| `true` | Full init | -| `false` | No-op, WARN, exit 0, the distributed opt-out | - -A dedicated option rather than reusing `graph.load_from_local_config`: that -property already means "whether `GraphManager` loads graph definitions from the -local directory", its declared default is `false`, and existing configs may -materialize that default explicitly. Giving it a second contract would make an -explicit `false` silently skip backend and admin init for standalone -RocksDB/HBase installs. - -Docker maps `HG_SERVER_INIT_STORE_ENABLED` onto the property in -`rest-server.properties`, the file `init-store.sh` passes to `InitStore`. - -### Auth bootstrap in skip mode - -When init-store is skipped, `StandardAuthenticator.initAdminUserIfNeeded()` -does not run, so a Docker `PASSWORD` piped into `init-store.sh` would be read -and discarded, so the server would then come up with the `auth.admin_pa` default -of `pa` instead of the requested credential. - -The entrypoint therefore writes `PASSWORD` to `auth.admin_pa` in skip mode -instead of piping it to `init-store.sh`, so the account the server creates on -startup uses the requested password. - -That startup path has a precondition. `GraphManager.initAdminUserIfNeeded()` is -reached only from `loadMetaFromPD()`, which runs only when `usePD` is true, and -`usePD` defaults to false. With init-store skipped and `usePD` false, no -component creates the built-in admin at all, so enabling auth would start a -server that enforces authentication against an empty account list. The -entrypoint refuses that combination and exits with an explanation, and -`InitStore` logs the same condition for non-Docker installs. - -`usePD=true` is the intended setting for distributed deployments: the -cluster-test `rest-server.properties.template` already pairs it with -`auth.authenticator` and `auth.admin_pa`. The shipped compose files not setting -it is a pre-existing gap, and is left to a follow-up because enabling it also -switches graph loading from local config to PD metadata. - -The entrypoint also does not write `docker/init_complete` in skip mode: nothing -was initialized, so a later run with init-store enabled must still be able to -perform the real initialization. - -## Files changed - -| File | Change | -|------|--------| -| `.../config/ServerOptions.java` | New `INIT_STORE_ENABLED` option, default `true` | -| `.../cmd/InitStore.java` | Early exit when `init_store.enabled=false` | -| `.../docker/docker-entrypoint.sh` | Env → property; `PASSWORD` → `auth.admin_pa` in skip mode; refuse auth + skip without `usePD`; no init flag when nothing was initialized | -| `.../unit/cmd/InitStoreConfigTest.java` | Option defaults + `main()`-level proof of the early exit | -| `.../unit/UnitTestSuite.java` | Suite entry | -| `.../docker/test/test-docker-entrypoint.sh` | Entrypoint lifecycle smoke tests | -| `.github/workflows/docker-build-ci.yml` | Run the entrypoint tests, and Docker CI on `hugegraph-dist/docker/**` | -| `docker/README.md` | Server env var reference | - -## Test plan - -```bash -mvn test -pl hugegraph-server/hugegraph-test -am -P unit-test -Dtest=InitStoreConfigTest -mvn test -pl hugegraph-server/hugegraph-test -am -P unit-test -``` - -`InitStoreConfigTest` points the temporary `rest-server.properties` at a -`graphs` directory that does not exist, then calls `InitStore.main()` with -`init_store.enabled=false`. A companion test asserts that the same directory -does make `ConfigUtil.scanGraphsDir` fail, so the skip test proves the gate is -applied before any graph or admin work rather than being a tautology. - -The entrypoint lifecycle is covered separately, with no JVM, backend or Docker -needed. The entrypoint runs against a throwaway install tree whose `./bin` -scripts are stubs that record their own invocation: - -```bash -hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh -``` - -Cases: default init, default + `PASSWORD`, skip via env, **skip + `PASSWORD`** -(asserts the password reaches `auth.admin_pa` and never `init-store.sh` stdin), -skip via a mounted property with no env var, env overriding a conflicting -property, `false → true` restart, flag-file suppression of re-init, boolean -parsing parity with the server (`FALSE`, `off`, `no`, and fail-fast on -non-booleans), the `:` property separator, a backslash password round trip, and -refusal of auth plus skip without `usePD`. - -**Manual smoke (optional):** - -1. Shipped `conf/rest-server.properties` (no flag): `bin/init-store.sh` → full - init, same as master. -2. Set `init_store.enabled=false`, run again → WARN and exit 0 without backend - init. - -## Out of scope - -- `util.sh` / `check_port` / lsof -- Helm chart Job / `SKIP_INIT` removal (the chart sets the env, and `usePD=true` when it enables auth) -- Setting `usePD=true` in the shipped compose files, which also switches graph - loading from local config to PD metadata -- `GraphManager` behavior and the `graph.load_from_local_config` default - -## Compatibility - -**No breaking change for tarball / bare init-store.** The option defaults to -`true`, so unset config keeps the full init path. - -Distributed deployments set `init_store.enabled=false` (or -`HG_SERVER_INIT_STORE_ENABLED=false`). diff --git a/docker/README.md b/docker/README.md index e956f007c9..bbaa191152 100644 --- a/docker/README.md +++ b/docker/README.md @@ -164,22 +164,22 @@ Configuration is injected via environment variables. The old `docker/configs/app | `HG_SERVER_INIT_STORE_ENABLED` | No | `true` | `init_store.enabled` in `rest-server.properties` | Set `false` in PD/HStore deployments so init-store skips local backend and admin initialization | > **The built-in authenticator with `HG_SERVER_INIT_STORE_ENABLED=false` -> requires `usePD=true`, unless `auth.remote_url` delegates auth elsewhere.** -> With init-store skipped, the only component that creates the built-in admin -> account is the PD metadata path, which the server takes only when `usePD` is -> true. Enabling auth without it would start a server that enforces -> authentication while no account exists, so the entrypoint refuses that -> combination and exits with an error rather than starting. A custom -> `auth.authenticator` is exempt, since it keeps its identities outside -> HugeGraph and needs no such account. +> requires `usePD=true` and an HStore-backed `auth.graph_store`, unless +> `auth.remote_url` delegates auth elsewhere.** With init-store skipped, the +> server creates the built-in admin in PD metadata. Only an HStore auth graph +> uses the PD-backed auth manager that can read that account. The entrypoint +> invokes InitStore's no-op validation and exits before server startup when the +> combination is unusable. A custom `auth.authenticator` is exempt because it +> manages its own identities. > > When the combination is valid, a `PASSWORD` is written to `auth.admin_pa` > instead of being passed to `init-store.sh`, because the admin account is then > created on server startup. Two consequences worth knowing: > -> - The password is stored in `conf/rest-server.properties`, so anyone who can -> read that config volume can read it. With init-store enabled the password -> only travels over stdin and is never written to disk. +> - Before writing the password, the entrypoint restricts +> `conf/rest-server.properties` to mode `0600`. Administrators with access to +> the config volume can still read it. With init-store enabled the password +> only travels over stdin and is not written to disk. > - `auth.admin_pa` takes effect only when the admin account is first created. > Changing `PASSWORD` on a later restart does not rotate an existing admin > password, and does not report an error. diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java index aecc8af282..a78eb8dd71 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java @@ -47,9 +47,10 @@ public class StandardAuthenticator implements HugeAuthenticator { private HugeGraph graph = null; - private void initAdminUser() throws Exception { + private void initAdminUser(String password, boolean fromConfig) + throws Exception { if (this.requireInitAdminUser()) { - this.initAdminUser(this.inputPassword()); + this.initAdminUser(fromConfig ? password : this.inputPassword()); } this.graph.close(); } @@ -210,6 +211,19 @@ public SaslNegotiator newSaslNegotiator(InetAddress remoteAddress) { } public static void initAdminUserIfNeeded(String confFile) throws Exception { + initAdminUserIfNeeded(confFile, null, false); + } + + public static void initAdminUserIfNeeded(String confFile, + String configuredPassword) + throws Exception { + initAdminUserIfNeeded(confFile, configuredPassword, true); + } + + private static void initAdminUserIfNeeded(String confFile, + String password, + boolean fromConfig) + throws Exception { StandardAuthenticator auth = new StandardAuthenticator(); HugeConfig config = new HugeConfig(confFile); String authClass = config.get(ServerOptions.AUTHENTICATOR); @@ -219,7 +233,7 @@ public static void initAdminUserIfNeeded(String confFile) throws Exception { config.addProperty(INITING_STORE, true); auth.setup(config); if (auth.graph().backendStoreFeatures().supportsPersistence()) { - auth.initAdminUser(); + auth.initAdminUser(password, fromConfig); } } diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java index d3549fd0ca..bce161a7e2 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java @@ -376,9 +376,9 @@ public class ServerOptions extends OptionHolder { "deployments (PD/HStore) where the storage side already " + "owns the metadata. Note that setting it false also means " + "the admin account is not created here, and the only " + - "other component that creates it requires 'usePD' to be " + - "true, so configuring the built-in authenticator without " + - "that would leave no account to authenticate against.", + "other component that creates it writes to PD metadata. " + + "The built-in authenticator can use that account only " + + "when 'usePD' is true and its auth graph uses HStore.", disallowEmpty(), true ); @@ -433,13 +433,6 @@ public class ServerOptions extends OptionHolder { nonNegativeInt(), 0); - // TODO: these keys are camelCase but conf/rest-server.properties ships them - // as arthas.telnet_port / arthas.http_port / arthas.disabled_commands, so - // nothing matches and operator values are silently replaced by the defaults - // below. Only arthas.ip works. Unnoticed so far because each shipped value - // equals its default, but e.g. arthas.disabled_commands=jad,exec is dropped - // and exec stays enabled. Rename these to snake_case (pre-existing, tracked - // separately from this change). public static final ConfigOption ARTHAS_TELNET_PORT = new ConfigOption<>( "arthas.telnetPort", diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index ff0f63b5d0..f0accb5134 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -19,6 +19,7 @@ set -euo pipefail DOCKER_FOLDER="./docker" INIT_FLAG_FILE="init_complete" +AUTH_INIT_STATE_FILE="auth_init_state" GRAPH_CONF="./conf/graphs/hugegraph.properties" REST_SERVER_CONF="./conf/rest-server.properties" GREMLIN_SERVER_CONF="./conf/gremlin-server.yaml" @@ -46,7 +47,7 @@ set_prop() { # The scratch file holds auth.admin_pa, so keep it off the process umask ( umask 077; : > "${tmp}" ) - SET_PROP_KEY="$key" SET_PROP_VAL="$val" awk ' + if ! SET_PROP_KEY="$key" SET_PROP_VAL="$val" awk ' BEGIN { key = ENVIRON["SET_PROP_KEY"]; val = ENVIRON["SET_PROP_VAL"] } { line = $0 @@ -62,30 +63,71 @@ set_prop() { print line } END { if (!done) print key "=" val } - ' "${file}" > "${tmp}" + ' "${file}" > "${tmp}"; then + rm -f "${tmp}" + return 1 + fi # Truncate and rewrite in place rather than rename: a single-file bind # mount cannot be replaced by rename, and a rename would also discard the # original ownership and mode, which matters where auth.admin_pa is written - cat "${tmp}" > "${file}" + if ! cat "${tmp}" > "${file}"; then + rm -f "${tmp}" + return 1 + fi rm -f "${tmp}" } -# Rewrites a key that is already present as a single canonical line, dropping -# any duplicate definitions. No-op when the key is absent. +count_prop() { + local key="$1" file="$2" + + [[ -f "${file}" ]] || { echo 0; return; } + SET_PROP_KEY="$key" awk ' + BEGIN { key = ENVIRON["SET_PROP_KEY"] } + { + probe = $0 + sub(/^[[:space:]]+/, "", probe) + if (index(probe, key) == 1) { + rest = substr(probe, length(key) + 1) + if (rest ~ /^[[:space:]]*[=:]/ || rest ~ /^[[:space:]]+/) { + count++ + } + } + } + END { print count + 0 } + ' "${file}" +} + +# Drops duplicate definitions while leaving a single valid definition untouched. +# Avoiding a needless rewrite lets complete read-only mounted configs start. canonicalize_prop() { - local key="$1" file="$2" cur - cur=$(get_prop "${key}" "${file}") - if [[ -n "${cur}" ]]; then + local key="$1" file="$2" count cur + count=$(count_prop "${key}" "${file}") + if (( count > 1 )); then + cur=$(get_prop "${key}" "${file}") set_prop "${key}" "${cur}" "${file}" fi } -# Escapes a value for Java-properties serialization. Backslashes must be -# doubled or the parser consumes them, so a password like `abc\def` would -# otherwise be read back as `abcdef`; a leading space would be stripped. +# Escapes a UTF-8 value for Java-properties serialization. Encoding every +# UTF-16 code unit as a Unicode escape keeps separators, leading whitespace, +# backslashes and embedded control characters out of the physical property +# line while the Java parser reconstructs the exact original string. props_escape() { - printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/^ /\\ /' + printf '%s' "$1" | iconv -f UTF-8 -t UTF-16BE | \ + od -An -v -t x1 | awk ' + { + for (i = 1; i <= NF; i++) { + if (high == "") { + high = $i + } else { + printf "\\u%s%s", high, $i + high = "" + } + } + } + END { if (high != "") exit 1 } + ' } # Echoes the value of a property, or nothing when the key or the file is @@ -138,44 +180,54 @@ graph_auth_proxy_configured() { # mounted config sets only auth.authenticator. So run it for the untouched # shipped config, and otherwise apply exactly the parts that are missing. ensure_auth_enabled() { - local authenticator + local authenticator class_pattern authenticator=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") + class_pattern='^[[:alpha:]_$][[:alnum:]_$]*(\.[[:alpha:]_$][[:alnum:]_$]*)*$' + if [[ -n "${authenticator}" && ! "${authenticator}" =~ ${class_pattern} ]]; then + log "ERROR: auth.authenticator must be an unescaped Java class name;" \ + "got '${authenticator}'" + return 1 + fi if [[ -z "${authenticator}" ]] && ! gremlin_auth_configured && \ ! graph_auth_proxy_configured; then ./bin/enable-auth.sh + authenticator=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") else log "auth is already configured in part; applying anything still missing" - if [[ -z "${authenticator}" ]]; then - set_prop "auth.authenticator" "${BUILTIN_AUTHENTICATOR}" \ - "${REST_SERVER_CONF}" - fi - if [[ -z "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")" ]]; then - set_prop "auth.graph_store" "hugegraph" "${REST_SERVER_CONF}" + fi + + # enable-auth.sh is guarded by conf-bak and can return success without + # changing restored configs. Enforce every postcondition after it runs. + if [[ -z "${authenticator}" ]]; then + authenticator="${BUILTIN_AUTHENTICATOR}" + set_prop "auth.authenticator" "${authenticator}" \ + "${REST_SERVER_CONF}" + fi + if [[ -z "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")" ]]; then + set_prop "auth.graph_store" "hugegraph" "${REST_SERVER_CONF}" + fi + if ! gremlin_auth_configured; then + # A file whose last line has no newline would otherwise absorb the + # first line of the block + if [[ -s "${GREMLIN_SERVER_CONF}" && \ + -n "$(tail -c 1 "${GREMLIN_SERVER_CONF}")" ]]; then + echo >> "${GREMLIN_SERVER_CONF}" fi - if ! gremlin_auth_configured; then - # A file whose last line has no newline would otherwise absorb the - # first line of the block - if [[ -s "${GREMLIN_SERVER_CONF}" && \ - -n "$(tail -c 1 "${GREMLIN_SERVER_CONF}")" ]]; then - echo >> "${GREMLIN_SERVER_CONF}" - fi - # Kept in step with bin/enable-auth.sh, which owns this block, - # except that Gremlin is pointed at whichever authenticator the - # REST config names rather than always at the built-in one - cat >> "${GREMLIN_SERVER_CONF}" <> "${GREMLIN_SERVER_CONF}" </dev/null || echo "false") - if [[ "${USE_PD}" != "true" ]]; then - log "ERROR: auth is enabled and init_store.enabled=false, but usePD is not true." - log "ERROR: With init-store skipped the admin account is only created on the PD" - log "ERROR: metadata path, so this combination would start a server that nobody" - log "ERROR: can authenticate against." - log "ERROR: Set usePD=true in ${REST_SERVER_CONF}, or leave init-store enabled" - log "ERROR: so that it can create the admin account locally." - exit 1 - fi + +# A mounted configuration can enable REST authentication without carrying the +# matching Gremlin handler or auth graph proxy. Complete all three configs for +# every configured authenticator, whether or not Docker supplied a PASSWORD. +AUTHENTICATOR=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") +if [[ -n "${PASSWORD:-}" || -n "${AUTHENTICATOR}" ]]; then + ensure_auth_enabled + AUTHENTICATOR=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") +fi + +AUTH_STATE="" +AUTH_INIT_REQUIRED=false +if [[ -n "${AUTHENTICATOR}" ]]; then + AUTH_STATE=$(printf '%s\n%s\n%s' \ + "${AUTHENTICATOR}" \ + "$(get_prop "auth.remote_url" "${REST_SERVER_CONF}")" \ + "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")") + STORED_AUTH_STATE=$(cat \ + "${DOCKER_FOLDER}/${AUTH_INIT_STATE_FILE}" 2>/dev/null || true) + if [[ -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" && + "${STORED_AUTH_STATE}" != "${AUTH_STATE}" ]]; then + AUTH_INIT_REQUIRED=true fi +fi + +if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then + log "init-store disabled; validating the no-op configuration" + + # Let InitStore make the type-aware decision about whether this effective + # authenticator needs the built-in admin and whether the configured auth + # graph can read the PD-created account. The gate returns before backend or + # plugin registration, so this invocation performs validation only. + ./bin/init-store.sh # Still wait: the server needs the storage side reachable at startup even # though nothing is initialized here @@ -278,31 +333,48 @@ if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then if [[ -n "${PASSWORD:-}" ]]; then log "enabling auth mode, admin password applied via auth.admin_pa" - ensure_auth_enabled # TODO: auth.admin_pa only applies when the admin account is first - # created, so changing PASSWORD on a later restart silently keeps the - # old one. It also leaves the password at rest in rest-server.properties, - # unlike the enabled path where it only travels over stdin. - set_prop "auth.admin_pa" "$(props_escape "${PASSWORD}")" "${REST_SERVER_CONF}" + # created, so changing PASSWORD on a later restart keeps the old one. + if ! ESCAPED_PASSWORD=$(props_escape "${PASSWORD}"); then + log "ERROR: PASSWORD must be valid UTF-8" + exit 1 + fi + if ! chmod 600 "${REST_SERVER_CONF}"; then + log "ERROR: cannot protect ${REST_SERVER_CONF} before writing auth.admin_pa" + exit 1 + fi + set_prop "auth.admin_pa" "${ESCAPED_PASSWORD}" "${REST_SERVER_CONF}" fi # No init flag is written here: nothing was initialized, so a later run # with init-store enabled must still perform the real initialization. -elif [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then +elif [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" || + "${AUTH_INIT_REQUIRED}" == "true" ]]; then + if [[ -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then + log "authentication configuration changed; running init-store once" + fi wait_storage if [[ -z "${PASSWORD:-}" ]]; then - log "init hugegraph with non-auth mode" - ./bin/init-store.sh + if [[ -n "${AUTHENTICATOR}" ]]; then + log "init hugegraph with configured auth.admin_pa" + ./bin/init-store.sh --use-configured-admin-password + else + log "init hugegraph with non-auth mode" + ./bin/init-store.sh + fi else log "init hugegraph with auth mode" - ensure_auth_enabled - echo "${PASSWORD}" | ./bin/init-store.sh + printf '%s\n' "${PASSWORD}" | ./bin/init-store.sh fi - # TODO: this flag only tracks "init has run", not what it ran with. On a - # persisted volume it survives env changes, so flipping HG_SERVER_* on a - # later start will not re-init. It also does not survive a Kubernetes pod - # restart, which is why init_store.enabled exists rather than a flag file. + # This flag tracks only that init has run. The branch above explicitly + # handles later auth-mode changes; other HG_SERVER_* changes do not re-init. + # It also does not survive a Kubernetes pod restart, which is why + # init_store.enabled exists rather than a flag file. touch "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" + if [[ -n "${AUTH_STATE}" ]]; then + printf '%s\n' "${AUTH_STATE}" > \ + "${DOCKER_FOLDER}/${AUTH_INIT_STATE_FILE}" + fi else log "HugeGraph initialization already done. Skipping re-init..." fi diff --git a/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesReader.java b/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesReader.java new file mode 100644 index 0000000000..96de2acb6a --- /dev/null +++ b/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesReader.java @@ -0,0 +1,49 @@ +/* + * 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. + */ + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +public final class JavaPropertiesReader { + + private JavaPropertiesReader() { + } + + public static void main(String[] args) throws Exception { + if (args.length != 2) { + throw new IllegalArgumentException("Expected "); + } + + Path file = Paths.get(args[0]); + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(file)) { + properties.load(input); + } + + String value = properties.getProperty(args[1]); + if (value == null) { + throw new IllegalArgumentException("Missing property: " + args[1]); + } + for (byte item : value.getBytes(StandardCharsets.UTF_8)) { + System.out.printf("%02x", item & 0xff); + } + } +} diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index 5ac717208a..1be37588c3 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -19,7 +19,8 @@ # # The entrypoint is run against a throwaway install tree whose ./bin scripts are # stubs recording their own invocation, so the tests assert on which scripts ran -# and on the resulting config, without needing a JVM, a backend or Docker. +# and on the resulting config without a backend or Docker. A source-file Java +# helper verifies password values with the same properties parser semantics. # # Usage: hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -73,6 +74,37 @@ assert_prop() { fi } +bytes_hex() { + printf '%s' "$1" | od -An -v -t x1 | tr -d '[:space:]' +} + +file_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + +file_mtime() { + stat -c '%Y' "$1" 2>/dev/null || stat -f '%m' "$1" +} + +# Read the generated property through java.util.Properties and compare UTF-8 +# bytes. This catches physical-line truncation and every escape-sequence error, +# including trailing newlines that command substitution would otherwise hide. +assert_prop_round_trip() { + local key="$1" expected="$2" actual_hex expected_hex + if ! actual_hex=$(java "${SELF_DIR}/JavaPropertiesReader.java" \ + "${INSTALL}/conf/rest-server.properties" "${key}" \ + 2> "${INSTALL}/java-properties.err"); then + fail "Java could not read '${key}': $(cat "${INSTALL}/java-properties.err")" + return + fi + expected_hex=$(bytes_hex "${expected}") + if [[ "${actual_hex}" == "${expected_hex}" ]]; then + ok + else + fail "'${key}' parsed as hex '${actual_hex}', expected '${expected_hex}'" + fi +} + # A scalar option must end up defined exactly once, on any separator, or the # properties parser exposes it as a list and a scalar read of it fails assert_prop_defined_once() { @@ -126,6 +158,7 @@ graphs=./conf/graphs #auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator #auth.admin_pa=pa EOF + chmod 644 "${INSTALL}/conf/rest-server.properties" cat > "${INSTALL}/conf/graphs/hugegraph.properties" <<'EOF' backend=rocksdb gremlin.graph=org.apache.hugegraph.HugeFactory @@ -147,19 +180,25 @@ EOF cat > "${INSTALL}/bin/init-store.sh" <> "${INSTALL}/calls.log" +if [[ \$# -gt 0 ]]; then + echo "init-store.sh:args=\$*" >> "${INSTALL}/calls.log" +fi if [[ ! -t 0 ]]; then stdin=\$(cat) [[ -n "\${stdin}" ]] && echo "init-store.sh:stdin=\${stdin}" >> "${INSTALL}/calls.log" fi -exit 0 +exit "\${INIT_STORE_STUB_RC:-0}" EOF chmod +x "${INSTALL}/bin/init-store.sh" # Mirrors bin/enable-auth.sh: appends the REST keys and the YAML - # authentication block, and switches the graph to the auth proxy + # authentication block and switches the graph to the auth proxy, but only + # before its one-time conf-bak guard exists. cat > "${INSTALL}/bin/enable-auth.sh" <> "${INSTALL}/calls.log" +if [[ ! -d "${INSTALL}/conf-bak" ]]; then +mkdir -p "${INSTALL}/conf-bak" { echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" echo "auth.graph_store=hugegraph" @@ -174,16 +213,20 @@ YAML sed -i.bak 's/gremlin.graph=org.apache.hugegraph.HugeFactory/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/g' \ "${INSTALL}/conf/graphs/hugegraph.properties" rm -f "${INSTALL}/conf/graphs/hugegraph.properties.bak" +fi EOF chmod +x "${INSTALL}/bin/enable-auth.sh" : > "${INSTALL}/calls.log" } -# Auth plus skipped init-store is only supported alongside the PD metadata -# path, which is what actually creates the admin account in that mode +# The built-in admin created on the PD path is usable only when the auth graph +# selects HStore's PD-backed auth manager. enable_pd() { echo "usePD=true" >> "${INSTALL}/conf/rest-server.properties" + sed -i.bak 's/^backend=rocksdb$/backend=hstore/' \ + "${INSTALL}/conf/graphs/hugegraph.properties" + rm -f "${INSTALL}/conf/graphs/hugegraph.properties.bak" } # Run the entrypoint inside the throwaway tree. No ./bin/pid is ever written by @@ -197,6 +240,15 @@ run_entrypoint() { return 0 } +run_entrypoint_fails() { + if ( cd "${INSTALL}" && env "$@" bash "${ENTRYPOINT}" ) \ + > "${INSTALL}/out.log" 2>&1; then + fail "expected entrypoint to fail" + else + ok + fi +} + cleanup() { [[ -n "${INSTALL:-}" ]] && rm -rf "${INSTALL}"; } trap cleanup EXIT @@ -219,11 +271,11 @@ assert_file "docker/init_complete" assert_auth_fully_enabled cleanup -echo "==> skip via env: init-store never runs and no init flag is written" +echo "==> skip via env: InitStore validates the no-op and no flag is written" new_install run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false assert_ran "wait-storage.sh" -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" assert_prop "init_store.enabled" "false" assert_no_file "docker/init_complete" cleanup @@ -233,9 +285,9 @@ new_install enable_pd run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret assert_ran "enable-auth.sh" -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" assert_not_ran "init-store.sh:stdin=s3cret" -assert_prop "auth.admin_pa" "s3cret" +assert_prop_round_trip "auth.admin_pa" "s3cret" assert_no_file "docker/init_complete" cleanup @@ -244,9 +296,9 @@ new_install enable_pd echo "init_store.enabled=false" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" assert_not_ran "init-store.sh:stdin=s3cret" -assert_prop "auth.admin_pa" "s3cret" +assert_prop_round_trip "auth.admin_pa" "s3cret" assert_no_file "docker/init_complete" cleanup @@ -261,7 +313,7 @@ cleanup echo "==> false then true: a restart with init enabled still initializes" new_install run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" assert_no_file "docker/init_complete" : > "${INSTALL}/calls.log" run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true @@ -279,14 +331,66 @@ assert_not_ran "init-store.sh" assert_file "docker/init_complete" cleanup +echo "==> adding PASSWORD after no-auth init still creates the admin" +new_install +mkdir -p "${INSTALL}/docker" +touch "${INSTALL}/docker/init_complete" +run_entrypoint PASSWORD=s3cret +assert_ran "enable-auth.sh" +assert_ran "init-store.sh" +assert_ran "init-store.sh:stdin=s3cret" +assert_auth_fully_enabled +assert_file "docker/init_complete" +assert_file "docker/auth_init_state" +cleanup + +echo "==> mounted built-in auth after no-auth init still creates the admin" +new_install +mkdir -p "${INSTALL}/docker" +touch "${INSTALL}/docker/init_complete" +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint PASSWORD=s3cret +assert_ran "init-store.sh" +assert_ran "init-store.sh:stdin=s3cret" +assert_auth_fully_enabled +assert_file "docker/auth_init_state" +cleanup + +echo "==> mounted built-in auth without PASSWORD uses configured admin password" +new_install +mkdir -p "${INSTALL}/docker" +touch "${INSTALL}/docker/init_complete" +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u PASSWORD +assert_ran "init-store.sh" +assert_ran "init-store.sh:args=--use-configured-admin-password" +assert_auth_fully_enabled +assert_file "docker/auth_init_state" +: > "${INSTALL}/calls.log" +run_entrypoint -u PASSWORD +assert_not_ran "init-store.sh" +assert_ran "start-hugegraph.sh" +cleanup + +echo "==> an existing conf-bak cannot suppress requested auth" +new_install +mkdir -p "${INSTALL}/conf-bak" +run_entrypoint PASSWORD=s3cret +assert_ran "enable-auth.sh" +assert_ran "init-store.sh:stdin=s3cret" +assert_auth_fully_enabled +cleanup + echo "==> uppercase FALSE is honoured, matching the server's boolean parsing" new_install enable_pd run_entrypoint HG_SERVER_INIT_STORE_ENABLED=FALSE PASSWORD=s3cret -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" assert_not_ran "init-store.sh:stdin=s3cret" assert_prop "init_store.enabled" "false" -assert_prop "auth.admin_pa" "s3cret" +assert_prop_round_trip "auth.admin_pa" "s3cret" assert_no_file "docker/init_complete" cleanup @@ -294,7 +398,7 @@ echo "==> 'off' and 'no' are honoured too" for value in off no; do new_install run_entrypoint -u PASSWORD "HG_SERVER_INIT_STORE_ENABLED=${value}" - assert_not_ran "init-store.sh" + assert_ran "init-store.sh" assert_no_file "docker/init_complete" cleanup done @@ -314,7 +418,7 @@ echo "==> mounted property with a ':' separator is honoured" new_install echo "init_store.enabled:false" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" assert_no_file "docker/init_complete" cleanup @@ -322,22 +426,27 @@ echo "==> password with backslashes survives the properties round trip" new_install enable_pd run_entrypoint 'PASSWORD=abc\def' HG_SERVER_INIT_STORE_ENABLED=false -assert_not_ran "init-store.sh" -# Written escaped, so the properties parser reads back the original `abc\def` -assert_prop "auth.admin_pa" 'abc\\def' +assert_ran "init-store.sh" +assert_prop_round_trip "auth.admin_pa" 'abc\def' cleanup -echo "==> skip + PASSWORD without usePD is refused, not silently started" +echo "==> properties metacharacters, controls and UTF-8 round-trip exactly" new_install -if ( cd "${INSTALL}" && env -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret \ - HG_SERVER_INIT_STORE_ENABLED=false bash "${ENTRYPOINT}" ) >/dev/null 2>&1; then - fail "expected auth + skip without usePD to be refused" -else - ok -fi -# Refused before starting the server, and without leaving auth half-enabled +enable_pd +complex_password=$' \tmeta:=#!\\\r\f\np\xc3\xa4ss\xe9\x9b\xaa' +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false \ + "PASSWORD=${complex_password}" +assert_prop_round_trip "auth.admin_pa" "${complex_password}" +assert_prop_defined_once "auth.admin_pa" +cleanup + +echo "==> a Java validation failure is propagated before password persistence" +new_install +run_entrypoint_fails -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret \ + HG_SERVER_INIT_STORE_ENABLED=false INIT_STORE_STUB_RC=1 assert_not_ran "start-hugegraph.sh" -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" +assert_no_prop_key "auth.admin_pa" assert_no_file "docker/init_complete" cleanup @@ -345,20 +454,17 @@ echo "==> skip with auth already in a mounted config, no usePD, is refused too" new_install echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ >> "${INSTALL}/conf/rest-server.properties" -if ( cd "${INSTALL}" && env -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ - bash "${ENTRYPOINT}" ) >/dev/null 2>&1; then - fail "expected mounted auth + skip without usePD to be refused" -else - ok -fi +run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ + INIT_STORE_STUB_RC=1 assert_not_ran "start-hugegraph.sh" +assert_ran "init-store.sh" cleanup echo "==> skip without auth is unaffected by the usePD requirement" new_install run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false assert_ran "start-hugegraph.sh" -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" assert_no_file "docker/init_complete" cleanup @@ -386,7 +492,7 @@ enable_pd echo "auth.admin_pa:old" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret assert_prop_defined_once "auth.admin_pa" -assert_prop "auth.admin_pa" "s3cret" +assert_prop_round_trip "auth.admin_pa" "s3cret" cleanup echo "==> commented-out defaults are not treated as definitions" @@ -410,7 +516,7 @@ echo "auth.graph_store=hugegraph" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret assert_not_ran "enable-auth.sh" assert_prop_defined_once "auth.admin_pa" -assert_prop "auth.admin_pa" "s3cret" +assert_prop_round_trip "auth.admin_pa" "s3cret" # The mounted config carried only the REST keys, so the YAML block and the auth # proxy still have to be applied, or Gremlin would stay unauthenticated assert_auth_fully_enabled @@ -427,6 +533,17 @@ assert_auth_fully_enabled assert_prop "auth.graph_store" "hugegraph" cleanup +echo "==> mounted auth without PASSWORD still protects Gremlin and the graph" +new_install +enable_pd +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_ran "init-store.sh" +assert_auth_fully_enabled +assert_no_prop_key "auth.admin_pa" +cleanup + echo "==> a gremlin-server.yaml without a trailing newline is still valid" new_install enable_pd @@ -454,13 +571,52 @@ assert_not_ran "enable-auth.sh" assert_auth_fully_enabled cleanup +echo "==> a complete read-only auth mount is not rewritten" +new_install +"${INSTALL}/bin/enable-auth.sh" +: > "${INSTALL}/calls.log" +touch -t 202001010000 "${INSTALL}/conf/rest-server.properties" +before_mtime=$(file_mtime "${INSTALL}/conf/rest-server.properties") +chmod 444 "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u PASSWORD +after_mtime=$(file_mtime "${INSTALL}/conf/rest-server.properties") +after_mode=$(file_mode "${INSTALL}/conf/rest-server.properties") +assert_ran "init-store.sh:args=--use-configured-admin-password" +assert_ran "start-hugegraph.sh" +assert_auth_fully_enabled +if [[ "${after_mtime}" == "${before_mtime}" ]]; then + ok +else + fail "read-only rest-server.properties was rewritten" +fi +if [[ "${after_mode}" == "444" ]]; then + ok +else + fail "read-only rest-server.properties mode became ${after_mode}" +fi +cleanup + echo "==> a custom authenticator is not held to the usePD requirement" new_install echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ >> "${INSTALL}/conf/rest-server.properties" run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false assert_ran "start-hugegraph.sh" -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" +assert_auth_fully_enabled +assert_prop "auth.authenticator" "org.example.auth.LdapAuthenticator" +cleanup + +echo "==> PASSWORD does not turn a custom authenticator into built-in auth" +new_install +echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_ran "start-hugegraph.sh" +assert_ran "init-store.sh" +assert_auth_fully_enabled +assert_prop "auth.authenticator" "org.example.auth.LdapAuthenticator" +assert_prop_round_trip "auth.admin_pa" "s3cret" cleanup echo "==> surrounding whitespace is trimmed, inner whitespace is kept" @@ -468,21 +624,29 @@ new_install printf 'auth.authenticator = %s \n' \ "org.apache.hugegraph.auth.StandardAuthenticator" \ >> "${INSTALL}/conf/rest-server.properties" -# Trailing spaces must not stop the value matching the built-in class, or the -# admin-account requirement below would be skipped for a config that needs it -if ( cd "${INSTALL}" && env -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ - bash "${ENTRYPOINT}" ) >/dev/null 2>&1; then - fail "expected a padded auth.authenticator to still be recognized" -else - ok -fi +# The Java validator is the source of truth for the class decision; the +# entrypoint must preserve its failure status for this padded value. +run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ + INIT_STORE_STUB_RC=1 +assert_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" +cleanup + +echo "==> escaped authenticator spelling is rejected before YAML generation" +new_install +echo 'auth.authenticator=org.apache.hugegraph.auth.Standard\u0041uthenticator' \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_not_ran "enable-auth.sh" +assert_not_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" cleanup echo "==> a value containing spaces survives the round trip" new_install enable_pd run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false 'PASSWORD=two words' -assert_prop "auth.admin_pa" "two words" +assert_prop_round_trip "auth.admin_pa" "two words" assert_prop_defined_once "auth.admin_pa" cleanup @@ -493,22 +657,25 @@ echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ echo "auth.remote_url=127.0.0.1:8899" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false assert_ran "start-hugegraph.sh" -assert_not_ran "init-store.sh" +assert_ran "init-store.sh" +assert_auth_fully_enabled cleanup -echo "==> set_prop preserves the config file's inode and mode" +echo "==> secret write preserves the inode and restricts the shipped mode" new_install -chmod 600 "${INSTALL}/conf/rest-server.properties" +enable_pd before_inode=$(ls -i "${INSTALL}/conf/rest-server.properties" | awk '{print $1}') -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +before_mode=$(file_mode "${INSTALL}/conf/rest-server.properties") +run_entrypoint PASSWORD=s3cret HG_SERVER_INIT_STORE_ENABLED=false after_inode=$(ls -i "${INSTALL}/conf/rest-server.properties" | awk '{print $1}') -after_mode=$(stat -c '%a' "${INSTALL}/conf/rest-server.properties" 2>/dev/null \ - || stat -f '%Lp' "${INSTALL}/conf/rest-server.properties") +after_mode=$(file_mode "${INSTALL}/conf/rest-server.properties") # Rewriting in place matters: a single-file bind mount cannot be replaced by -# rename, and a rename would drop the mode protecting auth.admin_pa +# rename. The shipped 0644 mode must be restricted before the secret is written. +if [[ "${before_mode}" == "644" ]]; then ok; else fail "fixture mode was ${before_mode}"; fi if [[ "${before_inode}" == "${after_inode}" ]]; then ok; else fail "inode changed"; fi if [[ "${after_mode}" == "600" ]]; then ok; else fail "mode became ${after_mode}, expected 600"; fi assert_prop "init_store.enabled" "false" +assert_prop_round_trip "auth.admin_pa" "s3cret" cleanup echo "==> no scratch file is left behind" @@ -523,23 +690,75 @@ cleanup echo "==> the entrypoint's auth block has not drifted from enable-auth.sh" # The entrypoint reapplies what bin/enable-auth.sh would have done when it -# cannot run the script itself, so the two must name the same classes and keys +# cannot run the script itself, so both sides must retain every exact invariant. ENABLE_AUTH="${SELF_DIR}/../../src/assembly/static/bin/enable-auth.sh" if [[ -f "${ENABLE_AUTH}" ]]; then - for token in "authentication: {" "WsAndHttpBasicAuthHandler" \ + for token in "authentication: {" \ + "org.apache.hugegraph.auth.StandardAuthenticator" \ + "WsAndHttpBasicAuthHandler" \ "tokens: conf/rest-server.properties" "auth.graph_store" \ "HugeFactoryAuthProxy"; do - if grep -qF "${token}" "${ENABLE_AUTH}" && \ - ! grep -qF "${token}" "${ENTRYPOINT}"; then - fail "enable-auth.sh has '${token}' but the entrypoint does not" + if grep -qF "${token}" "${ENABLE_AUTH}"; then + ok else + fail "enable-auth.sh is missing '${token}'" + fi + if grep -qF "${token}" "${ENTRYPOINT}"; then ok + else + fail "docker-entrypoint.sh is missing '${token}'" fi done else fail "enable-auth.sh not found at ${ENABLE_AUTH}" fi +echo "==> the production init-store wrapper preserves Java failures" +new_install +INIT_STORE_WRAPPER="${SELF_DIR}/../../src/assembly/static/bin/init-store.sh" +cp "${INIT_STORE_WRAPPER}" "${INSTALL}/bin/init-store.sh" +mkdir -p "${INSTALL}/lib" "${INSTALL}/plugins" +cat > "${INSTALL}/bin/util.sh" <<'EOF' +configure_riscv64_libatomic() { return 0; } +ensure_path_writable() { return 0; } +EOF +mkdir -p "${INSTALL}/fake-java/bin" +cat > "${INSTALL}/fake-java/bin/java" <<'EOF' +#!/bin/bash +if [[ -n "${FAKE_JAVA_ARGS_FILE:-}" ]]; then + printf '%s\n' "$*" > "${FAKE_JAVA_ARGS_FILE}" +fi +exit "${FAKE_JAVA_STATUS:-0}" +EOF +chmod +x "${INSTALL}/fake-java/bin/java" +if ( cd "${INSTALL}" && env FAKE_JAVA_STATUS=23 \ + FAKE_JAVA_ARGS_FILE="${INSTALL}/fake-java-args" \ + JAVA_HOME="${INSTALL}/fake-java" ./bin/init-store.sh \ + --use-configured-admin-password ) \ + > "${INSTALL}/init-store-wrapper.out" 2>&1; then + fail "production init-store.sh hid the Java failure" +else + status=$? + if [[ ${status} -eq 23 ]]; then + ok + else + fail "production init-store.sh returned ${status}, expected 23" + fi +fi +if grep -qF "Initialization finished." \ + "${INSTALL}/init-store-wrapper.out"; then + fail "production init-store.sh printed success after Java failed" +else + ok +fi +if grep -qE 'rest-server\.properties --use-configured-admin-password$' \ + "${INSTALL}/fake-java-args"; then + ok +else + fail "production init-store.sh did not forward its password-mode flag" +fi +cleanup + echo echo "passed: ${PASS}, failed: ${FAIL}" [[ ${FAIL} -eq 0 ]] diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh index b9da84166b..205344e4b0 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh @@ -50,11 +50,26 @@ DEFAULT_JAVA_OPTIONS="--add-exports=java.base/jdk.internal.reflect=ALL-UNNAMED" echo "Initializing HugeGraph Store..." +INIT_STORE_ARGS=() +if [[ $# -gt 1 || ( $# -eq 1 && + "$1" != "--use-configured-admin-password" ) ]]; then + echo "Usage: $0 [--use-configured-admin-password]" >&2 + exit 1 +fi +if [[ $# -eq 1 ]]; then + INIT_STORE_ARGS+=("$1") +fi + # Build classpath with hugegraph*.jar first to avoid class loading conflicts CP=$(find -L "${LIB}" -name 'hugegraph*.jar' | sort | tr '\n' ':') CP="$CP":$(find -L "${LIB}" -name '*.jar' \! -name 'hugegraph*' | sort | tr '\n' ':') CP="$CP":$(find -L "${PLUGINS}" -name '*.jar' | sort | tr '\n' ':') $JAVA -cp $CP ${DEFAULT_JAVA_OPTIONS} \ -org.apache.hugegraph.cmd.InitStore "${CONF}"/rest-server.properties +org.apache.hugegraph.cmd.InitStore "${CONF}"/rest-server.properties \ +"${INIT_STORE_ARGS[@]}" +INIT_STORE_STATUS=$? +if [[ ${INIT_STORE_STATUS} -ne 0 ]]; then + exit "${INIT_STORE_STATUS}" +fi echo "Initialization finished." diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index f73b80172e..3bfedc6462 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -41,15 +41,22 @@ public class InitStore { private static final Logger LOG = Log.logger(InitStore.class); + private static final String USE_CONFIGURED_ADMIN_PASSWORD = + "--use-configured-admin-password"; public static void main(String[] args) throws Exception { - E.checkArgument(args.length == 1, + E.checkArgument(args.length == 1 || + args.length == 2 && + USE_CONFIGURED_ADMIN_PASSWORD.equals(args[1]), "HugeGraph init-store need to pass the config file " + - "of RestServer, like: conf/rest-server.properties"); + "of RestServer, like: conf/rest-server.properties, " + + "with an optional %s flag", + USE_CONFIGURED_ADMIN_PASSWORD); E.checkArgument(args[0].endsWith(".properties"), "Expect the parameter is properties config file."); String restConf = args[0]; + boolean useConfiguredAdminPassword = args.length == 2; /* * Only the server options are needed to read the gate below. Backend @@ -76,12 +83,10 @@ public static void main(String[] args) throws Exception { * not survive one. * * NOTE: skipping also means the built-in admin account is not created - * here. The only other code path that creates it is - * GraphManager.initAdminUserIfNeeded(), reached from loadMetaFromPD(), - * which runs only when 'usePD' is true. Configuring the built-in - * authenticator with this option false and 'usePD' false therefore - * yields a server that enforces authentication with no account to - * authenticate against, which checkAdminBootstrapReachable() refuses. + * here. The PD startup path can replace that work only when usePD is + * true and the configured auth graph uses HStore, whose auth manager + * reads the same PD metadata. checkAdminBootstrapReachable() rejects + * every other local built-in-auth configuration before returning. */ if (!restServerConfig.get(ServerOptions.INIT_STORE_ENABLED)) { LOG.warn("Skipping init-store: '{}' is false in '{}'. Local " + @@ -112,7 +117,16 @@ public static void main(String[] args) throws Exception { } graphs.add(initGraph(configPath)); } - StandardAuthenticator.initAdminUserIfNeeded(restConf); + if (requiresBuiltinAdmin( + restServerConfig.get(ServerOptions.AUTHENTICATOR))) { + if (useConfiguredAdminPassword) { + StandardAuthenticator.initAdminUserIfNeeded( + restConf, + restServerConfig.get(ServerOptions.ADMIN_PA)); + } else { + StandardAuthenticator.initAdminUserIfNeeded(restConf); + } + } } finally { for (HugeGraph graph : graphs) { graph.close(); @@ -124,11 +138,11 @@ public static void main(String[] args) throws Exception { /** * Skipping means init-store does not create the built-in admin account, * and the only other component that creates it is - * GraphManager.initAdminUserIfNeeded(), reached from loadMetaFromPD() and - * so gated on 'usePD'. Failing here rather than returning zero keeps - * tarball and init-job callers, which see only the exit status, from - * continuing into a server that enforces authentication with no account - * to authenticate against. + * GraphManager.initAdminUserIfNeeded(), reached from loadMetaFromPD(). That + * creates the user in PD metadata, so it is reachable by the authenticator + * only when usePD is true and the auth graph uses HStore's + * StandardAuthManagerV2. Failing here rather than returning zero keeps + * tarball and init-job callers from continuing into an unusable server. *

* Remote auth is exempt: the auth manager is then an RPC client, and * StandardAuthenticator only bootstraps an admin for a local one. So is @@ -138,19 +152,56 @@ public static void main(String[] args) throws Exception { private static void checkAdminBootstrapReachable(HugeConfig conf, String restConf) { if (!requiresBuiltinAdmin(conf.get(ServerOptions.AUTHENTICATOR)) || - conf.get(ServerOptions.USE_PD) || !conf.get(ServerOptions.AUTH_REMOTE_URL).isEmpty()) { return; } - throw new IllegalStateException(String.format( - "Refusing to skip init-store: '%s' is set in '%s' but '%s' is " + - "false, so no component would create the built-in admin " + - "account. Set '%s' to true, set '%s' to delegate auth, or " + - "leave '%s' enabled so init-store creates the account.", - ServerOptions.AUTHENTICATOR.name(), restConf, - ServerOptions.USE_PD.name(), ServerOptions.USE_PD.name(), + + if (!conf.get(ServerOptions.USE_PD)) { + throw adminBootstrapUnavailable(restConf, String.format( + "'%s' is false", ServerOptions.USE_PD.name()), null); + } + + String graphName = conf.get(ServerOptions.AUTH_GRAPH_STORE); + String graphPath; + try { + Map graphConfs = ConfigUtil.scanGraphsDir( + conf.get(ServerOptions.GRAPHS)); + graphPath = graphConfs.get(graphName); + } catch (RuntimeException e) { + throw adminBootstrapUnavailable( + restConf, "the auth graph configuration cannot be read", e); + } + if (graphPath == null) { + throw adminBootstrapUnavailable(restConf, String.format( + "auth graph '%s' has no local configuration", graphName), + null); + } + + HugeConfig graphConfig = new HugeConfig(graphPath); + String backend = graphConfig.get(CoreOptions.BACKEND); + if (!Objects.equals(backend, "hstore")) { + throw adminBootstrapUnavailable(restConf, String.format( + "auth graph '%s' uses backend '%s', not 'hstore'", + graphName, backend), null); + } + } + + private static IllegalStateException adminBootstrapUnavailable( + String restConf, + String reason, + RuntimeException cause) { + String message = String.format( + "Refusing to skip init-store: the built-in authenticator is " + + "configured in '%s', but %s, so the admin created on the PD " + + "startup path would not be available to that authenticator. " + + "Set '%s' to true with an HStore auth graph, set '%s' to " + + "delegate auth, configure an external authenticator, or leave " + + "'%s' enabled so init-store creates the account.", + restConf, reason, ServerOptions.USE_PD.name(), ServerOptions.AUTH_REMOTE_URL.name(), - ServerOptions.INIT_STORE_ENABLED.name())); + ServerOptions.INIT_STORE_ENABLED.name()); + return cause == null ? new IllegalStateException(message) : + new IllegalStateException(message, cause); } /** diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 4f1dd82955..1119ad4d75 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -30,6 +30,7 @@ import org.apache.commons.configuration2.PropertiesConfiguration; import org.apache.hugegraph.auth.StandardAuthenticator; import org.apache.hugegraph.cmd.InitStore; +import org.apache.hugegraph.config.CoreOptions; import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.config.OptionSpace; import org.apache.hugegraph.config.ServerOptions; @@ -118,6 +119,23 @@ public void testDuplicateDefinitionFailsToLoad() throws IOException { }, e -> Assert.assertContains("[false, true]", e.getMessage())); } + @Test + public void testUnicodeEscapedAdminPasswordRoundTrips() throws IOException { + String password = " \tmeta:=#!\\\r\f\np\u00e4ss\u96ea"; + StringBuilder serialized = new StringBuilder( + ServerOptions.ADMIN_PA.name() + "="); + for (char item : password.toCharArray()) { + serialized.append(String.format("\\u%04x", (int) item)); + } + + Path conf = this.workDir.resolve("escaped-password.properties"); + Files.write(conf, Arrays.asList(serialized.toString()), + StandardCharsets.UTF_8); + + HugeConfig config = new HugeConfig(conf.toString()); + Assert.assertEquals(password, config.get(ServerOptions.ADMIN_PA)); + } + /** * The graphs directory referenced by the temporary config does not exist, * so every code path that reaches graph scanning fails. That is what makes @@ -140,6 +158,21 @@ public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { InitStore.main(new String[]{restConf}); } + @Test + public void testConfiguredAdminPasswordFlagAccepted() throws Exception { + String restConf = this.writeDisabledRestServerConf(); + InitStore.main(new String[]{restConf, + "--use-configured-admin-password"}); + } + + @Test + public void testUnknownInitStoreFlagRejected() throws IOException { + String restConf = this.writeDisabledRestServerConf(); + Assert.assertThrows(IllegalArgumentException.class, () -> { + InitStore.main(new String[]{restConf, "--unknown"}); + }, e -> Assert.assertContains("optional", e.getMessage())); + } + /** * Disabled mode must not reach RegisterUtil.registerBackends() or * registerPlugins(): plugin registration runs every discovered plugin's @@ -173,7 +206,7 @@ public void testDisabledInitStoreFailsWhenAdminCannotBeCreated() throws IOException { String restConf = this.writeDisabledRestServerConf( ServerOptions.AUTHENTICATOR.name() + - "=org.apache.hugegraph.auth.StandardAuthenticator"); + "=" + StandardAuthenticator.class.getName()); Assert.assertThrows(IllegalStateException.class, () -> { InitStore.main(new String[]{restConf}); @@ -181,6 +214,37 @@ public void testDisabledInitStoreFailsWhenAdminCannotBeCreated() e.getMessage())); } + @Test + public void testDisabledInitStoreFailsWithNonHstoreAuthGraph() + throws IOException { + Path graphsDir = this.writeAuthGraphConfig("memory"); + String restConf = this.writeDisabledRestServerConfForGraphs( + graphsDir, + ServerOptions.AUTHENTICATOR.name() + + "=" + StandardAuthenticator.class.getName(), + ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph", + ServerOptions.USE_PD.name() + "=true"); + + Assert.assertThrows(IllegalStateException.class, () -> { + InitStore.main(new String[]{restConf}); + }, e -> Assert.assertContains("uses backend 'memory', not 'hstore'", + e.getMessage())); + } + + @Test + public void testDisabledInitStoreAllowsPdBackedHstoreAuthGraph() + throws Exception { + Path graphsDir = this.writeAuthGraphConfig("hstore"); + String restConf = this.writeDisabledRestServerConfForGraphs( + graphsDir, + ServerOptions.AUTHENTICATOR.name() + + "=" + StandardAuthenticator.class.getName(), + ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph", + ServerOptions.USE_PD.name() + "=true"); + + InitStore.main(new String[]{restConf}); + } + /** * Remote auth delegates to another service, so there is no local admin to * create and the check above must not fire. @@ -189,7 +253,7 @@ public void testDisabledInitStoreFailsWhenAdminCannotBeCreated() public void testDisabledInitStoreAllowsRemoteAuth() throws Exception { String restConf = this.writeDisabledRestServerConf( ServerOptions.AUTHENTICATOR.name() + - "=org.apache.hugegraph.auth.StandardAuthenticator", + "=" + StandardAuthenticator.class.getName(), ServerOptions.AUTH_REMOTE_URL.name() + "=127.0.0.1:8899"); InitStore.main(new String[]{restConf}); @@ -222,11 +286,17 @@ public void testDisabledInitStoreAllowsCustomAuthenticator() private String writeDisabledRestServerConf(String... extraLines) throws IOException { + return this.writeDisabledRestServerConfForGraphs( + this.workDir.resolve(MISSING_GRAPHS_DIR), extraLines); + } + + private String writeDisabledRestServerConfForGraphs(Path graphsDir, + String... extraLines) + throws IOException { // A distinct file per call, so two configs written by one test do not // collide inside its temporary directory Path restConf = this.workDir.resolve( "rest-server-" + this.confSeq++ + ".properties"); - String graphsDir = this.workDir.resolve(MISSING_GRAPHS_DIR).toString(); List lines = new ArrayList<>(); lines.add(ServerOptions.GRAPHS.name() + "=" + graphsDir); lines.add(ServerOptions.INIT_STORE_ENABLED.name() + "=false"); @@ -235,6 +305,15 @@ private String writeDisabledRestServerConf(String... extraLines) return restConf.toString(); } + private Path writeAuthGraphConfig(String backend) throws IOException { + Path graphsDir = this.workDir.resolve("graphs-" + this.confSeq++); + Files.createDirectories(graphsDir); + Files.write(graphsDir.resolve("hugegraph.properties"), + Arrays.asList(CoreOptions.BACKEND.name() + "=" + backend), + StandardCharsets.UTF_8); + return graphsDir; + } + /** * Stands in for a deployment that subclasses the built-in authenticator, * which still depends on the admin account it creates. From 4b298bf2477685e886516acb54de880904a57ff5 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Wed, 29 Jul 2026 22:32:04 +0530 Subject: [PATCH 10/19] fix(hugegraph-dist): fail closed on auth config errors --- .../hugegraph/auth/StandardAuthenticator.java | 16 +++++++-- .../docker/docker-entrypoint.sh | 35 ++++++++++++++----- .../docker/test/test-docker-entrypoint.sh | 29 +++++++++++++++ .../unit/cmd/InitStoreConfigTest.java | 32 +++++++++++++++++ 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java index a78eb8dd71..72a70eabe8 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java @@ -49,10 +49,20 @@ public class StandardAuthenticator implements HugeAuthenticator { private void initAdminUser(String password, boolean fromConfig) throws Exception { - if (this.requireInitAdminUser()) { - this.initAdminUser(fromConfig ? password : this.inputPassword()); + try { + if (this.requireInitAdminUser()) { + if (fromConfig) { + E.checkArgument(password != null && !password.isEmpty(), + "The configured admin password can't be " + + "null or empty"); + } else { + password = this.inputPassword(); + } + this.initAdminUser(password); + } + } finally { + this.graph.close(); } - this.graph.close(); } @Override diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index f0accb5134..775b2786bb 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -42,10 +42,13 @@ log() { echo "[hugegraph-server-entrypoint] $*"; } # so no regex escaping of the key or value is needed. set_prop() { local key="$1" val="$2" file="$3" tmp - tmp="${file}.tmp.$$" # The scratch file holds auth.admin_pa, so keep it off the process umask - ( umask 077; : > "${tmp}" ) + # and use an unpredictable same-directory name rather than following a + # pre-created predictable symlink. + if ! tmp=$(umask 077; mktemp "${file}.tmp.XXXXXX"); then + return 1 + fi if ! SET_PROP_KEY="$key" SET_PROP_VAL="$val" awk ' BEGIN { key = ENVIRON["SET_PROP_KEY"]; val = ENVIRON["SET_PROP_VAL"] } @@ -201,11 +204,18 @@ ensure_auth_enabled() { # changing restored configs. Enforce every postcondition after it runs. if [[ -z "${authenticator}" ]]; then authenticator="${BUILTIN_AUTHENTICATOR}" - set_prop "auth.authenticator" "${authenticator}" \ - "${REST_SERVER_CONF}" + if ! set_prop "auth.authenticator" "${authenticator}" \ + "${REST_SERVER_CONF}"; then + log "ERROR: cannot write auth.authenticator to ${REST_SERVER_CONF}" + return 1 + fi fi if [[ -z "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")" ]]; then - set_prop "auth.graph_store" "hugegraph" "${REST_SERVER_CONF}" + if ! set_prop "auth.graph_store" "hugegraph" \ + "${REST_SERVER_CONF}"; then + log "ERROR: cannot write auth.graph_store to ${REST_SERVER_CONF}" + return 1 + fi fi if ! gremlin_auth_configured; then # A file whose last line has no newline would otherwise absorb the @@ -225,9 +235,12 @@ authentication: { EOF fi if ! graph_auth_proxy_configured; then - set_prop "gremlin.graph" \ - "org.apache.hugegraph.auth.HugeFactoryAuthProxy" \ - "${GRAPH_CONF}" + if ! set_prop "gremlin.graph" \ + "org.apache.hugegraph.auth.HugeFactoryAuthProxy" \ + "${GRAPH_CONF}"; then + log "ERROR: cannot write gremlin.graph to ${GRAPH_CONF}" + return 1 + fi fi # enable-auth.sh appends rather than replaces, so collapse whatever it left @@ -343,7 +356,11 @@ if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then log "ERROR: cannot protect ${REST_SERVER_CONF} before writing auth.admin_pa" exit 1 fi - set_prop "auth.admin_pa" "${ESCAPED_PASSWORD}" "${REST_SERVER_CONF}" + if ! set_prop "auth.admin_pa" "${ESCAPED_PASSWORD}" \ + "${REST_SERVER_CONF}"; then + log "ERROR: cannot write auth.admin_pa to ${REST_SERVER_CONF}" + exit 1 + fi fi # No init flag is written here: nothing was initialized, so a later run # with init-store enabled must still perform the real initialization. diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index 1be37588c3..6e4b6edf68 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -65,6 +65,14 @@ assert_no_file() { if [[ -f "${INSTALL}/$1" ]]; then fail "expected file '$1' NOT to exist"; else ok; fi } +assert_output_contains() { + if grep -qF "$1" "${INSTALL}/out.log" 2>/dev/null; then + ok + else + fail "expected output to contain '$1': $(cat "${INSTALL}/out.log")" + fi +} + assert_prop() { local expected="$1=$2" if grep -qxF "${expected}" "${INSTALL}/conf/rest-server.properties" 2>/dev/null; then @@ -440,6 +448,15 @@ assert_prop_round_trip "auth.admin_pa" "${complex_password}" assert_prop_defined_once "auth.admin_pa" cleanup +echo "==> a trailing newline survives the properties round trip" +new_install +enable_pd +trailing_newline_password=$'ends-with-newline\n' +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false \ + "PASSWORD=${trailing_newline_password}" +assert_prop_round_trip "auth.admin_pa" "${trailing_newline_password}" +cleanup + echo "==> a Java validation failure is propagated before password persistence" new_install run_entrypoint_fails -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret \ @@ -596,6 +613,18 @@ else fi cleanup +echo "==> an incomplete read-only auth mount fails with the missing property" +new_install +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +chmod 444 "${INSTALL}/conf/rest-server.properties" +run_entrypoint_fails -u PASSWORD +assert_not_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" +assert_output_contains \ + "ERROR: cannot write auth.graph_store to ./conf/rest-server.properties" +cleanup + echo "==> a custom authenticator is not held to the usePD requirement" new_install echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 1119ad4d75..e28e50412b 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -28,6 +28,8 @@ import java.util.stream.Stream; import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.hugegraph.HugeGraph; +import org.apache.hugegraph.auth.StandardAuthManager; import org.apache.hugegraph.auth.StandardAuthenticator; import org.apache.hugegraph.cmd.InitStore; import org.apache.hugegraph.config.CoreOptions; @@ -36,11 +38,13 @@ import org.apache.hugegraph.config.ServerOptions; import org.apache.hugegraph.dist.RegisterUtil; import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.testutil.Whitebox; import org.apache.hugegraph.util.ConfigUtil; import org.junit.After; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; +import org.mockito.Mockito; /** * {@code init_store.enabled} controls whether init-store performs local @@ -83,6 +87,12 @@ public void testInitStoreEnabledByDefault() { Assert.assertTrue(config.get(ServerOptions.INIT_STORE_ENABLED)); } + @Test + public void testAdminPasswordDefaultsToPa() { + HugeConfig config = new HugeConfig(new PropertiesConfiguration()); + Assert.assertEquals("pa", config.get(ServerOptions.ADMIN_PA)); + } + @Test public void testExplicitTrueKeepsInitStoreEnabled() { PropertiesConfiguration properties = new PropertiesConfiguration(); @@ -165,6 +175,28 @@ public void testConfiguredAdminPasswordFlagAccepted() throws Exception { "--use-configured-admin-password"}); } + @Test + public void testConfiguredAdminPasswordRejectsNullOrEmptyValue() + throws Exception { + HugeGraph graph = Mockito.mock(HugeGraph.class); + StandardAuthManager authManager = Mockito.mock(StandardAuthManager.class); + Mockito.when(graph.hugegraph()).thenReturn(graph); + Mockito.when(graph.authManager()).thenReturn(authManager); + + StandardAuthenticator authenticator = new StandardAuthenticator(); + Whitebox.setInternalState(authenticator, "graph", graph); + + for (String password : new String[]{null, ""}) { + Assert.assertThrows(IllegalArgumentException.class, () -> { + Whitebox.invoke(StandardAuthenticator.class, + new Class[]{String.class, boolean.class}, + "initAdminUser", authenticator, password, true); + }, e -> Assert.assertContains("can't be null or empty", + e.getMessage())); + } + Mockito.verify(graph, Mockito.times(2)).close(); + } + @Test public void testUnknownInitStoreFlagRejected() throws IOException { String restConf = this.writeDisabledRestServerConf(); From 906a3ef630294f8fad8e1d415aaa50d4af966a44 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Wed, 29 Jul 2026 23:48:03 +0530 Subject: [PATCH 11/19] fix(hugegraph-api): always close init auth graph --- .../hugegraph/auth/StandardAuthenticator.java | 37 +++-- .../unit/cmd/InitStoreConfigTest.java | 155 +++++++++++++++++- 2 files changed, 174 insertions(+), 18 deletions(-) diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java index 72a70eabe8..1b83716b16 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java @@ -49,19 +49,15 @@ public class StandardAuthenticator implements HugeAuthenticator { private void initAdminUser(String password, boolean fromConfig) throws Exception { - try { - if (this.requireInitAdminUser()) { - if (fromConfig) { - E.checkArgument(password != null && !password.isEmpty(), - "The configured admin password can't be " + - "null or empty"); - } else { - password = this.inputPassword(); - } - this.initAdminUser(password); + if (this.requireInitAdminUser()) { + if (fromConfig) { + E.checkArgument(password != null && !password.isEmpty(), + "The configured admin password can't be " + + "null or empty"); + } else { + password = this.inputPassword(); } - } finally { - this.graph.close(); + this.initAdminUser(password); } } @@ -241,9 +237,20 @@ private static void initAdminUserIfNeeded(String confFile, return; } config.addProperty(INITING_STORE, true); - auth.setup(config); - if (auth.graph().backendStoreFeatures().supportsPersistence()) { - auth.initAdminUser(password, fromConfig); + auth.initAdminUser(config, password, fromConfig); + } + + private void initAdminUser(HugeConfig config, String password, + boolean fromConfig) throws Exception { + try { + this.setup(config); + if (this.graph().backendStoreFeatures().supportsPersistence()) { + this.initAdminUser(password, fromConfig); + } + } finally { + if (this.graph != null) { + this.graph.close(); + } } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index e28e50412b..b61ac32f11 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -29,8 +29,10 @@ import org.apache.commons.configuration2.PropertiesConfiguration; import org.apache.hugegraph.HugeGraph; +import org.apache.hugegraph.auth.HugeUser; import org.apache.hugegraph.auth.StandardAuthManager; import org.apache.hugegraph.auth.StandardAuthenticator; +import org.apache.hugegraph.backend.store.BackendFeatures; import org.apache.hugegraph.cmd.InitStore; import org.apache.hugegraph.config.CoreOptions; import org.apache.hugegraph.config.HugeConfig; @@ -178,25 +180,172 @@ public void testConfiguredAdminPasswordFlagAccepted() throws Exception { @Test public void testConfiguredAdminPasswordRejectsNullOrEmptyValue() throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); HugeGraph graph = Mockito.mock(HugeGraph.class); + BackendFeatures features = Mockito.mock(BackendFeatures.class); StandardAuthManager authManager = Mockito.mock(StandardAuthManager.class); Mockito.when(graph.hugegraph()).thenReturn(graph); Mockito.when(graph.authManager()).thenReturn(authManager); + Mockito.when(graph.backendStoreFeatures()).thenReturn(features); + Mockito.when(features.supportsPersistence()).thenReturn(true); - StandardAuthenticator authenticator = new StandardAuthenticator(); + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); Whitebox.setInternalState(authenticator, "graph", graph); + Mockito.doNothing().when(authenticator).setup(config); for (String password : new String[]{null, ""}) { Assert.assertThrows(IllegalArgumentException.class, () -> { Whitebox.invoke(StandardAuthenticator.class, - new Class[]{String.class, boolean.class}, - "initAdminUser", authenticator, password, true); + new Class[]{HugeConfig.class, String.class, + boolean.class}, + "initAdminUser", authenticator, config, + password, true); }, e -> Assert.assertContains("can't be null or empty", e.getMessage())); } Mockito.verify(graph, Mockito.times(2)).close(); } + @Test + public void testAdminGraphClosedForNonPersistentBackend() throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + BackendFeatures features = Mockito.mock(BackendFeatures.class); + Mockito.when(graph.backendStoreFeatures()).thenReturn(features); + Mockito.when(features.supportsPersistence()).thenReturn(false); + + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); + Whitebox.setInternalState(authenticator, "graph", graph); + Mockito.doNothing().when(authenticator).setup(config); + + Whitebox.invoke(StandardAuthenticator.class, + new Class[]{HugeConfig.class, String.class, + boolean.class}, + "initAdminUser", authenticator, config, null, false); + + Mockito.verify(graph).close(); + } + + @Test + public void testAdminGraphClosedAfterSetupFailure() throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); + Whitebox.setInternalState(authenticator, "graph", graph); + Mockito.doThrow(new IllegalStateException("setup failed")) + .when(authenticator).setup(config); + + Assert.assertThrows(IllegalStateException.class, () -> { + Whitebox.invoke(StandardAuthenticator.class, + new Class[]{HugeConfig.class, String.class, + boolean.class}, + "initAdminUser", authenticator, config, null, false); + }, e -> Assert.assertContains("setup failed", e.getMessage())); + + Mockito.verify(graph).close(); + } + + @Test + public void testSetupFailureWithoutGraphPreservesOriginalException() + throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); + Mockito.doThrow(new IllegalStateException("setup failed before open")) + .when(authenticator).setup(config); + + Assert.assertThrows(IllegalStateException.class, () -> { + Whitebox.invoke(StandardAuthenticator.class, + new Class[]{HugeConfig.class, String.class, + boolean.class}, + "initAdminUser", authenticator, config, null, false); + }, e -> Assert.assertContains("setup failed before open", + e.getMessage())); + } + + @Test + public void testAdminGraphClosedAfterFeatureInspectionFailure() + throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + Mockito.when(graph.backendStoreFeatures()) + .thenThrow(new IllegalStateException("feature check failed")); + + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); + Whitebox.setInternalState(authenticator, "graph", graph); + Mockito.doNothing().when(authenticator).setup(config); + + Assert.assertThrows(IllegalStateException.class, () -> { + Whitebox.invoke(StandardAuthenticator.class, + new Class[]{HugeConfig.class, String.class, + boolean.class}, + "initAdminUser", authenticator, config, null, false); + }, e -> Assert.assertContains("feature check failed", e.getMessage())); + + Mockito.verify(graph).close(); + } + + @Test + public void testAdminGraphClosedWhenAdminAlreadyExists() throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + BackendFeatures features = Mockito.mock(BackendFeatures.class); + StandardAuthManager authManager = Mockito.mock(StandardAuthManager.class); + Mockito.when(graph.backendStoreFeatures()).thenReturn(features); + Mockito.when(features.supportsPersistence()).thenReturn(true); + Mockito.when(graph.hugegraph()).thenReturn(graph); + Mockito.when(graph.authManager()).thenReturn(authManager); + Mockito.when(authManager.findUser("admin")) + .thenReturn(Mockito.mock(HugeUser.class)); + + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); + Whitebox.setInternalState(authenticator, "graph", graph); + Mockito.doNothing().when(authenticator).setup(config); + + Whitebox.invoke(StandardAuthenticator.class, + new Class[]{HugeConfig.class, String.class, + boolean.class}, + "initAdminUser", authenticator, config, null, true); + + Mockito.verify(authManager, Mockito.never()) + .createUser(Mockito.any(HugeUser.class)); + Mockito.verify(graph).close(); + } + + @Test + public void testAdminGraphClosedAfterCreateUserFailure() throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + BackendFeatures features = Mockito.mock(BackendFeatures.class); + StandardAuthManager authManager = Mockito.mock(StandardAuthManager.class); + Mockito.when(graph.backendStoreFeatures()).thenReturn(features); + Mockito.when(features.supportsPersistence()).thenReturn(true); + Mockito.when(graph.hugegraph()).thenReturn(graph); + Mockito.when(graph.authManager()).thenReturn(authManager); + Mockito.when(authManager.createUser(Mockito.any(HugeUser.class))) + .thenThrow(new IllegalStateException("create user failed")); + + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); + Whitebox.setInternalState(authenticator, "graph", graph); + Mockito.doNothing().when(authenticator).setup(config); + + Assert.assertThrows(IllegalStateException.class, () -> { + Whitebox.invoke(StandardAuthenticator.class, + new Class[]{HugeConfig.class, String.class, + boolean.class}, + "initAdminUser", authenticator, config, + "secret", true); + }, e -> Assert.assertContains("create user failed", e.getMessage())); + + Mockito.verify(graph).close(); + } + @Test public void testUnknownInitStoreFlagRejected() throws IOException { String restConf = this.writeDisabledRestServerConf(); From 1434969c48b35ba2b6f1eebc8feb2a31f6f53bf6 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Thu, 30 Jul 2026 22:47:46 +0530 Subject: [PATCH 12/19] fix(hugegraph-dist): address init-store review findings --- .github/workflows/docker-build-ci.yml | 5 + docker/README.md | 12 +- .../hugegraph/auth/StandardAuthenticator.java | 14 +- .../docker/docker-entrypoint.sh | 201 ++++++----------- .../docker/test/JavaPropertiesTool.java | 92 ++++++++ .../docker/test/test-docker-entrypoint.sh | 108 +++++++-- .../src/assembly/static/bin/config-tool.sh | 50 +++++ .../org/apache/hugegraph/cmd/ConfigTool.java | 211 ++++++++++++++++++ .../org/apache/hugegraph/cmd/InitStore.java | 47 +++- .../unit/cmd/InitStoreConfigTest.java | 173 +++++++++++++- 10 files changed, 745 insertions(+), 168 deletions(-) create mode 100644 hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java create mode 100755 hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh create mode 100644 hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index 1b42f528b7..b698d970dd 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -27,8 +27,13 @@ on: - '**/Dockerfile*' - '.dockerignore' - 'hugegraph-server/hugegraph-dist/docker/**' + - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh' - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh' - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh' + - 'hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java' + - 'hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java' + - 'hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java' + - 'hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java' - '**/docker/docker-entrypoint.sh' jobs: diff --git a/docker/README.md b/docker/README.md index bbaa191152..524d107ea5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -160,7 +160,7 @@ Configuration is injected via environment variables. The old `docker/configs/app | `HG_SERVER_BACKEND` | Yes | — | `backend` in `hugegraph.properties` | Storage backend (e.g. `hstore`) | | `HG_SERVER_PD_PEERS` | Yes | — | `pd.peers` | PD cluster addresses (e.g. `pd0:8686,pd1:8686,pd2:8686`) | | `STORE_REST` | No | — | Used by `wait-partition.sh` | Store REST endpoint for partition verification (e.g. `store0:8520`) | -| `PASSWORD` | No | — | Enables auth mode | Optional authentication password | +| `PASSWORD` | No | — | Enables built-in auth mode | Optional built-in admin password; ignored when custom or remote auth is already configured | | `HG_SERVER_INIT_STORE_ENABLED` | No | `true` | `init_store.enabled` in `rest-server.properties` | Set `false` in PD/HStore deployments so init-store skips local backend and admin initialization | > **The built-in authenticator with `HG_SERVER_INIT_STORE_ENABLED=false` @@ -172,9 +172,13 @@ Configuration is injected via environment variables. The old `docker/configs/app > combination is unusable. A custom `auth.authenticator` is exempt because it > manages its own identities. > -> When the combination is valid, a `PASSWORD` is written to `auth.admin_pa` -> instead of being passed to `init-store.sh`, because the admin account is then -> created on server startup. Two consequences worth knowing: +> When the combination is valid for local built-in auth, a `PASSWORD` is written +> to `auth.admin_pa` instead of being passed to `init-store.sh`, because the admin +> account is then created on server startup. Without `PASSWORD`, `auth.admin_pa` +> must be explicitly configured and non-empty; its public `pa` default is not +> accepted for bootstrap. Custom and remote authenticators do not consume the +> built-in admin password, so an inherited `PASSWORD` is ignored rather than +> persisted. Two consequences worth knowing: > > - Before writing the password, the entrypoint restricts > `conf/rest-server.properties` to mode `0600`. Administrators with access to diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java index 1b83716b16..a64ec9446d 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java @@ -242,14 +242,26 @@ private static void initAdminUserIfNeeded(String confFile, private void initAdminUser(HugeConfig config, String password, boolean fromConfig) throws Exception { + Throwable failure = null; try { this.setup(config); if (this.graph().backendStoreFeatures().supportsPersistence()) { this.initAdminUser(password, fromConfig); } + } catch (Exception | Error e) { + failure = e; + throw e; } finally { if (this.graph != null) { - this.graph.close(); + try { + this.graph.close(); + } catch (Exception | Error closeFailure) { + if (failure != null) { + failure.addSuppressed(closeFailure); + } else { + throw closeFailure; + } + } } } } diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 775b2786bb..ecbf377aa4 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -23,6 +23,7 @@ AUTH_INIT_STATE_FILE="auth_init_state" GRAPH_CONF="./conf/graphs/hugegraph.properties" REST_SERVER_CONF="./conf/rest-server.properties" GREMLIN_SERVER_CONF="./conf/gremlin-server.yaml" +CONFIG_TOOL="./bin/config-tool.sh" # The only in-tree HugeAuthenticator that bootstraps HugeGraph's built-in admin # account. auth.authenticator accepts any implementation class, and a custom one @@ -34,121 +35,27 @@ mkdir -p "${DOCKER_FOLDER}" log() { echo "[hugegraph-server-entrypoint] $*"; } -# Sets a property to exactly one canonical `key=value` line. Existing -# definitions are matched on any separator a properties file allows (`=`, `:` -# or whitespace) and collapsed into that single line, because leaving a second -# definition behind would make the parser expose the key as a list and a scalar -# read of it would then fail. Comment lines are left alone. Matching is literal, -# so no regex escaping of the key or value is needed. +# Property access goes through Commons Configuration, the parser HugeConfig +# uses. This keeps escaped keys, continuations, duplicate definitions and value +# serialization identical between the entrypoint and the server. set_prop() { - local key="$1" val="$2" file="$3" tmp - - # The scratch file holds auth.admin_pa, so keep it off the process umask - # and use an unpredictable same-directory name rather than following a - # pre-created predictable symlink. - if ! tmp=$(umask 077; mktemp "${file}.tmp.XXXXXX"); then - return 1 - fi - - if ! SET_PROP_KEY="$key" SET_PROP_VAL="$val" awk ' - BEGIN { key = ENVIRON["SET_PROP_KEY"]; val = ENVIRON["SET_PROP_VAL"] } - { - line = $0 - probe = line - sub(/^[[:space:]]+/, "", probe) - if (index(probe, key) == 1) { - rest = substr(probe, length(key) + 1) - if (rest ~ /^[[:space:]]*[=:]/ || rest ~ /^[[:space:]]+/) { - if (!done) { print key "=" val; done = 1 } - next - } - } - print line - } - END { if (!done) print key "=" val } - ' "${file}" > "${tmp}"; then - rm -f "${tmp}" - return 1 - fi - - # Truncate and rewrite in place rather than rename: a single-file bind - # mount cannot be replaced by rename, and a rename would also discard the - # original ownership and mode, which matters where auth.admin_pa is written - if ! cat "${tmp}" > "${file}"; then - rm -f "${tmp}" - return 1 - fi - rm -f "${tmp}" + "${CONFIG_TOOL}" set "$3" "$1" "$2" } -count_prop() { - local key="$1" file="$2" - - [[ -f "${file}" ]] || { echo 0; return; } - SET_PROP_KEY="$key" awk ' - BEGIN { key = ENVIRON["SET_PROP_KEY"] } - { - probe = $0 - sub(/^[[:space:]]+/, "", probe) - if (index(probe, key) == 1) { - rest = substr(probe, length(key) + 1) - if (rest ~ /^[[:space:]]*[=:]/ || rest ~ /^[[:space:]]+/) { - count++ - } - } - } - END { print count + 0 } - ' "${file}" +get_prop() { + "${CONFIG_TOOL}" get "$2" "$1" } -# Drops duplicate definitions while leaving a single valid definition untouched. -# Avoiding a needless rewrite lets complete read-only mounted configs start. -canonicalize_prop() { - local key="$1" file="$2" count cur - count=$(count_prop "${key}" "${file}") - if (( count > 1 )); then - cur=$(get_prop "${key}" "${file}") - set_prop "${key}" "${cur}" "${file}" - fi +has_prop() { + "${CONFIG_TOOL}" has "$2" "$1" } -# Escapes a UTF-8 value for Java-properties serialization. Encoding every -# UTF-16 code unit as a Unicode escape keeps separators, leading whitespace, -# backslashes and embedded control characters out of the physical property -# line while the Java parser reconstructs the exact original string. -props_escape() { - printf '%s' "$1" | iconv -f UTF-8 -t UTF-16BE | \ - od -An -v -t x1 | awk ' - { - for (i = 1; i <= NF; i++) { - if (high == "") { - high = $i - } else { - printf "\\u%s%s", high, $i - high = "" - } - } - } - END { if (high != "") exit 1 } - ' +requires_local_admin() { + "${CONFIG_TOOL}" requires-local-admin "${REST_SERVER_CONF}" } -# Echoes the value of a property, or nothing when the key or the file is -# absent, so callers apply their own default. Accepts the `=`, `:` and -# whitespace separators that properties files allow. On duplicate keys the last -# one wins, matching how the properties parser reads the same file. Only -# surrounding whitespace is trimmed, as the parser does; whitespace inside a -# value is part of the value and deleting it would corrupt one. -get_prop() { - local key="$1" file="$2" - local esc_key - - [[ -f "${file}" ]] || return 0 - esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') - # '#' delimits the s command because the pattern itself contains '|'. - # '-E' rather than '-r': both GNU and BSD sed accept it - sed -En "s#^[[:space:]]*${esc_key}([[:space:]]*[=:]|[[:space:]]+)[[:space:]]*(.*)\$#\\2#p" \ - "${file}" | tail -n 1 | sed -e 's/[[:space:]]*$//' +validate_skip() { + "${CONFIG_TOOL}" validate-skip "${REST_SERVER_CONF}" } # Canonicalizes a boolean the way the server does. HugeConfig parses these @@ -187,7 +94,7 @@ ensure_auth_enabled() { authenticator=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") class_pattern='^[[:alpha:]_$][[:alnum:]_$]*(\.[[:alpha:]_$][[:alnum:]_$]*)*$' if [[ -n "${authenticator}" && ! "${authenticator}" =~ ${class_pattern} ]]; then - log "ERROR: auth.authenticator must be an unescaped Java class name;" \ + log "ERROR: auth.authenticator must be a Java class name;" \ "got '${authenticator}'" return 1 fi @@ -242,11 +149,6 @@ EOF return 1 fi fi - - # enable-auth.sh appends rather than replaces, so collapse whatever it left - # behind into one definition per key - canonicalize_prop "auth.authenticator" "${REST_SERVER_CONF}" - canonicalize_prop "auth.graph_store" "${REST_SERVER_CONF}" } migrate_env() { @@ -316,6 +218,11 @@ if [[ -n "${PASSWORD:-}" || -n "${AUTHENTICATOR}" ]]; then AUTHENTICATOR=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") fi +LOCAL_BUILTIN_AUTH=false +if [[ -n "${AUTHENTICATOR}" ]] && requires_local_admin; then + LOCAL_BUILTIN_AUTH=true +fi + AUTH_STATE="" AUTH_INIT_REQUIRED=false if [[ -n "${AUTHENTICATOR}" ]]; then @@ -334,34 +241,41 @@ fi if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then log "init-store disabled; validating the no-op configuration" - # Let InitStore make the type-aware decision about whether this effective - # authenticator needs the built-in admin and whether the configured auth - # graph can read the PD-created account. The gate returns before backend or - # plugin registration, so this invocation performs validation only. + # Validate topology before writing a secret. The final init-store invocation + # below repeats the Java gate after auth.admin_pa has been prepared and also + # enforces that local built-in auth has an explicit non-empty password. + validate_skip + + if [[ "${LOCAL_BUILTIN_AUTH}" == "true" ]]; then + if [[ -n "${PASSWORD:-}" ]]; then + log "enabling built-in auth, admin password applied via auth.admin_pa" + # TODO: auth.admin_pa only applies when the admin account is first + # created, so changing PASSWORD on a later restart keeps the old one. + if ! chmod 600 "${REST_SERVER_CONF}"; then + log "ERROR: cannot protect ${REST_SERVER_CONF} before writing auth.admin_pa" + exit 1 + fi + if ! set_prop "auth.admin_pa" "${PASSWORD}" \ + "${REST_SERVER_CONF}"; then + log "ERROR: cannot write auth.admin_pa to ${REST_SERVER_CONF}" + exit 1 + fi + elif ! has_prop "auth.admin_pa" "${REST_SERVER_CONF}" || + [[ -z "$(get_prop "auth.admin_pa" "${REST_SERVER_CONF}")" ]]; then + log "ERROR: local built-in auth requires PASSWORD or an explicitly configured non-empty auth.admin_pa" + exit 1 + fi + elif [[ -n "${PASSWORD:-}" ]]; then + log "PASSWORD ignored: the configured authenticator does not use HugeGraph's local built-in admin" + fi + + # The gate returns before backend or plugin registration, so this performs + # validation only. ./bin/init-store.sh # Still wait: the server needs the storage side reachable at startup even # though nothing is initialized here wait_storage - - if [[ -n "${PASSWORD:-}" ]]; then - log "enabling auth mode, admin password applied via auth.admin_pa" - # TODO: auth.admin_pa only applies when the admin account is first - # created, so changing PASSWORD on a later restart keeps the old one. - if ! ESCAPED_PASSWORD=$(props_escape "${PASSWORD}"); then - log "ERROR: PASSWORD must be valid UTF-8" - exit 1 - fi - if ! chmod 600 "${REST_SERVER_CONF}"; then - log "ERROR: cannot protect ${REST_SERVER_CONF} before writing auth.admin_pa" - exit 1 - fi - if ! set_prop "auth.admin_pa" "${ESCAPED_PASSWORD}" \ - "${REST_SERVER_CONF}"; then - log "ERROR: cannot write auth.admin_pa to ${REST_SERVER_CONF}" - exit 1 - fi - fi # No init flag is written here: nothing was initialized, so a later run # with init-store enabled must still perform the real initialization. elif [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" || @@ -372,16 +286,27 @@ elif [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" || wait_storage if [[ -z "${PASSWORD:-}" ]]; then - if [[ -n "${AUTHENTICATOR}" ]]; then + if [[ "${LOCAL_BUILTIN_AUTH}" == "true" ]]; then + if ! has_prop "auth.admin_pa" "${REST_SERVER_CONF}" || + [[ -z "$(get_prop "auth.admin_pa" "${REST_SERVER_CONF}")" ]]; then + log "ERROR: local built-in auth requires PASSWORD or an explicitly configured non-empty auth.admin_pa" + exit 1 + fi log "init hugegraph with configured auth.admin_pa" ./bin/init-store.sh --use-configured-admin-password + elif [[ -n "${AUTHENTICATOR}" ]]; then + log "init hugegraph with external authentication" + ./bin/init-store.sh else log "init hugegraph with non-auth mode" ./bin/init-store.sh fi - else + elif [[ "${LOCAL_BUILTIN_AUTH}" == "true" ]]; then log "init hugegraph with auth mode" printf '%s\n' "${PASSWORD}" | ./bin/init-store.sh + else + log "PASSWORD ignored: the configured authenticator does not use HugeGraph's local built-in admin" + ./bin/init-store.sh fi # This flag tracks only that init has run. The branch above explicitly # handles later auth-mode changes; other HG_SERVER_* changes do not re-init. @@ -399,7 +324,7 @@ fi ./bin/start-hugegraph.sh -j "${JAVA_OPTS:-}" -t 120 # Post-startup cluster stabilization check (hstore only — rocksdb has no partitions) -ACTUAL_BACKEND=$(grep -E '^[[:space:]]*backend[[:space:]]*=' "${GRAPH_CONF}" | head -n 1 | sed 's/.*=//' | tr -d '[:space:]' || true) +ACTUAL_BACKEND=$(get_prop "backend" "${GRAPH_CONF}") if [[ "${ACTUAL_BACKEND}" == "hstore" ]]; then STORE_REST="${STORE_REST:-store:8520}" export STORE_REST diff --git a/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java b/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java new file mode 100644 index 0000000000..578462deba --- /dev/null +++ b/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java @@ -0,0 +1,92 @@ +/* + * 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. + */ + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Objects; +import java.util.Properties; + +public final class JavaPropertiesTool { + + private JavaPropertiesTool() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 3) { + throw new IllegalArgumentException( + "Expected [value]"); + } + + String command = args[0]; + Path file = Paths.get(args[1]); + String key = args[2]; + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(file)) { + properties.load(input); + } + + switch (command) { + case "get": + String value = properties.getProperty(key); + if (value != null) { + System.out.print(value); + } + return; + case "has": + if (!properties.containsKey(key)) { + System.exit(1); + } + return; + case "set": + if (args.length != 4) { + throw new IllegalArgumentException( + "Expected set "); + } + if (Objects.equals(properties.getProperty(key), args[3])) { + return; + } + properties.setProperty(key, args[3]); + Path scratch = Files.createTempFile( + file.toAbsolutePath().getParent(), + file.getFileName() + ".tmp.", null); + try { + try (OutputStream output = Files.newOutputStream(scratch)) { + properties.store(output, null); + } + try (InputStream input = Files.newInputStream(scratch); + OutputStream output = Files.newOutputStream( + file, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) != -1) { + output.write(buffer, 0, length); + } + } + } finally { + Files.deleteIfExists(scratch); + } + return; + default: + throw new IllegalArgumentException("Unknown command: " + command); + } + } +} diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index 6e4b6edf68..330f773450 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -28,6 +28,9 @@ set -uo pipefail SELF_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENTRYPOINT="${SELF_DIR}/../docker-entrypoint.sh" +TEST_CLASSES=$(mktemp -d "${TMPDIR:-/tmp}/hg-entrypoint-classes.XXXXXX") +javac -d "${TEST_CLASSES}" "${SELF_DIR}/JavaPropertiesReader.java" \ + "${SELF_DIR}/JavaPropertiesTool.java" PASS=0 FAIL=0 @@ -99,7 +102,7 @@ file_mtime() { # including trailing newlines that command substitution would otherwise hide. assert_prop_round_trip() { local key="$1" expected="$2" actual_hex expected_hex - if ! actual_hex=$(java "${SELF_DIR}/JavaPropertiesReader.java" \ + if ! actual_hex=$(java -cp "${TEST_CLASSES}" JavaPropertiesReader \ "${INSTALL}/conf/rest-server.properties" "${key}" \ 2> "${INSTALL}/java-properties.err"); then fail "Java could not read '${key}': $(cat "${INSTALL}/java-properties.err")" @@ -199,6 +202,28 @@ exit "\${INIT_STORE_STUB_RC:-0}" EOF chmod +x "${INSTALL}/bin/init-store.sh" + # The production wrapper launches ConfigTool from the assembled jars. This + # source-launch stub keeps the shell suite backend-free while using Java's + # properties grammar for escaped keys and continued logical lines. + cat > "${INSTALL}/bin/config-tool.sh" < default: no flag set, full init runs" new_install @@ -365,12 +394,13 @@ assert_auth_fully_enabled assert_file "docker/auth_init_state" cleanup -echo "==> mounted built-in auth without PASSWORD uses configured admin password" +echo "==> mounted built-in auth without PASSWORD uses an explicit admin password" new_install mkdir -p "${INSTALL}/docker" touch "${INSTALL}/docker/init_complete" echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ >> "${INSTALL}/conf/rest-server.properties" +echo "auth.admin_pa=s3cret" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint -u PASSWORD assert_ran "init-store.sh" assert_ran "init-store.sh:args=--use-configured-admin-password" @@ -430,6 +460,17 @@ assert_ran "init-store.sh" assert_no_file "docker/init_complete" cleanup +echo "==> a continued mounted boolean is parsed as one Java property" +new_install +{ + printf 'init_store.enabled=fal\\\n' + echo ' se' +} >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD +assert_ran "init-store.sh" +assert_no_file "docker/init_complete" +cleanup + echo "==> password with backslashes survives the properties round trip" new_install enable_pd @@ -462,7 +503,7 @@ new_install run_entrypoint_fails -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret \ HG_SERVER_INIT_STORE_ENABLED=false INIT_STORE_STUB_RC=1 assert_not_ran "start-hugegraph.sh" -assert_ran "init-store.sh" +assert_not_ran "init-store.sh" assert_no_prop_key "auth.admin_pa" assert_no_file "docker/init_complete" cleanup @@ -474,7 +515,7 @@ echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ INIT_STORE_STUB_RC=1 assert_not_ran "start-hugegraph.sh" -assert_ran "init-store.sh" +assert_not_ran "init-store.sh" cleanup echo "==> skip without auth is unaffected by the usePD requirement" @@ -555,10 +596,11 @@ new_install enable_pd echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ >> "${INSTALL}/conf/rest-server.properties" +echo "auth.admin_pa=s3cret" >> "${INSTALL}/conf/rest-server.properties" run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false assert_ran "init-store.sh" assert_auth_fully_enabled -assert_no_prop_key "auth.admin_pa" +assert_prop_round_trip "auth.admin_pa" "s3cret" cleanup echo "==> a gremlin-server.yaml without a trailing newline is still valid" @@ -591,6 +633,7 @@ cleanup echo "==> a complete read-only auth mount is not rewritten" new_install "${INSTALL}/bin/enable-auth.sh" +echo "auth.admin_pa=s3cret" >> "${INSTALL}/conf/rest-server.properties" : > "${INSTALL}/calls.log" touch -t 202001010000 "${INSTALL}/conf/rest-server.properties" before_mtime=$(file_mtime "${INSTALL}/conf/rest-server.properties") @@ -645,7 +688,8 @@ assert_ran "start-hugegraph.sh" assert_ran "init-store.sh" assert_auth_fully_enabled assert_prop "auth.authenticator" "org.example.auth.LdapAuthenticator" -assert_prop_round_trip "auth.admin_pa" "s3cret" +assert_no_prop_key "auth.admin_pa" +assert_output_contains "PASSWORD ignored" cleanup echo "==> surrounding whitespace is trimmed, inner whitespace is kept" @@ -657,18 +701,21 @@ printf 'auth.authenticator = %s \n' \ # entrypoint must preserve its failure status for this padded value. run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ INIT_STORE_STUB_RC=1 -assert_ran "init-store.sh" +assert_not_ran "init-store.sh" assert_not_ran "start-hugegraph.sh" cleanup -echo "==> escaped authenticator spelling is rejected before YAML generation" +echo "==> escaped authenticator key and value use Java properties grammar" new_install -echo 'auth.authenticator=org.apache.hugegraph.auth.Standard\u0041uthenticator' \ +enable_pd +echo 'auth\.authenticator=org.apache.hugegraph.auth.Standard\u0041uthenticator' \ >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +run_entrypoint PASSWORD=s3cret HG_SERVER_INIT_STORE_ENABLED=false assert_not_ran "enable-auth.sh" -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" +assert_ran "init-store.sh" +assert_ran "start-hugegraph.sh" +assert_auth_fully_enabled +assert_prop_round_trip "auth.admin_pa" "s3cret" cleanup echo "==> a value containing spaces survives the round trip" @@ -690,6 +737,41 @@ assert_ran "init-store.sh" assert_auth_fully_enabled cleanup +echo "==> PASSWORD is not persisted for remote auth" +new_install +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +echo "auth.remote_url=127.0.0.1:8899" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint PASSWORD=s3cret HG_SERVER_INIT_STORE_ENABLED=false +assert_ran "start-hugegraph.sh" +assert_no_prop_key "auth.admin_pa" +assert_output_contains "PASSWORD ignored" +cleanup + +echo "==> configured built-in auth cannot bootstrap with the default password" +new_install +mkdir -p "${INSTALL}/docker" +touch "${INSTALL}/docker/init_complete" +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint_fails -u PASSWORD +assert_not_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" +assert_output_contains "requires PASSWORD or an explicitly configured non-empty auth.admin_pa" +cleanup + +echo "==> skipped built-in auth also rejects the default password" +new_install +enable_pd +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_not_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" +assert_output_contains "requires PASSWORD or an explicitly configured non-empty auth.admin_pa" +cleanup + echo "==> secret write preserves the inode and restricts the shipped mode" new_install enable_pd diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh new file mode 100755 index 0000000000..247dd49905 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh @@ -0,0 +1,50 @@ +#!/bin/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. +# + +function abs_path() { + SOURCE="${BASH_SOURCE[0]}" + while [[ -h "${SOURCE}" ]]; do + DIR="$(cd -P "$(dirname "${SOURCE}")" && pwd)" + SOURCE="$(readlink "${SOURCE}")" + [[ ${SOURCE} != /* ]] && SOURCE="${DIR}/${SOURCE}" + done + cd -P "$(dirname "${SOURCE}")" && pwd +} + +BIN=$(abs_path) +TOP="$(cd "${BIN}"/../ && pwd)" +LIB="${TOP}/lib" +PLUGINS="${TOP}/plugins" + +if [[ -n "${JAVA_HOME:-}" ]]; then + JAVA="${JAVA_HOME}/bin/java" +else + JAVA=java +fi + +cd "${TOP}" || exit 1 + +DEFAULT_JAVA_OPTIONS="--add-exports=java.base/jdk.internal.reflect=ALL-UNNAMED" +CP=$(find -L "${LIB}" -name 'hugegraph*.jar' | sort | tr '\n' ':') +CP="${CP}":$(find -L "${LIB}" -name '*.jar' \! -name 'hugegraph*' | sort | tr '\n' ':') +if [[ -d "${PLUGINS}" ]]; then + CP="${CP}":$(find -L "${PLUGINS}" -name '*.jar' | sort | tr '\n' ':') +fi + +exec "${JAVA}" -cp "${CP}" ${DEFAULT_JAVA_OPTIONS} \ +org.apache.hugegraph.cmd.ConfigTool "$@" diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java new file mode 100644 index 0000000000..f4426ddd18 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java @@ -0,0 +1,211 @@ +/* + * 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.cmd; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collection; +import java.util.Objects; + +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.commons.configuration2.PropertiesConfiguration.IOFactory; +import org.apache.commons.configuration2.PropertiesConfiguration.PropertiesReader; +import org.apache.commons.configuration2.PropertiesConfiguration.PropertiesWriter; +import org.apache.commons.configuration2.builder.fluent.Configurations; +import org.apache.commons.configuration2.convert.ListDelimiterHandler; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.io.FileHandler; +import org.apache.commons.text.StringEscapeUtils; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.config.ServerOptions; +import org.apache.hugegraph.dist.RegisterUtil; +import org.apache.hugegraph.util.E; + +public final class ConfigTool { + + private static final String GET = "get"; + private static final String HAS = "has"; + private static final String SET = "set"; + private static final String REQUIRES_LOCAL_ADMIN = + "requires-local-admin"; + private static final String VALIDATE_SKIP = "validate-skip"; + private static final IOFactory EXACT_PROPERTIES_IO_FACTORY = + new IOFactory() { + + @Override + public PropertiesReader createPropertiesReader(Reader reader) { + return new PropertiesReader(reader); + } + + @Override + public PropertiesWriter createPropertiesWriter( + Writer writer, + ListDelimiterHandler delimiterHandler) { + return new PropertiesWriter(writer, delimiterHandler, + ConfigTool::escapePropertyValue); + } + }; + + private ConfigTool() { + } + + public static void main(String[] args) throws Exception { + E.checkArgument(args.length >= 2, "Usage: ConfigTool ..."); + + String command = args[0]; + String file = args[1]; + switch (command) { + case GET: + E.checkArgument(args.length == 3, + "Usage: ConfigTool get "); + String value = getProperty(file, args[2]); + if (value != null) { + // CHECKSTYLE:OFF + System.out.print(value); + // CHECKSTYLE:ON + } + break; + case HAS: + E.checkArgument(args.length == 3, + "Usage: ConfigTool has "); + if (!hasProperty(file, args[2])) { + System.exit(1); + } + break; + case SET: + E.checkArgument(args.length == 4, + "Usage: ConfigTool set "); + setProperty(file, args[2], args[3]); + break; + case REQUIRES_LOCAL_ADMIN: + E.checkArgument(args.length == 2, + "Usage: ConfigTool requires-local-admin " + + ""); + if (!requiresLocalAdmin(file)) { + System.exit(1); + } + break; + case VALIDATE_SKIP: + E.checkArgument(args.length == 2, + "Usage: ConfigTool validate-skip " + + ""); + validateSkip(file); + break; + default: + throw new IllegalArgumentException("Unknown command: " + command); + } + } + + static String getProperty(String file, String key) + throws ConfigurationException { + Object value = load(file).getProperty(key); + if (value == null) { + return null; + } + E.checkArgument(!(value instanceof Collection), + "Property '%s' must contain one value, got '%s'", + key, value); + return value.toString(); + } + + static boolean hasProperty(String file, String key) + throws ConfigurationException { + return load(file).containsKey(key); + } + + static void setProperty(String file, String key, String value) + throws ConfigurationException, IOException { + PropertiesConfiguration config = load(file); + Object current = config.getProperty(key); + if (!(current instanceof Collection) && + Objects.equals(current, value)) { + return; + } + + config.setProperty(key, value); + config.setIOFactory(EXACT_PROPERTIES_IO_FACTORY); + Path target = new File(file).toPath().toAbsolutePath(); + Path parent = target.getParent(); + E.checkState(parent != null, "Config file has no parent: %s", file); + Path scratch = Files.createTempFile(parent, + target.getFileName() + ".tmp.", + null); + try { + FileHandler handler = new FileHandler(config); + handler.save(scratch.toFile()); + try (InputStream input = Files.newInputStream(scratch); + OutputStream output = Files.newOutputStream( + target, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) != -1) { + output.write(buffer, 0, length); + } + } + } finally { + Files.deleteIfExists(scratch); + } + } + + static boolean requiresLocalAdmin(String file) { + RegisterUtil.registerServer(); + HugeConfig config = new HugeConfig(file); + return InitStore.requiresLocalBuiltinAdmin(config); + } + + static void validateSkip(String file) { + RegisterUtil.registerServer(); + HugeConfig config = new HugeConfig(file); + E.checkArgument(!config.get(ServerOptions.INIT_STORE_ENABLED), + "'%s' must be false for skip validation", + ServerOptions.INIT_STORE_ENABLED.name()); + InitStore.checkAdminBootstrapReachable(config, file, false); + } + + private static PropertiesConfiguration load(String file) + throws ConfigurationException { + return new Configurations().properties(new File(file)); + } + + private static Object escapePropertyValue(Object value) { + String escaped = StringEscapeUtils.escapeJava(String.valueOf(value)); + int leadingSpaces = 0; + while (leadingSpaces < escaped.length() && + escaped.charAt(leadingSpaces) == ' ') { + leadingSpaces++; + } + if (leadingSpaces == 0) { + return escaped; + } + + StringBuilder result = new StringBuilder(escaped.length() + + leadingSpaces * 5); + for (int i = 0; i < leadingSpaces; i++) { + result.append("\\u0020"); + } + return result.append(escaped.substring(leadingSpaces)).toString(); + } +} diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 3bfedc6462..5b7f46385c 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -76,11 +76,12 @@ public static void main(String[] args) throws Exception { * init path. * * The loop below already skips hstore backends, so what this gate - * additionally avoids is scanning the graphs directory (which must - * otherwise exist), and, when auth is configured, opening the auth - * graph store in initAdminUserIfNeeded(). On Kubernetes that ran on - * every Server pod restart, since the entrypoint's init flag file does - * not survive one. + * additionally avoids is the full graph scan and opening the auth graph + * store in initAdminUserIfNeeded(). The validation below may still scan + * graph configuration files to prove that a local built-in authenticator + * can reach the PD-created admin, but it never opens those graphs. On + * Kubernetes the full work ran on every Server pod restart, since the + * entrypoint's init flag file does not survive one. * * NOTE: skipping also means the built-in admin account is not created * here. The PD startup path can replace that work only when usePD is @@ -92,7 +93,7 @@ public static void main(String[] args) throws Exception { LOG.warn("Skipping init-store: '{}' is false in '{}'. Local " + "backend and admin initialization are not performed.", ServerOptions.INIT_STORE_ENABLED.name(), restConf); - checkAdminBootstrapReachable(restServerConfig, restConf); + checkAdminBootstrapReachable(restServerConfig, restConf, true); return; } @@ -122,7 +123,8 @@ public static void main(String[] args) throws Exception { if (useConfiguredAdminPassword) { StandardAuthenticator.initAdminUserIfNeeded( restConf, - restServerConfig.get(ServerOptions.ADMIN_PA)); + configuredAdminPassword(restServerConfig, + restConf)); } else { StandardAuthenticator.initAdminUserIfNeeded(restConf); } @@ -149,10 +151,9 @@ public static void main(String[] args) throws Exception { * any authenticator other than the built-in one, which keeps its * identities outside HugeGraph and needs no such account. */ - private static void checkAdminBootstrapReachable(HugeConfig conf, - String restConf) { - if (!requiresBuiltinAdmin(conf.get(ServerOptions.AUTHENTICATOR)) || - !conf.get(ServerOptions.AUTH_REMOTE_URL).isEmpty()) { + static void checkAdminBootstrapReachable(HugeConfig conf, String restConf, + boolean requirePassword) { + if (!requiresLocalBuiltinAdmin(conf)) { return; } @@ -184,6 +185,10 @@ private static void checkAdminBootstrapReachable(HugeConfig conf, "auth graph '%s' uses backend '%s', not 'hstore'", graphName, backend), null); } + + if (requirePassword) { + configuredAdminPassword(conf, restConf); + } } private static IllegalStateException adminBootstrapUnavailable( @@ -212,6 +217,11 @@ private static IllegalStateException adminBootstrapUnavailable( * resolved without initializing it, and one that is not on the init-store * classpath is by definition not the built-in authenticator. */ + static boolean requiresLocalBuiltinAdmin(HugeConfig conf) { + return conf.get(ServerOptions.AUTH_REMOTE_URL).isEmpty() && + requiresBuiltinAdmin(conf.get(ServerOptions.AUTHENTICATOR)); + } + private static boolean requiresBuiltinAdmin(String authClass) { if (authClass.isEmpty()) { return false; @@ -227,6 +237,21 @@ private static boolean requiresBuiltinAdmin(String authClass) { } } + private static String configuredAdminPassword(HugeConfig conf, + String restConf) { + String key = ServerOptions.ADMIN_PA.name(); + E.checkArgument(conf.containsKey(key), + "The local built-in authenticator in '%s' must " + + "explicitly define a non-empty '%s'; the default " + + "value is not accepted for admin bootstrap", + restConf, key); + String password = conf.get(ServerOptions.ADMIN_PA); + E.checkArgument(!password.isEmpty(), + "The configured admin password in '%s' can't be empty", + restConf); + return password; + } + private static HugeGraph initGraph(String configPath) throws Exception { LOG.info("Init graph with config file: {}", configPath); HugeConfig config = new HugeConfig(configPath); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index b61ac32f11..4ad494eca1 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -33,6 +33,7 @@ import org.apache.hugegraph.auth.StandardAuthManager; import org.apache.hugegraph.auth.StandardAuthenticator; import org.apache.hugegraph.backend.store.BackendFeatures; +import org.apache.hugegraph.cmd.ConfigTool; import org.apache.hugegraph.cmd.InitStore; import org.apache.hugegraph.config.CoreOptions; import org.apache.hugegraph.config.HugeConfig; @@ -148,6 +149,59 @@ public void testUnicodeEscapedAdminPasswordRoundTrips() throws IOException { Assert.assertEquals(password, config.get(ServerOptions.ADMIN_PA)); } + @Test + public void testConfigToolUsesJavaPropertiesGrammar() throws Exception { + Path conf = this.workDir.resolve("grammar.properties"); + Files.write(conf, Arrays.asList( + "auth\\.authenticator=" + + StandardAuthenticator.class.getName(), + "init_store.enabled=fal\\", + " se"), StandardCharsets.UTF_8); + + Assert.assertEquals(StandardAuthenticator.class.getName(), + configToolGet(conf, "auth.authenticator")); + Assert.assertEquals("false", + configToolGet(conf, "init_store.enabled")); + } + + @Test + public void testConfigToolReplacesLogicalPropertyAndPreservesLayout() + throws Exception { + Path conf = this.workDir.resolve("rewrite.properties"); + String key = ServerOptions.AUTHENTICATOR.name(); + Files.write(conf, Arrays.asList( + "# mounted configuration", + "auth\\.authenticator=old.Authenticator", + key + "=duplicate.Authenticator"), StandardCharsets.UTF_8); + + configToolSet(conf, key, StandardAuthenticator.class.getName()); + + Assert.assertEquals(StandardAuthenticator.class.getName(), + configToolGet(conf, key)); + HugeConfig config = new HugeConfig(conf.toString()); + Assert.assertEquals(StandardAuthenticator.class.getName(), + config.get(ServerOptions.AUTHENTICATOR)); + Assert.assertContains("# mounted configuration", + new String(Files.readAllBytes(conf), + StandardCharsets.UTF_8)); + try (Stream files = Files.list(this.workDir)) { + Assert.assertFalse(files.anyMatch(path -> + path.getFileName().toString().contains(".tmp."))); + } + } + + @Test + public void testConfigToolSerializesPasswordExactly() throws Exception { + Path conf = this.workDir.resolve("password.properties"); + Files.write(conf, Arrays.asList("# password"), StandardCharsets.UTF_8); + String password = " \tmeta:=#!\\\r\f\np\u00e4ss\u96ea\n"; + + configToolSet(conf, ServerOptions.ADMIN_PA.name(), password); + + HugeConfig config = new HugeConfig(conf.toString()); + Assert.assertEquals(password, config.get(ServerOptions.ADMIN_PA)); + } + /** * The graphs directory referenced by the temporary config does not exist, * so every code path that reaches graph scanning fails. That is what makes @@ -248,6 +302,49 @@ public void testAdminGraphClosedAfterSetupFailure() throws Exception { Mockito.verify(graph).close(); } + @Test + public void testPrimaryAdminFailureSuppressesCloseFailure() throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); + Whitebox.setInternalState(authenticator, "graph", graph); + Mockito.doThrow(new IllegalStateException("setup failed")) + .when(authenticator).setup(config); + Mockito.doThrow(new IllegalStateException("close failed")) + .when(graph).close(); + + Assert.assertThrows(IllegalStateException.class, () -> { + invokeInitAdminUser(authenticator, config, null, false); + }, e -> { + Assert.assertContains("setup failed", e.getMessage()); + Assert.assertEquals(1, e.getSuppressed().length); + Assert.assertContains("close failed", + e.getSuppressed()[0].getMessage()); + }); + } + + @Test + public void testAdminCloseFailureSurfacesWhenItIsOnlyFailure() + throws Exception { + HugeConfig config = Mockito.mock(HugeConfig.class); + HugeGraph graph = Mockito.mock(HugeGraph.class); + BackendFeatures features = Mockito.mock(BackendFeatures.class); + Mockito.when(graph.backendStoreFeatures()).thenReturn(features); + Mockito.when(features.supportsPersistence()).thenReturn(false); + Mockito.doThrow(new IllegalStateException("close failed")) + .when(graph).close(); + + StandardAuthenticator authenticator = + Mockito.spy(new StandardAuthenticator()); + Whitebox.setInternalState(authenticator, "graph", graph); + Mockito.doNothing().when(authenticator).setup(config); + + Assert.assertThrows(IllegalStateException.class, () -> { + invokeInitAdminUser(authenticator, config, null, false); + }, e -> Assert.assertContains("close failed", e.getMessage())); + } + @Test public void testSetupFailureWithoutGraphPreservesOriginalException() throws Exception { @@ -421,11 +518,32 @@ public void testDisabledInitStoreAllowsPdBackedHstoreAuthGraph() ServerOptions.AUTHENTICATOR.name() + "=" + StandardAuthenticator.class.getName(), ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph", - ServerOptions.USE_PD.name() + "=true"); + ServerOptions.USE_PD.name() + "=true", + ServerOptions.ADMIN_PA.name() + "=secret"); InitStore.main(new String[]{restConf}); } + @Test + public void testDisabledInitStoreRejectsDefaultAdminPassword() + throws Exception { + Path graphsDir = this.writeAuthGraphConfig("hstore"); + String restConf = this.writeDisabledRestServerConfForGraphs( + graphsDir, + ServerOptions.AUTHENTICATOR.name() + + "=" + StandardAuthenticator.class.getName(), + ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph", + ServerOptions.USE_PD.name() + "=true"); + + // Docker uses this topology-only preflight before it writes PASSWORD. + Whitebox.invokeStatic(ConfigTool.class, + new Class[]{String.class}, + "validateSkip", restConf); + Assert.assertThrows(IllegalArgumentException.class, () -> { + InitStore.main(new String[]{restConf}); + }, e -> Assert.assertContains("must explicitly define", e.getMessage())); + } + /** * Remote auth delegates to another service, so there is no local admin to * create and the check above must not fire. @@ -465,6 +583,59 @@ public void testDisabledInitStoreAllowsCustomAuthenticator() e.getMessage())); } + @Test + public void testConfigToolClassifiesLocalBuiltinAuth() throws Exception { + String standard = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + "=" + + StandardAuthenticator.class.getName()); + String derived = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + "=" + + DerivedAuthenticator.class.getName()); + String remote = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + "=" + + StandardAuthenticator.class.getName(), + ServerOptions.AUTH_REMOTE_URL.name() + "=127.0.0.1:8899"); + String custom = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + + "=org.example.auth.LdapAuthenticator"); + + Assert.assertTrue(configToolRequiresLocalAdmin(standard)); + Assert.assertTrue(configToolRequiresLocalAdmin(derived)); + Assert.assertFalse(configToolRequiresLocalAdmin(remote)); + Assert.assertFalse(configToolRequiresLocalAdmin(custom)); + } + + private static String configToolGet(Path file, String key) { + return Whitebox.invokeStatic( + ConfigTool.class, + new Class[]{String.class, String.class}, + "getProperty", file.toString(), key); + } + + private static void configToolSet(Path file, String key, String value) { + Whitebox.invokeStatic( + ConfigTool.class, + new Class[]{String.class, String.class, String.class}, + "setProperty", file.toString(), key, value); + } + + private static boolean configToolRequiresLocalAdmin(String file) { + return Whitebox.invokeStatic( + ConfigTool.class, new Class[]{String.class}, + "requiresLocalAdmin", file); + } + + private static void invokeInitAdminUser( + StandardAuthenticator authenticator, + HugeConfig config, String password, + boolean fromConfig) { + Whitebox.invoke(StandardAuthenticator.class, + new Class[]{HugeConfig.class, String.class, + boolean.class}, + "initAdminUser", authenticator, config, + password, fromConfig); + } + private String writeDisabledRestServerConf(String... extraLines) throws IOException { return this.writeDisabledRestServerConfForGraphs( From 3e505a811b10e42c9e3b80d9a18b09ac80181ce0 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Thu, 30 Jul 2026 23:32:40 +0530 Subject: [PATCH 13/19] fix(hugegraph-dist): fail fast on init-store override writes --- .../docker/docker-entrypoint.sh | 22 +++++++++++++------ .../docker/test/test-docker-entrypoint.sh | 10 +++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index ecbf377aa4..a8e927e1d8 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -151,6 +151,14 @@ EOF fi } +require_configured_admin_password() { + if ! has_prop "auth.admin_pa" "${REST_SERVER_CONF}" || + [[ -z "$(get_prop "auth.admin_pa" "${REST_SERVER_CONF}")" ]]; then + log "ERROR: local built-in auth requires PASSWORD or an explicitly configured non-empty auth.admin_pa" + return 1 + fi +} + migrate_env() { local old_name="$1" new_name="$2" @@ -173,7 +181,11 @@ if [[ -n "${HG_SERVER_INIT_STORE_ENABLED:-}" ]]; then log "ERROR: HG_SERVER_INIT_STORE_ENABLED must be a boolean, got '${HG_SERVER_INIT_STORE_ENABLED}'" exit 1 fi - set_prop "init_store.enabled" "${HG_SERVER_INIT_STORE_ENABLED}" "${REST_SERVER_CONF}" + if ! set_prop "init_store.enabled" "${HG_SERVER_INIT_STORE_ENABLED}" \ + "${REST_SERVER_CONF}"; then + log "ERROR: cannot write init_store.enabled to ${REST_SERVER_CONF}" + exit 1 + fi fi # ── Build wait-storage env ───────────────────────────────────────────── @@ -260,9 +272,7 @@ if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then log "ERROR: cannot write auth.admin_pa to ${REST_SERVER_CONF}" exit 1 fi - elif ! has_prop "auth.admin_pa" "${REST_SERVER_CONF}" || - [[ -z "$(get_prop "auth.admin_pa" "${REST_SERVER_CONF}")" ]]; then - log "ERROR: local built-in auth requires PASSWORD or an explicitly configured non-empty auth.admin_pa" + elif ! require_configured_admin_password; then exit 1 fi elif [[ -n "${PASSWORD:-}" ]]; then @@ -287,9 +297,7 @@ elif [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" || if [[ -z "${PASSWORD:-}" ]]; then if [[ "${LOCAL_BUILTIN_AUTH}" == "true" ]]; then - if ! has_prop "auth.admin_pa" "${REST_SERVER_CONF}" || - [[ -z "$(get_prop "auth.admin_pa" "${REST_SERVER_CONF}")" ]]; then - log "ERROR: local built-in auth requires PASSWORD or an explicitly configured non-empty auth.admin_pa" + if ! require_configured_admin_password; then exit 1 fi log "init hugegraph with configured auth.admin_pa" diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index 330f773450..efdf33110b 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -668,6 +668,16 @@ assert_output_contains \ "ERROR: cannot write auth.graph_store to ./conf/rest-server.properties" cleanup +echo "==> a read-only mount rejects an init-store env override" +new_install +chmod 444 "${INSTALL}/conf/rest-server.properties" +run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_not_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" +assert_output_contains \ + "ERROR: cannot write init_store.enabled to ./conf/rest-server.properties" +cleanup + echo "==> a custom authenticator is not held to the usePD requirement" new_install echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ From da85e46b781089acba6ae76eb88b7fc902b8ff58 Mon Sep 17 00:00:00 2001 From: imbajin Date: Fri, 31 Jul 2026 21:27:52 +0800 Subject: [PATCH 14/19] fix(server): harden auth bootstrap upgrades - migrate verified legacy auth volumes without old secrets - fail closed on REST and Gremlin auth mismatches - protect config rewrites and reject include flattening - read administrator passwords from standard input - verify read-only mounts in Docker CI --- .github/workflows/docker-build-ci.yml | 11 ++ .../docker/docker-entrypoint.sh | 78 ++++++++- .../docker/test/JavaPropertiesTool.java | 63 ++++--- .../docker/test/test-docker-entrypoint.sh | 161 ++++++++++++++++-- .../src/assembly/static/bin/init-store.sh | 29 ++-- .../org/apache/hugegraph/cmd/ConfigTool.java | 91 ++++++++-- .../unit/cmd/InitStoreConfigTest.java | 67 ++++++++ 7 files changed, 436 insertions(+), 64 deletions(-) diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index b698d970dd..38b1fa9886 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -78,3 +78,14 @@ jobs: HC=$(docker inspect --format='{{json .Config.Healthcheck}}' "$IMAGE_ID") echo "Healthcheck: $HC" [[ "$HC" != "null" ]] || { echo "ERROR: HEALTHCHECK missing in ${{ matrix.dockerfile }}"; exit 1; } + if [[ "${{ matrix.dockerfile }}" == hugegraph-server/Dockerfile* ]]; then + RO_CONF=$(mktemp) + printf 'restserver.url=http://0.0.0.0:8080\n' > "$RO_CONF" + if docker run --rm --entrypoint bash \ + -v "$RO_CONF:/tmp/rest-server.properties:ro" "$IMAGE_ID" \ + -c './bin/config-tool.sh set /tmp/rest-server.properties init_store.enabled false; status=$?; [[ $status -eq 0 ]] || compgen -G "/tmp/rest-server.properties.tmp.*" >/dev/null; unexpected=$?; [[ $unexpected -eq 0 ]] && exit 0; exit "$status"'; then + echo "ERROR: ConfigTool rewrote a read-only bind mount or leaked a scratch file" + exit 1 + fi + grep -qxF 'restserver.url=http://0.0.0.0:8080' "$RO_CONF" + fi diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index a8e927e1d8..183456bc5d 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -23,7 +23,7 @@ AUTH_INIT_STATE_FILE="auth_init_state" GRAPH_CONF="./conf/graphs/hugegraph.properties" REST_SERVER_CONF="./conf/rest-server.properties" GREMLIN_SERVER_CONF="./conf/gremlin-server.yaml" -CONFIG_TOOL="./bin/config-tool.sh" +CONFIG_TOOL="${CONFIG_TOOL:-./bin/config-tool.sh}" # The only in-tree HugeAuthenticator that bootstraps HugeGraph's built-in admin # account. auth.authenticator accepts any implementation class, and a custom one @@ -42,6 +42,10 @@ set_prop() { "${CONFIG_TOOL}" set "$3" "$1" "$2" } +set_secret_prop() { + printf '%s' "$2" | "${CONFIG_TOOL}" set-stdin "$3" "$1" +} + get_prop() { "${CONFIG_TOOL}" get "$2" "$1" } @@ -73,7 +77,41 @@ to_bool() { } gremlin_auth_configured() { - grep -qE '^[[:space:]]*authentication:' "${GREMLIN_SERVER_CONF}" 2>/dev/null + grep -qE '^authentication[[:space:]]*:' \ + "${GREMLIN_SERVER_CONF}" 2>/dev/null +} + +# Print the single authenticator selected by the top-level authentication +# mapping. This intentionally accepts only the simple scalar form emitted by +# HugeGraph and Gremlin Server examples; aliases, substitutions, duplicates, +# or malformed mappings fail closed instead of risking different REST and +# Gremlin authentication providers. +gremlin_authenticator() { + local blocks auth_block values + blocks=$(grep -cE '^authentication[[:space:]]*:' \ + "${GREMLIN_SERVER_CONF}" 2>/dev/null || true) + [[ "${blocks}" == "1" ]] || return 1 + + auth_block=$(awk ' + /^authentication[[:space:]]*:/ { active=1 } + active { + if (seen && $0 ~ /^[^[:space:]#][^:]*[[:space:]]*:/) exit + print + seen=1 + if ($0 ~ /^[[:space:]]*}[[:space:]]*$/) exit + } + ' "${GREMLIN_SERVER_CONF}") + values=$(printf '%s\n' "${auth_block}" | sed -nE \ + 's/.*(^|[,{[:space:]])authenticator[[:space:]]*:[[:space:]]*([[:alnum:]_.$]+).*/\2/p') + [[ "$(printf '%s\n' "${values}" | sed '/^$/d' | wc -l | tr -d ' ')" == "1" ]] || \ + return 1 + printf '%s\n' "${values}" +} + +gremlin_auth_matches() { + local configured + configured=$(gremlin_authenticator) || return 1 + [[ "${configured}" == "$1" ]] } graph_auth_proxy_configured() { @@ -124,7 +162,12 @@ ensure_auth_enabled() { return 1 fi fi - if ! gremlin_auth_configured; then + if gremlin_auth_configured; then + if ! gremlin_auth_matches "${authenticator}"; then + log "ERROR: Gremlin authenticator must match REST auth.authenticator '${authenticator}'" + return 1 + fi + else # A file whose last line has no newline would otherwise absorb the # first line of the block if [[ -s "${GREMLIN_SERVER_CONF}" && \ @@ -225,6 +268,20 @@ fi # matching Gremlin handler or auth graph proxy. Complete all three configs for # every configured authenticator, whether or not Docker supplied a PASSWORD. AUTHENTICATOR=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") +LEGACY_AUTH_CONFIG_ALIGNED=false +if [[ -n "${AUTHENTICATOR}" ]] && + [[ -d "./conf-bak" ]] && + [[ -n "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")" ]] && + gremlin_auth_matches "${AUTHENTICATOR}" && + graph_auth_proxy_configured; then + LEGACY_AUTH_CONFIG_ALIGNED=true +fi +if [[ -z "${AUTHENTICATOR}" ]] && + { gremlin_auth_configured || graph_auth_proxy_configured; }; then + log "ERROR: REST authentication is disabled while Gremlin or the graph" \ + "auth proxy remains enabled" + exit 1 +fi if [[ -n "${PASSWORD:-}" || -n "${AUTHENTICATOR}" ]]; then ensure_auth_enabled AUTHENTICATOR=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") @@ -246,7 +303,14 @@ if [[ -n "${AUTHENTICATOR}" ]]; then "${DOCKER_FOLDER}/${AUTH_INIT_STATE_FILE}" 2>/dev/null || true) if [[ -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" && "${STORED_AUTH_STATE}" != "${AUTH_STATE}" ]]; then - AUTH_INIT_REQUIRED=true + if [[ -z "${STORED_AUTH_STATE}" && + "${LEGACY_AUTH_CONFIG_ALIGNED}" == "true" ]]; then + log "legacy authenticated volume detected; recording auth state without re-initialization" + printf '%s\n' "${AUTH_STATE}" > \ + "${DOCKER_FOLDER}/${AUTH_INIT_STATE_FILE}" + else + AUTH_INIT_REQUIRED=true + fi fi fi @@ -267,8 +331,8 @@ if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then log "ERROR: cannot protect ${REST_SERVER_CONF} before writing auth.admin_pa" exit 1 fi - if ! set_prop "auth.admin_pa" "${PASSWORD}" \ - "${REST_SERVER_CONF}"; then + if ! set_secret_prop "auth.admin_pa" "${PASSWORD}" \ + "${REST_SERVER_CONF}"; then log "ERROR: cannot write auth.admin_pa to ${REST_SERVER_CONF}" exit 1 fi @@ -281,7 +345,7 @@ if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then # The gate returns before backend or plugin registration, so this performs # validation only. - ./bin/init-store.sh + ./bin/init-store.sh --validate-only # Still wait: the server needs the storage side reachable at startup even # though nothing is initialized here diff --git a/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java b/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java index 578462deba..c6c43c49de 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java +++ b/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java @@ -17,6 +17,7 @@ import java.io.InputStream; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -32,7 +33,7 @@ private JavaPropertiesTool() { public static void main(String[] args) throws Exception { if (args.length < 3) { throw new IllegalArgumentException( - "Expected [value]"); + "Expected [value]"); } String command = args[0]; @@ -60,33 +61,47 @@ public static void main(String[] args) throws Exception { throw new IllegalArgumentException( "Expected set "); } - if (Objects.equals(properties.getProperty(key), args[3])) { - return; - } - properties.setProperty(key, args[3]); - Path scratch = Files.createTempFile( - file.toAbsolutePath().getParent(), - file.getFileName() + ".tmp.", null); - try { - try (OutputStream output = Files.newOutputStream(scratch)) { - properties.store(output, null); - } - try (InputStream input = Files.newInputStream(scratch); - OutputStream output = Files.newOutputStream( - file, StandardOpenOption.WRITE, - StandardOpenOption.TRUNCATE_EXISTING)) { - byte[] buffer = new byte[8192]; - int length; - while ((length = input.read(buffer)) != -1) { - output.write(buffer, 0, length); - } - } - } finally { - Files.deleteIfExists(scratch); + setProperty(file, key, args[3], properties); + return; + case "set-stdin": + if (args.length != 3) { + throw new IllegalArgumentException( + "Expected set-stdin "); } + String stdinValue = new String(System.in.readAllBytes(), + StandardCharsets.UTF_8); + setProperty(file, key, stdinValue, properties); return; default: throw new IllegalArgumentException("Unknown command: " + command); } } + + private static void setProperty(Path file, String key, String value, + Properties properties) throws Exception { + if (Objects.equals(properties.getProperty(key), value)) { + return; + } + properties.setProperty(key, value); + Path scratch = Files.createTempFile( + file.toAbsolutePath().getParent(), + file.getFileName() + ".tmp.", null); + try { + try (OutputStream output = Files.newOutputStream(scratch)) { + properties.store(output, null); + } + try (InputStream input = Files.newInputStream(scratch); + OutputStream output = Files.newOutputStream( + file, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) != -1) { + output.write(buffer, 0, length); + } + } + } finally { + Files.deleteIfExists(scratch); + } + } } diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index efdf33110b..a16a922245 100755 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -140,7 +140,7 @@ assert_no_prop_key() { # the gremlin-server.yaml authentication block and the graph's auth proxy assert_auth_fully_enabled() { local n - n=$(grep -cE '^[[:space:]]*authentication:' \ + n=$(grep -cE '^authentication[[:space:]]*:' \ "${INSTALL}/conf/gremlin-server.yaml" 2>/dev/null || true) if [[ "${n}" == "1" ]]; then ok @@ -220,6 +220,10 @@ if [[ "\$1" == "requires-local-admin" ]]; then -z "\${remote_url}" ]] exit fi +if [[ "\$1" == "set" || "\$1" == "set-stdin" ]] && + [[ -n "\${CONFIG_TOOL_STUB_RC:-}" ]]; then + exit "\${CONFIG_TOOL_STUB_RC}" +fi exec java -cp "${TEST_CLASSES}" JavaPropertiesTool "\$@" EOF chmod +x "${INSTALL}/bin/config-tool.sh" @@ -412,6 +416,18 @@ assert_not_ran "init-store.sh" assert_ran "start-hugegraph.sh" cleanup +echo "==> legacy authenticated volume upgrades without the old password" +new_install +mkdir -p "${INSTALL}/docker" +touch "${INSTALL}/docker/init_complete" +"${INSTALL}/bin/enable-auth.sh" +: > "${INSTALL}/calls.log" +run_entrypoint -u PASSWORD +assert_not_ran "init-store.sh" +assert_ran "start-hugegraph.sh" +assert_file "docker/auth_init_state" +cleanup + echo "==> an existing conf-bak cannot suppress requested auth" new_install mkdir -p "${INSTALL}/conf-bak" @@ -630,14 +646,78 @@ assert_not_ran "enable-auth.sh" assert_auth_fully_enabled cleanup -echo "==> a complete read-only auth mount is not rewritten" +echo "==> a stale Gremlin authenticator fails closed" +new_install +echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' +authentication: { + authenticator: org.apache.hugegraph.auth.StandardAuthenticator, + authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, + config: {tokens: conf/rest-server.properties} +} +YAML +run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +assert_not_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" +assert_output_contains "Gremlin authenticator" +cleanup + +echo "==> removing REST auth while Gremlin auth remains fails closed" +new_install +cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' +authentication: { + authenticator: org.apache.hugegraph.auth.StandardAuthenticator, + authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, + config: {tokens: conf/rest-server.properties} +} +YAML +run_entrypoint_fails -u PASSWORD +assert_not_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" +assert_output_contains "REST authentication is disabled" +cleanup + +echo "==> authentication with separator whitespace is recognized" +new_install +enable_pd +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' +authentication : { + authenticator: org.apache.hugegraph.auth.StandardAuthenticator, + authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, + config: {tokens: conf/rest-server.properties} +} +YAML +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_auth_fully_enabled +cleanup + +echo "==> nested authentication does not masquerade as top-level auth" +new_install +enable_pd +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' +security: + authentication: { + authenticator: org.apache.hugegraph.auth.StandardAuthenticator, + authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, + config: {tokens: conf/rest-server.properties} + } +YAML +run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret +assert_auth_fully_enabled +cleanup + +echo "==> a complete auth configuration is not rewritten" new_install "${INSTALL}/bin/enable-auth.sh" echo "auth.admin_pa=s3cret" >> "${INSTALL}/conf/rest-server.properties" : > "${INSTALL}/calls.log" touch -t 202001010000 "${INSTALL}/conf/rest-server.properties" before_mtime=$(file_mtime "${INSTALL}/conf/rest-server.properties") -chmod 444 "${INSTALL}/conf/rest-server.properties" run_entrypoint -u PASSWORD after_mtime=$(file_mtime "${INSTALL}/conf/rest-server.properties") after_mode=$(file_mode "${INSTALL}/conf/rest-server.properties") @@ -649,29 +729,28 @@ if [[ "${after_mtime}" == "${before_mtime}" ]]; then else fail "read-only rest-server.properties was rewritten" fi -if [[ "${after_mode}" == "444" ]]; then +if [[ "${after_mode}" == "644" ]]; then ok else - fail "read-only rest-server.properties mode became ${after_mode}" + fail "rest-server.properties mode became ${after_mode}" fi cleanup -echo "==> an incomplete read-only auth mount fails with the missing property" +echo "==> a failed auth config mutation stops startup" new_install echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ >> "${INSTALL}/conf/rest-server.properties" -chmod 444 "${INSTALL}/conf/rest-server.properties" -run_entrypoint_fails -u PASSWORD +run_entrypoint_fails -u PASSWORD CONFIG_TOOL_STUB_RC=30 assert_not_ran "init-store.sh" assert_not_ran "start-hugegraph.sh" assert_output_contains \ "ERROR: cannot write auth.graph_store to ./conf/rest-server.properties" cleanup -echo "==> a read-only mount rejects an init-store env override" +echo "==> a failed init-store env mutation stops startup" new_install -chmod 444 "${INSTALL}/conf/rest-server.properties" -run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false +run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ + CONFIG_TOOL_STUB_RC=30 assert_not_ran "init-store.sh" assert_not_ran "start-hugegraph.sh" assert_output_contains \ @@ -771,6 +850,30 @@ assert_not_ran "start-hugegraph.sh" assert_output_contains "requires PASSWORD or an explicitly configured non-empty auth.admin_pa" cleanup +echo "==> complete auth mounted onto a no-auth volume still needs bootstrap" +new_install +mkdir -p "${INSTALL}/docker" +touch "${INSTALL}/docker/init_complete" +echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ + >> "${INSTALL}/conf/rest-server.properties" +echo "auth.graph_store=hugegraph" >> "${INSTALL}/conf/rest-server.properties" +cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' +authentication: { + authenticator: org.apache.hugegraph.auth.StandardAuthenticator, + authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, + config: {tokens: conf/rest-server.properties} +} +YAML +sed -i.bak 's/gremlin.graph=org.apache.hugegraph.HugeFactory/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/' \ + "${INSTALL}/conf/graphs/hugegraph.properties" +rm -f "${INSTALL}/conf/graphs/hugegraph.properties.bak" +run_entrypoint_fails -u PASSWORD +assert_not_ran "init-store.sh" +assert_not_ran "start-hugegraph.sh" +assert_output_contains \ + "requires PASSWORD or an explicitly configured non-empty auth.admin_pa" +cleanup + echo "==> skipped built-in auth also rejects the default password" new_install enable_pd @@ -799,6 +902,25 @@ assert_prop "init_store.enabled" "false" assert_prop_round_trip "auth.admin_pa" "s3cret" cleanup +echo "==> PASSWORD is never forwarded in ConfigTool argv" +new_install +enable_pd +cat > "${INSTALL}/bin/config-tool-wrapper.sh" <> "${INSTALL}/config-tool-argv.log" +exec "${INSTALL}/bin/config-tool.sh" "\$@" +EOF +chmod +x "${INSTALL}/bin/config-tool-wrapper.sh" +run_entrypoint CONFIG_TOOL=./bin/config-tool-wrapper.sh \ + HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=argv-secret +if grep -qF "argv-secret" "${INSTALL}/config-tool-argv.log" 2>/dev/null; then + fail "PASSWORD appeared in ConfigTool argv" +else + ok +fi +assert_prop_round_trip "auth.admin_pa" "argv-secret" +cleanup + echo "==> no scratch file is left behind" new_install run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false @@ -841,7 +963,7 @@ cp "${INIT_STORE_WRAPPER}" "${INSTALL}/bin/init-store.sh" mkdir -p "${INSTALL}/lib" "${INSTALL}/plugins" cat > "${INSTALL}/bin/util.sh" <<'EOF' configure_riscv64_libatomic() { return 0; } -ensure_path_writable() { return 0; } +ensure_path_writable() { return "${ENSURE_PATH_STATUS:-0}"; } EOF mkdir -p "${INSTALL}/fake-java/bin" cat > "${INSTALL}/fake-java/bin/java" <<'EOF' @@ -878,6 +1000,21 @@ if grep -qE 'rest-server\.properties --use-configured-admin-password$' \ else fail "production init-store.sh did not forward its password-mode flag" fi + +echo "==> validation-only wrapper does not require writable plugins" +if ( cd "${INSTALL}" && env ENSURE_PATH_STATUS=91 FAKE_JAVA_STATUS=0 \ + FAKE_JAVA_ARGS_FILE="${INSTALL}/validate-only-java-args" \ + JAVA_HOME="${INSTALL}/fake-java" ./bin/init-store.sh --validate-only ) \ + > "${INSTALL}/validate-only.out" 2>&1; then + ok +else + fail "validation-only wrapper required writable plugins" +fi +if grep -qF -- "--validate-only" "${INSTALL}/validate-only-java-args"; then + fail "validation-only wrapper forwarded its private flag to Java" +else + ok +fi cleanup echo diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh index 205344e4b0..c42027515d 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh @@ -34,7 +34,24 @@ PLUGINS="$TOP/plugins" . "${BIN}"/util.sh configure_riscv64_libatomic || exit 1 -ensure_path_writable "${PLUGINS}" + +VALIDATE_ONLY=false +INIT_STORE_ARGS=() +if [[ $# -gt 1 || ( $# -eq 1 && + "$1" != "--use-configured-admin-password" && + "$1" != "--validate-only" ) ]]; then + echo "Usage: $0 [--use-configured-admin-password|--validate-only]" >&2 + exit 1 +fi +if [[ $# -eq 1 && "$1" == "--validate-only" ]]; then + VALIDATE_ONLY=true +elif [[ $# -eq 1 ]]; then + INIT_STORE_ARGS+=("$1") +fi + +if [[ "${VALIDATE_ONLY}" != "true" ]]; then + ensure_path_writable "${PLUGINS}" || exit 1 +fi if [[ -n "$JAVA_HOME" ]]; then JAVA="$JAVA_HOME"/bin/java @@ -50,16 +67,6 @@ DEFAULT_JAVA_OPTIONS="--add-exports=java.base/jdk.internal.reflect=ALL-UNNAMED" echo "Initializing HugeGraph Store..." -INIT_STORE_ARGS=() -if [[ $# -gt 1 || ( $# -eq 1 && - "$1" != "--use-configured-admin-password" ) ]]; then - echo "Usage: $0 [--use-configured-admin-password]" >&2 - exit 1 -fi -if [[ $# -eq 1 ]]; then - INIT_STORE_ARGS+=("$1") -fi - # Build classpath with hugegraph*.jar first to avoid class loading conflicts CP=$(find -L "${LIB}" -name 'hugegraph*.jar' | sort | tr '\n' ':') CP="$CP":$(find -L "${LIB}" -name '*.jar' \! -name 'hugegraph*' | sort | tr '\n' ':') diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java index f4426ddd18..298315bb69 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java @@ -23,6 +23,7 @@ import java.io.OutputStream; import java.io.Reader; import java.io.Writer; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; @@ -48,6 +49,7 @@ public final class ConfigTool { private static final String GET = "get"; private static final String HAS = "has"; private static final String SET = "set"; + private static final String SET_STDIN = "set-stdin"; private static final String REQUIRES_LOCAL_ADMIN = "requires-local-admin"; private static final String VALIDATE_SKIP = "validate-skip"; @@ -99,6 +101,13 @@ public static void main(String[] args) throws Exception { "Usage: ConfigTool set "); setProperty(file, args[2], args[3]); break; + case SET_STDIN: + E.checkArgument(args.length == 3, + "Usage: ConfigTool set-stdin "); + String stdinValue = new String(System.in.readAllBytes(), + StandardCharsets.UTF_8); + setProperty(file, args[2], stdinValue); + break; case REQUIRES_LOCAL_ADMIN: E.checkArgument(args.length == 2, "Usage: ConfigTool requires-local-admin " + @@ -144,9 +153,10 @@ static void setProperty(String file, String key, String value) return; } + Path target = new File(file).toPath().toAbsolutePath(); + rejectIncludes(target); config.setProperty(key, value); config.setIOFactory(EXACT_PROPERTIES_IO_FACTORY); - Path target = new File(file).toPath().toAbsolutePath(); Path parent = target.getParent(); E.checkState(parent != null, "Config file has no parent: %s", file); Path scratch = Files.createTempFile(parent, @@ -155,21 +165,82 @@ static void setProperty(String file, String key, String value) try { FileHandler handler = new FileHandler(config); handler.save(scratch.toFile()); - try (InputStream input = Files.newInputStream(scratch); - OutputStream output = Files.newOutputStream( - target, StandardOpenOption.WRITE, - StandardOpenOption.TRUNCATE_EXISTING)) { - byte[] buffer = new byte[8192]; - int length; - while ((length = input.read(buffer)) != -1) { - output.write(buffer, 0, length); + replaceInPlace(target, scratch, ConfigTool::copyFile); + } finally { + Files.deleteIfExists(scratch); + } + } + + private static void rejectIncludes(Path target) throws IOException { + String include = PropertiesConfiguration.getInclude(); + String optional = PropertiesConfiguration.getIncludeOptional(); + try (Reader input = Files.newBufferedReader(target, + StandardCharsets.UTF_8)) { + PropertiesReader reader = new PropertiesReader(input); + while (reader.nextProperty()) { + String key = reader.getPropertyName(); + if (key.equalsIgnoreCase(include) || + key.equalsIgnoreCase(optional)) { + throw new IllegalStateException(String.format( + "Refusing to rewrite '%s': '%s' directives would " + + "be flattened; update the included file instead", + target, key)); } } + } + } + + private static void replaceInPlace(Path target, Path replacement, + FileCopier copier) throws IOException { + Path parent = target.getParent(); + Path backup = Files.createTempFile(parent, + target.getFileName() + ".bak.", + null); + boolean retainBackup = false; + try { + Files.copy(target, backup, + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + try { + copier.copy(replacement, target); + } catch (IOException writeFailure) { + try { + copyFile(backup, target); + } catch (IOException restoreFailure) { + retainBackup = true; + writeFailure.addSuppressed(restoreFailure); + throw new IOException(String.format( + "Failed to rewrite '%s'; recovery also failed, " + + "backup retained at '%s'", target, backup), + writeFailure); + } + throw writeFailure; + } } finally { - Files.deleteIfExists(scratch); + if (!retainBackup) { + Files.deleteIfExists(backup); + } + } + } + + private static void copyFile(Path source, Path target) throws IOException { + try (InputStream input = Files.newInputStream(source); + OutputStream output = Files.newOutputStream( + target, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) != -1) { + output.write(buffer, 0, length); + } } } + @FunctionalInterface + private interface FileCopier { + + void copy(Path source, Path target) throws IOException; + } + static boolean requiresLocalAdmin(String file) { RegisterUtil.registerServer(); HugeConfig config = new HugeConfig(file); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 4ad494eca1..21928eed62 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -18,6 +18,7 @@ package org.apache.hugegraph.unit.cmd; import java.io.IOException; +import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -202,6 +203,39 @@ public void testConfigToolSerializesPasswordExactly() throws Exception { Assert.assertEquals(password, config.get(ServerOptions.ADMIN_PA)); } + @Test + public void testConfigToolRejectsRewriteWithInclude() throws Exception { + Path child = this.workDir.resolve("included.properties"); + Files.write(child, Arrays.asList("child.key=child-value"), + StandardCharsets.UTF_8); + Path conf = this.workDir.resolve("parent.properties"); + Files.write(conf, Arrays.asList( + "include=included.properties", + "parent.key=old-value"), StandardCharsets.UTF_8); + + Assert.assertThrows(IllegalStateException.class, () -> { + configToolSet(conf, "parent.key", "new-value"); + }, e -> Assert.assertContains("include", e.getMessage())); + + String content = new String(Files.readAllBytes(conf), + StandardCharsets.UTF_8); + Assert.assertContains("include=included.properties", content); + Assert.assertContains("parent.key=old-value", content); + } + + @Test + public void testConfigToolRejectsRewriteWithOptionalInclude() + throws Exception { + Path conf = this.workDir.resolve("optional-parent.properties"); + Files.write(conf, Arrays.asList( + "include\\u006fptional=missing.properties", + "parent.key=old-value"), StandardCharsets.UTF_8); + + Assert.assertThrows(IllegalStateException.class, () -> { + configToolSet(conf, "parent.key", "new-value"); + }, e -> Assert.assertContains("includeoptional", e.getMessage())); + } + /** * The graphs directory referenced by the temporary config does not exist, * so every code path that reaches graph scanning fails. That is what makes @@ -216,6 +250,39 @@ public void testMissingGraphsDirFailsScan() { }); } + @Test + public void testConfigToolRestoresTargetAfterInterruptedCopy() + throws Exception { + Path target = this.workDir.resolve("recover.properties"); + Path replacement = this.workDir.resolve("replacement.properties"); + byte[] original = "key=original\n".getBytes(StandardCharsets.UTF_8); + Files.write(target, original); + Files.write(replacement, + "key=replacement\n".getBytes(StandardCharsets.UTF_8)); + + Class copierType = Class.forName( + "org.apache.hugegraph.cmd.ConfigTool$FileCopier"); + Object failingCopier = Proxy.newProxyInstance( + copierType.getClassLoader(), new Class[]{copierType}, + (proxy, method, args) -> { + Files.write((Path) args[1], + "partial".getBytes(StandardCharsets.UTF_8)); + throw new IOException("injected mid-copy failure"); + }); + + Assert.assertThrows(RuntimeException.class, () -> { + Whitebox.invokeStatic( + ConfigTool.class, + new Class[]{Path.class, Path.class, copierType}, + "replaceInPlace", target, replacement, failingCopier); + }); + Assert.assertArrayEquals(original, Files.readAllBytes(target)); + try (Stream files = Files.list(this.workDir)) { + Assert.assertFalse(files.anyMatch(path -> + path.getFileName().toString().contains(".bak."))); + } + } + @Test public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { String restConf = this.writeDisabledRestServerConf(); From ba6b7e78805fdde008042fc7e5b1b4e20d569ed4 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sat, 1 Aug 2026 23:21:27 +0530 Subject: [PATCH 15/19] refactor(hugegraph-dist): narrow init-store gate to what #3118 asks for The Docker auth bootstrap and the ConfigTool it depends on grew into a second change sharing this branch, which made the diff hard to review as one idea. They move to a follow-up, leaving the option, the gate, the env mapping and the init-flag guard here. Review fixes carried over: - init-store.sh propagates InitStore's exit status. The trailing "Initialization finished." echo made the script exit 0 no matter what, so a refused configuration let the entrypoint start a server nobody could log in to. - The entrypoint normalizes HG_SERVER_INIT_STORE_ENABLED and rejects values HugeConfig would refuse, instead of recording an initialization that never happened. - The admin bootstrap check no longer requires auth.admin_pa, which nothing in the container writes. usePD and the hstore auth graph stay. - PASSWORD is documented as having no effect once init-store is skipped, and the entrypoint warns when both are set. --- .github/workflows/docker-build-ci.yml | 39 - docker/README.md | 32 +- .../hugegraph/auth/StandardAuthenticator.java | 55 +- .../hugegraph/config/ServerOptions.java | 6 +- .../docker/docker-entrypoint.sh | 373 +----- .../docker/test/JavaPropertiesReader.java | 49 - .../docker/test/JavaPropertiesTool.java | 107 -- .../docker/test/test-docker-entrypoint.sh | 1022 ----------------- .../src/assembly/static/bin/config-tool.sh | 50 - .../src/assembly/static/bin/init-store.sh | 22 +- .../org/apache/hugegraph/cmd/ConfigTool.java | 282 ----- .../org/apache/hugegraph/cmd/InitStore.java | 173 +-- .../unit/cmd/InitStoreConfigTest.java | 526 +-------- 13 files changed, 174 insertions(+), 2562 deletions(-) delete mode 100644 hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesReader.java delete mode 100644 hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java delete mode 100755 hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh delete mode 100755 hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh delete mode 100644 hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index 38b1fa9886..8d31ff266f 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -26,36 +26,8 @@ on: paths: - '**/Dockerfile*' - '.dockerignore' - - 'hugegraph-server/hugegraph-dist/docker/**' - - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh' - - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh' - - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh' - - 'hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java' - - 'hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java' - - 'hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java' - - 'hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java' - - '**/docker/docker-entrypoint.sh' jobs: - entrypoint-test: - permissions: - contents: read - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Set up Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '11' - - - name: Server entrypoint smoke tests - run: hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh - docker-build: runs-on: ubuntu-24.04 strategy: @@ -78,14 +50,3 @@ jobs: HC=$(docker inspect --format='{{json .Config.Healthcheck}}' "$IMAGE_ID") echo "Healthcheck: $HC" [[ "$HC" != "null" ]] || { echo "ERROR: HEALTHCHECK missing in ${{ matrix.dockerfile }}"; exit 1; } - if [[ "${{ matrix.dockerfile }}" == hugegraph-server/Dockerfile* ]]; then - RO_CONF=$(mktemp) - printf 'restserver.url=http://0.0.0.0:8080\n' > "$RO_CONF" - if docker run --rm --entrypoint bash \ - -v "$RO_CONF:/tmp/rest-server.properties:ro" "$IMAGE_ID" \ - -c './bin/config-tool.sh set /tmp/rest-server.properties init_store.enabled false; status=$?; [[ $status -eq 0 ]] || compgen -G "/tmp/rest-server.properties.tmp.*" >/dev/null; unexpected=$?; [[ $unexpected -eq 0 ]] && exit 0; exit "$status"'; then - echo "ERROR: ConfigTool rewrote a read-only bind mount or leaked a scratch file" - exit 1 - fi - grep -qxF 'restserver.url=http://0.0.0.0:8080' "$RO_CONF" - fi diff --git a/docker/README.md b/docker/README.md index 524d107ea5..1145446ea4 100644 --- a/docker/README.md +++ b/docker/README.md @@ -160,33 +160,27 @@ Configuration is injected via environment variables. The old `docker/configs/app | `HG_SERVER_BACKEND` | Yes | — | `backend` in `hugegraph.properties` | Storage backend (e.g. `hstore`) | | `HG_SERVER_PD_PEERS` | Yes | — | `pd.peers` | PD cluster addresses (e.g. `pd0:8686,pd1:8686,pd2:8686`) | | `STORE_REST` | No | — | Used by `wait-partition.sh` | Store REST endpoint for partition verification (e.g. `store0:8520`) | -| `PASSWORD` | No | — | Enables built-in auth mode | Optional built-in admin password; ignored when custom or remote auth is already configured | +| `PASSWORD` | No | — | Enables auth mode | Optional authentication password; ignored when `HG_SERVER_INIT_STORE_ENABLED` is `false` (see below) | | `HG_SERVER_INIT_STORE_ENABLED` | No | `true` | `init_store.enabled` in `rest-server.properties` | Set `false` in PD/HStore deployments so init-store skips local backend and admin initialization | > **The built-in authenticator with `HG_SERVER_INIT_STORE_ENABLED=false` > requires `usePD=true` and an HStore-backed `auth.graph_store`, unless > `auth.remote_url` delegates auth elsewhere.** With init-store skipped, the -> server creates the built-in admin in PD metadata. Only an HStore auth graph -> uses the PD-backed auth manager that can read that account. The entrypoint -> invokes InitStore's no-op validation and exits before server startup when the -> combination is unusable. A custom `auth.authenticator` is exempt because it +> server creates the built-in admin in PD metadata, and only an HStore auth +> graph uses the PD-backed auth manager that can read that account. init-store +> exits non-zero when the combination is unusable, rather than leaving a server +> nobody can log in to. A custom `auth.authenticator` is exempt because it > manages its own identities. > -> When the combination is valid for local built-in auth, a `PASSWORD` is written -> to `auth.admin_pa` instead of being passed to `init-store.sh`, because the admin -> account is then created on server startup. Without `PASSWORD`, `auth.admin_pa` -> must be explicitly configured and non-empty; its public `pa` default is not -> accepted for bootstrap. Custom and remote authenticators do not consume the -> built-in admin password, so an inherited `PASSWORD` is ignored rather than -> persisted. Two consequences worth knowing: +> With `false`, the entrypoint deliberately never writes `docker/init_complete`, +> so a later re-enable is still able to initialize. > -> - Before writing the password, the entrypoint restricts -> `conf/rest-server.properties` to mode `0600`. Administrators with access to -> the config volume can still read it. With init-store enabled the password -> only travels over stdin and is not written to disk. -> - `auth.admin_pa` takes effect only when the admin account is first created. -> Changing `PASSWORD` on a later restart does not rotate an existing admin -> password, and does not report an error. +> **`PASSWORD` has no effect on that path.** init-store reads it from standard +> input, and a disabled one returns before doing so. The admin is created on +> the PD startup path from `auth.admin_pa`, which defaults to the public value +> `pa`, so set it explicitly in a mounted `rest-server.properties`. It applies +> only when the account is first created, so changing it later does not rotate +> an existing password. **Deprecated aliases** (still work but log a warning): diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java index a64ec9446d..aecc8af282 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/auth/StandardAuthenticator.java @@ -47,18 +47,11 @@ public class StandardAuthenticator implements HugeAuthenticator { private HugeGraph graph = null; - private void initAdminUser(String password, boolean fromConfig) - throws Exception { + private void initAdminUser() throws Exception { if (this.requireInitAdminUser()) { - if (fromConfig) { - E.checkArgument(password != null && !password.isEmpty(), - "The configured admin password can't be " + - "null or empty"); - } else { - password = this.inputPassword(); - } - this.initAdminUser(password); + this.initAdminUser(this.inputPassword()); } + this.graph.close(); } @Override @@ -217,19 +210,6 @@ public SaslNegotiator newSaslNegotiator(InetAddress remoteAddress) { } public static void initAdminUserIfNeeded(String confFile) throws Exception { - initAdminUserIfNeeded(confFile, null, false); - } - - public static void initAdminUserIfNeeded(String confFile, - String configuredPassword) - throws Exception { - initAdminUserIfNeeded(confFile, configuredPassword, true); - } - - private static void initAdminUserIfNeeded(String confFile, - String password, - boolean fromConfig) - throws Exception { StandardAuthenticator auth = new StandardAuthenticator(); HugeConfig config = new HugeConfig(confFile); String authClass = config.get(ServerOptions.AUTHENTICATOR); @@ -237,32 +217,9 @@ private static void initAdminUserIfNeeded(String confFile, return; } config.addProperty(INITING_STORE, true); - auth.initAdminUser(config, password, fromConfig); - } - - private void initAdminUser(HugeConfig config, String password, - boolean fromConfig) throws Exception { - Throwable failure = null; - try { - this.setup(config); - if (this.graph().backendStoreFeatures().supportsPersistence()) { - this.initAdminUser(password, fromConfig); - } - } catch (Exception | Error e) { - failure = e; - throw e; - } finally { - if (this.graph != null) { - try { - this.graph.close(); - } catch (Exception | Error closeFailure) { - if (failure != null) { - failure.addSuppressed(closeFailure); - } else { - throw closeFailure; - } - } - } + auth.setup(config); + if (auth.graph().backendStoreFeatures().supportsPersistence()) { + auth.initAdminUser(); } } diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java index bce161a7e2..4f59a79b51 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java @@ -374,11 +374,7 @@ public class ServerOptions extends OptionHolder { "Whether init-store initializes the local backend stores " + "and the built-in admin account. Set false in distributed " + "deployments (PD/HStore) where the storage side already " + - "owns the metadata. Note that setting it false also means " + - "the admin account is not created here, and the only " + - "other component that creates it writes to PD metadata. " + - "The built-in authenticator can use that account only " + - "when 'usePD' is true and its auth graph uses HStore.", + "owns the metadata.", disallowEmpty(), true ); diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 183456bc5d..f9fab34514 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -19,186 +19,24 @@ set -euo pipefail DOCKER_FOLDER="./docker" INIT_FLAG_FILE="init_complete" -AUTH_INIT_STATE_FILE="auth_init_state" GRAPH_CONF="./conf/graphs/hugegraph.properties" REST_SERVER_CONF="./conf/rest-server.properties" -GREMLIN_SERVER_CONF="./conf/gremlin-server.yaml" -CONFIG_TOOL="${CONFIG_TOOL:-./bin/config-tool.sh}" - -# The only in-tree HugeAuthenticator that bootstraps HugeGraph's built-in admin -# account. auth.authenticator accepts any implementation class, and a custom one -# (LDAP, OIDC, a plugin) manages its identities elsewhere, so the admin-account -# requirement below must not be applied to it. -BUILTIN_AUTHENTICATOR="org.apache.hugegraph.auth.StandardAuthenticator" mkdir -p "${DOCKER_FOLDER}" log() { echo "[hugegraph-server-entrypoint] $*"; } -# Property access goes through Commons Configuration, the parser HugeConfig -# uses. This keeps escaped keys, continuations, duplicate definitions and value -# serialization identical between the entrypoint and the server. set_prop() { - "${CONFIG_TOOL}" set "$3" "$1" "$2" -} - -set_secret_prop() { - printf '%s' "$2" | "${CONFIG_TOOL}" set-stdin "$3" "$1" -} - -get_prop() { - "${CONFIG_TOOL}" get "$2" "$1" -} - -has_prop() { - "${CONFIG_TOOL}" has "$2" "$1" -} - -requires_local_admin() { - "${CONFIG_TOOL}" requires-local-admin "${REST_SERVER_CONF}" -} - -validate_skip() { - "${CONFIG_TOOL}" validate-skip "${REST_SERVER_CONF}" -} - -# Canonicalizes a boolean the way the server does. HugeConfig parses these -# options through commons-configuration2 PropertyConverter.toBoolean, i.e. -# BooleanUtils, which is case-insensitive and accepts y/t/on/yes/true and -# n/f/no/off/false. The shell must agree with it, or the two layers can -# disagree about whether to skip: `FALSE` once meant "skip" to Java and "run" -# to this script. Unrecognized values fail here, as they do in the server. -to_bool() { - case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in - y|t|on|yes|true) echo "true" ;; - n|f|no|off|false) echo "false" ;; - *) return 1 ;; - esac -} - -gremlin_auth_configured() { - grep -qE '^authentication[[:space:]]*:' \ - "${GREMLIN_SERVER_CONF}" 2>/dev/null -} - -# Print the single authenticator selected by the top-level authentication -# mapping. This intentionally accepts only the simple scalar form emitted by -# HugeGraph and Gremlin Server examples; aliases, substitutions, duplicates, -# or malformed mappings fail closed instead of risking different REST and -# Gremlin authentication providers. -gremlin_authenticator() { - local blocks auth_block values - blocks=$(grep -cE '^authentication[[:space:]]*:' \ - "${GREMLIN_SERVER_CONF}" 2>/dev/null || true) - [[ "${blocks}" == "1" ]] || return 1 + local key="$1" val="$2" file="$3" + local esc_key esc_val - auth_block=$(awk ' - /^authentication[[:space:]]*:/ { active=1 } - active { - if (seen && $0 ~ /^[^[:space:]#][^:]*[[:space:]]*:/) exit - print - seen=1 - if ($0 ~ /^[[:space:]]*}[[:space:]]*$/) exit - } - ' "${GREMLIN_SERVER_CONF}") - values=$(printf '%s\n' "${auth_block}" | sed -nE \ - 's/.*(^|[,{[:space:]])authenticator[[:space:]]*:[[:space:]]*([[:alnum:]_.$]+).*/\2/p') - [[ "$(printf '%s\n' "${values}" | sed '/^$/d' | wc -l | tr -d ' ')" == "1" ]] || \ - return 1 - printf '%s\n' "${values}" -} - -gremlin_auth_matches() { - local configured - configured=$(gremlin_authenticator) || return 1 - [[ "${configured}" == "$1" ]] -} - -graph_auth_proxy_configured() { - [[ "$(get_prop "gremlin.graph" "${GRAPH_CONF}")" == \ - "org.apache.hugegraph.auth.HugeFactoryAuthProxy" ]] -} - -# Enables auth across all three configs. bin/enable-auth.sh does the same, but -# it appends unconditionally and guards itself only with conf-bak, which a -# mounted conf directory does not carry. Running it over a config that already -# enables auth would define the REST keys twice, which the parser rejects, and -# append a second `authentication:` block to the YAML. Skipping it instead would -# leave Gremlin unauthenticated and the graph outside the auth proxy whenever a -# mounted config sets only auth.authenticator. So run it for the untouched -# shipped config, and otherwise apply exactly the parts that are missing. -ensure_auth_enabled() { - local authenticator class_pattern - authenticator=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") - class_pattern='^[[:alpha:]_$][[:alnum:]_$]*(\.[[:alpha:]_$][[:alnum:]_$]*)*$' - if [[ -n "${authenticator}" && ! "${authenticator}" =~ ${class_pattern} ]]; then - log "ERROR: auth.authenticator must be a Java class name;" \ - "got '${authenticator}'" - return 1 - fi - - if [[ -z "${authenticator}" ]] && ! gremlin_auth_configured && \ - ! graph_auth_proxy_configured; then - ./bin/enable-auth.sh - authenticator=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") - else - log "auth is already configured in part; applying anything still missing" - fi + esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') + esc_val=$(printf '%s' "$val" | sed -e 's/[&|\\]/\\&/g') - # enable-auth.sh is guarded by conf-bak and can return success without - # changing restored configs. Enforce every postcondition after it runs. - if [[ -z "${authenticator}" ]]; then - authenticator="${BUILTIN_AUTHENTICATOR}" - if ! set_prop "auth.authenticator" "${authenticator}" \ - "${REST_SERVER_CONF}"; then - log "ERROR: cannot write auth.authenticator to ${REST_SERVER_CONF}" - return 1 - fi - fi - if [[ -z "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")" ]]; then - if ! set_prop "auth.graph_store" "hugegraph" \ - "${REST_SERVER_CONF}"; then - log "ERROR: cannot write auth.graph_store to ${REST_SERVER_CONF}" - return 1 - fi - fi - if gremlin_auth_configured; then - if ! gremlin_auth_matches "${authenticator}"; then - log "ERROR: Gremlin authenticator must match REST auth.authenticator '${authenticator}'" - return 1 - fi + if grep -qE "^[[:space:]]*${esc_key}[[:space:]]*=" "${file}"; then + sed -ri "s|^([[:space:]]*${esc_key}[[:space:]]*=).*|\\1${esc_val}|" "${file}" else - # A file whose last line has no newline would otherwise absorb the - # first line of the block - if [[ -s "${GREMLIN_SERVER_CONF}" && \ - -n "$(tail -c 1 "${GREMLIN_SERVER_CONF}")" ]]; then - echo >> "${GREMLIN_SERVER_CONF}" - fi - # Kept in step with bin/enable-auth.sh, which owns this block, except - # that Gremlin uses whichever authenticator the REST config names. - cat >> "${GREMLIN_SERVER_CONF}" <> "${file}" fi } @@ -217,178 +55,57 @@ migrate_env "PD_PEERS" "HG_SERVER_PD_PEERS" # ── Map env → properties file ───────────────────────────────────────── [[ -n "${HG_SERVER_BACKEND:-}" ]] && set_prop "backend" "${HG_SERVER_BACKEND}" "${GRAPH_CONF}" [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && set_prop "pd.peers" "${HG_SERVER_PD_PEERS}" "${GRAPH_CONF}" -if [[ -n "${HG_SERVER_INIT_STORE_ENABLED:-}" ]]; then - # Canonicalize before writing, so the property file only ever holds `true` - # or `false` and cannot be read differently by the shell and the server - if ! HG_SERVER_INIT_STORE_ENABLED=$(to_bool "${HG_SERVER_INIT_STORE_ENABLED}"); then - log "ERROR: HG_SERVER_INIT_STORE_ENABLED must be a boolean, got '${HG_SERVER_INIT_STORE_ENABLED}'" - exit 1 - fi - if ! set_prop "init_store.enabled" "${HG_SERVER_INIT_STORE_ENABLED}" \ - "${REST_SERVER_CONF}"; then - log "ERROR: cannot write init_store.enabled to ${REST_SERVER_CONF}" - exit 1 - fi -fi + +# Normalized once here and reused by the init-flag guard below. The accepted +# spellings are the ones HugeConfig accepts (BooleanUtils, case-insensitive); +# anything else is rejected now rather than touching the init flag for a value +# the server is going to refuse anyway. +INIT_STORE_ENABLED=$(printf '%s' "${HG_SERVER_INIT_STORE_ENABLED:-}" | + tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') +case "${INIT_STORE_ENABLED}" in + "" | y | t | yes | on | true | n | f | no | off | false) ;; + *) log "ERROR: invalid HG_SERVER_INIT_STORE_ENABLED" \ + "'${HG_SERVER_INIT_STORE_ENABLED}'" + exit 1 ;; +esac +[[ -n "${INIT_STORE_ENABLED}" ]] && \ + set_prop "init_store.enabled" "${INIT_STORE_ENABLED}" "${REST_SERVER_CONF}" # ── Build wait-storage env ───────────────────────────────────────────── WAIT_ENV=() [[ -n "${HG_SERVER_BACKEND:-}" ]] && WAIT_ENV+=("hugegraph.backend=${HG_SERVER_BACKEND}") [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && WAIT_ENV+=("hugegraph.pd.peers=${HG_SERVER_PD_PEERS}") -wait_storage() { +# ── Init store (once) ───────────────────────────────────────────────── +if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then if (( ${#WAIT_ENV[@]} > 0 )); then env "${WAIT_ENV[@]}" ./bin/wait-storage.sh else ./bin/wait-storage.sh fi -} - -# ── Init store (once) ───────────────────────────────────────────────── -# With `init_store.enabled=false` (distributed PD/HStore) init-store is a no-op: -# storage owns the metadata and the admin account is created on server startup -# from `auth.admin_pa`. A requested PASSWORD is therefore written to that -# property rather than piped into init-store.sh, where it would be read and -# discarded without creating the account. -# -# The value is read back from the config file rather than from the env var, so -# that a rest-server.properties mounted with the property already set behaves -# the same as `HG_SERVER_INIT_STORE_ENABLED` (the env mapping above has already -# been applied, so env still wins). -INIT_STORE_ENABLED=$(get_prop "init_store.enabled" "${REST_SERVER_CONF}") -if [[ -n "${INIT_STORE_ENABLED}" ]]; then - if ! INIT_STORE_ENABLED=$(to_bool "${INIT_STORE_ENABLED}"); then - log "ERROR: init_store.enabled in ${REST_SERVER_CONF} must be a boolean," \ - "got '${INIT_STORE_ENABLED}'" - exit 1 - fi -fi - -# A mounted configuration can enable REST authentication without carrying the -# matching Gremlin handler or auth graph proxy. Complete all three configs for -# every configured authenticator, whether or not Docker supplied a PASSWORD. -AUTHENTICATOR=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") -LEGACY_AUTH_CONFIG_ALIGNED=false -if [[ -n "${AUTHENTICATOR}" ]] && - [[ -d "./conf-bak" ]] && - [[ -n "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")" ]] && - gremlin_auth_matches "${AUTHENTICATOR}" && - graph_auth_proxy_configured; then - LEGACY_AUTH_CONFIG_ALIGNED=true -fi -if [[ -z "${AUTHENTICATOR}" ]] && - { gremlin_auth_configured || graph_auth_proxy_configured; }; then - log "ERROR: REST authentication is disabled while Gremlin or the graph" \ - "auth proxy remains enabled" - exit 1 -fi -if [[ -n "${PASSWORD:-}" || -n "${AUTHENTICATOR}" ]]; then - ensure_auth_enabled - AUTHENTICATOR=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") -fi - -LOCAL_BUILTIN_AUTH=false -if [[ -n "${AUTHENTICATOR}" ]] && requires_local_admin; then - LOCAL_BUILTIN_AUTH=true -fi - -AUTH_STATE="" -AUTH_INIT_REQUIRED=false -if [[ -n "${AUTHENTICATOR}" ]]; then - AUTH_STATE=$(printf '%s\n%s\n%s' \ - "${AUTHENTICATOR}" \ - "$(get_prop "auth.remote_url" "${REST_SERVER_CONF}")" \ - "$(get_prop "auth.graph_store" "${REST_SERVER_CONF}")") - STORED_AUTH_STATE=$(cat \ - "${DOCKER_FOLDER}/${AUTH_INIT_STATE_FILE}" 2>/dev/null || true) - if [[ -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" && - "${STORED_AUTH_STATE}" != "${AUTH_STATE}" ]]; then - if [[ -z "${STORED_AUTH_STATE}" && - "${LEGACY_AUTH_CONFIG_ALIGNED}" == "true" ]]; then - log "legacy authenticated volume detected; recording auth state without re-initialization" - printf '%s\n' "${AUTH_STATE}" > \ - "${DOCKER_FOLDER}/${AUTH_INIT_STATE_FILE}" - else - AUTH_INIT_REQUIRED=true - fi - fi -fi - -if [[ "${INIT_STORE_ENABLED:-true}" == "false" ]]; then - log "init-store disabled; validating the no-op configuration" - - # Validate topology before writing a secret. The final init-store invocation - # below repeats the Java gate after auth.admin_pa has been prepared and also - # enforces that local built-in auth has an explicit non-empty password. - validate_skip - - if [[ "${LOCAL_BUILTIN_AUTH}" == "true" ]]; then - if [[ -n "${PASSWORD:-}" ]]; then - log "enabling built-in auth, admin password applied via auth.admin_pa" - # TODO: auth.admin_pa only applies when the admin account is first - # created, so changing PASSWORD on a later restart keeps the old one. - if ! chmod 600 "${REST_SERVER_CONF}"; then - log "ERROR: cannot protect ${REST_SERVER_CONF} before writing auth.admin_pa" - exit 1 - fi - if ! set_secret_prop "auth.admin_pa" "${PASSWORD}" \ - "${REST_SERVER_CONF}"; then - log "ERROR: cannot write auth.admin_pa to ${REST_SERVER_CONF}" - exit 1 - fi - elif ! require_configured_admin_password; then - exit 1 - fi - elif [[ -n "${PASSWORD:-}" ]]; then - log "PASSWORD ignored: the configured authenticator does not use HugeGraph's local built-in admin" - fi - - # The gate returns before backend or plugin registration, so this performs - # validation only. - ./bin/init-store.sh --validate-only - - # Still wait: the server needs the storage side reachable at startup even - # though nothing is initialized here - wait_storage - # No init flag is written here: nothing was initialized, so a later run - # with init-store enabled must still perform the real initialization. -elif [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" || - "${AUTH_INIT_REQUIRED}" == "true" ]]; then - if [[ -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then - log "authentication configuration changed; running init-store once" - fi - wait_storage if [[ -z "${PASSWORD:-}" ]]; then - if [[ "${LOCAL_BUILTIN_AUTH}" == "true" ]]; then - if ! require_configured_admin_password; then - exit 1 - fi - log "init hugegraph with configured auth.admin_pa" - ./bin/init-store.sh --use-configured-admin-password - elif [[ -n "${AUTHENTICATOR}" ]]; then - log "init hugegraph with external authentication" - ./bin/init-store.sh - else - log "init hugegraph with non-auth mode" - ./bin/init-store.sh - fi - elif [[ "${LOCAL_BUILTIN_AUTH}" == "true" ]]; then - log "init hugegraph with auth mode" - printf '%s\n' "${PASSWORD}" | ./bin/init-store.sh - else - log "PASSWORD ignored: the configured authenticator does not use HugeGraph's local built-in admin" + log "init hugegraph with non-auth mode" ./bin/init-store.sh - fi - # This flag tracks only that init has run. The branch above explicitly - # handles later auth-mode changes; other HG_SERVER_* changes do not re-init. - # It also does not survive a Kubernetes pod restart, which is why - # init_store.enabled exists rather than a flag file. - touch "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" - if [[ -n "${AUTH_STATE}" ]]; then - printf '%s\n' "${AUTH_STATE}" > \ - "${DOCKER_FOLDER}/${AUTH_INIT_STATE_FILE}" - fi + else + log "init hugegraph with auth mode" + ./bin/enable-auth.sh + # init-store reads the password from stdin, and a disabled one returns + # before it gets there, so say plainly that PASSWORD is being dropped + case "${INIT_STORE_ENABLED}" in + n | f | no | off | false) + log "WARN: PASSWORD is ignored while init-store is disabled;" \ + "the admin is created on the PD startup path from" \ + "'auth.admin_pa', which defaults to the public value 'pa'" ;; + esac + echo "${PASSWORD}" | ./bin/init-store.sh + fi + # A disabled init-store initialized nothing, so recording it as done would + # stop a later re-enable from initializing. + case "${INIT_STORE_ENABLED}" in + n | f | no | off | false) ;; + *) touch "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ;; + esac else log "HugeGraph initialization already done. Skipping re-init..." fi @@ -396,7 +113,7 @@ fi ./bin/start-hugegraph.sh -j "${JAVA_OPTS:-}" -t 120 # Post-startup cluster stabilization check (hstore only — rocksdb has no partitions) -ACTUAL_BACKEND=$(get_prop "backend" "${GRAPH_CONF}") +ACTUAL_BACKEND=$(grep -E '^[[:space:]]*backend[[:space:]]*=' "${GRAPH_CONF}" | head -n 1 | sed 's/.*=//' | tr -d '[:space:]' || true) if [[ "${ACTUAL_BACKEND}" == "hstore" ]]; then STORE_REST="${STORE_REST:-store:8520}" export STORE_REST diff --git a/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesReader.java b/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesReader.java deleted file mode 100644 index 96de2acb6a..0000000000 --- a/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesReader.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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. - */ - -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Properties; - -public final class JavaPropertiesReader { - - private JavaPropertiesReader() { - } - - public static void main(String[] args) throws Exception { - if (args.length != 2) { - throw new IllegalArgumentException("Expected "); - } - - Path file = Paths.get(args[0]); - Properties properties = new Properties(); - try (InputStream input = Files.newInputStream(file)) { - properties.load(input); - } - - String value = properties.getProperty(args[1]); - if (value == null) { - throw new IllegalArgumentException("Missing property: " + args[1]); - } - for (byte item : value.getBytes(StandardCharsets.UTF_8)) { - System.out.printf("%02x", item & 0xff); - } - } -} diff --git a/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java b/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java deleted file mode 100644 index c6c43c49de..0000000000 --- a/hugegraph-server/hugegraph-dist/docker/test/JavaPropertiesTool.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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. - */ - -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; -import java.util.Objects; -import java.util.Properties; - -public final class JavaPropertiesTool { - - private JavaPropertiesTool() { - } - - public static void main(String[] args) throws Exception { - if (args.length < 3) { - throw new IllegalArgumentException( - "Expected [value]"); - } - - String command = args[0]; - Path file = Paths.get(args[1]); - String key = args[2]; - Properties properties = new Properties(); - try (InputStream input = Files.newInputStream(file)) { - properties.load(input); - } - - switch (command) { - case "get": - String value = properties.getProperty(key); - if (value != null) { - System.out.print(value); - } - return; - case "has": - if (!properties.containsKey(key)) { - System.exit(1); - } - return; - case "set": - if (args.length != 4) { - throw new IllegalArgumentException( - "Expected set "); - } - setProperty(file, key, args[3], properties); - return; - case "set-stdin": - if (args.length != 3) { - throw new IllegalArgumentException( - "Expected set-stdin "); - } - String stdinValue = new String(System.in.readAllBytes(), - StandardCharsets.UTF_8); - setProperty(file, key, stdinValue, properties); - return; - default: - throw new IllegalArgumentException("Unknown command: " + command); - } - } - - private static void setProperty(Path file, String key, String value, - Properties properties) throws Exception { - if (Objects.equals(properties.getProperty(key), value)) { - return; - } - properties.setProperty(key, value); - Path scratch = Files.createTempFile( - file.toAbsolutePath().getParent(), - file.getFileName() + ".tmp.", null); - try { - try (OutputStream output = Files.newOutputStream(scratch)) { - properties.store(output, null); - } - try (InputStream input = Files.newInputStream(scratch); - OutputStream output = Files.newOutputStream( - file, StandardOpenOption.WRITE, - StandardOpenOption.TRUNCATE_EXISTING)) { - byte[] buffer = new byte[8192]; - int length; - while ((length = input.read(buffer)) != -1) { - output.write(buffer, 0, length); - } - } - } finally { - Files.deleteIfExists(scratch); - } - } -} diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh deleted file mode 100755 index a16a922245..0000000000 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ /dev/null @@ -1,1022 +0,0 @@ -#!/bin/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. -# -# Smoke tests for docker-entrypoint.sh init-store lifecycle. -# -# The entrypoint is run against a throwaway install tree whose ./bin scripts are -# stubs recording their own invocation, so the tests assert on which scripts ran -# and on the resulting config without a backend or Docker. A source-file Java -# helper verifies password values with the same properties parser semantics. -# -# Usage: hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh - -set -uo pipefail - -SELF_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENTRYPOINT="${SELF_DIR}/../docker-entrypoint.sh" -TEST_CLASSES=$(mktemp -d "${TMPDIR:-/tmp}/hg-entrypoint-classes.XXXXXX") -javac -d "${TEST_CLASSES}" "${SELF_DIR}/JavaPropertiesReader.java" \ - "${SELF_DIR}/JavaPropertiesTool.java" - -PASS=0 -FAIL=0 - -fail() { - echo " FAIL: $*" - FAIL=$((FAIL + 1)) -} - -ok() { - PASS=$((PASS + 1)) -} - -assert_ran() { - if grep -qxF "$1" "${INSTALL}/calls.log" 2>/dev/null; then - ok - else - fail "expected '$1' to run; calls were: $(tr '\n' ' ' < "${INSTALL}/calls.log")" - fi -} - -assert_not_ran() { - if grep -qxF "$1" "${INSTALL}/calls.log" 2>/dev/null; then - fail "expected '$1' NOT to run" - else - ok - fi -} - -assert_file() { - if [[ -f "${INSTALL}/$1" ]]; then ok; else fail "expected file '$1' to exist"; fi -} - -assert_no_file() { - if [[ -f "${INSTALL}/$1" ]]; then fail "expected file '$1' NOT to exist"; else ok; fi -} - -assert_output_contains() { - if grep -qF "$1" "${INSTALL}/out.log" 2>/dev/null; then - ok - else - fail "expected output to contain '$1': $(cat "${INSTALL}/out.log")" - fi -} - -assert_prop() { - local expected="$1=$2" - if grep -qxF "${expected}" "${INSTALL}/conf/rest-server.properties" 2>/dev/null; then - ok - else - fail "expected property '${expected}' in rest-server.properties" - fi -} - -bytes_hex() { - printf '%s' "$1" | od -An -v -t x1 | tr -d '[:space:]' -} - -file_mode() { - stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" -} - -file_mtime() { - stat -c '%Y' "$1" 2>/dev/null || stat -f '%m' "$1" -} - -# Read the generated property through java.util.Properties and compare UTF-8 -# bytes. This catches physical-line truncation and every escape-sequence error, -# including trailing newlines that command substitution would otherwise hide. -assert_prop_round_trip() { - local key="$1" expected="$2" actual_hex expected_hex - if ! actual_hex=$(java -cp "${TEST_CLASSES}" JavaPropertiesReader \ - "${INSTALL}/conf/rest-server.properties" "${key}" \ - 2> "${INSTALL}/java-properties.err"); then - fail "Java could not read '${key}': $(cat "${INSTALL}/java-properties.err")" - return - fi - expected_hex=$(bytes_hex "${expected}") - if [[ "${actual_hex}" == "${expected_hex}" ]]; then - ok - else - fail "'${key}' parsed as hex '${actual_hex}', expected '${expected_hex}'" - fi -} - -# A scalar option must end up defined exactly once, on any separator, or the -# properties parser exposes it as a list and a scalar read of it fails -assert_prop_defined_once() { - local n - n=$(grep -cE "^[[:space:]]*$1([[:space:]]*[=:]|[[:space:]])" \ - "${INSTALL}/conf/rest-server.properties" 2>/dev/null || true) - if [[ "${n}" == "1" ]]; then ok; else fail "expected '$1' defined once, found ${n}"; fi -} - -# Matches the separator set assert_prop_defined_once uses, so a `key:value` or -# `key value` definition cannot pass as absent -assert_no_prop_key() { - if grep -qE "^[[:space:]]*$1([[:space:]]*[=:]|[[:space:]])" \ - "${INSTALL}/conf/rest-server.properties" 2>/dev/null; then - fail "expected no '$1' property" - else - ok - fi -} - -# Auth is only fully enabled when all three configs agree: the REST properties, -# the gremlin-server.yaml authentication block and the graph's auth proxy -assert_auth_fully_enabled() { - local n - n=$(grep -cE '^authentication[[:space:]]*:' \ - "${INSTALL}/conf/gremlin-server.yaml" 2>/dev/null || true) - if [[ "${n}" == "1" ]]; then - ok - else - fail "expected one gremlin-server.yaml authentication block, found ${n}" - fi - if grep -qxF "gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy" \ - "${INSTALL}/conf/graphs/hugegraph.properties" 2>/dev/null; then - ok - else - fail "expected hugegraph.properties to use HugeFactoryAuthProxy" - fi - assert_prop_defined_once "auth.authenticator" - assert_prop_defined_once "auth.graph_store" -} - -# Build a throwaway install tree with stubbed bin scripts -new_install() { - INSTALL=$(mktemp -d "${TMPDIR:-/tmp}/hg-entrypoint-test.XXXXXX") - mkdir -p "${INSTALL}/bin" "${INSTALL}/conf/graphs" - - # Mirrors the shipped conf: the auth properties are present but commented - cat > "${INSTALL}/conf/rest-server.properties" <<'EOF' -restserver.url=http://0.0.0.0:8080 -graphs=./conf/graphs -#auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator -#auth.admin_pa=pa -EOF - chmod 644 "${INSTALL}/conf/rest-server.properties" - cat > "${INSTALL}/conf/graphs/hugegraph.properties" <<'EOF' -backend=rocksdb -gremlin.graph=org.apache.hugegraph.HugeFactory -EOF - # Shipped without an authentication block, which is what enable-auth.sh adds - echo "host: 0.0.0.0" > "${INSTALL}/conf/gremlin-server.yaml" - - local script - for script in wait-storage start-hugegraph wait-partition; do - cat > "${INSTALL}/bin/${script}.sh" <> "${INSTALL}/calls.log" -EOF - chmod +x "${INSTALL}/bin/${script}.sh" - done - - # Records whether a password was piped in, which is how the entrypoint - # passes a Docker PASSWORD to the admin bootstrap - cat > "${INSTALL}/bin/init-store.sh" <> "${INSTALL}/calls.log" -if [[ \$# -gt 0 ]]; then - echo "init-store.sh:args=\$*" >> "${INSTALL}/calls.log" -fi -if [[ ! -t 0 ]]; then - stdin=\$(cat) - [[ -n "\${stdin}" ]] && echo "init-store.sh:stdin=\${stdin}" >> "${INSTALL}/calls.log" -fi -exit "\${INIT_STORE_STUB_RC:-0}" -EOF - chmod +x "${INSTALL}/bin/init-store.sh" - - # The production wrapper launches ConfigTool from the assembled jars. This - # source-launch stub keeps the shell suite backend-free while using Java's - # properties grammar for escaped keys and continued logical lines. - cat > "${INSTALL}/bin/config-tool.sh" < "${INSTALL}/bin/enable-auth.sh" <> "${INSTALL}/calls.log" -if [[ ! -d "${INSTALL}/conf-bak" ]]; then -mkdir -p "${INSTALL}/conf-bak" -{ - echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" - echo "auth.graph_store=hugegraph" -} >> "${INSTALL}/conf/rest-server.properties" -cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' -authentication: { - authenticator: org.apache.hugegraph.auth.StandardAuthenticator, - authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, - config: {tokens: conf/rest-server.properties} -} -YAML -sed -i.bak 's/gremlin.graph=org.apache.hugegraph.HugeFactory/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/g' \ - "${INSTALL}/conf/graphs/hugegraph.properties" -rm -f "${INSTALL}/conf/graphs/hugegraph.properties.bak" -fi -EOF - chmod +x "${INSTALL}/bin/enable-auth.sh" - - : > "${INSTALL}/calls.log" -} - -# The built-in admin created on the PD path is usable only when the auth graph -# selects HStore's PD-backed auth manager. -enable_pd() { - echo "usePD=true" >> "${INSTALL}/conf/rest-server.properties" - sed -i.bak 's/^backend=rocksdb$/backend=hstore/' \ - "${INSTALL}/conf/graphs/hugegraph.properties" - rm -f "${INSTALL}/conf/graphs/hugegraph.properties.bak" -} - -# Run the entrypoint inside the throwaway tree. No ./bin/pid is ever written by -# the stubs, so the entrypoint's tail-on-pid block is skipped and it returns. -run_entrypoint() { - ( cd "${INSTALL}" && env "$@" bash "${ENTRYPOINT}" ) > "${INSTALL}/out.log" 2>&1 - local rc=$? - if [[ ${rc} -ne 0 ]]; then - fail "entrypoint exited ${rc}; output: $(cat "${INSTALL}/out.log")" - fi - return 0 -} - -run_entrypoint_fails() { - if ( cd "${INSTALL}" && env "$@" bash "${ENTRYPOINT}" ) \ - > "${INSTALL}/out.log" 2>&1; then - fail "expected entrypoint to fail" - else - ok - fi -} - -cleanup() { [[ -n "${INSTALL:-}" ]] && rm -rf "${INSTALL}"; } -cleanup_all() { - cleanup - rm -rf "${TEST_CLASSES}" -} -trap cleanup_all EXIT - -echo "==> default: no flag set, full init runs" -new_install -run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD -assert_ran "wait-storage.sh" -assert_ran "init-store.sh" -assert_not_ran "enable-auth.sh" -assert_file "docker/init_complete" -cleanup - -echo "==> default + PASSWORD: auth enabled, password piped to init-store" -new_install -run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret -assert_ran "enable-auth.sh" -assert_ran "init-store.sh" -assert_ran "init-store.sh:stdin=s3cret" -assert_file "docker/init_complete" -assert_auth_fully_enabled -cleanup - -echo "==> skip via env: InitStore validates the no-op and no flag is written" -new_install -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_ran "wait-storage.sh" -assert_ran "init-store.sh" -assert_prop "init_store.enabled" "false" -assert_no_file "docker/init_complete" -cleanup - -echo "==> skip + PASSWORD: password reaches auth.admin_pa, not init-store stdin" -new_install -enable_pd -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_ran "enable-auth.sh" -assert_ran "init-store.sh" -assert_not_ran "init-store.sh:stdin=s3cret" -assert_prop_round_trip "auth.admin_pa" "s3cret" -assert_no_file "docker/init_complete" -cleanup - -echo "==> skip via mounted property only: env var absent behaves the same" -new_install -enable_pd -echo "init_store.enabled=false" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret -assert_ran "init-store.sh" -assert_not_ran "init-store.sh:stdin=s3cret" -assert_prop_round_trip "auth.admin_pa" "s3cret" -assert_no_file "docker/init_complete" -cleanup - -echo "==> env wins over a conflicting mounted property" -new_install -echo "init_store.enabled=false" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true -assert_ran "init-store.sh" -assert_file "docker/init_complete" -cleanup - -echo "==> false then true: a restart with init enabled still initializes" -new_install -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_ran "init-store.sh" -assert_no_file "docker/init_complete" -: > "${INSTALL}/calls.log" -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true -assert_ran "init-store.sh" -assert_file "docker/init_complete" -cleanup - -echo "==> restart with init enabled: the flag file suppresses re-init" -new_install -run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD -assert_ran "init-store.sh" -: > "${INSTALL}/calls.log" -run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD -assert_not_ran "init-store.sh" -assert_file "docker/init_complete" -cleanup - -echo "==> adding PASSWORD after no-auth init still creates the admin" -new_install -mkdir -p "${INSTALL}/docker" -touch "${INSTALL}/docker/init_complete" -run_entrypoint PASSWORD=s3cret -assert_ran "enable-auth.sh" -assert_ran "init-store.sh" -assert_ran "init-store.sh:stdin=s3cret" -assert_auth_fully_enabled -assert_file "docker/init_complete" -assert_file "docker/auth_init_state" -cleanup - -echo "==> mounted built-in auth after no-auth init still creates the admin" -new_install -mkdir -p "${INSTALL}/docker" -touch "${INSTALL}/docker/init_complete" -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint PASSWORD=s3cret -assert_ran "init-store.sh" -assert_ran "init-store.sh:stdin=s3cret" -assert_auth_fully_enabled -assert_file "docker/auth_init_state" -cleanup - -echo "==> mounted built-in auth without PASSWORD uses an explicit admin password" -new_install -mkdir -p "${INSTALL}/docker" -touch "${INSTALL}/docker/init_complete" -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -echo "auth.admin_pa=s3cret" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u PASSWORD -assert_ran "init-store.sh" -assert_ran "init-store.sh:args=--use-configured-admin-password" -assert_auth_fully_enabled -assert_file "docker/auth_init_state" -: > "${INSTALL}/calls.log" -run_entrypoint -u PASSWORD -assert_not_ran "init-store.sh" -assert_ran "start-hugegraph.sh" -cleanup - -echo "==> legacy authenticated volume upgrades without the old password" -new_install -mkdir -p "${INSTALL}/docker" -touch "${INSTALL}/docker/init_complete" -"${INSTALL}/bin/enable-auth.sh" -: > "${INSTALL}/calls.log" -run_entrypoint -u PASSWORD -assert_not_ran "init-store.sh" -assert_ran "start-hugegraph.sh" -assert_file "docker/auth_init_state" -cleanup - -echo "==> an existing conf-bak cannot suppress requested auth" -new_install -mkdir -p "${INSTALL}/conf-bak" -run_entrypoint PASSWORD=s3cret -assert_ran "enable-auth.sh" -assert_ran "init-store.sh:stdin=s3cret" -assert_auth_fully_enabled -cleanup - -echo "==> uppercase FALSE is honoured, matching the server's boolean parsing" -new_install -enable_pd -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=FALSE PASSWORD=s3cret -assert_ran "init-store.sh" -assert_not_ran "init-store.sh:stdin=s3cret" -assert_prop "init_store.enabled" "false" -assert_prop_round_trip "auth.admin_pa" "s3cret" -assert_no_file "docker/init_complete" -cleanup - -echo "==> 'off' and 'no' are honoured too" -for value in off no; do - new_install - run_entrypoint -u PASSWORD "HG_SERVER_INIT_STORE_ENABLED=${value}" - assert_ran "init-store.sh" - assert_no_file "docker/init_complete" - cleanup -done - -echo "==> a non-boolean value fails fast instead of diverging" -new_install -if ( cd "${INSTALL}" && env -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=maybe \ - bash "${ENTRYPOINT}" ) >/dev/null 2>&1; then - fail "expected a non-boolean HG_SERVER_INIT_STORE_ENABLED to fail" -else - ok -fi -assert_not_ran "start-hugegraph.sh" -cleanup - -echo "==> mounted property with a ':' separator is honoured" -new_install -echo "init_store.enabled:false" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD -assert_ran "init-store.sh" -assert_no_file "docker/init_complete" -cleanup - -echo "==> a continued mounted boolean is parsed as one Java property" -new_install -{ - printf 'init_store.enabled=fal\\\n' - echo ' se' -} >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED -u PASSWORD -assert_ran "init-store.sh" -assert_no_file "docker/init_complete" -cleanup - -echo "==> password with backslashes survives the properties round trip" -new_install -enable_pd -run_entrypoint 'PASSWORD=abc\def' HG_SERVER_INIT_STORE_ENABLED=false -assert_ran "init-store.sh" -assert_prop_round_trip "auth.admin_pa" 'abc\def' -cleanup - -echo "==> properties metacharacters, controls and UTF-8 round-trip exactly" -new_install -enable_pd -complex_password=$' \tmeta:=#!\\\r\f\np\xc3\xa4ss\xe9\x9b\xaa' -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false \ - "PASSWORD=${complex_password}" -assert_prop_round_trip "auth.admin_pa" "${complex_password}" -assert_prop_defined_once "auth.admin_pa" -cleanup - -echo "==> a trailing newline survives the properties round trip" -new_install -enable_pd -trailing_newline_password=$'ends-with-newline\n' -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false \ - "PASSWORD=${trailing_newline_password}" -assert_prop_round_trip "auth.admin_pa" "${trailing_newline_password}" -cleanup - -echo "==> a Java validation failure is propagated before password persistence" -new_install -run_entrypoint_fails -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret \ - HG_SERVER_INIT_STORE_ENABLED=false INIT_STORE_STUB_RC=1 -assert_not_ran "start-hugegraph.sh" -assert_not_ran "init-store.sh" -assert_no_prop_key "auth.admin_pa" -assert_no_file "docker/init_complete" -cleanup - -echo "==> skip with auth already in a mounted config, no usePD, is refused too" -new_install -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ - INIT_STORE_STUB_RC=1 -assert_not_ran "start-hugegraph.sh" -assert_not_ran "init-store.sh" -cleanup - -echo "==> skip without auth is unaffected by the usePD requirement" -new_install -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_ran "start-hugegraph.sh" -assert_ran "init-store.sh" -assert_no_file "docker/init_complete" -cleanup - -echo "==> env override of a colon-form property leaves one canonical key" -new_install -echo "init_store.enabled:false" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true -assert_prop_defined_once "init_store.enabled" -assert_prop "init_store.enabled" "true" -assert_ran "init-store.sh" -cleanup - -echo "==> env override of a whitespace-form property leaves one canonical key" -new_install -echo "init_store.enabled false" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=true -assert_prop_defined_once "init_store.enabled" -assert_prop "init_store.enabled" "true" -assert_ran "init-store.sh" -cleanup - -echo "==> PASSWORD override of a colon-form auth.admin_pa leaves one key" -new_install -enable_pd -echo "auth.admin_pa:old" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_prop_defined_once "auth.admin_pa" -assert_prop_round_trip "auth.admin_pa" "s3cret" -cleanup - -echo "==> commented-out defaults are not treated as definitions" -new_install -run_entrypoint -u HG_SERVER_INIT_STORE_ENABLED PASSWORD=s3cret -# The shipped file ships '#auth.admin_pa=pa' commented; it must stay commented -# and must not count as an existing definition -if grep -qxF "#auth.admin_pa=pa" "${INSTALL}/conf/rest-server.properties"; then - ok -else - fail "expected the commented '#auth.admin_pa=pa' line to be preserved" -fi -cleanup - -echo "==> mounted config that already enables auth is not duplicated" -new_install -enable_pd -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -echo "auth.graph_store=hugegraph" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_not_ran "enable-auth.sh" -assert_prop_defined_once "auth.admin_pa" -assert_prop_round_trip "auth.admin_pa" "s3cret" -# The mounted config carried only the REST keys, so the YAML block and the auth -# proxy still have to be applied, or Gremlin would stay unauthenticated -assert_auth_fully_enabled -cleanup - -echo "==> mounted config with only auth.authenticator still authenticates Gremlin" -new_install -enable_pd -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_not_ran "enable-auth.sh" -assert_auth_fully_enabled -assert_prop "auth.graph_store" "hugegraph" -cleanup - -echo "==> mounted auth without PASSWORD still protects Gremlin and the graph" -new_install -enable_pd -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -echo "auth.admin_pa=s3cret" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_ran "init-store.sh" -assert_auth_fully_enabled -assert_prop_round_trip "auth.admin_pa" "s3cret" -cleanup - -echo "==> a gremlin-server.yaml without a trailing newline is still valid" -new_install -enable_pd -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -printf 'host: 0.0.0.0' > "${INSTALL}/conf/gremlin-server.yaml" -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_auth_fully_enabled -# The appended block must start on its own line, not glued onto 'host: 0.0.0.0' -if grep -qxF "host: 0.0.0.0" "${INSTALL}/conf/gremlin-server.yaml"; then - ok -else - fail "the last pre-existing line was absorbed by the appended block" -fi -cleanup - -echo "==> a pre-authenticated mount is completed, not duplicated" -new_install -enable_pd -# Everything already in place, as after a restart with the conf dir mounted -"${INSTALL}/bin/enable-auth.sh" -: > "${INSTALL}/calls.log" -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_not_ran "enable-auth.sh" -assert_auth_fully_enabled -cleanup - -echo "==> a stale Gremlin authenticator fails closed" -new_install -echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' -authentication: { - authenticator: org.apache.hugegraph.auth.StandardAuthenticator, - authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, - config: {tokens: conf/rest-server.properties} -} -YAML -run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" -assert_output_contains "Gremlin authenticator" -cleanup - -echo "==> removing REST auth while Gremlin auth remains fails closed" -new_install -cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' -authentication: { - authenticator: org.apache.hugegraph.auth.StandardAuthenticator, - authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, - config: {tokens: conf/rest-server.properties} -} -YAML -run_entrypoint_fails -u PASSWORD -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" -assert_output_contains "REST authentication is disabled" -cleanup - -echo "==> authentication with separator whitespace is recognized" -new_install -enable_pd -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' -authentication : { - authenticator: org.apache.hugegraph.auth.StandardAuthenticator, - authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, - config: {tokens: conf/rest-server.properties} -} -YAML -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_auth_fully_enabled -cleanup - -echo "==> nested authentication does not masquerade as top-level auth" -new_install -enable_pd -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' -security: - authentication: { - authenticator: org.apache.hugegraph.auth.StandardAuthenticator, - authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, - config: {tokens: conf/rest-server.properties} - } -YAML -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_auth_fully_enabled -cleanup - -echo "==> a complete auth configuration is not rewritten" -new_install -"${INSTALL}/bin/enable-auth.sh" -echo "auth.admin_pa=s3cret" >> "${INSTALL}/conf/rest-server.properties" -: > "${INSTALL}/calls.log" -touch -t 202001010000 "${INSTALL}/conf/rest-server.properties" -before_mtime=$(file_mtime "${INSTALL}/conf/rest-server.properties") -run_entrypoint -u PASSWORD -after_mtime=$(file_mtime "${INSTALL}/conf/rest-server.properties") -after_mode=$(file_mode "${INSTALL}/conf/rest-server.properties") -assert_ran "init-store.sh:args=--use-configured-admin-password" -assert_ran "start-hugegraph.sh" -assert_auth_fully_enabled -if [[ "${after_mtime}" == "${before_mtime}" ]]; then - ok -else - fail "read-only rest-server.properties was rewritten" -fi -if [[ "${after_mode}" == "644" ]]; then - ok -else - fail "rest-server.properties mode became ${after_mode}" -fi -cleanup - -echo "==> a failed auth config mutation stops startup" -new_install -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint_fails -u PASSWORD CONFIG_TOOL_STUB_RC=30 -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" -assert_output_contains \ - "ERROR: cannot write auth.graph_store to ./conf/rest-server.properties" -cleanup - -echo "==> a failed init-store env mutation stops startup" -new_install -run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ - CONFIG_TOOL_STUB_RC=30 -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" -assert_output_contains \ - "ERROR: cannot write init_store.enabled to ./conf/rest-server.properties" -cleanup - -echo "==> a custom authenticator is not held to the usePD requirement" -new_install -echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_ran "start-hugegraph.sh" -assert_ran "init-store.sh" -assert_auth_fully_enabled -assert_prop "auth.authenticator" "org.example.auth.LdapAuthenticator" -cleanup - -echo "==> PASSWORD does not turn a custom authenticator into built-in auth" -new_install -echo "auth.authenticator=org.example.auth.LdapAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=s3cret -assert_ran "start-hugegraph.sh" -assert_ran "init-store.sh" -assert_auth_fully_enabled -assert_prop "auth.authenticator" "org.example.auth.LdapAuthenticator" -assert_no_prop_key "auth.admin_pa" -assert_output_contains "PASSWORD ignored" -cleanup - -echo "==> surrounding whitespace is trimmed, inner whitespace is kept" -new_install -printf 'auth.authenticator = %s \n' \ - "org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -# The Java validator is the source of truth for the class decision; the -# entrypoint must preserve its failure status for this padded value. -run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false \ - INIT_STORE_STUB_RC=1 -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" -cleanup - -echo "==> escaped authenticator key and value use Java properties grammar" -new_install -enable_pd -echo 'auth\.authenticator=org.apache.hugegraph.auth.Standard\u0041uthenticator' \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint PASSWORD=s3cret HG_SERVER_INIT_STORE_ENABLED=false -assert_not_ran "enable-auth.sh" -assert_ran "init-store.sh" -assert_ran "start-hugegraph.sh" -assert_auth_fully_enabled -assert_prop_round_trip "auth.admin_pa" "s3cret" -cleanup - -echo "==> a value containing spaces survives the round trip" -new_install -enable_pd -run_entrypoint HG_SERVER_INIT_STORE_ENABLED=false 'PASSWORD=two words' -assert_prop_round_trip "auth.admin_pa" "two words" -assert_prop_defined_once "auth.admin_pa" -cleanup - -echo "==> remote auth is exempt from the usePD requirement" -new_install -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -echo "auth.remote_url=127.0.0.1:8899" >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_ran "start-hugegraph.sh" -assert_ran "init-store.sh" -assert_auth_fully_enabled -cleanup - -echo "==> PASSWORD is not persisted for remote auth" -new_install -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -echo "auth.remote_url=127.0.0.1:8899" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint PASSWORD=s3cret HG_SERVER_INIT_STORE_ENABLED=false -assert_ran "start-hugegraph.sh" -assert_no_prop_key "auth.admin_pa" -assert_output_contains "PASSWORD ignored" -cleanup - -echo "==> configured built-in auth cannot bootstrap with the default password" -new_install -mkdir -p "${INSTALL}/docker" -touch "${INSTALL}/docker/init_complete" -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint_fails -u PASSWORD -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" -assert_output_contains "requires PASSWORD or an explicitly configured non-empty auth.admin_pa" -cleanup - -echo "==> complete auth mounted onto a no-auth volume still needs bootstrap" -new_install -mkdir -p "${INSTALL}/docker" -touch "${INSTALL}/docker/init_complete" -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -echo "auth.graph_store=hugegraph" >> "${INSTALL}/conf/rest-server.properties" -cat >> "${INSTALL}/conf/gremlin-server.yaml" <<'YAML' -authentication: { - authenticator: org.apache.hugegraph.auth.StandardAuthenticator, - authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, - config: {tokens: conf/rest-server.properties} -} -YAML -sed -i.bak 's/gremlin.graph=org.apache.hugegraph.HugeFactory/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/' \ - "${INSTALL}/conf/graphs/hugegraph.properties" -rm -f "${INSTALL}/conf/graphs/hugegraph.properties.bak" -run_entrypoint_fails -u PASSWORD -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" -assert_output_contains \ - "requires PASSWORD or an explicitly configured non-empty auth.admin_pa" -cleanup - -echo "==> skipped built-in auth also rejects the default password" -new_install -enable_pd -echo "auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator" \ - >> "${INSTALL}/conf/rest-server.properties" -run_entrypoint_fails -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -assert_not_ran "init-store.sh" -assert_not_ran "start-hugegraph.sh" -assert_output_contains "requires PASSWORD or an explicitly configured non-empty auth.admin_pa" -cleanup - -echo "==> secret write preserves the inode and restricts the shipped mode" -new_install -enable_pd -before_inode=$(ls -i "${INSTALL}/conf/rest-server.properties" | awk '{print $1}') -before_mode=$(file_mode "${INSTALL}/conf/rest-server.properties") -run_entrypoint PASSWORD=s3cret HG_SERVER_INIT_STORE_ENABLED=false -after_inode=$(ls -i "${INSTALL}/conf/rest-server.properties" | awk '{print $1}') -after_mode=$(file_mode "${INSTALL}/conf/rest-server.properties") -# Rewriting in place matters: a single-file bind mount cannot be replaced by -# rename. The shipped 0644 mode must be restricted before the secret is written. -if [[ "${before_mode}" == "644" ]]; then ok; else fail "fixture mode was ${before_mode}"; fi -if [[ "${before_inode}" == "${after_inode}" ]]; then ok; else fail "inode changed"; fi -if [[ "${after_mode}" == "600" ]]; then ok; else fail "mode became ${after_mode}, expected 600"; fi -assert_prop "init_store.enabled" "false" -assert_prop_round_trip "auth.admin_pa" "s3cret" -cleanup - -echo "==> PASSWORD is never forwarded in ConfigTool argv" -new_install -enable_pd -cat > "${INSTALL}/bin/config-tool-wrapper.sh" <> "${INSTALL}/config-tool-argv.log" -exec "${INSTALL}/bin/config-tool.sh" "\$@" -EOF -chmod +x "${INSTALL}/bin/config-tool-wrapper.sh" -run_entrypoint CONFIG_TOOL=./bin/config-tool-wrapper.sh \ - HG_SERVER_INIT_STORE_ENABLED=false PASSWORD=argv-secret -if grep -qF "argv-secret" "${INSTALL}/config-tool-argv.log" 2>/dev/null; then - fail "PASSWORD appeared in ConfigTool argv" -else - ok -fi -assert_prop_round_trip "auth.admin_pa" "argv-secret" -cleanup - -echo "==> no scratch file is left behind" -new_install -run_entrypoint -u PASSWORD HG_SERVER_INIT_STORE_ENABLED=false -if compgen -G "${INSTALL}/conf/rest-server.properties.tmp*" >/dev/null; then - fail "a set_prop scratch file was left behind" -else - ok -fi -cleanup - -echo "==> the entrypoint's auth block has not drifted from enable-auth.sh" -# The entrypoint reapplies what bin/enable-auth.sh would have done when it -# cannot run the script itself, so both sides must retain every exact invariant. -ENABLE_AUTH="${SELF_DIR}/../../src/assembly/static/bin/enable-auth.sh" -if [[ -f "${ENABLE_AUTH}" ]]; then - for token in "authentication: {" \ - "org.apache.hugegraph.auth.StandardAuthenticator" \ - "WsAndHttpBasicAuthHandler" \ - "tokens: conf/rest-server.properties" "auth.graph_store" \ - "HugeFactoryAuthProxy"; do - if grep -qF "${token}" "${ENABLE_AUTH}"; then - ok - else - fail "enable-auth.sh is missing '${token}'" - fi - if grep -qF "${token}" "${ENTRYPOINT}"; then - ok - else - fail "docker-entrypoint.sh is missing '${token}'" - fi - done -else - fail "enable-auth.sh not found at ${ENABLE_AUTH}" -fi - -echo "==> the production init-store wrapper preserves Java failures" -new_install -INIT_STORE_WRAPPER="${SELF_DIR}/../../src/assembly/static/bin/init-store.sh" -cp "${INIT_STORE_WRAPPER}" "${INSTALL}/bin/init-store.sh" -mkdir -p "${INSTALL}/lib" "${INSTALL}/plugins" -cat > "${INSTALL}/bin/util.sh" <<'EOF' -configure_riscv64_libatomic() { return 0; } -ensure_path_writable() { return "${ENSURE_PATH_STATUS:-0}"; } -EOF -mkdir -p "${INSTALL}/fake-java/bin" -cat > "${INSTALL}/fake-java/bin/java" <<'EOF' -#!/bin/bash -if [[ -n "${FAKE_JAVA_ARGS_FILE:-}" ]]; then - printf '%s\n' "$*" > "${FAKE_JAVA_ARGS_FILE}" -fi -exit "${FAKE_JAVA_STATUS:-0}" -EOF -chmod +x "${INSTALL}/fake-java/bin/java" -if ( cd "${INSTALL}" && env FAKE_JAVA_STATUS=23 \ - FAKE_JAVA_ARGS_FILE="${INSTALL}/fake-java-args" \ - JAVA_HOME="${INSTALL}/fake-java" ./bin/init-store.sh \ - --use-configured-admin-password ) \ - > "${INSTALL}/init-store-wrapper.out" 2>&1; then - fail "production init-store.sh hid the Java failure" -else - status=$? - if [[ ${status} -eq 23 ]]; then - ok - else - fail "production init-store.sh returned ${status}, expected 23" - fi -fi -if grep -qF "Initialization finished." \ - "${INSTALL}/init-store-wrapper.out"; then - fail "production init-store.sh printed success after Java failed" -else - ok -fi -if grep -qE 'rest-server\.properties --use-configured-admin-password$' \ - "${INSTALL}/fake-java-args"; then - ok -else - fail "production init-store.sh did not forward its password-mode flag" -fi - -echo "==> validation-only wrapper does not require writable plugins" -if ( cd "${INSTALL}" && env ENSURE_PATH_STATUS=91 FAKE_JAVA_STATUS=0 \ - FAKE_JAVA_ARGS_FILE="${INSTALL}/validate-only-java-args" \ - JAVA_HOME="${INSTALL}/fake-java" ./bin/init-store.sh --validate-only ) \ - > "${INSTALL}/validate-only.out" 2>&1; then - ok -else - fail "validation-only wrapper required writable plugins" -fi -if grep -qF -- "--validate-only" "${INSTALL}/validate-only-java-args"; then - fail "validation-only wrapper forwarded its private flag to Java" -else - ok -fi -cleanup - -echo -echo "passed: ${PASS}, failed: ${FAIL}" -[[ ${FAIL} -eq 0 ]] diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh deleted file mode 100755 index 247dd49905..0000000000 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/config-tool.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/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. -# - -function abs_path() { - SOURCE="${BASH_SOURCE[0]}" - while [[ -h "${SOURCE}" ]]; do - DIR="$(cd -P "$(dirname "${SOURCE}")" && pwd)" - SOURCE="$(readlink "${SOURCE}")" - [[ ${SOURCE} != /* ]] && SOURCE="${DIR}/${SOURCE}" - done - cd -P "$(dirname "${SOURCE}")" && pwd -} - -BIN=$(abs_path) -TOP="$(cd "${BIN}"/../ && pwd)" -LIB="${TOP}/lib" -PLUGINS="${TOP}/plugins" - -if [[ -n "${JAVA_HOME:-}" ]]; then - JAVA="${JAVA_HOME}/bin/java" -else - JAVA=java -fi - -cd "${TOP}" || exit 1 - -DEFAULT_JAVA_OPTIONS="--add-exports=java.base/jdk.internal.reflect=ALL-UNNAMED" -CP=$(find -L "${LIB}" -name 'hugegraph*.jar' | sort | tr '\n' ':') -CP="${CP}":$(find -L "${LIB}" -name '*.jar' \! -name 'hugegraph*' | sort | tr '\n' ':') -if [[ -d "${PLUGINS}" ]]; then - CP="${CP}":$(find -L "${PLUGINS}" -name '*.jar' | sort | tr '\n' ':') -fi - -exec "${JAVA}" -cp "${CP}" ${DEFAULT_JAVA_OPTIONS} \ -org.apache.hugegraph.cmd.ConfigTool "$@" diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh index c42027515d..74ec0bb731 100755 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh @@ -34,24 +34,7 @@ PLUGINS="$TOP/plugins" . "${BIN}"/util.sh configure_riscv64_libatomic || exit 1 - -VALIDATE_ONLY=false -INIT_STORE_ARGS=() -if [[ $# -gt 1 || ( $# -eq 1 && - "$1" != "--use-configured-admin-password" && - "$1" != "--validate-only" ) ]]; then - echo "Usage: $0 [--use-configured-admin-password|--validate-only]" >&2 - exit 1 -fi -if [[ $# -eq 1 && "$1" == "--validate-only" ]]; then - VALIDATE_ONLY=true -elif [[ $# -eq 1 ]]; then - INIT_STORE_ARGS+=("$1") -fi - -if [[ "${VALIDATE_ONLY}" != "true" ]]; then - ensure_path_writable "${PLUGINS}" || exit 1 -fi +ensure_path_writable "${PLUGINS}" if [[ -n "$JAVA_HOME" ]]; then JAVA="$JAVA_HOME"/bin/java @@ -72,8 +55,7 @@ CP=$(find -L "${LIB}" -name 'hugegraph*.jar' | sort | tr '\n' ':') CP="$CP":$(find -L "${LIB}" -name '*.jar' \! -name 'hugegraph*' | sort | tr '\n' ':') CP="$CP":$(find -L "${PLUGINS}" -name '*.jar' | sort | tr '\n' ':') $JAVA -cp $CP ${DEFAULT_JAVA_OPTIONS} \ -org.apache.hugegraph.cmd.InitStore "${CONF}"/rest-server.properties \ -"${INIT_STORE_ARGS[@]}" +org.apache.hugegraph.cmd.InitStore "${CONF}"/rest-server.properties INIT_STORE_STATUS=$? if [[ ${INIT_STORE_STATUS} -ne 0 ]]; then exit "${INIT_STORE_STATUS}" diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java deleted file mode 100644 index 298315bb69..0000000000 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/ConfigTool.java +++ /dev/null @@ -1,282 +0,0 @@ -/* - * 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.cmd; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.Reader; -import java.io.Writer; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.Collection; -import java.util.Objects; - -import org.apache.commons.configuration2.PropertiesConfiguration; -import org.apache.commons.configuration2.PropertiesConfiguration.IOFactory; -import org.apache.commons.configuration2.PropertiesConfiguration.PropertiesReader; -import org.apache.commons.configuration2.PropertiesConfiguration.PropertiesWriter; -import org.apache.commons.configuration2.builder.fluent.Configurations; -import org.apache.commons.configuration2.convert.ListDelimiterHandler; -import org.apache.commons.configuration2.ex.ConfigurationException; -import org.apache.commons.configuration2.io.FileHandler; -import org.apache.commons.text.StringEscapeUtils; -import org.apache.hugegraph.config.HugeConfig; -import org.apache.hugegraph.config.ServerOptions; -import org.apache.hugegraph.dist.RegisterUtil; -import org.apache.hugegraph.util.E; - -public final class ConfigTool { - - private static final String GET = "get"; - private static final String HAS = "has"; - private static final String SET = "set"; - private static final String SET_STDIN = "set-stdin"; - private static final String REQUIRES_LOCAL_ADMIN = - "requires-local-admin"; - private static final String VALIDATE_SKIP = "validate-skip"; - private static final IOFactory EXACT_PROPERTIES_IO_FACTORY = - new IOFactory() { - - @Override - public PropertiesReader createPropertiesReader(Reader reader) { - return new PropertiesReader(reader); - } - - @Override - public PropertiesWriter createPropertiesWriter( - Writer writer, - ListDelimiterHandler delimiterHandler) { - return new PropertiesWriter(writer, delimiterHandler, - ConfigTool::escapePropertyValue); - } - }; - - private ConfigTool() { - } - - public static void main(String[] args) throws Exception { - E.checkArgument(args.length >= 2, "Usage: ConfigTool ..."); - - String command = args[0]; - String file = args[1]; - switch (command) { - case GET: - E.checkArgument(args.length == 3, - "Usage: ConfigTool get "); - String value = getProperty(file, args[2]); - if (value != null) { - // CHECKSTYLE:OFF - System.out.print(value); - // CHECKSTYLE:ON - } - break; - case HAS: - E.checkArgument(args.length == 3, - "Usage: ConfigTool has "); - if (!hasProperty(file, args[2])) { - System.exit(1); - } - break; - case SET: - E.checkArgument(args.length == 4, - "Usage: ConfigTool set "); - setProperty(file, args[2], args[3]); - break; - case SET_STDIN: - E.checkArgument(args.length == 3, - "Usage: ConfigTool set-stdin "); - String stdinValue = new String(System.in.readAllBytes(), - StandardCharsets.UTF_8); - setProperty(file, args[2], stdinValue); - break; - case REQUIRES_LOCAL_ADMIN: - E.checkArgument(args.length == 2, - "Usage: ConfigTool requires-local-admin " + - ""); - if (!requiresLocalAdmin(file)) { - System.exit(1); - } - break; - case VALIDATE_SKIP: - E.checkArgument(args.length == 2, - "Usage: ConfigTool validate-skip " + - ""); - validateSkip(file); - break; - default: - throw new IllegalArgumentException("Unknown command: " + command); - } - } - - static String getProperty(String file, String key) - throws ConfigurationException { - Object value = load(file).getProperty(key); - if (value == null) { - return null; - } - E.checkArgument(!(value instanceof Collection), - "Property '%s' must contain one value, got '%s'", - key, value); - return value.toString(); - } - - static boolean hasProperty(String file, String key) - throws ConfigurationException { - return load(file).containsKey(key); - } - - static void setProperty(String file, String key, String value) - throws ConfigurationException, IOException { - PropertiesConfiguration config = load(file); - Object current = config.getProperty(key); - if (!(current instanceof Collection) && - Objects.equals(current, value)) { - return; - } - - Path target = new File(file).toPath().toAbsolutePath(); - rejectIncludes(target); - config.setProperty(key, value); - config.setIOFactory(EXACT_PROPERTIES_IO_FACTORY); - Path parent = target.getParent(); - E.checkState(parent != null, "Config file has no parent: %s", file); - Path scratch = Files.createTempFile(parent, - target.getFileName() + ".tmp.", - null); - try { - FileHandler handler = new FileHandler(config); - handler.save(scratch.toFile()); - replaceInPlace(target, scratch, ConfigTool::copyFile); - } finally { - Files.deleteIfExists(scratch); - } - } - - private static void rejectIncludes(Path target) throws IOException { - String include = PropertiesConfiguration.getInclude(); - String optional = PropertiesConfiguration.getIncludeOptional(); - try (Reader input = Files.newBufferedReader(target, - StandardCharsets.UTF_8)) { - PropertiesReader reader = new PropertiesReader(input); - while (reader.nextProperty()) { - String key = reader.getPropertyName(); - if (key.equalsIgnoreCase(include) || - key.equalsIgnoreCase(optional)) { - throw new IllegalStateException(String.format( - "Refusing to rewrite '%s': '%s' directives would " + - "be flattened; update the included file instead", - target, key)); - } - } - } - } - - private static void replaceInPlace(Path target, Path replacement, - FileCopier copier) throws IOException { - Path parent = target.getParent(); - Path backup = Files.createTempFile(parent, - target.getFileName() + ".bak.", - null); - boolean retainBackup = false; - try { - Files.copy(target, backup, - java.nio.file.StandardCopyOption.REPLACE_EXISTING); - try { - copier.copy(replacement, target); - } catch (IOException writeFailure) { - try { - copyFile(backup, target); - } catch (IOException restoreFailure) { - retainBackup = true; - writeFailure.addSuppressed(restoreFailure); - throw new IOException(String.format( - "Failed to rewrite '%s'; recovery also failed, " + - "backup retained at '%s'", target, backup), - writeFailure); - } - throw writeFailure; - } - } finally { - if (!retainBackup) { - Files.deleteIfExists(backup); - } - } - } - - private static void copyFile(Path source, Path target) throws IOException { - try (InputStream input = Files.newInputStream(source); - OutputStream output = Files.newOutputStream( - target, StandardOpenOption.WRITE, - StandardOpenOption.TRUNCATE_EXISTING)) { - byte[] buffer = new byte[8192]; - int length; - while ((length = input.read(buffer)) != -1) { - output.write(buffer, 0, length); - } - } - } - - @FunctionalInterface - private interface FileCopier { - - void copy(Path source, Path target) throws IOException; - } - - static boolean requiresLocalAdmin(String file) { - RegisterUtil.registerServer(); - HugeConfig config = new HugeConfig(file); - return InitStore.requiresLocalBuiltinAdmin(config); - } - - static void validateSkip(String file) { - RegisterUtil.registerServer(); - HugeConfig config = new HugeConfig(file); - E.checkArgument(!config.get(ServerOptions.INIT_STORE_ENABLED), - "'%s' must be false for skip validation", - ServerOptions.INIT_STORE_ENABLED.name()); - InitStore.checkAdminBootstrapReachable(config, file, false); - } - - private static PropertiesConfiguration load(String file) - throws ConfigurationException { - return new Configurations().properties(new File(file)); - } - - private static Object escapePropertyValue(Object value) { - String escaped = StringEscapeUtils.escapeJava(String.valueOf(value)); - int leadingSpaces = 0; - while (leadingSpaces < escaped.length() && - escaped.charAt(leadingSpaces) == ' ') { - leadingSpaces++; - } - if (leadingSpaces == 0) { - return escaped; - } - - StringBuilder result = new StringBuilder(escaped.length() + - leadingSpaces * 5); - for (int i = 0; i < leadingSpaces; i++) { - result.append("\\u0020"); - } - return result.append(escaped.substring(leadingSpaces)).toString(); - } -} diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 5b7f46385c..e8363d5434 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -41,59 +41,35 @@ public class InitStore { private static final Logger LOG = Log.logger(InitStore.class); - private static final String USE_CONFIGURED_ADMIN_PASSWORD = - "--use-configured-admin-password"; public static void main(String[] args) throws Exception { - E.checkArgument(args.length == 1 || - args.length == 2 && - USE_CONFIGURED_ADMIN_PASSWORD.equals(args[1]), + E.checkArgument(args.length == 1, "HugeGraph init-store need to pass the config file " + - "of RestServer, like: conf/rest-server.properties, " + - "with an optional %s flag", - USE_CONFIGURED_ADMIN_PASSWORD); + "of RestServer, like: conf/rest-server.properties"); E.checkArgument(args[0].endsWith(".properties"), "Expect the parameter is properties config file."); String restConf = args[0]; - boolean useConfiguredAdminPassword = args.length == 2; - /* - * Only the server options are needed to read the gate below. Backend - * and plugin registration is deferred to the enabled path: - * registerPlugins() invokes every discovered plugin's register() and - * propagates their failures, which must not happen on a path that is - * documented to be a no-op. - */ + // Server options alone can answer the gate below. Backend and plugin + // registration waits for the enabled path, because registerPlugins() + // runs every plugin's register() and propagates its failures. RegisterUtil.registerServer(); HugeConfig restServerConfig = new HugeConfig(restConf); /* - * Distributed deployments (PD/HStore) let the storage side own the - * metadata, so there is nothing for init-store to do. The option - * defaults to true, keeping standalone/tarball installs on the full - * init path. - * - * The loop below already skips hstore backends, so what this gate - * additionally avoids is the full graph scan and opening the auth graph - * store in initAdminUserIfNeeded(). The validation below may still scan - * graph configuration files to prove that a local built-in authenticator - * can reach the PD-created admin, but it never opens those graphs. On - * Kubernetes the full work ran on every Server pod restart, since the - * entrypoint's init flag file does not survive one. - * - * NOTE: skipping also means the built-in admin account is not created - * here. The PD startup path can replace that work only when usePD is - * true and the configured auth graph uses HStore, whose auth manager - * reads the same PD metadata. checkAdminBootstrapReachable() rejects - * every other local built-in-auth configuration before returning. + * PD/HStore deployments let the storage side own the metadata, so + * init-store has nothing to do; on Kubernetes it re-ran on every Server + * pod restart, since the entrypoint's flag file does not survive one. + * Skipping also skips creating the built-in admin, which only the PD + * startup path can replace, and only for a PD-backed HStore auth graph. */ if (!restServerConfig.get(ServerOptions.INIT_STORE_ENABLED)) { LOG.warn("Skipping init-store: '{}' is false in '{}'. Local " + "backend and admin initialization are not performed.", ServerOptions.INIT_STORE_ENABLED.name(), restConf); - checkAdminBootstrapReachable(restServerConfig, restConf, true); + checkAdminBootstrapReachable(restServerConfig, restConf); return; } @@ -118,17 +94,7 @@ public static void main(String[] args) throws Exception { } graphs.add(initGraph(configPath)); } - if (requiresBuiltinAdmin( - restServerConfig.get(ServerOptions.AUTHENTICATOR))) { - if (useConfiguredAdminPassword) { - StandardAuthenticator.initAdminUserIfNeeded( - restConf, - configuredAdminPassword(restServerConfig, - restConf)); - } else { - StandardAuthenticator.initAdminUserIfNeeded(restConf); - } - } + StandardAuthenticator.initAdminUserIfNeeded(restConf); } finally { for (HugeGraph graph : graphs) { graph.close(); @@ -138,75 +104,44 @@ public static void main(String[] args) throws Exception { } /** - * Skipping means init-store does not create the built-in admin account, - * and the only other component that creates it is - * GraphManager.initAdminUserIfNeeded(), reached from loadMetaFromPD(). That - * creates the user in PD metadata, so it is reachable by the authenticator - * only when usePD is true and the auth graph uses HStore's - * StandardAuthManagerV2. Failing here rather than returning zero keeps - * tarball and init-job callers from continuing into an unusable server. - *

- * Remote auth is exempt: the auth manager is then an RPC client, and - * StandardAuthenticator only bootstraps an admin for a local one. So is - * any authenticator other than the built-in one, which keeps its - * identities outside HugeGraph and needs no such account. + * Skipping leaves the built-in admin to GraphManager.initAdminUserIfNeeded() + * on the PD startup path, which writes it to PD metadata. Only an HStore + * auth graph reads that metadata back, so every other local built-in-auth + * configuration would start a server nobody can log in to. Remote auth and + * custom authenticators keep their identities elsewhere and are exempt. */ - static void checkAdminBootstrapReachable(HugeConfig conf, String restConf, - boolean requirePassword) { + private static void checkAdminBootstrapReachable(HugeConfig conf, + String restConf) { if (!requiresLocalBuiltinAdmin(conf)) { return; } - if (!conf.get(ServerOptions.USE_PD)) { - throw adminBootstrapUnavailable(restConf, String.format( - "'%s' is false", ServerOptions.USE_PD.name()), null); - } - - String graphName = conf.get(ServerOptions.AUTH_GRAPH_STORE); - String graphPath; - try { - Map graphConfs = ConfigUtil.scanGraphsDir( - conf.get(ServerOptions.GRAPHS)); - graphPath = graphConfs.get(graphName); - } catch (RuntimeException e) { - throw adminBootstrapUnavailable( - restConf, "the auth graph configuration cannot be read", e); - } - if (graphPath == null) { - throw adminBootstrapUnavailable(restConf, String.format( - "auth graph '%s' has no local configuration", graphName), - null); + throw unreachableAdmin(restConf, ServerOptions.USE_PD.name() + + " is false"); } - HugeConfig graphConfig = new HugeConfig(graphPath); - String backend = graphConfig.get(CoreOptions.BACKEND); - if (!Objects.equals(backend, "hstore")) { - throw adminBootstrapUnavailable(restConf, String.format( - "auth graph '%s' uses backend '%s', not 'hstore'", - graphName, backend), null); + String name = conf.get(ServerOptions.AUTH_GRAPH_STORE); + String path = ConfigUtil.scanGraphsDir( + conf.get(ServerOptions.GRAPHS)).get(name); + if (path == null) { + throw unreachableAdmin(restConf, "auth graph '" + name + + "' has no local configuration"); } - - if (requirePassword) { - configuredAdminPassword(conf, restConf); + String backend = new HugeConfig(path).get(CoreOptions.BACKEND); + if (!"hstore".equals(backend)) { + throw unreachableAdmin(restConf, "auth graph '" + name + + "' uses backend '" + backend + + "', not 'hstore'"); } } - private static IllegalStateException adminBootstrapUnavailable( - String restConf, - String reason, - RuntimeException cause) { - String message = String.format( - "Refusing to skip init-store: the built-in authenticator is " + - "configured in '%s', but %s, so the admin created on the PD " + - "startup path would not be available to that authenticator. " + - "Set '%s' to true with an HStore auth graph, set '%s' to " + - "delegate auth, configure an external authenticator, or leave " + - "'%s' enabled so init-store creates the account.", - restConf, reason, ServerOptions.USE_PD.name(), - ServerOptions.AUTH_REMOTE_URL.name(), - ServerOptions.INIT_STORE_ENABLED.name()); - return cause == null ? new IllegalStateException(message) : - new IllegalStateException(message, cause); + private static IllegalStateException unreachableAdmin(String restConf, + String reason) { + return new IllegalStateException(String.format( + "Refusing to skip init-store: '%s' configures the built-in " + + "authenticator but %s, so the admin created on the PD startup " + + "path would be unreachable. See docker/README.md.", + restConf, reason)); } /** @@ -217,12 +152,11 @@ private static IllegalStateException adminBootstrapUnavailable( * resolved without initializing it, and one that is not on the init-store * classpath is by definition not the built-in authenticator. */ - static boolean requiresLocalBuiltinAdmin(HugeConfig conf) { - return conf.get(ServerOptions.AUTH_REMOTE_URL).isEmpty() && - requiresBuiltinAdmin(conf.get(ServerOptions.AUTHENTICATOR)); - } - - private static boolean requiresBuiltinAdmin(String authClass) { + private static boolean requiresLocalBuiltinAdmin(HugeConfig conf) { + if (!conf.get(ServerOptions.AUTH_REMOTE_URL).isEmpty()) { + return false; + } + String authClass = conf.get(ServerOptions.AUTHENTICATOR); if (authClass.isEmpty()) { return false; } @@ -231,27 +165,12 @@ private static boolean requiresBuiltinAdmin(String authClass) { InitStore.class.getClassLoader()); return StandardAuthenticator.class.isAssignableFrom(clazz); } catch (ClassNotFoundException | LinkageError e) { - LOG.info("Authenticator '{}' is not resolvable here, assuming it " + - "does not need the built-in admin account", authClass); + LOG.info("Authenticator '{}' is not on the init-store classpath, " + + "so it is not the built-in one", authClass); return false; } } - private static String configuredAdminPassword(HugeConfig conf, - String restConf) { - String key = ServerOptions.ADMIN_PA.name(); - E.checkArgument(conf.containsKey(key), - "The local built-in authenticator in '%s' must " + - "explicitly define a non-empty '%s'; the default " + - "value is not accepted for admin bootstrap", - restConf, key); - String password = conf.get(ServerOptions.ADMIN_PA); - E.checkArgument(!password.isEmpty(), - "The configured admin password in '%s' can't be empty", - restConf); - return password; - } - private static HugeGraph initGraph(String configPath) throws Exception { LOG.info("Init graph with config file: {}", configPath); HugeConfig config = new HugeConfig(configPath); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 21928eed62..26cc838fe7 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -18,7 +18,6 @@ package org.apache.hugegraph.unit.cmd; import java.io.IOException; -import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -29,26 +28,20 @@ import java.util.stream.Stream; import org.apache.commons.configuration2.PropertiesConfiguration; -import org.apache.hugegraph.HugeGraph; -import org.apache.hugegraph.auth.HugeUser; -import org.apache.hugegraph.auth.StandardAuthManager; import org.apache.hugegraph.auth.StandardAuthenticator; -import org.apache.hugegraph.backend.store.BackendFeatures; -import org.apache.hugegraph.cmd.ConfigTool; import org.apache.hugegraph.cmd.InitStore; +import org.apache.hugegraph.config.ConfigException; import org.apache.hugegraph.config.CoreOptions; import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.config.OptionSpace; import org.apache.hugegraph.config.ServerOptions; import org.apache.hugegraph.dist.RegisterUtil; import org.apache.hugegraph.testutil.Assert; -import org.apache.hugegraph.testutil.Whitebox; import org.apache.hugegraph.util.ConfigUtil; import org.junit.After; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; -import org.mockito.Mockito; /** * {@code init_store.enabled} controls whether init-store performs local @@ -91,35 +84,48 @@ public void testInitStoreEnabledByDefault() { Assert.assertTrue(config.get(ServerOptions.INIT_STORE_ENABLED)); } + /** + * docker-entrypoint.sh writes this property verbatim from the environment + * and decides on its own whether to record initialization as done, so its + * accepted spellings have to be the ones pinned here. The shell side is + * not executed by this test; keep the two lists in step by hand. + */ @Test - public void testAdminPasswordDefaultsToPa() { - HugeConfig config = new HugeConfig(new PropertiesConfiguration()); - Assert.assertEquals("pa", config.get(ServerOptions.ADMIN_PA)); + public void testBooleanSpellingsAcceptedByHugeConfig() { + for (String value : new String[]{"true", "TRUE", "t", "on", "y", "yes"}) { + Assert.assertTrue(value, initStoreEnabled(value)); + } + for (String value : new String[]{"false", "FALSE", "f", "off", "n", "no"}) { + Assert.assertFalse(value, initStoreEnabled(value)); + } } + /** + * Anything outside that vocabulary is refused while the config loads, so + * the entrypoint rejects such a value instead of passing it through and + * recording an initialization that never happened. + */ @Test - public void testExplicitTrueKeepsInitStoreEnabled() { - PropertiesConfiguration properties = new PropertiesConfiguration(); - properties.setProperty(ServerOptions.INIT_STORE_ENABLED.name(), "true"); - HugeConfig config = new HugeConfig(properties); - Assert.assertTrue(config.get(ServerOptions.INIT_STORE_ENABLED)); + public void testUnparseableBooleanFailsToLoad() { + for (String value : new String[]{"0", "1", "disabled"}) { + Assert.assertThrows(ConfigException.class, () -> { + initStoreEnabled(value); + }); + } } - @Test - public void testExplicitFalseDisablesInitStore() { + private static boolean initStoreEnabled(String value) { PropertiesConfiguration properties = new PropertiesConfiguration(); - properties.setProperty(ServerOptions.INIT_STORE_ENABLED.name(), "false"); - HugeConfig config = new HugeConfig(properties); - Assert.assertFalse(config.get(ServerOptions.INIT_STORE_ENABLED)); + properties.setProperty(ServerOptions.INIT_STORE_ENABLED.name(), value); + return new HugeConfig(properties).get(ServerOptions.INIT_STORE_ENABLED); } /** * A key defined twice collects both values into a list, which fails the * scalar type check while the config is still being loaded. Both - * init-store and server startup would therefore fail outright. That is why - * docker-entrypoint.sh collapses any existing definition into one - * canonical line instead of appending a second one when it maps an - * environment variable onto the property. + * init-store and server startup would therefore fail outright, so a + * caller that maps an environment variable onto the property has to + * replace any existing definition rather than append a second one. */ @Test public void testDuplicateDefinitionFailsToLoad() throws IOException { @@ -133,109 +139,6 @@ public void testDuplicateDefinitionFailsToLoad() throws IOException { }, e -> Assert.assertContains("[false, true]", e.getMessage())); } - @Test - public void testUnicodeEscapedAdminPasswordRoundTrips() throws IOException { - String password = " \tmeta:=#!\\\r\f\np\u00e4ss\u96ea"; - StringBuilder serialized = new StringBuilder( - ServerOptions.ADMIN_PA.name() + "="); - for (char item : password.toCharArray()) { - serialized.append(String.format("\\u%04x", (int) item)); - } - - Path conf = this.workDir.resolve("escaped-password.properties"); - Files.write(conf, Arrays.asList(serialized.toString()), - StandardCharsets.UTF_8); - - HugeConfig config = new HugeConfig(conf.toString()); - Assert.assertEquals(password, config.get(ServerOptions.ADMIN_PA)); - } - - @Test - public void testConfigToolUsesJavaPropertiesGrammar() throws Exception { - Path conf = this.workDir.resolve("grammar.properties"); - Files.write(conf, Arrays.asList( - "auth\\.authenticator=" + - StandardAuthenticator.class.getName(), - "init_store.enabled=fal\\", - " se"), StandardCharsets.UTF_8); - - Assert.assertEquals(StandardAuthenticator.class.getName(), - configToolGet(conf, "auth.authenticator")); - Assert.assertEquals("false", - configToolGet(conf, "init_store.enabled")); - } - - @Test - public void testConfigToolReplacesLogicalPropertyAndPreservesLayout() - throws Exception { - Path conf = this.workDir.resolve("rewrite.properties"); - String key = ServerOptions.AUTHENTICATOR.name(); - Files.write(conf, Arrays.asList( - "# mounted configuration", - "auth\\.authenticator=old.Authenticator", - key + "=duplicate.Authenticator"), StandardCharsets.UTF_8); - - configToolSet(conf, key, StandardAuthenticator.class.getName()); - - Assert.assertEquals(StandardAuthenticator.class.getName(), - configToolGet(conf, key)); - HugeConfig config = new HugeConfig(conf.toString()); - Assert.assertEquals(StandardAuthenticator.class.getName(), - config.get(ServerOptions.AUTHENTICATOR)); - Assert.assertContains("# mounted configuration", - new String(Files.readAllBytes(conf), - StandardCharsets.UTF_8)); - try (Stream files = Files.list(this.workDir)) { - Assert.assertFalse(files.anyMatch(path -> - path.getFileName().toString().contains(".tmp."))); - } - } - - @Test - public void testConfigToolSerializesPasswordExactly() throws Exception { - Path conf = this.workDir.resolve("password.properties"); - Files.write(conf, Arrays.asList("# password"), StandardCharsets.UTF_8); - String password = " \tmeta:=#!\\\r\f\np\u00e4ss\u96ea\n"; - - configToolSet(conf, ServerOptions.ADMIN_PA.name(), password); - - HugeConfig config = new HugeConfig(conf.toString()); - Assert.assertEquals(password, config.get(ServerOptions.ADMIN_PA)); - } - - @Test - public void testConfigToolRejectsRewriteWithInclude() throws Exception { - Path child = this.workDir.resolve("included.properties"); - Files.write(child, Arrays.asList("child.key=child-value"), - StandardCharsets.UTF_8); - Path conf = this.workDir.resolve("parent.properties"); - Files.write(conf, Arrays.asList( - "include=included.properties", - "parent.key=old-value"), StandardCharsets.UTF_8); - - Assert.assertThrows(IllegalStateException.class, () -> { - configToolSet(conf, "parent.key", "new-value"); - }, e -> Assert.assertContains("include", e.getMessage())); - - String content = new String(Files.readAllBytes(conf), - StandardCharsets.UTF_8); - Assert.assertContains("include=included.properties", content); - Assert.assertContains("parent.key=old-value", content); - } - - @Test - public void testConfigToolRejectsRewriteWithOptionalInclude() - throws Exception { - Path conf = this.workDir.resolve("optional-parent.properties"); - Files.write(conf, Arrays.asList( - "include\\u006fptional=missing.properties", - "parent.key=old-value"), StandardCharsets.UTF_8); - - Assert.assertThrows(IllegalStateException.class, () -> { - configToolSet(conf, "parent.key", "new-value"); - }, e -> Assert.assertContains("includeoptional", e.getMessage())); - } - /** * The graphs directory referenced by the temporary config does not exist, * so every code path that reaches graph scanning fails. That is what makes @@ -250,39 +153,6 @@ public void testMissingGraphsDirFailsScan() { }); } - @Test - public void testConfigToolRestoresTargetAfterInterruptedCopy() - throws Exception { - Path target = this.workDir.resolve("recover.properties"); - Path replacement = this.workDir.resolve("replacement.properties"); - byte[] original = "key=original\n".getBytes(StandardCharsets.UTF_8); - Files.write(target, original); - Files.write(replacement, - "key=replacement\n".getBytes(StandardCharsets.UTF_8)); - - Class copierType = Class.forName( - "org.apache.hugegraph.cmd.ConfigTool$FileCopier"); - Object failingCopier = Proxy.newProxyInstance( - copierType.getClassLoader(), new Class[]{copierType}, - (proxy, method, args) -> { - Files.write((Path) args[1], - "partial".getBytes(StandardCharsets.UTF_8)); - throw new IOException("injected mid-copy failure"); - }); - - Assert.assertThrows(RuntimeException.class, () -> { - Whitebox.invokeStatic( - ConfigTool.class, - new Class[]{Path.class, Path.class, copierType}, - "replaceInPlace", target, replacement, failingCopier); - }); - Assert.assertArrayEquals(original, Files.readAllBytes(target)); - try (Stream files = Files.list(this.workDir)) { - Assert.assertFalse(files.anyMatch(path -> - path.getFileName().toString().contains(".bak."))); - } - } - @Test public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { String restConf = this.writeDisabledRestServerConf(); @@ -291,233 +161,6 @@ public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { InitStore.main(new String[]{restConf}); } - @Test - public void testConfiguredAdminPasswordFlagAccepted() throws Exception { - String restConf = this.writeDisabledRestServerConf(); - InitStore.main(new String[]{restConf, - "--use-configured-admin-password"}); - } - - @Test - public void testConfiguredAdminPasswordRejectsNullOrEmptyValue() - throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - HugeGraph graph = Mockito.mock(HugeGraph.class); - BackendFeatures features = Mockito.mock(BackendFeatures.class); - StandardAuthManager authManager = Mockito.mock(StandardAuthManager.class); - Mockito.when(graph.hugegraph()).thenReturn(graph); - Mockito.when(graph.authManager()).thenReturn(authManager); - Mockito.when(graph.backendStoreFeatures()).thenReturn(features); - Mockito.when(features.supportsPersistence()).thenReturn(true); - - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Whitebox.setInternalState(authenticator, "graph", graph); - Mockito.doNothing().when(authenticator).setup(config); - - for (String password : new String[]{null, ""}) { - Assert.assertThrows(IllegalArgumentException.class, () -> { - Whitebox.invoke(StandardAuthenticator.class, - new Class[]{HugeConfig.class, String.class, - boolean.class}, - "initAdminUser", authenticator, config, - password, true); - }, e -> Assert.assertContains("can't be null or empty", - e.getMessage())); - } - Mockito.verify(graph, Mockito.times(2)).close(); - } - - @Test - public void testAdminGraphClosedForNonPersistentBackend() throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - HugeGraph graph = Mockito.mock(HugeGraph.class); - BackendFeatures features = Mockito.mock(BackendFeatures.class); - Mockito.when(graph.backendStoreFeatures()).thenReturn(features); - Mockito.when(features.supportsPersistence()).thenReturn(false); - - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Whitebox.setInternalState(authenticator, "graph", graph); - Mockito.doNothing().when(authenticator).setup(config); - - Whitebox.invoke(StandardAuthenticator.class, - new Class[]{HugeConfig.class, String.class, - boolean.class}, - "initAdminUser", authenticator, config, null, false); - - Mockito.verify(graph).close(); - } - - @Test - public void testAdminGraphClosedAfterSetupFailure() throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - HugeGraph graph = Mockito.mock(HugeGraph.class); - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Whitebox.setInternalState(authenticator, "graph", graph); - Mockito.doThrow(new IllegalStateException("setup failed")) - .when(authenticator).setup(config); - - Assert.assertThrows(IllegalStateException.class, () -> { - Whitebox.invoke(StandardAuthenticator.class, - new Class[]{HugeConfig.class, String.class, - boolean.class}, - "initAdminUser", authenticator, config, null, false); - }, e -> Assert.assertContains("setup failed", e.getMessage())); - - Mockito.verify(graph).close(); - } - - @Test - public void testPrimaryAdminFailureSuppressesCloseFailure() throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - HugeGraph graph = Mockito.mock(HugeGraph.class); - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Whitebox.setInternalState(authenticator, "graph", graph); - Mockito.doThrow(new IllegalStateException("setup failed")) - .when(authenticator).setup(config); - Mockito.doThrow(new IllegalStateException("close failed")) - .when(graph).close(); - - Assert.assertThrows(IllegalStateException.class, () -> { - invokeInitAdminUser(authenticator, config, null, false); - }, e -> { - Assert.assertContains("setup failed", e.getMessage()); - Assert.assertEquals(1, e.getSuppressed().length); - Assert.assertContains("close failed", - e.getSuppressed()[0].getMessage()); - }); - } - - @Test - public void testAdminCloseFailureSurfacesWhenItIsOnlyFailure() - throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - HugeGraph graph = Mockito.mock(HugeGraph.class); - BackendFeatures features = Mockito.mock(BackendFeatures.class); - Mockito.when(graph.backendStoreFeatures()).thenReturn(features); - Mockito.when(features.supportsPersistence()).thenReturn(false); - Mockito.doThrow(new IllegalStateException("close failed")) - .when(graph).close(); - - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Whitebox.setInternalState(authenticator, "graph", graph); - Mockito.doNothing().when(authenticator).setup(config); - - Assert.assertThrows(IllegalStateException.class, () -> { - invokeInitAdminUser(authenticator, config, null, false); - }, e -> Assert.assertContains("close failed", e.getMessage())); - } - - @Test - public void testSetupFailureWithoutGraphPreservesOriginalException() - throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Mockito.doThrow(new IllegalStateException("setup failed before open")) - .when(authenticator).setup(config); - - Assert.assertThrows(IllegalStateException.class, () -> { - Whitebox.invoke(StandardAuthenticator.class, - new Class[]{HugeConfig.class, String.class, - boolean.class}, - "initAdminUser", authenticator, config, null, false); - }, e -> Assert.assertContains("setup failed before open", - e.getMessage())); - } - - @Test - public void testAdminGraphClosedAfterFeatureInspectionFailure() - throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - HugeGraph graph = Mockito.mock(HugeGraph.class); - Mockito.when(graph.backendStoreFeatures()) - .thenThrow(new IllegalStateException("feature check failed")); - - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Whitebox.setInternalState(authenticator, "graph", graph); - Mockito.doNothing().when(authenticator).setup(config); - - Assert.assertThrows(IllegalStateException.class, () -> { - Whitebox.invoke(StandardAuthenticator.class, - new Class[]{HugeConfig.class, String.class, - boolean.class}, - "initAdminUser", authenticator, config, null, false); - }, e -> Assert.assertContains("feature check failed", e.getMessage())); - - Mockito.verify(graph).close(); - } - - @Test - public void testAdminGraphClosedWhenAdminAlreadyExists() throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - HugeGraph graph = Mockito.mock(HugeGraph.class); - BackendFeatures features = Mockito.mock(BackendFeatures.class); - StandardAuthManager authManager = Mockito.mock(StandardAuthManager.class); - Mockito.when(graph.backendStoreFeatures()).thenReturn(features); - Mockito.when(features.supportsPersistence()).thenReturn(true); - Mockito.when(graph.hugegraph()).thenReturn(graph); - Mockito.when(graph.authManager()).thenReturn(authManager); - Mockito.when(authManager.findUser("admin")) - .thenReturn(Mockito.mock(HugeUser.class)); - - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Whitebox.setInternalState(authenticator, "graph", graph); - Mockito.doNothing().when(authenticator).setup(config); - - Whitebox.invoke(StandardAuthenticator.class, - new Class[]{HugeConfig.class, String.class, - boolean.class}, - "initAdminUser", authenticator, config, null, true); - - Mockito.verify(authManager, Mockito.never()) - .createUser(Mockito.any(HugeUser.class)); - Mockito.verify(graph).close(); - } - - @Test - public void testAdminGraphClosedAfterCreateUserFailure() throws Exception { - HugeConfig config = Mockito.mock(HugeConfig.class); - HugeGraph graph = Mockito.mock(HugeGraph.class); - BackendFeatures features = Mockito.mock(BackendFeatures.class); - StandardAuthManager authManager = Mockito.mock(StandardAuthManager.class); - Mockito.when(graph.backendStoreFeatures()).thenReturn(features); - Mockito.when(features.supportsPersistence()).thenReturn(true); - Mockito.when(graph.hugegraph()).thenReturn(graph); - Mockito.when(graph.authManager()).thenReturn(authManager); - Mockito.when(authManager.createUser(Mockito.any(HugeUser.class))) - .thenThrow(new IllegalStateException("create user failed")); - - StandardAuthenticator authenticator = - Mockito.spy(new StandardAuthenticator()); - Whitebox.setInternalState(authenticator, "graph", graph); - Mockito.doNothing().when(authenticator).setup(config); - - Assert.assertThrows(IllegalStateException.class, () -> { - Whitebox.invoke(StandardAuthenticator.class, - new Class[]{HugeConfig.class, String.class, - boolean.class}, - "initAdminUser", authenticator, config, - "secret", true); - }, e -> Assert.assertContains("create user failed", e.getMessage())); - - Mockito.verify(graph).close(); - } - - @Test - public void testUnknownInitStoreFlagRejected() throws IOException { - String restConf = this.writeDisabledRestServerConf(); - Assert.assertThrows(IllegalArgumentException.class, () -> { - InitStore.main(new String[]{restConf, "--unknown"}); - }, e -> Assert.assertContains("optional", e.getMessage())); - } - /** * Disabled mode must not reach RegisterUtil.registerBackends() or * registerPlugins(): plugin registration runs every discovered plugin's @@ -526,10 +169,17 @@ public void testUnknownInitStoreFlagRejected() throws IOException { * registerBackends(), so their registration state shows whether it ran. * OptionSpace is process-wide, so the assertion is that main() leaves that * state unchanged rather than that it starts out empty. + *

+ * The enabled run at the end is the control: it shares this method so that + * it provably happens after the disabled assertions, since registering + * backends here would otherwise make them vacuous. Without it, every + * assertion above would still hold for a gate that skipped unconditionally. + * That run leaves backend providers registered for the rest of the suite's + * JVM, and BackendProviderFactory.register() rejects a duplicate, so a + * later suite member registering backends itself has to tolerate that. */ @Test - public void testDisabledInitStoreSkipsBackendAndPluginRegistration() - throws Exception { + public void testGateDecidesWhetherRegistrationRuns() throws Exception { boolean backendsRegistered = OptionSpace.containKey(ROCKSDB_OPTION); InitStore.main(new String[]{this.writeDisabledRestServerConf()}); @@ -539,6 +189,13 @@ public void testDisabledInitStoreSkipsBackendAndPluginRegistration() // Server options are still registered, since the gate is read from them Assert.assertTrue(OptionSpace.containKey( ServerOptions.INIT_STORE_ENABLED.name())); + + // Absent gate means enabled, so the same config now has to reach the + // graph scan and fail on the directory the disabled run never looked at + Assert.assertThrows(IllegalArgumentException.class, () -> { + InitStore.main(new String[]{this.writeEnabledRestServerConf()}); + }); + Assert.assertTrue(OptionSpace.containKey(ROCKSDB_OPTION)); } /** @@ -580,21 +237,6 @@ public void testDisabledInitStoreFailsWithNonHstoreAuthGraph() public void testDisabledInitStoreAllowsPdBackedHstoreAuthGraph() throws Exception { Path graphsDir = this.writeAuthGraphConfig("hstore"); - String restConf = this.writeDisabledRestServerConfForGraphs( - graphsDir, - ServerOptions.AUTHENTICATOR.name() + - "=" + StandardAuthenticator.class.getName(), - ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph", - ServerOptions.USE_PD.name() + "=true", - ServerOptions.ADMIN_PA.name() + "=secret"); - - InitStore.main(new String[]{restConf}); - } - - @Test - public void testDisabledInitStoreRejectsDefaultAdminPassword() - throws Exception { - Path graphsDir = this.writeAuthGraphConfig("hstore"); String restConf = this.writeDisabledRestServerConfForGraphs( graphsDir, ServerOptions.AUTHENTICATOR.name() + @@ -602,13 +244,7 @@ public void testDisabledInitStoreRejectsDefaultAdminPassword() ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph", ServerOptions.USE_PD.name() + "=true"); - // Docker uses this topology-only preflight before it writes PASSWORD. - Whitebox.invokeStatic(ConfigTool.class, - new Class[]{String.class}, - "validateSkip", restConf); - Assert.assertThrows(IllegalArgumentException.class, () -> { - InitStore.main(new String[]{restConf}); - }, e -> Assert.assertContains("must explicitly define", e.getMessage())); + InitStore.main(new String[]{restConf}); } /** @@ -650,65 +286,25 @@ public void testDisabledInitStoreAllowsCustomAuthenticator() e.getMessage())); } - @Test - public void testConfigToolClassifiesLocalBuiltinAuth() throws Exception { - String standard = this.writeDisabledRestServerConf( - ServerOptions.AUTHENTICATOR.name() + "=" + - StandardAuthenticator.class.getName()); - String derived = this.writeDisabledRestServerConf( - ServerOptions.AUTHENTICATOR.name() + "=" + - DerivedAuthenticator.class.getName()); - String remote = this.writeDisabledRestServerConf( - ServerOptions.AUTHENTICATOR.name() + "=" + - StandardAuthenticator.class.getName(), - ServerOptions.AUTH_REMOTE_URL.name() + "=127.0.0.1:8899"); - String custom = this.writeDisabledRestServerConf( - ServerOptions.AUTHENTICATOR.name() + - "=org.example.auth.LdapAuthenticator"); - - Assert.assertTrue(configToolRequiresLocalAdmin(standard)); - Assert.assertTrue(configToolRequiresLocalAdmin(derived)); - Assert.assertFalse(configToolRequiresLocalAdmin(remote)); - Assert.assertFalse(configToolRequiresLocalAdmin(custom)); - } - - private static String configToolGet(Path file, String key) { - return Whitebox.invokeStatic( - ConfigTool.class, - new Class[]{String.class, String.class}, - "getProperty", file.toString(), key); - } - - private static void configToolSet(Path file, String key, String value) { - Whitebox.invokeStatic( - ConfigTool.class, - new Class[]{String.class, String.class, String.class}, - "setProperty", file.toString(), key, value); - } - - private static boolean configToolRequiresLocalAdmin(String file) { - return Whitebox.invokeStatic( - ConfigTool.class, new Class[]{String.class}, - "requiresLocalAdmin", file); - } - - private static void invokeInitAdminUser( - StandardAuthenticator authenticator, - HugeConfig config, String password, - boolean fromConfig) { - Whitebox.invoke(StandardAuthenticator.class, - new Class[]{HugeConfig.class, String.class, - boolean.class}, - "initAdminUser", authenticator, config, - password, fromConfig); - } - private String writeDisabledRestServerConf(String... extraLines) throws IOException { return this.writeDisabledRestServerConfForGraphs( this.workDir.resolve(MISSING_GRAPHS_DIR), extraLines); } + /** + * The same configuration with the gate left out entirely, so it takes the + * option's default rather than an explicit value. + */ + private String writeEnabledRestServerConf() throws IOException { + Path restConf = this.workDir.resolve( + "rest-server-" + this.confSeq++ + ".properties"); + Files.write(restConf, Arrays.asList(ServerOptions.GRAPHS.name() + "=" + + this.workDir.resolve(MISSING_GRAPHS_DIR)), + StandardCharsets.UTF_8); + return restConf.toString(); + } + private String writeDisabledRestServerConfForGraphs(Path graphsDir, String... extraLines) throws IOException { From 526809161b74faa8f21920c9f9066eab16cf0ad2 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sat, 1 Aug 2026 23:56:24 +0530 Subject: [PATCH 16/19] style(hugegraph-dist): address review follow-ups on the init-store gate - Name the actual boolean converter in the entrypoint comment. "BooleanUtils" alone is ambiguous: commons-lang3 accepts 0 and 1, the commons-lang 2.x one reached through commons-configuration 1.x PropertyConverter does not, and the accepted spellings follow the latter. - Say that the init_complete guard applies to the environment variable, not to the property set directly in a mounted rest-server.properties. - Move InitStoreConfigTest into its own cmd group in UnitTestSuite instead of the core one. - Trim test comments closer to the density of the surrounding suite. --- docker/README.md | 6 ++- .../docker/docker-entrypoint.sh | 8 ++-- .../apache/hugegraph/unit/UnitTestSuite.java | 4 +- .../unit/cmd/InitStoreConfigTest.java | 48 +++++++------------ 4 files changed, 29 insertions(+), 37 deletions(-) diff --git a/docker/README.md b/docker/README.md index 1145446ea4..51288f7ad9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -172,8 +172,10 @@ Configuration is injected via environment variables. The old `docker/configs/app > nobody can log in to. A custom `auth.authenticator` is exempt because it > manages its own identities. > -> With `false`, the entrypoint deliberately never writes `docker/init_complete`, -> so a later re-enable is still able to initialize. +> With the variable set to `false`, the entrypoint deliberately never writes +> `docker/init_complete`, so a later re-enable is still able to initialize. +> Setting the property directly in a mounted `rest-server.properties` does not +> get that guard. > > **`PASSWORD` has no effect on that path.** init-store reads it from standard > input, and a disabled one returns before doing so. The admin is created on diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index f9fab34514..292eeb6407 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -57,9 +57,11 @@ migrate_env "PD_PEERS" "HG_SERVER_PD_PEERS" [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && set_prop "pd.peers" "${HG_SERVER_PD_PEERS}" "${GRAPH_CONF}" # Normalized once here and reused by the init-flag guard below. The accepted -# spellings are the ones HugeConfig accepts (BooleanUtils, case-insensitive); -# anything else is rejected now rather than touching the init flag for a value -# the server is going to refuse anyway. +# spellings are the ones HugeConfig accepts, case-insensitive: commons-lang 2.x +# BooleanUtils, reached through commons-configuration 1.x PropertyConverter. +# That set excludes 0 and 1, which commons-lang3 would have taken. Anything +# outside it is rejected now rather than touching the init flag for a value the +# server is going to refuse anyway. INIT_STORE_ENABLED=$(printf '%s' "${HG_SERVER_INIT_STORE_ENABLED:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') case "${INIT_STORE_ENABLED}" in diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index a2531f0c2d..c7771fbdc3 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -144,7 +144,6 @@ SecurityManagerTest.class, RolePermissionTest.class, ExceptionTest.class, - InitStoreConfigTest.class, GraphManagerConfigTest.class, BackendStoreInfoTest.class, TraversalUtilTest.class, @@ -157,6 +156,9 @@ HugeGraphAuthProxyTest.class, SchemaElementTest.class, + /* cmd */ + InitStoreConfigTest.class, + /* serializer */ BytesBufferTest.class, SerializerFactoryTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 26cc838fe7..ae4b0c740d 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -85,10 +85,8 @@ public void testInitStoreEnabledByDefault() { } /** - * docker-entrypoint.sh writes this property verbatim from the environment - * and decides on its own whether to record initialization as done, so its - * accepted spellings have to be the ones pinned here. The shell side is - * not executed by this test; keep the two lists in step by hand. + * Pins the spellings docker-entrypoint.sh may pass through. The shell side + * is not executed here, so keep the two lists in step by hand. */ @Test public void testBooleanSpellingsAcceptedByHugeConfig() { @@ -101,9 +99,9 @@ public void testBooleanSpellingsAcceptedByHugeConfig() { } /** - * Anything outside that vocabulary is refused while the config loads, so - * the entrypoint rejects such a value instead of passing it through and - * recording an initialization that never happened. + * Conversion runs at load time via commons-configuration 1.x + * PropertyConverter, which delegates to commons-lang 2.x BooleanUtils — + * so "0" and "1" are refused here even though commons-lang3 accepts them. */ @Test public void testUnparseableBooleanFailsToLoad() { @@ -121,11 +119,8 @@ private static boolean initStoreEnabled(String value) { } /** - * A key defined twice collects both values into a list, which fails the - * scalar type check while the config is still being loaded. Both - * init-store and server startup would therefore fail outright, so a - * caller that maps an environment variable onto the property has to - * replace any existing definition rather than append a second one. + * A key defined twice loads as a list and fails the scalar type check, so + * a caller mapping an env var onto it must replace, not append. */ @Test public void testDuplicateDefinitionFailsToLoad() throws IOException { @@ -140,10 +135,8 @@ public void testDuplicateDefinitionFailsToLoad() throws IOException { } /** - * The graphs directory referenced by the temporary config does not exist, - * so every code path that reaches graph scanning fails. That is what makes - * {@link #testDisabledInitStoreExitsBeforeGraphInit()} a real assertion - * rather than a tautology. + * Establishes that graph scanning fails for these configs, which is what + * keeps {@link #testDisabledInitStoreExitsBeforeGraphInit()} honest. */ @Test public void testMissingGraphsDirFailsScan() { @@ -162,21 +155,14 @@ public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { } /** - * Disabled mode must not reach RegisterUtil.registerBackends() or - * registerPlugins(): plugin registration runs every discovered plugin's - * register() and propagates its failures, which a documented no-op path - * must not do. Backend options land in OptionSpace only via - * registerBackends(), so their registration state shows whether it ran. - * OptionSpace is process-wide, so the assertion is that main() leaves that - * state unchanged rather than that it starts out empty. - *

- * The enabled run at the end is the control: it shares this method so that - * it provably happens after the disabled assertions, since registering - * backends here would otherwise make them vacuous. Without it, every - * assertion above would still hold for a gate that skipped unconditionally. - * That run leaves backend providers registered for the rest of the suite's - * JVM, and BackendProviderFactory.register() rejects a duplicate, so a - * later suite member registering backends itself has to tolerate that. + * Disabled mode must not reach registerBackends() or registerPlugins(), + * since plugin registration propagates every plugin's failures. Backend + * options reach OptionSpace only through registerBackends(), so that state + * shows whether it ran; OptionSpace is process-wide, hence unchanged rather + * than empty. The enabled run shares this method so it provably follows the + * disabled assertions, which registering backends first would make vacuous. + * It leaves backend providers registered for the rest of the suite's JVM, + * and BackendProviderFactory.register() rejects duplicates. */ @Test public void testGateDecidesWhetherRegistrationRuns() throws Exception { From 9707febaa3009ae6f8e35f4e1f8394aebbf95eec Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 2 Aug 2026 10:35:11 +0530 Subject: [PATCH 17/19] fix(hugegraph-dist): fail closed on the default admin password and record init by result Two findings from review of the disabled init-store path. The check now also requires an explicit non-empty auth.admin_pa. Without it a fresh built-in-auth deployment reaches GraphManager.initAdminUserIfNeeded() on the PD startup path, which creates the admin from that option's public 'pa' default while Docker PASSWORD is discarded on this path. The accepted-HStore test had no admin_pa, so it pinned exactly that case. The docker init marker is now written by init-store itself, and only when it initialized. The entrypoint could only decide from its own environment variable, so a config mounted with init_store.enabled=false was still recorded as initialized, and setting it back to true later skipped initialization for good. The marker path is passed in, so callers that do not want one are unaffected. --- .../docker/docker-entrypoint.sh | 11 ++- .../org/apache/hugegraph/cmd/InitStore.java | 53 ++++++++++++ .../unit/cmd/InitStoreConfigTest.java | 81 +++++++++++++++---- 3 files changed, 122 insertions(+), 23 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 292eeb6407..aece800d72 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -86,6 +86,11 @@ if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then ./bin/wait-storage.sh fi + # init-store writes the marker itself, and only if it initialized. Deciding + # here would mean guessing from the environment variable, which says + # nothing about a config mounted with the property already set. + export HG_SERVER_INIT_COMPLETE_MARKER="${DOCKER_FOLDER}/${INIT_FLAG_FILE}" + if [[ -z "${PASSWORD:-}" ]]; then log "init hugegraph with non-auth mode" ./bin/init-store.sh @@ -102,12 +107,6 @@ if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then esac echo "${PASSWORD}" | ./bin/init-store.sh fi - # A disabled init-store initialized nothing, so recording it as done would - # stop a later re-enable from initializing. - case "${INIT_STORE_ENABLED}" in - n | f | no | off | false) ;; - *) touch "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ;; - esac else log "HugeGraph initialization already done. Skipping re-init..." fi diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index e8363d5434..3de5f42969 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -17,6 +17,10 @@ package org.apache.hugegraph.cmd; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -42,6 +46,16 @@ public class InitStore { private static final Logger LOG = Log.logger(InitStore.class); + /** + * Where to record that initialization actually happened. The caller that + * wants the record supplies the path; nothing is written when it is unset, + * so tarball callers are unaffected. + */ + public static final String INIT_COMPLETE_MARKER = + "hugegraph.init_complete_marker"; + private static final String INIT_COMPLETE_MARKER_ENV = + "HG_SERVER_INIT_COMPLETE_MARKER"; + public static void main(String[] args) throws Exception { E.checkArgument(args.length == 1, "HugeGraph init-store need to pass the config file " + @@ -101,6 +115,32 @@ public static void main(String[] args) throws Exception { } HugeFactory.shutdown(30L, true); } + + recordInitComplete(); + } + + /** + * Only this process knows whether it initialized anything. The Docker + * entrypoint used to decide from its environment variable alone, so a + * mounted config that disabled init-store was still recorded as done and a + * later re-enable skipped for good. Reached only on the enabled path, and + * only after initialization succeeded. + */ + private static void recordInitComplete() throws IOException { + String marker = System.getProperty(INIT_COMPLETE_MARKER, + System.getenv(INIT_COMPLETE_MARKER_ENV)); + if (marker == null || marker.isEmpty()) { + return; + } + Path path = Paths.get(marker); + Path dir = path.toAbsolutePath().getParent(); + if (dir != null) { + Files.createDirectories(dir); + } + if (Files.notExists(path)) { + Files.createFile(path); + } + LOG.info("Recorded init-store completion at '{}'", path); } /** @@ -133,6 +173,19 @@ private static void checkAdminBootstrapReachable(HugeConfig conf, "' uses backend '" + backend + "', not 'hstore'"); } + + // The server creates the admin from this value and cannot prompt for + // it, and Docker PASSWORD never reaches this path. An absent or empty + // one would hand out the public 'pa' default, so fail instead. Checked + // with containsKey because the default is not a configured secret. + if (!conf.containsKey(ServerOptions.ADMIN_PA.name()) || + conf.get(ServerOptions.ADMIN_PA).isEmpty()) { + throw unreachableAdmin(restConf, "no explicit non-empty '" + + ServerOptions.ADMIN_PA.name() + + "' is configured, so the admin " + + "would be created with the " + + "public default"); + } } private static IllegalStateException unreachableAdmin(String restConf, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index ae4b0c740d..5fb4e3bee7 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -162,26 +162,45 @@ public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception { * than empty. The enabled run shares this method so it provably follows the * disabled assertions, which registering backends first would make vacuous. * It leaves backend providers registered for the rest of the suite's JVM, - * and BackendProviderFactory.register() rejects duplicates. + * and BackendProviderFactory.register() rejects duplicates, so this has to + * stay the only enabled run in the class. + *

+ * The completion marker is asserted here for the same reason. A disabled + * run must not write it: the entrypoint treats it as "already initialized", + * so recording one for a config mounted with the property set to false + * would skip the real initialization for good on a later re-enable. Only + * a successful enabled run may write it, which needs a live backend and so + * is not covered here. */ @Test public void testGateDecidesWhetherRegistrationRuns() throws Exception { boolean backendsRegistered = OptionSpace.containKey(ROCKSDB_OPTION); - - InitStore.main(new String[]{this.writeDisabledRestServerConf()}); - - Assert.assertEquals(backendsRegistered, - OptionSpace.containKey(ROCKSDB_OPTION)); - // Server options are still registered, since the gate is read from them - Assert.assertTrue(OptionSpace.containKey( - ServerOptions.INIT_STORE_ENABLED.name())); - - // Absent gate means enabled, so the same config now has to reach the - // graph scan and fail on the directory the disabled run never looked at - Assert.assertThrows(IllegalArgumentException.class, () -> { - InitStore.main(new String[]{this.writeEnabledRestServerConf()}); - }); - Assert.assertTrue(OptionSpace.containKey(ROCKSDB_OPTION)); + Path marker = this.workDir.resolve("docker/init_complete"); + System.setProperty(InitStore.INIT_COMPLETE_MARKER, marker.toString()); + + try { + InitStore.main(new String[]{this.writeDisabledRestServerConf()}); + + Assert.assertEquals(backendsRegistered, + OptionSpace.containKey(ROCKSDB_OPTION)); + // Server options stay registered, the gate is read from them + Assert.assertTrue(OptionSpace.containKey( + ServerOptions.INIT_STORE_ENABLED.name())); + Assert.assertFalse("a disabled run initialized nothing", + Files.exists(marker)); + + // Absent gate means enabled, so the same config now has to reach + // the graph scan and fail on the directory the disabled run never + // looked at — a re-enable is not short-circuited by the marker + Assert.assertThrows(IllegalArgumentException.class, () -> { + InitStore.main(new String[]{this.writeEnabledRestServerConf()}); + }); + Assert.assertTrue(OptionSpace.containKey(ROCKSDB_OPTION)); + Assert.assertFalse("a failed run initialized nothing", + Files.exists(marker)); + } finally { + System.clearProperty(InitStore.INIT_COMPLETE_MARKER); + } } /** @@ -228,11 +247,39 @@ public void testDisabledInitStoreAllowsPdBackedHstoreAuthGraph() ServerOptions.AUTHENTICATOR.name() + "=" + StandardAuthenticator.class.getName(), ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph", - ServerOptions.USE_PD.name() + "=true"); + ServerOptions.USE_PD.name() + "=true", + ServerOptions.ADMIN_PA.name() + "=secret"); InitStore.main(new String[]{restConf}); } + /** + * The server creates the admin from auth.admin_pa without prompting, and + * Docker PASSWORD never reaches this path, so an absent or empty value + * would publish the well-known 'pa' default as a working credential. + */ + @Test + public void testDisabledInitStoreRejectsDefaultAdminPassword() + throws IOException { + Path graphsDir = this.writeAuthGraphConfig("hstore"); + for (String adminPa : new String[]{null, ""}) { + List extra = new ArrayList<>(Arrays.asList( + ServerOptions.AUTHENTICATOR.name() + + "=" + StandardAuthenticator.class.getName(), + ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph", + ServerOptions.USE_PD.name() + "=true")); + if (adminPa != null) { + extra.add(ServerOptions.ADMIN_PA.name() + "=" + adminPa); + } + String restConf = this.writeDisabledRestServerConfForGraphs( + graphsDir, extra.toArray(new String[0])); + + Assert.assertThrows(IllegalStateException.class, () -> { + InitStore.main(new String[]{restConf}); + }, e -> Assert.assertContains("public default", e.getMessage())); + } + } + /** * Remote auth delegates to another service, so there is no local admin to * create and the check above must not fire. From ff1325aceb3203b6b124a6c5652e941ce3bc789f Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 2 Aug 2026 10:59:33 +0530 Subject: [PATCH 18/19] docs(docker): correct the init_complete description after the marker moved init-store writes the marker now, so a run disabled by a mounted property records nothing just as one disabled by the environment variable does. The text still described the entrypoint owning it, and still claimed the mounted case was unguarded, which the change removed. --- docker/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docker/README.md b/docker/README.md index 51288f7ad9..111f6d0ef7 100644 --- a/docker/README.md +++ b/docker/README.md @@ -172,17 +172,17 @@ Configuration is injected via environment variables. The old `docker/configs/app > nobody can log in to. A custom `auth.authenticator` is exempt because it > manages its own identities. > -> With the variable set to `false`, the entrypoint deliberately never writes -> `docker/init_complete`, so a later re-enable is still able to initialize. -> Setting the property directly in a mounted `rest-server.properties` does not -> get that guard. +> `docker/init_complete` is written by init-store itself, and only after it has +> initialized. A skipped run therefore records nothing, whether it was disabled +> by the variable or by the property in a mounted `rest-server.properties`, so a +> later re-enable is still able to initialize. > -> **`PASSWORD` has no effect on that path.** init-store reads it from standard -> input, and a disabled one returns before doing so. The admin is created on -> the PD startup path from `auth.admin_pa`, which defaults to the public value -> `pa`, so set it explicitly in a mounted `rest-server.properties`. It applies -> only when the account is first created, so changing it later does not rotate -> an existing password. +> **`PASSWORD` does not reach that path.** init-store reads it from standard +> input, and a disabled one returns before doing so. The admin is instead +> created from `auth.admin_pa`, whose `pa` default is public, so init-store +> refuses to skip unless it is explicitly set to a non-empty value in a mounted +> `rest-server.properties`. It applies only when the account is first created, +> so changing it later does not rotate an existing password. **Deprecated aliases** (still work but log a warning): From edf07d0f2b0243156234f1f594ce98e5f7b2cf61 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 2 Aug 2026 19:49:44 +0530 Subject: [PATCH 19/19] fix(server): validate the disabled init path on every startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An existing docker/init_complete marker skipped bin/init-store.sh entirely, so the disabled path's fail-closed check never ran after an upgrade or a configuration change: built-in auth could start without usePD, an HStore auth graph, or an explicit auth.admin_pa. The entrypoint now runs init-store on every startup and hands it the marker path — absolute, so the in-Java check agrees with the shell guard — and init-store consults the marker only after the gate check, so a marker left by an earlier release or an earlier enabled run skips re-initialization and nothing else. An enabled run with the marker present returns before backend registration and stdin, keeping the restart branch free of wait-storage and PASSWORD. GraphManager.initAdminUserIfNeeded() swallowed every failure from metaManager.createUser() and initDefaultGraphSpace(), so a PD write, permission or validation failure still started a server with no usable administrator — the exact outcome the gate check refuses. Only the already-exists case is benign now, judged by re-reading the user rather than by matching the message (the probe failure is attached as suppressed if the re-read itself fails); everything else aborts startup. Writing the completion marker also tolerates a concurrent container having recorded it first. Regressions: an existing marker with an invalid built-in-auth disabled configuration still fails closed; an existing marker with the gate enabled returns before the graph scan; the admin is created when absent, kept without failing when already present, and a non-duplicate creation failure propagates with its cause. --- docker/README.md | 5 +- .../apache/hugegraph/core/GraphManager.java | 30 +++- .../docker/docker-entrypoint.sh | 27 +++- .../org/apache/hugegraph/cmd/InitStore.java | 45 +++++- .../apache/hugegraph/unit/UnitTestSuite.java | 2 + .../unit/cmd/InitStoreConfigTest.java | 56 +++++++ .../unit/core/GraphManagerAdminInitTest.java | 145 ++++++++++++++++++ 7 files changed, 293 insertions(+), 17 deletions(-) create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerAdminInitTest.java diff --git a/docker/README.md b/docker/README.md index 111f6d0ef7..e55879874c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -175,7 +175,10 @@ Configuration is injected via environment variables. The old `docker/configs/app > `docker/init_complete` is written by init-store itself, and only after it has > initialized. A skipped run therefore records nothing, whether it was disabled > by the variable or by the property in a mounted `rest-server.properties`, so a -> later re-enable is still able to initialize. +> later re-enable is still able to initialize. The marker only short-circuits +> re-initialization: init-store runs on every container start, and a disabled +> one performs the fail-closed check above first, so a marker left by an +> earlier release or an earlier enabled run cannot bypass it. > > **`PASSWORD` does not reach that path.** init-store reads it from standard > input, and a disabled one returns before doing so. The admin is instead diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java index f716285c67..4e02d1f955 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java @@ -368,6 +368,13 @@ private void loadMetaFromPD() { this.listenMetaChanges(); } + /** + * Creates the built-in admin account in PD metadata. With init-store + * disabled this is the only bootstrap that admin gets, and init-store's + * fail-closed check assumes it works, so only the already-exists case is + * benign; any other failure aborts startup instead of leaving the server + * without a usable administrator. + */ public void initAdminUserIfNeeded(String password) { HugeUser user = new HugeUser("admin"); user.nickname("超级管理员"); @@ -380,10 +387,29 @@ public void initAdminUserIfNeeded(String password) { user.create(new Date()); user.avatar("/image.png"); try { - this.metaManager.createUser(user); + try { + this.metaManager.createUser(user); + } catch (Exception e) { + // Judged by re-reading rather than by matching the message: + // benign only if the admin actually exists, from an earlier + // startup or from a concurrent server that won the race + HugeUser existing; + try { + existing = this.metaManager.findUser(user.name()); + } catch (Exception probe) { + e.addSuppressed(probe); + throw e; + } + if (existing == null) { + throw e; + } + LOG.info("The built-in admin user already exists, " + + "skip creating it"); + } this.metaManager.initDefaultGraphSpace(); } catch (Exception e) { - LOG.info(e.getMessage()); + throw new HugeException("Failed to init the built-in admin " + + "user or the default graph space", e); } } diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index aece800d72..0862a22f49 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -78,19 +78,23 @@ WAIT_ENV=() [[ -n "${HG_SERVER_BACKEND:-}" ]] && WAIT_ENV+=("hugegraph.backend=${HG_SERVER_BACKEND}") [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && WAIT_ENV+=("hugegraph.pd.peers=${HG_SERVER_PD_PEERS}") -# ── Init store (once) ───────────────────────────────────────────────── -if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then +# ── Init store ──────────────────────────────────────────────────────── +# init-store owns the marker: it skips re-initialization when the marker is +# present and writes it only after it has actually initialized. Deciding here +# would mean guessing from the environment variable, which says nothing about +# a config mounted with the property already set. Absolute, so the in-Java +# existence check agrees with the guard below no matter where init-store.sh +# leaves its working directory. +INIT_MARKER_PATH="$(cd "${DOCKER_FOLDER}" && pwd)/${INIT_FLAG_FILE}" +export HG_SERVER_INIT_COMPLETE_MARKER="${INIT_MARKER_PATH}" + +if [[ ! -f "${INIT_MARKER_PATH}" ]]; then if (( ${#WAIT_ENV[@]} > 0 )); then env "${WAIT_ENV[@]}" ./bin/wait-storage.sh else ./bin/wait-storage.sh fi - # init-store writes the marker itself, and only if it initialized. Deciding - # here would mean guessing from the environment variable, which says - # nothing about a config mounted with the property already set. - export HG_SERVER_INIT_COMPLETE_MARKER="${DOCKER_FOLDER}/${INIT_FLAG_FILE}" - if [[ -z "${PASSWORD:-}" ]]; then log "init hugegraph with non-auth mode" ./bin/init-store.sh @@ -108,7 +112,14 @@ if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then echo "${PASSWORD}" | ./bin/init-store.sh fi else - log "HugeGraph initialization already done. Skipping re-init..." + log "HugeGraph initialization already done. Revalidating the config..." + # The marker skips re-initialization inside init-store, not init-store + # itself: a disabled one must pass its fail-closed check on every startup, + # because the marker may predate this configuration or this release and + # says nothing about whether the admin the current config relies on is + # reachable. An enabled one returns at the marker, before it touches the + # backend or reads stdin, so neither wait-storage nor PASSWORD is needed. + ./bin/init-store.sh fi ./bin/start-hugegraph.sh -j "${JAVA_OPTS:-}" -t 120 diff --git a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java index 3de5f42969..f878a0c073 100644 --- a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java +++ b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java @@ -18,6 +18,7 @@ package org.apache.hugegraph.cmd; import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -48,8 +49,11 @@ public class InitStore { /** * Where to record that initialization actually happened. The caller that - * wants the record supplies the path; nothing is written when it is unset, - * so tarball callers are unaffected. + * wants the record supplies the path; nothing is read or written when it + * is unset, so tarball callers are unaffected. A present marker skips + * re-initialization only: the disabled path's fail-closed check runs + * before it is consulted, since a marker left by an earlier release or an + * earlier enabled run says nothing about the current configuration. */ public static final String INIT_COMPLETE_MARKER = "hugegraph.init_complete_marker"; @@ -87,6 +91,14 @@ public static void main(String[] args) throws Exception { return; } + String initedMarker = presentInitCompleteMarker(); + if (initedMarker != null) { + LOG.info("Skipping init-store: completion marker '{}' is " + + "present, so this deployment is already initialized", + initedMarker); + return; + } + RegisterUtil.registerBackends(); RegisterUtil.registerPlugins(); @@ -119,6 +131,25 @@ public static void main(String[] args) throws Exception { recordInitComplete(); } + private static String configuredInitCompleteMarker() { + String marker = System.getProperty(INIT_COMPLETE_MARKER, + System.getenv(INIT_COMPLETE_MARKER_ENV)); + return marker == null || marker.isEmpty() ? null : marker; + } + + /** + * The configured marker path, or null when none is configured or the + * file does not exist yet. Consulted only after the disabled-path check, + * so an existing marker can never bypass the fail-closed validation. + */ + private static String presentInitCompleteMarker() { + String marker = configuredInitCompleteMarker(); + if (marker != null && Files.exists(Paths.get(marker))) { + return marker; + } + return null; + } + /** * Only this process knows whether it initialized anything. The Docker * entrypoint used to decide from its environment variable alone, so a @@ -127,9 +158,8 @@ public static void main(String[] args) throws Exception { * only after initialization succeeded. */ private static void recordInitComplete() throws IOException { - String marker = System.getProperty(INIT_COMPLETE_MARKER, - System.getenv(INIT_COMPLETE_MARKER_ENV)); - if (marker == null || marker.isEmpty()) { + String marker = configuredInitCompleteMarker(); + if (marker == null) { return; } Path path = Paths.get(marker); @@ -137,8 +167,11 @@ private static void recordInitComplete() throws IOException { if (dir != null) { Files.createDirectories(dir); } - if (Files.notExists(path)) { + try { Files.createFile(path); + } catch (FileAlreadyExistsException e) { + // A concurrent container finishing its own successful init has + // already recorded it, which is the same outcome } LOG.info("Recorded init-store completion at '{}'", path); } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index c7771fbdc3..1733680e3f 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -46,6 +46,7 @@ import org.apache.hugegraph.unit.core.DataTypeTest; import org.apache.hugegraph.unit.core.DirectionsTest; import org.apache.hugegraph.unit.core.ExceptionTest; +import org.apache.hugegraph.unit.core.GraphManagerAdminInitTest; import org.apache.hugegraph.unit.core.GraphManagerConfigTest; import org.apache.hugegraph.unit.core.LocksTableTest; import org.apache.hugegraph.unit.core.PageStateTest; @@ -144,6 +145,7 @@ SecurityManagerTest.class, RolePermissionTest.class, ExceptionTest.class, + GraphManagerAdminInitTest.class, GraphManagerConfigTest.class, BackendStoreInfoTest.class, TraversalUtilTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java index 5fb4e3bee7..959f8ef140 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java @@ -203,6 +203,62 @@ public void testGateDecidesWhetherRegistrationRuns() throws Exception { } } + /** + * The upgrade scenario: earlier releases wrote the completion marker + * from the entrypoint, so one can exist for a configuration that was + * never validated — including a later switch to the disabled gate. The + * entrypoint half of the regression (skipping init-store entirely on a + * present marker) is shell and out of a unit test's reach; what is + * pinned here is the ordering invariant the fix relies on instead: the + * fail-closed check runs before the marker is consulted, so it fires + * with the marker present exactly as {@code + * testDisabledInitStoreFailsWhenAdminCannotBeCreated} shows without it. + */ + @Test + public void testExistingMarkerDoesNotBypassDisabledPathCheck() + throws IOException { + System.setProperty(InitStore.INIT_COMPLETE_MARKER, + this.writeExistingMarker().toString()); + try { + String restConf = this.writeDisabledRestServerConf( + ServerOptions.AUTHENTICATOR.name() + + "=" + StandardAuthenticator.class.getName()); + + Assert.assertThrows(IllegalStateException.class, () -> { + InitStore.main(new String[]{restConf}); + }, e -> Assert.assertContains("Refusing to skip init-store", + e.getMessage())); + } finally { + System.clearProperty(InitStore.INIT_COMPLETE_MARKER); + } + } + + /** + * The enabled path with a present marker is the plain Docker restart: it + * must return before the graph scan — completing on a config whose graphs + * directory is missing proves that — and before backend registration, + * which {@link #testGateDecidesWhetherRegistrationRuns} relies on being + * run at most once per JVM. + */ + @Test + public void testExistingMarkerSkipsReinitializationWhenEnabled() + throws Exception { + System.setProperty(InitStore.INIT_COMPLETE_MARKER, + this.writeExistingMarker().toString()); + try { + InitStore.main(new String[]{this.writeEnabledRestServerConf()}); + } finally { + System.clearProperty(InitStore.INIT_COMPLETE_MARKER); + } + } + + private Path writeExistingMarker() throws IOException { + Path marker = this.workDir.resolve("docker/init_complete"); + Files.createDirectories(marker.getParent()); + Files.createFile(marker); + return marker; + } + /** * The CLI must not report success for a configuration that would start an * auth-enabled server with no admin account, since tarball and init-job diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerAdminInitTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerAdminInitTest.java new file mode 100644 index 0000000000..cb4a0b8abb --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerAdminInitTest.java @@ -0,0 +1,145 @@ +/* + * 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.unit.core; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.hugegraph.HugeException; +import org.apache.hugegraph.auth.HugeUser; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.core.GraphManager; +import org.apache.hugegraph.event.EventHub; +import org.apache.hugegraph.meta.MetaDriver; +import org.apache.hugegraph.meta.MetaManager; +import org.apache.hugegraph.meta.managers.AuthMetaManager; +import org.apache.hugegraph.meta.managers.SpaceMetaManager; +import org.apache.hugegraph.testutil.Assert; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +/** + * {@link GraphManager#initAdminUserIfNeeded} is the only bootstrap the + * built-in admin gets when init-store is disabled, and init-store's + * fail-closed check assumes it works. Only the already-exists case is benign; + * every other failure must abort startup rather than leave the server up + * without a usable administrator. + */ +public class GraphManagerAdminInitTest { + + private static final String CLUSTER = "admin-init-test"; + + private Map store; + private MetaDriver driver; + private Object originalAuthManager; + private Object originalSpaceManager; + private GraphManager manager; + + @Before + public void setup() throws Exception { + this.store = new HashMap<>(); + this.driver = Mockito.mock(MetaDriver.class); + Mockito.when(this.driver.get(Mockito.anyString())).thenAnswer( + i -> this.store.get(i.getArgument(0))); + Mockito.doAnswer(i -> this.store.put(i.getArgument(0), + i.getArgument(1))) + .when(this.driver) + .put(Mockito.anyString(), Mockito.anyString()); + + this.originalAuthManager = swapMetaManagerField( + "authMetaManager", new AuthMetaManager(this.driver, CLUSTER)); + this.originalSpaceManager = swapMetaManagerField( + "spaceMetaManager", new SpaceMetaManager(this.driver, CLUSTER)); + this.manager = new GraphManager( + new HugeConfig(new PropertiesConfiguration()), + new EventHub("admin-init-test")); + } + + @After + public void teardown() throws Exception { + try { + if (this.manager != null) { + this.manager.close(); + } + } finally { + swapMetaManagerField("authMetaManager", this.originalAuthManager); + swapMetaManagerField("spaceMetaManager", + this.originalSpaceManager); + } + } + + @Test + public void testCreatesAdminOnFreshMetadata() throws Exception { + this.manager.initAdminUserIfNeeded("s3cret"); + + HugeUser admin = MetaManager.instance().findUser("admin"); + Assert.assertNotNull("the admin must be created", admin); + } + + /** + * Every restart after the first sees the admin already recorded and + * surfaces the already-exists signal, which must neither fail startup + * nor rotate the existing password. (True concurrency is weaker than + * this: createUser is get-then-put without compare-and-set, so a tight + * race can overwrite rather than throw — benign only because every + * server writes the admin derived from the same configured password.) + */ + @Test + public void testExistingAdminIsKeptWithoutFailing() throws Exception { + this.manager.initAdminUserIfNeeded("first"); + HugeUser created = MetaManager.instance().findUser("admin"); + + this.manager.initAdminUserIfNeeded("second"); + + HugeUser kept = MetaManager.instance().findUser("admin"); + Assert.assertNotNull(kept); + Assert.assertEquals("an existing admin's password must not rotate", + created.password(), kept.password()); + } + + /** + * A PD write, permission or validation failure used to be logged and + * swallowed, so the server started with no usable administrator. It has + * to propagate instead: the failure is not the already-exists case, + * proven by the admin still being absent. + */ + @Test + public void testNonDuplicateCreationFailurePropagates() { + RuntimeException refused = new RuntimeException("pd write refused"); + Mockito.doThrow(refused).when(this.driver) + .put(Mockito.anyString(), Mockito.anyString()); + + Assert.assertThrows(HugeException.class, () -> { + this.manager.initAdminUserIfNeeded("s3cret"); + }, e -> Assert.assertEquals(refused, e.getCause())); + } + + private static Object swapMetaManagerField(String field, + Object replacement) + throws Exception { + Field f = MetaManager.class.getDeclaredField(field); + f.setAccessible(true); + Object previous = f.get(MetaManager.instance()); + f.set(MetaManager.instance(), replacement); + return previous; + } +}