diff --git a/hugegraph-server/Dockerfile b/hugegraph-server/Dockerfile index 67866974cf..8f1430d073 100644 --- a/hugegraph-server/Dockerfile +++ b/hugegraph-server/Dockerfile @@ -70,6 +70,9 @@ RUN sed -i "s/^restserver.url.*$/restserver.url=http:\/\/0.0.0.0:8080/g" ./conf/ COPY hugegraph-server/hugegraph-dist/docker/scripts/remote-connect.groovy ./scripts COPY hugegraph-server/hugegraph-dist/docker/scripts/detect-storage.groovy ./scripts COPY hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh . +# props.awk needs no COPY: it ships in the assembly bin/ above, which is also +# where bin/enable-auth.sh finds it. yamlscan.awk serves only the entrypoint. +COPY hugegraph-server/hugegraph-dist/docker/yamlscan.awk . RUN chmod 755 ./docker-entrypoint.sh EXPOSE 8080 diff --git a/hugegraph-server/Dockerfile-hstore b/hugegraph-server/Dockerfile-hstore index e90979563b..e18d76bd07 100644 --- a/hugegraph-server/Dockerfile-hstore +++ b/hugegraph-server/Dockerfile-hstore @@ -72,6 +72,9 @@ RUN cd /hugegraph-server/conf/graphs \ COPY hugegraph-server/hugegraph-dist/docker/scripts/remote-connect.groovy ./scripts #COPY hugegraph-server/hugegraph-dist/docker/scripts/detect-storage.groovy ./scripts COPY hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh . +# props.awk needs no COPY: it ships in the assembly bin/ above, which is also +# where bin/enable-auth.sh finds it. yamlscan.awk serves only the entrypoint. +COPY hugegraph-server/hugegraph-dist/docker/yamlscan.awk . RUN chmod 755 ./docker-entrypoint.sh EXPOSE 8080 diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh index 42137dc3b5..54d289bf61 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh @@ -17,12 +17,28 @@ # set -euo pipefail +# CI runs this harness under a backend matrix: server-ci.yml exports BACKEND for +# the rocksdb leg, which is the only leg that reaches these tests. The +# entrypoint maps BACKEND to HG_SERVER_BACKEND and then overwrites whatever a +# fixture writes into hugegraph.properties, so a case that decides on the +# on-disk backend -- the escaped-hstore assertion below -- would be answered by +# the matrix value rather than by the file it is checking, and would fail in CI +# while passing locally. Clear the inherited backend/pd environment so the +# harness is hermetic; cases that mean to drive the entrypoint from the +# environment set it on their own invocation (see the hstore mapping test). The +# production precedence (environment beats file) is left exactly as it is. +unset BACKEND HG_SERVER_BACKEND PD_PEERS HG_SERVER_PD_PEERS + SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) TEST_HOME=$(mktemp -d "${TMPDIR:-/tmp}/hugegraph-entrypoint-test.XXXXXX") trap 'rm -rf "${TEST_HOME}"' EXIT mkdir -p "${TEST_HOME}/bin" "${TEST_HOME}/conf/graphs" "${TEST_HOME}/docker" cp "${SCRIPT_DIR}/docker-entrypoint.sh" "${TEST_HOME}/docker-entrypoint.sh" +# props.awk is packaged in the release bin/; the image gets it from there, and +# the entrypoint accepts it beside itself so this harness can stage either. +cp "${SCRIPT_DIR}/../src/assembly/static/bin/props.awk" "${TEST_HOME}/props.awk" +cp "${SCRIPT_DIR}/yamlscan.awk" "${TEST_HOME}/yamlscan.awk" touch "${TEST_HOME}/docker/init_complete" cat > "${TEST_HOME}/conf/rest-server.properties" <<'EOF' @@ -54,6 +70,7 @@ printf 'called\n' >> ./docker/enable-auth-calls EOF cat > "${TEST_HOME}/bin/wait-partition.sh" <<'EOF' #!/usr/bin/env bash +printf 'called\n' >> ./docker/wait-partition-calls exit 0 EOF cat > "${TEST_HOME}/bin/wait-storage.sh" <<'EOF' @@ -200,7 +217,7 @@ reused_complex_secret=$(sed -n 's/^auth\.token_secret=//p' \ "${TEST_HOME}/conf/rest-server.properties") [[ "${reused_complex_secret}" == "${complex_secret}" ]] [[ "${reused_complex_secret}" == \ - 'Strong\\Secret\ 9!0123456789abcdef' ]] + 'Strong\\Secret\u00209!0123456789abcdef' ]] ( cd "${TEST_HOME}" @@ -217,8 +234,38 @@ trailing_space_secret=$(sed -n 's/^auth\.token_secret=//p' \ reused_trailing_space_secret=$(sed -n 's/^auth\.token_secret=//p' \ "${TEST_HOME}/conf/rest-server.properties") [[ "${trailing_space_secret}" == \ - 'SecretEnds\ 0123456789abcdefABCDE\ ' ]] + 'SecretEnds\u00200123456789abcdefABCDE\u0020' ]] [[ "${reused_trailing_space_secret}" == "${trailing_space_secret}" ]] +grep -Fqx 'auth.admin_pa=pa' \ + "${TEST_HOME}/conf/rest-server.properties" +# The secret above ends in a space, and commons-configuration trims a physical +# line before it asks whether that line continues: the `\ ` the encoder used to +# write survives the trim as a lone trailing backslash, which pulls the property +# under it into the password -- that is how a file that plainly carried +# auth.admin_pa next to it would reach the server as one long secret. \u0020 +# leaves the trimmer nothing to take. +grep -Fqx 'auth.token_secret=SecretEnds\u00200123456789abcdefABCDE\u0020' \ + "${TEST_HOME}/conf/rest-server.properties" || { + echo "a trailing space must not be written as a backslash-space" >&2 + sed -n 's/^auth\.token_secret=/written: [&]/p' \ + "${TEST_HOME}/conf/rest-server.properties" >&2 + exit 1 +} +# Round trip: the secret comes back with both spaces it started with, and the +# property written under it is still its own property. +props_read() { + PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY="$1" \ + PROPS_FILE="${TEST_HOME}/conf/rest-server.properties" \ + awk -f "${TEST_HOME}/props.awk" /dev/null +} +[[ "$(props_read auth.token_secret)" == 'SecretEnds 0123456789abcdefABCDE ' ]] || { + echo "auth.token_secret lost its spaces: [$(props_read auth.token_secret)]" >&2 + exit 1 +} +[[ "$(props_read auth.admin_pa)" == "pa" ]] || { + echo "auth.admin_pa is not its own property any more: [$(props_read auth.admin_pa)]" >&2 + exit 1 +} grep -Fqx 'auth.admin_pa=pa' \ "${TEST_HOME}/conf/rest-server.properties" @@ -226,7 +273,7 @@ grep -Fqx 'auth.admin_pa=pa' \ cd "${TEST_HOME}" PASSWORD='Strong\Pass 9!' bash ./docker-entrypoint.sh ) -grep -Fqx 'auth.admin_pa=Strong\\Pass\ 9!' \ +grep -Fqx 'auth.admin_pa=Strong\\Pass\u00209!' \ "${TEST_HOME}/conf/rest-server.properties" rm -f "${TEST_HOME}/docker/init_complete" @@ -324,4 +371,95 @@ done ) assert_start_timeout 120 +# A mounted rest-server.properties that already carries auth.authenticator, +# with no matching yaml mapping and no PASSWORD given, used to start without a +# word: the parity check ran only inside the PASSWORD branch, so nothing ever +# compared the two sides and the server came up with REST enforcing and Gremlin +# on AllowAllAuthenticator. The check now runs on every start, and a refusal +# has to come before anything touches the backend. +printf '%s\n' 'host: 8182' > "${TEST_HOME}/conf/gremlin-server.yaml" +grep -qx 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ + "${TEST_HOME}/conf/rest-server.properties" || + printf '%s\n' \ + 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ + >> "${TEST_HOME}/conf/rest-server.properties" +rm -f "${TEST_HOME}/docker/init_complete" +before_calls="$(wc -l < "${TEST_HOME}/docker/init-store-calls")" +before_auth="$(wc -l < "${TEST_HOME}/docker/enable-auth-calls")" +status=0 +( + cd "${TEST_HOME}" + bash ./docker-entrypoint.sh +) || status=$? +if (( status == 0 )); then + echo "entrypoint must refuse a mounted REST-only authenticator with no PASSWORD" >&2 + exit 1 +fi +if [[ "$(wc -l < "${TEST_HOME}/docker/init-store-calls")" != "${before_calls}" ]]; then + echo "the refusal must happen before init-store runs" >&2 + exit 1 +fi +if [[ "$(wc -l < "${TEST_HOME}/docker/enable-auth-calls")" != "${before_auth}" ]]; then + echo "a refused start must not run enable-auth.sh" >&2 + exit 1 +fi + +# The same start is accepted once both sides agree, so the check above is a +# parity decision and not a blanket refusal to run without PASSWORD. +printf '%s\n' \ + 'authentication: {' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator,' \ + ' config: {tokens: conf/rest-server.properties}' \ + '}' > "${TEST_HOME}/conf/gremlin-server.yaml" +rm -f "${TEST_HOME}/docker/init_complete" +( + cd "${TEST_HOME}" + bash ./docker-entrypoint.sh +) + +# ── The stabilization check follows the backend the JVM actually loaded ── +# ACTUAL_BACKEND is compared against a literal, so it has to be the decoded +# value. A mounted hugegraph.properties may spell the word with a unicode +# escape for the s, which java.util.Properties hands the server as hstore; +# reading the on-disk escaping instead compared something else to hstore, +# skipped wait-partition.sh, and let startup continue before the partitions +# were assigned. bs is the backslash, taken from its code point rather than +# written here: printf '%c' 92 hands back the digit 9, which would have built a +# fixture holding a different word than the one being decoded. +bs=$(awk 'BEGIN { printf "%c", 92 }') +if [[ "${#bs}" != 1 || "$(printf '%d' "'${bs}")" != 92 ]]; then + echo "this host did not yield a backslash for code point 92" >&2 + exit 1 +fi +touch "${TEST_HOME}/docker/init_complete" +rm -f "${TEST_HOME}/docker/wait-partition-calls" +printf '%s\n' "backend=h${bs}u0073tore" 'pd.peers=pd:8686' \ + > "${TEST_HOME}/conf/graphs/hugegraph.properties" +if [[ "$(head -n 1 "${TEST_HOME}/conf/graphs/hugegraph.properties")" != \ + "backend=h${bs}u0073tore" ]]; then + echo "the fixture has to hold the escaped bytes, not the decoded word" >&2 + exit 1 +fi +( + cd "${TEST_HOME}" + bash ./docker-entrypoint.sh +) +if [[ ! -s "${TEST_HOME}/docker/wait-partition-calls" ]]; then + echo "an escaped hstore backend must still reach wait-partition.sh" >&2 + exit 1 +fi +# The other half: this is a read that follows the server, not a switch that +# simply always waits. +rm -f "${TEST_HOME}/docker/wait-partition-calls" +printf '%s\n' 'backend=rocksdb' 'pd.peers=pd:8686' \ + > "${TEST_HOME}/conf/graphs/hugegraph.properties" +( + cd "${TEST_HOME}" + bash ./docker-entrypoint.sh +) +if [[ -e "${TEST_HOME}/docker/wait-partition-calls" ]]; then + echo "wait-partition.sh ran for a rocksdb backend" >&2 + exit 1 +fi + echo "PASS: Docker entrypoint configures HStore discovery and authentication" diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index b5ba2de34f..1005a2e608 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -26,6 +26,45 @@ mkdir -p "${DOCKER_FOLDER}" log() { echo "[hugegraph-server-entrypoint] $*"; } +# Property reading/writing goes through props.awk, which implements the +# java.util.Properties grammar HugeConfig applies (escapes, `:`/whitespace +# separators, CR/CRLF/LF line terminators, continuations, first-definition-wins +# duplicates). grep/sed rewrites disagree with it on mounted or upgraded +# configs, silently producing two definitions of one key. Values move through +# environment variables rather than argv so a PASSWORD never shows up in `ps` +# output. +# +# props.awk lives in the packaged bin/ directory because bin/enable-auth.sh +# reads properties with it too, and that assembly fileSet is what both the +# release tarball and this image are built from. Beside the entrypoint is only +# where the source tree and the tests put it. +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +props_from_env="${PROPS_AWK:-}" +yaml_from_env="${YAMLSCAN_AWK:-}" +PROPS_AWK="" +for candidate in "${props_from_env}" "${HERE}/props.awk" "${HERE}/bin/props.awk"; do + if [[ -n "${candidate}" && -f "${candidate}" ]]; then + PROPS_AWK="${candidate}" + break + fi +done +if [[ -z "${PROPS_AWK}" ]]; then + log "ERROR: props.awk not found beside the entrypoint or in bin/" + exit 1 +fi + +YAMLSCAN="" +for candidate in "${yaml_from_env}" "${HERE}/yamlscan.awk"; do + if [[ -n "${candidate}" && -f "${candidate}" ]]; then + YAMLSCAN="${candidate}" + break + fi +done +if [[ -z "${YAMLSCAN}" ]]; then + log "ERROR: yamlscan.awk not found beside the entrypoint" + exit 1 +fi + encode_prop_value() { local value="$1" encoded="" char local i @@ -35,7 +74,13 @@ encode_prop_value() { char="${value:i:1}" case "${char}" in "\\") encoded+="\\\\" ;; - " ") encoded+="\\ " ;; + # \u0020 and not `\ `: both readers turn it back into a space, but + # a line right-trimmed before the continuation check -- which is how + # commons-configuration reads it -- leaves the backslash of `\ ` + # behind at the end of the line, and that swallows the property + # written under it. A secret ending in a space used to move + # auth.authenticator inside the password value. + " ") encoded+="\\u0020" ;; $'\t') encoded+="\\t" ;; $'\n') encoded+="\\n" ;; $'\r') encoded+="\\r" ;; @@ -48,18 +93,10 @@ encode_prop_value() { set_prop_encoded() { local key="$1" encoded_val="$2" file="$3" - local esc_key esc_val key_re - - esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') - esc_val=$(printf '%s' "$encoded_val" | sed -e 's/[&|\\~]/\\&/g') - key_re="^[[:space:]]*${esc_key}([[:space:]]*[:=]|[[:space:]]+|[[:space:]]*$)" - if grep -qE "${key_re}" "${file}"; then - sed -ri "0,/${key_re}/!{/${key_re}/d;}" "${file}" - sed -ri "0,/${key_re}/s~${key_re}.*~${key}=${esc_val}~" "${file}" - else - printf '%s=%s\n' "$key" "$encoded_val" >> "${file}" - fi + PROPS_MODE=set PROPS_KEY="${key}" \ + PROPS_VALUE_ENCODED="${encoded_val}" PROPS_FILE="${file}" \ + awk -f "${PROPS_AWK}" /dev/null } set_prop() { @@ -70,12 +107,82 @@ set_prop() { get_prop_encoded() { local key="$1" file="$2" - local esc_key - esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g') - sed -nE \ - "s~^[[:space:]]*${esc_key}([[:space:]]*[:=][[:space:]]*|[[:space:]]+)(.*)$~\\2~p" \ - "${file}" | head -n 1 + PROPS_MODE=get PROPS_KEY="${key}" PROPS_FILE="${file}" \ + awk -f "${PROPS_AWK}" /dev/null +} + +# The value as java.util.Properties hands it to the server, escapes resolved. +# Compare against this, not the on-disk bytes: `backend=h\u0073tore` is a legal +# spelling of hstore that the JVM reads as hstore and a string compare against +# the raw text does not. +get_prop_decoded() { + local key="$1" file="$2" + + PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY="${key}" PROPS_FILE="${file}" \ + awk -f "${PROPS_AWK}" /dev/null +} + +# What the top-level authentication mapping of gremlin-server.yaml says about +# authentication, as one of three states: none, named, nameless. +# +# The question and its answer live in yamlscan.awk, which reads the mapping the +# way snakeyaml presents it to the server: only a column-0 `authentication` +# mapping counts, only its direct `authenticator` child names a class, comment +# text never counts as content, and a nested `config.authenticator` belongs to +# the config map rather than to the server. Those distinctions are the whole +# decision -- an earlier grep-shaped version of this function reported `named` +# for `authentication: {} # authenticator: X` and for a class nested under +# `config:`, which passed the REST/Gremlin parity check while Gremlin was +# running on AllowAllAuthenticator. +yaml_auth_state() { + local yaml="./conf/gremlin-server.yaml" + + [[ -f "${yaml}" ]] || { echo "none"; return 0; } + awk -f "${YAMLSCAN}" "${yaml}" +} + +# Authentication has to be configured on both sides or on neither. A mounted +# config carrying only one is refused rather than completed: the entrypoint +# cannot know which class the operator means, and finishing the other side from +# a guessed default is how Gremlin ends up on AllowAllAuthenticator while REST +# enforces StandardAuthenticator. A mapping that names no authenticator is +# refused by itself, because enable-auth.sh guards on the presence of that +# mapping and would otherwise write only the REST side. +check_auth_sides() { + local rest=0 yaml=0 state rest_value + + state=$(yaml_auth_state) + if [[ "${state}" == "nameless" ]]; then + log "ERROR: gremlin-server.yaml carries a top-level authentication" \ + "mapping that names no authenticator; add an authenticator entry" \ + "to it or remove the mapping, then restart." + return 1 + fi + # A nonzero status here means the reader could not answer at all -- props.awk + # exits 2 rather than guess, e.g. for a file that splices another one with an + # commons-configuration `include`. Calling that "configured on one side" + # would send the operator to the wrong file, and calling it absent is the + # direction that lets REST start open beside a Gremlin that authenticates. + if ! rest_value=$(get_prop_encoded "auth.authenticator" "${REST_SERVER_CONF}"); then + log "ERROR: cannot read auth.authenticator from ${REST_SERVER_CONF};" \ + "see the reason above, fix it, then restart." + return 1 + fi + if [[ -n "${rest_value}" ]]; then + rest=1 + fi + if [[ "${state}" == "named" ]]; then + yaml=1 + fi + if (( rest == yaml )); then + return 0 + fi + log "ERROR: authentication is configured in only one of" \ + "rest-server.properties (auth.authenticator) and" \ + "gremlin-server.yaml (authentication.authenticator);" \ + "configure both or neither, then restart." + return 1 } migrate_env() { @@ -168,6 +275,14 @@ elif [[ -n "${AUTH_TOKEN_SECRET_ENCODED}" ]]; then set_prop_encoded "auth.token_secret" "${AUTH_TOKEN_SECRET_ENCODED}" \ "${GRAPH_CONF}" fi +# Both sides have to agree whether authentication is on, whatever the reason +# the container was started for. Running this only inside the PASSWORD branch +# below left a mounted rest-server.properties that carried auth.authenticator +# with no matching yaml mapping completely unvalidated: with no PASSWORD the +# entrypoint skipped the check, enable-auth.sh never ran, and the server came +# up with REST enforcing and Gremlin open. A refusal exits under set -e. +check_auth_sides + if [[ -n "${PASSWORD:-}" ]]; then set_prop "auth.admin_pa" "${PASSWORD}" "${REST_SERVER_CONF}" # This script is idempotent and must run outside the initialization guard: @@ -243,7 +358,15 @@ fi ./bin/start-hugegraph.sh -j "${JAVA_OPTS:-}" -t "${SERVER_STARTUP_TIMEOUT_S}" # 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) +# Read through props.awk so a mounted config using the `:` or bare-whitespace +# separator is seen at all, and first-definition-wins matches HugeConfig; the +# grep this replaces only ever accepted `=`. Decoded, because this is compared +# against a literal: the JVM reads `backend=h\u0073tore` as hstore while the +# on-disk bytes are not that string, and the comparison deciding to skip +# wait-partition.sh is how startup continued before partitions were assigned. +# Trailing whitespace is dropped here rather than in the reader, which reports +# the value verbatim apart from the escapes java.util.Properties resolves. +ACTUAL_BACKEND=$(get_prop_decoded "backend" "${GRAPH_CONF}" | 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/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh index d5e11c5022..c59ad10f37 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -23,11 +23,93 @@ entrypoint="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/docker-entrypoint.s test_dir="$(mktemp -d)" trap 'rm -rf "${test_dir}"' EXIT -eval "$(awk ' - /^encode_prop_value\(\) \{/ { capture = 1 } - capture { print } - capture && /^\}$/ && ++function_ends == 3 { exit } -' "${entrypoint}")" +# Eval the property and yaml helpers one by one. The entrypoint's +# top-level code hard-exits when props.awk is missing, so it cannot be +# sourced directly; extracting by function name keeps this independent of +# helper order. PROPS_AWK is recomputed below. +for fn in encode_prop_value set_prop_encoded set_prop get_prop_encoded \ + get_prop_decoded yaml_auth_state check_auth_sides; do + eval "$(awk -v fn="${fn}" ' + index($0, fn "() {") == 1 { capture = 1 } + capture { print } + capture && /^}$/ { exit } + ' "${entrypoint}")" +done +log() { echo "[hugegraph-server-entrypoint] $*"; } +static_bin="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)" +docker_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROPS_AWK="${static_bin}/props.awk" +YAMLSCAN="${docker_dir}/yamlscan.awk" +export PROPS_AWK YAMLSCAN + +# enable-auth.sh reads and writes .properties through props.awk, which the +# release assembly packages in the same bin/ directory. A test tree that runs +# the script therefore has to carry both, or it is not the layout it ships in. +# yamlscan.awk goes one level up, in the install home, exactly where the +# Dockerfile puts it: the guard in enable-auth.sh has to answer the Gremlin +# question with the same reader check_auth_sides uses, so a tree without it +# would be testing the tarball fallback rather than the image. +install_enable_auth() { + local dir="$1" + mkdir -p "${dir}/bin" + cp "${static_bin}/enable-auth.sh" "${dir}/bin/enable-auth.sh" + cp "${static_bin}/props.awk" "${dir}/bin/props.awk" + cp "${docker_dir}/yamlscan.awk" "${dir}/yamlscan.awk" + chmod +x "${dir}/bin/enable-auth.sh" +} + +# ── What this host can actually be asked about ───────────────────────── +# CI runs these assertions on Ubuntu, where every one of them means what it +# says. Developed against a Windows host, three things silently stop being +# observations about props.awk and become observations about the platform: +# MSYS gawk opens text files in translation mode and drops the CR of a CRLF +# pair, chmod does not affect the mode stat reports, and a symlinked config is +# not a symlink. Each group is therefore gated on a probe of the host, and a +# skipped group says so out loud rather than passing quietly. +skip() { echo "note: skipped $1 -- this host cannot exercise it; it runs under CI" >&2; } + +probe="${test_dir}/probe" + +awk_sees_crlf_cr=0 +if [[ "$(printf 'x\r\n' | awk 'NR == 1 { print length($0) }')" == "2" ]]; then + awk_sees_crlf_cr=1 +fi +awk_sees_lone_cr=0 +if [[ "$(printf 'a\rb' | awk 'NR == 1 { print length($0) }')" == "3" ]]; then + awk_sees_lone_cr=1 +fi + +host_keeps_chmod=0 +printf '%s\n' x > "${probe}" +chmod 600 "${probe}" +[[ "$(stat -c '%a' "${probe}")" == "600" ]] && host_keeps_chmod=1 +rm -f "${probe}" + +# The probe above asks whether the host reports a mode back, which is a question +# about `stat`. The read-only-config group below needs a different answer: does +# a 0444 mode actually stop a write. The two are independent -- a Windows host +# under MSYS keeps the write denial (redirect, append and `test -w` all honour +# the read-only attribute) while `stat` still prints 644 -- so gating that group +# on host_keeps_chmod would skip it where it genuinely runs, and gating it on +# nothing would fail on a host that ignores modes. Asking the host what it does +# is the only honest way to choose. +host_denies_write=0 +printf '%s\n' x > "${probe}" +chmod 444 "${probe}" +# The redirect runs inside a group carrying the 2>/dev/null, because a failed +# redirection is reported by the shell that opens the file, not by printf: put on +# the command itself the redirect error still reaches stderr and the probe prints +# a spurious failure on every host that denies the write. +if ! { printf 'y' >> "${probe}"; } 2>/dev/null && ! test -w "${probe}"; then + host_denies_write=1 +fi +chmod 644 "${probe}" +rm -f "${probe}" + +host_keeps_symlink=0 +printf '%s\n' x > "${probe}-t" +ln -s "${probe}-t" "${probe}-l" 2>/dev/null && [[ -L "${probe}-l" ]] && host_keeps_symlink=1 +rm -f "${probe}-t" "${probe}-l" assert_replaced() { local separator="$1" @@ -66,3 +148,2151 @@ assert_line_count 1 \ "${duplicate_file}" assert_line_count 1 '^init_store\.enabled=true$' "${duplicate_file}" grep -q '^unrelated=true$' "${duplicate_file}" + +# An escaped key is one logical definition of that key, not a key with +# backslashes in its name: setting the plain key must rewrite it in place +# rather than appending a second definition whose only resolution is +# parser-dependent (and which HugeConfig then reports as a list). +escaped_file="${test_dir}/config-escaped-key" +printf '%s\n' \ + 'auth\.admin_pa=old' \ + 'unrelated=true' > "${escaped_file}" +set_prop "auth.admin_pa" "new" "${escaped_file}" +assert_line_count 1 '^auth\.admin_pa=new$' "${escaped_file}" +assert_line_count 1 '^unrelated=true$' "${escaped_file}" + +# A value continued onto the next line is part of the same definition: +# setting the key must remove the continuation, not leave it behind as a +# stray property of its own. +continued_file="${test_dir}/config-continuation" +printf '%s\n' \ + 'pd.peers 127.0.0.1:8686,\' \ + ' 127.0.0.2:8686' \ + 'unrelated=true' > "${continued_file}" +set_prop "pd.peers" "10.0.0.1:8686" "${continued_file}" +assert_line_count 1 '^pd\.peers=10\.0\.0\.1:8686$' "${continued_file}" +assert_line_count 1 '^unrelated=true$' "${continued_file}" +[[ "$(grep -c '127\.0\.0\.2' "${continued_file}")" -eq 0 ]] + +# get_prop_encoded reads through the same grammar: separators, escapes, +# continuations, and first-definition-wins duplicates. +get_file="${test_dir}/config-get" +printf '%s\n' \ + '#comment' \ + 'a\=b : colon value' \ + 'multiline first \' \ + ' second' \ + 'dup : one' \ + 'dup=two' > "${get_file}" +[[ "$(get_prop_encoded 'a=b' "${get_file}")" == "colon value" ]] +[[ "$(get_prop_encoded 'multiline' "${get_file}")" == "first second" ]] +[[ "$(get_prop_encoded 'dup' "${get_file}")" == "one" ]] + +# Appends must still happen when the file has no definition of the key, +# including when the only occurrences are inside comments. +append_file="${test_dir}/config-append" +printf '%s\n' \ + '#init_store.enabled=false' \ + 'unrelated=true' > "${append_file}" +set_prop "init_store.enabled" "true" "${append_file}" +assert_line_count 1 '^init_store\.enabled=true$' "${append_file}" +assert_line_count 1 '^#init_store\.enabled=false$' "${append_file}" + +# A key indented with leading whitespace is still one definition of the +# key: java.util.Properties ignores whitespace before a key, so an +# indented key must be read and rewritten in place rather than duplicated. +indented_file="${test_dir}/config-indented-key" +printf '%s\n' \ + ' auth.token_secret: old-secret' \ + 'unrelated=true' > "${indented_file}" +[[ "$(get_prop_encoded 'auth.token_secret' "${indented_file}")" == "old-secret" ]] +set_prop_encoded 'auth.token_secret' 'new-secret' "${indented_file}" +assert_line_count 1 'auth\.token_secret' "${indented_file}" +assert_line_count 1 '^unrelated=true$' "${indented_file}" + +# yaml_auth_state reports whether the top-level authentication mapping names +# an authenticator, without ever reading the class: quoted scalars and inline +# comments still count as naming one, a flow mapping on the key line counts, a +# mapping with no authenticator is "nameless", and an `authentication:` nested +# under some other key is not the Gremlin mapping at all. +yaml_dir="${test_dir}/yaml" +mkdir -p "${yaml_dir}/conf" +( + cd "${yaml_dir}" || exit 1 + state_file="conf/gremlin-server.yaml" + + want_state() { + if [[ "$1" != "$2" ]]; then + echo "expected yaml state '$1', got '$2'" >&2 + exit 1 + fi + } + + printf '%s\n' 'host: 0.0.0.0' > "${state_file}" + want_state none "$(yaml_auth_state)" + + printf '%s\n' \ + 'authentication:' \ + ' authenticator: com.example.MyAuth' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + printf '%s\n' \ + 'authentication:' \ + ' authenticator: "com.example.MyAuth" # custom' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + printf '%s\n' \ + 'authentication: {authenticator: com.example.FlowAuth, authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, config: {tokens: conf/rest-server.properties}}' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + printf '%s\n' \ + 'authentication:' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + > "${state_file}" + want_state nameless "$(yaml_auth_state)" + + # The nested mapping belongs to someFeature, not to the Gremlin server. + # Reading it as the Gremlin one would let com.example.Nested authenticate + # REST while Gremlin stayed on TinkerPop's AllowAllAuthenticator default. + printf '%s\n' \ + 'someFeature:' \ + ' authentication:' \ + ' authenticator: com.example.Nested' \ + > "${state_file}" + want_state none "$(yaml_auth_state)" + + # An authenticator that only appears after the block ends is a sibling's. + printf '%s\n' \ + 'authentication:' \ + ' tokens: conf/rest-server.properties' \ + 'other:' \ + ' authenticator: com.example.Other' \ + > "${state_file}" + want_state nameless "$(yaml_auth_state)" + + # A blank line does not close a YAML mapping. + printf '%s\n' \ + 'authentication:' \ + ' tokens: conf/rest-server.properties' \ + '' \ + ' authenticator: com.example.Later' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + # A flow mapping spread over several lines closes at the root indentation. + # Taking that brace for a root sibling stopped the mapping one entry early, + # so a config naming a class answered nameless and the boot was refused. + printf '%s\n' \ + 'authentication: {' \ + ' authenticator: com.example.SpreadAuth' \ + '}' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + # The same form with no direct authenticator still has to be nameless, which + # is what keeps the fix from turning the refusal into a blanket pass. + printf '%s\n' \ + 'authentication: {' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + '}' \ + > "${state_file}" + want_state nameless "$(yaml_auth_state)" + + # A double quoted key resolves its escapes before it is a key, so this is + # the authentication mapping. Comparing the raw bytes called it absent, + # which is the answer that lets REST start open beside a Gremlin that + # authenticates. The value is spelled out in hex below to keep the backslash. + printf '%s\n' \ + '"authentic\u0061tion":' \ + ' authenticator: com.example.EscapedAuth' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + # Same for the direct child key. + printf '%s\n' \ + 'authentication:' \ + ' "authentic\u0061tor": com.example.EscapedChildAuth' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + # An escape this reader does not implement has to be refused, not missed. + printf '%s\n' \ + '"authentic\q0061tion":' \ + ' authenticator: com.example.UnresolvableAuth' \ + > "${state_file}" + want_state nameless "$(yaml_auth_state)" + + # A block scalar carries its content on the deeper lines. The indicator on + # its own is an empty string, which names no class, while the form with a + # class under it does name one. + printf '%s\n' 'authentication:' ' authenticator: |' > "${state_file}" + want_state nameless "$(yaml_auth_state)" + + printf '%s\n' \ + 'authentication:' \ + ' authenticator: |' \ + ' com.example.BlockAuth' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + # The whole document as one flow mapping is a shape this reader does not + # walk. Answering none for it reported an authenticating Gremlin as + # unauthenticated, so it is refused until it is written the block way. + printf '%s\n' \ + '{ host: 8182, authentication: { authenticator: org.example.Auth } }' \ + > "${state_file}" + want_state nameless "$(yaml_auth_state)" + + # YAML ends a line at a bare CR as much as at an LF. Gated on the host + # probe above because a reader that drops the CR byte is not observing the + # scanner here. + if [[ "${awk_sees_lone_cr}" == "1" ]]; then + printf 'host: 1\rauthentication:\r authenticator: com.example.CrAuth\r' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + else + skip "the bare-CR yaml check -- this host eats the CR byte; it runs under CI" + fi + + rm -f "${state_file}" + want_state none "$(yaml_auth_state)" +) + +# check_auth_sides keeps the guarantee the class parsing used to serve: REST and +# Gremlin never end up with authentication on one side only. Neither and both +# pass; one side, or a mapping that names no authenticator, stops the boot. +sides_dir="${test_dir}/sides" +mkdir -p "${sides_dir}/conf" +( + cd "${sides_dir}" || exit 1 + REST_SERVER_CONF="./conf/rest-server.properties" + + must_refuse() { + if check_auth_sides; then + echo "check_auth_sides must refuse: $1" >&2 + exit 1 + fi + } + + printf '%s\n' 'host: 0.0.0.0' > conf/gremlin-server.yaml + : > "${REST_SERVER_CONF}" + check_auth_sides + + # Both sides configured, different classes: untouched. enable-auth.sh's + # per-file guards then make its appends no-ops, so nothing here has to + # know which class either side names. + printf '%s\n' 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ + > "${REST_SERVER_CONF}" + printf '%s\n' 'authentication:' ' authenticator: com.example.OtherAuth' \ + > conf/gremlin-server.yaml + check_auth_sides + grep -Eq '^[[:blank:]]*auth[\\]?\.authenticator[[:blank:]]*([:=]|[[:blank:]])com\.example\.OtherAuth' \ + "${REST_SERVER_CONF}" && { + echo "check_auth_sides must not copy a class into rest-server.properties" >&2 + exit 1 + } + + # One side only. + printf '%s\n' 'auth.authenticator=com.example.MyAuth' > "${REST_SERVER_CONF}" + printf '%s\n' 'host: 0.0.0.0' > conf/gremlin-server.yaml + must_refuse "rest-server.properties names an authenticator and the yaml does not" + + : > "${REST_SERVER_CONF}" + printf '%s\n' 'authentication:' ' authenticator: com.example.YamlAuth' \ + > conf/gremlin-server.yaml + must_refuse "the yaml names an authenticator and rest-server.properties does not" + + # A mapping that names no authenticator is refused even when REST is empty: + # enable-auth.sh guards on the presence of `authentication:`, so it would + # write the REST file alone and leave Gremlin unauthenticated. + printf '%s\n' 'authentication:' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + > conf/gremlin-server.yaml + : > "${REST_SERVER_CONF}" + must_refuse "the yaml mapping names no authenticator" + printf '%s\n' 'auth.authenticator=com.example.MyAuth' > "${REST_SERVER_CONF}" + must_refuse "the yaml mapping names no authenticator and REST does" +) + +# The entrypoint refuses this tree before enable-auth.sh is ever reached, but +# the script also runs on its own: the release tarball ships it with no +# yamlscan.awk at all, so there is no check_auth_sides in front of it. Left to +# itself it used to answer a mapping that names no authenticator by writing the +# REST side alone -- StandardAuthenticator on REST beside TinkerPop's +# AllowAllAuthenticator on Gremlin -- which is the one-sided boot this whole +# guard exists to prevent. It has to refuse and change nothing, both with the +# reader it shares with the entrypoint and on the grep fallback. +nameless_tree() { + local dir="$1" + rm -rf "${dir}" + mkdir -p "${dir}/conf/graphs" + install_enable_auth "${dir}" + if [[ "${2:-}" == "no-yamlscan" ]]; then + rm -f "${dir}/yamlscan.awk" + fi + printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > "${dir}/conf/graphs/hugegraph.properties" + : > "${dir}/conf/rest-server.properties" + printf '%s\n' \ + 'authentication:' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + > "${dir}/conf/gremlin-server.yaml" +} + +# A mapping that names no class: refused by the entrypoint, so the script never +# sees this tree through it. +onesided_dir="${test_dir}/yaml-onesided" +nameless_tree "${onesided_dir}" +( + cd "${onesided_dir}" || exit 1 + REST_SERVER_CONF="./conf/rest-server.properties" + if check_auth_sides; then + echo "check_auth_sides must refuse a yaml mapping without an authenticator" >&2 + exit 1 + fi +) + +# refuse_nameless : the script must stop before touching any config. +refuse_nameless() { + local dir="$1" desc="$2" + ( + cd "${dir}" || exit 1 + if ./bin/enable-auth.sh; then + echo "${desc}: enable-auth.sh must refuse a mapping that names no authenticator" >&2 + exit 1 + fi + if [[ -s conf/rest-server.properties ]]; then + echo "${desc}: a refused run still wrote rest-server.properties" >&2 + exit 1 + fi + if grep -Eq '^[[:blank:]]*authenticator[[:blank:]]*:' conf/gremlin-server.yaml; then + echo "${desc}: a refused run still edited the yaml mapping" >&2 + exit 1 + fi + if grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties; then + echo "${desc}: a refused run still wrapped the graph factory" >&2 + exit 1 + fi + ) +} + +refuse_nameless "${onesided_dir}" "image layout (yamlscan.awk present)" + +nameless_dir="${test_dir}/yaml-onesided-tarball" +nameless_tree "${nameless_dir}" no-yamlscan +refuse_nameless "${nameless_dir}" "release tarball (no yamlscan.awk)" + +# CRLF (Windows-saved) configs parse the way java.util.Properties reads +# them: one trailing CR is a line terminator, not part of the value, and +# a backslash before CRLF still continues the value onto the next line. +# Untouched lines keep their CR bytes on rewrite. +crlf_file="${test_dir}/config-crlf" +printf 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator\r\n' > "${crlf_file}" +# The backslash goes through %s on purpose: in one format string, `\\\r` is +# reduced to a backslash followed by the letter r by some printf +# implementations, which quietly turns this continuation case into a plain line +# and makes the assertions below pass for the wrong reason. +printf '%s\r\n' 'pd.peers=a,\' >> "${crlf_file}" +printf ' b\r\n' >> "${crlf_file}" +printf 'unrelated=true\r\n' >> "${crlf_file}" +[[ "$(get_prop_encoded 'auth.authenticator' "${crlf_file}")" == \ + "org.apache.hugegraph.auth.StandardAuthenticator" ]] +[[ "$(get_prop_encoded 'pd.peers' "${crlf_file}")" == "a,b" ]] +set_prop 'auth.authenticator' 'com.example.NewAuth' "${crlf_file}" +grep -q '^auth\.authenticator=com\.example\.NewAuth$' "${crlf_file}" +[[ "$(get_prop_encoded 'pd.peers' "${crlf_file}")" == "a,b" ]] +if (( awk_sees_crlf_cr )); then + if ! grep -q $'^unrelated=true\r$' "${crlf_file}"; then + echo "CRLF bytes of untouched lines must be preserved" >&2 + exit 1 + fi +else + skip "the CRLF byte check" +fi + +# An escaped key is the same key: java.util.Properties unescapes the name, so +# `auth\.authenticator` has to be found by a read or a write of +# `auth.authenticator` instead of being treated as absent and appended beside. +# (Comparing the class across the two files went away with the yaml scalar +# parser, so only the key grammar is left to pin down here.) +escaped_auth_dir="${test_dir}/escaped-auth-key" +mkdir -p "${escaped_auth_dir}/conf" +( + cd "${escaped_auth_dir}" || exit 1 + REST_SERVER_CONF="./conf/rest-server.properties" + printf '%s\n' \ + 'auth\.authenticator=com.example.OldAuth' \ + 'unrelated=true' \ + > "${REST_SERVER_CONF}" + [[ "$(get_prop_encoded 'auth.authenticator' "${REST_SERVER_CONF}")" == \ + "com.example.OldAuth" ]] + set_prop 'auth.authenticator' 'com.example.NewAuth' "${REST_SERVER_CONF}" + assert_line_count 1 'auth[\\]?\.authenticator' "${REST_SERVER_CONF}" + grep -q '^auth\.authenticator=com\.example\.NewAuth$' "${REST_SERVER_CONF}" + assert_line_count 1 '^unrelated=true$' "${REST_SERVER_CONF}" +) + +# A set must keep the config's inode: a copy-back preserves the file's +# permissions (a 0600 config holding secrets must not come back +# umask-readable) and leaves a symlinked config pointing at its target +# instead of replacing it with a regular file. +mode_file="${test_dir}/config-mode" +printf '%s\n' 'unrelated=true' > "${mode_file}" +chmod 600 "${mode_file}" +set_prop "init_store.enabled" "true" "${mode_file}" +grep -q '^init_store\.enabled=true$' "${mode_file}" +grep -q '^unrelated=true$' "${mode_file}" +[[ ! -e "${mode_file}.tmp" ]] +[[ ! -e "${mode_file}.bak" ]] +if (( host_keeps_chmod )); then + [[ "$(stat -c '%a' "${mode_file}")" == "600" ]] +else + skip "the config-mode-preservation check" +fi + +target_file="${test_dir}/config-target" +link_file="${test_dir}/config-link" +if (( host_keeps_symlink )); then + # The whole block has to be gated, not just the -L check: where ln -s + # produces a copy instead, writing the link updates a regular file and the + # target stays untouched, which would fail for the host's reason. + printf '%s\n' 'unrelated=true' > "${target_file}" + ln -s "${target_file}" "${link_file}" + set_prop "init_store.enabled" "true" "${link_file}" + [[ -L "${link_file}" ]] || { + echo "a set must not replace a symlinked config with a regular file" >&2 + exit 1 + } + grep -q '^init_store\.enabled=true$' "${target_file}" +else + skip "the symlinked-config check" +fi + +# Two yaml shapes the scoping has to keep getting right: a sibling mapping +# that carries its own authenticator must not hide the block's, and a commented +# authenticator must not count as one. +scope_dir="${test_dir}/yaml-scope" +mkdir -p "${scope_dir}/conf" +( + cd "${scope_dir}" || exit 1 + want_state() { + if [[ "$1" != "$2" ]]; then + echo "expected yaml state '$1', got '$2'" >&2 + exit 1 + fi + } + + printf '%s\n' \ + 'authentication:' \ + ' authenticator: com.example.GremlinAuth' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + 'ssl:' \ + ' authenticator: com.example.TlsOnly' \ + > conf/gremlin-server.yaml + want_state named "$(yaml_auth_state)" + + printf '%s\n' \ + 'authentication:' \ + '# authenticator: com.example.CommentedAuth' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + > conf/gremlin-server.yaml + want_state nameless "$(yaml_auth_state)" +) + +# A child value can open a flow collection or a quoted scalar that its own line +# does not close, and flow content ignores indentation, so the lines under it +# belong to the nested node rather than to the authentication mapping. Reading +# one of them as a direct child answered `named` for a file whose only +# authenticator sits inside `authentication.config` -- the direction that leaves +# REST enforcing beside a Gremlin on AllowAllAuthenticator. The two spellings +# that open a flow mapping behind an anchor or on the first child line load fine +# on the server, so answering `nameless` for them stops a boot that works. +span_dir="${test_dir}/yaml-flow-span" +mkdir -p "${span_dir}/conf" +( + cd "${span_dir}" || exit 1 + want_state() { + if [[ "$1" != "$2" ]]; then + echo "expected yaml state '$1', got '$2'" >&2 + exit 1 + fi + } + + printf '%s\n' \ + 'authentication:' \ + ' config: {tokens: conf/rest-server.properties,' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator}' \ + > conf/gremlin-server.yaml + want_state nameless "$(yaml_auth_state)" + + # A quoted scalar open past the end of the line has the same effect. + printf '%s\n' \ + 'authentication:' \ + ' config: {tokens: "conf/rest-server.properties,' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator"}' \ + > conf/gremlin-server.yaml + want_state nameless "$(yaml_auth_state)" + + printf '%s\n' \ + 'authentication: &auth {authenticator: org.apache.hugegraph.auth.StandardAuthenticator, authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler}' \ + > conf/gremlin-server.yaml + want_state named "$(yaml_auth_state)" + + printf '%s\n' \ + 'authentication:' \ + ' {authenticator: org.apache.hugegraph.auth.StandardAuthenticator, authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler}' \ + > conf/gremlin-server.yaml + want_state named "$(yaml_auth_state)" + + # The span ends where the collection closes, so a direct child written after + # it still counts: the mapping is not simply swallowed to the end of file. + printf '%s\n' \ + 'authentication:' \ + ' config: {tokens: conf/rest-server.properties,' \ + ' handler: org.apache.hugegraph.auth.StandardAuthenticator}' \ + ' authenticator: com.example.GremlinAuth' \ + > conf/gremlin-server.yaml + want_state named "$(yaml_auth_state)" + + # A collection that never closes is a file the server rejects, so it is + # refused through the nameless state rather than settled from a guess. + printf '%s\n' \ + 'authentication:' \ + ' config: {tokens: conf/rest-server.properties' \ + ' authenticator: com.example.GremlinAuth' \ + > conf/gremlin-server.yaml + want_state nameless "$(yaml_auth_state)" +) + +# The consequence rather than the answer: this is the tree that used to be armed +# one-sided, because a reader that said `named` told enable-auth.sh the yaml was +# already configured, so it wrote the REST side alone and exited 0. It has to +# refuse and change nothing. Note the guard on the yaml is on the properties +# and the factory only: the file itself carries an authenticator line nested in +# config, which is the shape under test. +span_tree_dir="${test_dir}/yaml-flow-span-tree" +mkdir -p "${span_tree_dir}/conf/graphs" +install_enable_auth "${span_tree_dir}" +printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > "${span_tree_dir}/conf/graphs/hugegraph.properties" +: > "${span_tree_dir}/conf/rest-server.properties" +printf '%s\n' \ + 'authentication:' \ + ' config: {tokens: conf/rest-server.properties,' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator}' \ + > "${span_tree_dir}/conf/gremlin-server.yaml" +( + cd "${span_tree_dir}" || exit 1 + if ./bin/enable-auth.sh; then + echo "enable-auth.sh must refuse a mapping whose authenticator is nested in config" >&2 + exit 1 + fi + if [[ -s conf/rest-server.properties ]]; then + echo "a refused run still wrote rest-server.properties" >&2 + exit 1 + fi + if grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties; then + echo "a refused run still wrapped the graph factory" >&2 + exit 1 + fi +) + +# Both sides silent means "bootstrap authentication", and the class then comes +# from enable-auth.sh: an operator who passed AUTHENTICATOR_CLASS gets the class +# they asked for, and only an unset one falls back to StandardAuthenticator. +# With the entrypoint no longer exporting a class of its own, this is the whole +# of the guarantee, so it is asserted where the default now lives. +class_dir="${test_dir}/authenticator-class" +( + # A fresh tree per run: enable-auth.sh keeps its own backup of the configs + # it writes, so re-running it over one directory is not a clean case. + run_enable_auth() { + local dir="$1" want="$2" + mkdir -p "${dir}/conf/graphs" + install_enable_auth "${dir}" + printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > "${dir}/conf/graphs/hugegraph.properties" + : > "${dir}/conf/rest-server.properties" + : > "${dir}/conf/gremlin-server.yaml" + ( + cd "${dir}" || exit 1 + if [[ -n "${want}" ]]; then + AUTHENTICATOR_CLASS="${want}" + export AUTHENTICATOR_CLASS + else + unset AUTHENTICATOR_CLASS + fi + ./bin/enable-auth.sh + ) + } + + run_enable_auth "${class_dir}/operator" "com.example.OperatorAuth" + grep -q '^auth\.authenticator=com\.example\.OperatorAuth$' \ + "${class_dir}/operator/conf/rest-server.properties" + grep -q '^ authenticator: com\.example\.OperatorAuth,$' \ + "${class_dir}/operator/conf/gremlin-server.yaml" + + run_enable_auth "${class_dir}/default" "" + grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ + "${class_dir}/default/conf/rest-server.properties" + grep -q '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \ + "${class_dir}/default/conf/gremlin-server.yaml" + + # refused_class : a class the writer would reject has to be + # rejected before any config is touched, and the tree has to still be + # workable afterwards. The character check used to sit only in props_set, + # which runs at the rest-server.properties write one statement after the + # yaml block was appended with that same class, so a refused value left + # gremlin-server.yaml naming a class no server can load beside an empty + # REST config -- the one-sided tree check_auth_sides stops the next boot on. + # It did not self-repair: the yaml then reads as `named`, so the append was + # skipped and a rerun with the variable fixed wrote the default to REST only + # and exited 0, leaving the two servers on different authenticators. + refused_class() { + local dir="$1" desc="$2" + ( + cd "${dir}" || exit 1 + if AUTHENTICATOR_CLASS='com.example.My Auth' ./bin/enable-auth.sh; then + echo "${desc}: enable-auth.sh must refuse a class its writer rejects" >&2 + exit 1 + fi + if [[ -s conf/rest-server.properties ]]; then + echo "${desc}: a refused class still wrote rest-server.properties" >&2 + exit 1 + fi + if grep -Eq '^[[:blank:]]*authenticator[[:blank:]]*:' conf/gremlin-server.yaml; then + echo "${desc}: a refused class still edited gremlin-server.yaml" >&2 + exit 1 + fi + if grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties; then + echo "${desc}: a refused class still wrapped the graph factory" >&2 + exit 1 + fi + # Nothing was left behind, so the corrected run arms both sides with + # one class rather than adopting the half-written tree. + unset AUTHENTICATOR_CLASS + if ! ./bin/enable-auth.sh; then + echo "${desc}: enable-auth.sh failed on the tree a refused run left" >&2 + exit 1 + fi + if ! grep -q \ + '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ + conf/rest-server.properties; then + echo "${desc}: the corrected run wrote no class to REST" >&2 + exit 1 + fi + if ! grep -q \ + '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \ + conf/gremlin-server.yaml; then + echo "${desc}: the corrected run named no class in the yaml" >&2 + exit 1 + fi + ) + } + + refused_tree() { + local dir="$1" with_scan="$2" + mkdir -p "${dir}/conf/graphs" + install_enable_auth "${dir}" + if [[ "${with_scan}" == "no-yamlscan" ]]; then + rm -f "${dir}/yamlscan.awk" + fi + printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > "${dir}/conf/graphs/hugegraph.properties" + : > "${dir}/conf/rest-server.properties" + : > "${dir}/conf/gremlin-server.yaml" + } + + # Both layouts, because the append happens in both: the image has the real + # reader, the plain tarball answers `none` from grep on an empty file. + refused_tree "${class_dir}/refused-image" yes + refused_class "${class_dir}/refused-image" "image layout (yamlscan.awk present)" + refused_tree "${class_dir}/refused-tarball" no-yamlscan + refused_class "${class_dir}/refused-tarball" "release tarball (no yamlscan.awk)" +) + +# An empty mounted config still gets its definitions. GNU sed's `$` +# address never matches when the file has no lines, so enable-auth.sh's +# `sed -i '$a\...'` appends were silent no-ops on an empty +# rest-server.properties and an empty gremlin-server.yaml: the +# entrypoint had already written auth.admin_pa and init-store had run in +# auth mode, yet neither server was told to authenticate at all. +empty_dir="${test_dir}/empty-config" +mkdir -p "${empty_dir}/bin" "${empty_dir}/conf/graphs" +install_enable_auth "${empty_dir}" +( + cd "${empty_dir}" || exit 1 + : > conf/rest-server.properties + : > conf/gremlin-server.yaml + printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > conf/graphs/hugegraph.properties + unset AUTHENTICATOR_CLASS + ./bin/enable-auth.sh + grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ + conf/rest-server.properties + grep -q '^auth\.graph_store=hugegraph$' conf/rest-server.properties + grep -q '^authentication: {$' conf/gremlin-server.yaml + grep -q '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \ + conf/gremlin-server.yaml + grep -q '^ config: {tokens: conf/rest-server\.properties}$' \ + conf/gremlin-server.yaml + grep -q '^}' conf/gremlin-server.yaml + grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties + # Idempotent: a second run adds nothing to what the first one wrote. + wc -l < conf/gremlin-server.yaml > "${test_dir}/empty-yaml-count" + ./bin/enable-auth.sh + [[ "$(wc -l < conf/gremlin-server.yaml)" == \ + "$(cat "${test_dir}/empty-yaml-count")" ]] + + # A config whose last line has no terminator still gets a line of its + # own; `sed -i '$a'` closed that terminator for us. + printf 'restserver.url=http://127.0.0.1:8080' > conf/rest-server.properties + ./bin/enable-auth.sh + grep -q '^auth\.authenticator=' conf/rest-server.properties + grep -q '^restserver\.url=http://127\.0\.0\.1:8080$' conf/rest-server.properties +) + +# A copy-back that fails part way must not leave a truncated config. The +# shell's `>` truncates the destination before cat writes a byte, so +# props.awk snapshots the original first and puts it back. The snapshot +# `cat` is replaced through PATH to fail the copy the way ENOSPC would: +# stdout here *is* the already-truncated destination, so a few bytes and a +# non-zero exit is exactly a half-written config. +# +# The staging names are unpredictable now, so this group follows the paths +# props.awk left behind instead of naming `.tmp` and `.bak`: a +# fixed name is the thing that had to go, and an assertion that has to guess +# the name would have to be rewritten every time the generator changes. +failbin="${test_dir}/fakebin" +mkdir -p "${failbin}" +real_cat="$(command -v cat)" +printf '%s\n' \ + '#!/bin/sh' \ + 'case "$2" in' \ + ' *.tmp*) printf "auth.authenticator=par"; exit 1 ;;' \ + ' *.bak*) [ -n "${FAKE_BAK_FAIL:-}" ] && exit 1 ;;' \ + 'esac' \ + 'exec "${FAKE_CAT_REAL}" "$@"' \ + > "${failbin}/cat" +chmod +x "${failbin}/cat" +rb_file="${test_dir}/config-rollback" +rb_expect="${test_dir}/config-rollback.expected" +printf '%s\n' \ + 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ + 'auth.token_secret=s3cr3t' \ + 'unrelated=true' > "${rb_file}" +cp -p "${rb_file}" "${rb_expect}" +staged() { + find "${test_dir}" -maxdepth 1 -name "config-rollback.$1.*" | sort +} +( + PATH="${failbin}:${PATH}" + FAKE_CAT_REAL="${real_cat}" + export PATH FAKE_CAT_REAL + if set_prop 'auth.authenticator' 'com.example.HalfWritten' "${rb_file}"; then + echo "set_prop must fail when the copy-back fails" >&2 + exit 1 + fi +) 2>/dev/null +cmp -s "${rb_file}" "${rb_expect}" || { + echo "a failed copy-back must leave the previous content in place" >&2 + exit 1 +} +# Both staging files survive on purpose: the temp file is what was being +# written, and the snapshot is the operator's way back. +rb_left_tmp=$(staged tmp | head -1) +rb_left_bak=$(staged bak | head -1) +[[ -n "${rb_left_tmp}" && -n "${rb_left_bak}" ]] || { + echo "a failed copy-back must leave both staging files for the operator" >&2 + exit 1 +} +# Once the condition clears the same set goes through, and leaves nothing +# behind. The operator's leftovers go first, so a leftover from this run cannot +# be mistaken for one from that run. +rm -f "${rb_left_tmp}" "${rb_left_bak}" +set_prop 'auth.authenticator' 'com.example.HalfWritten' "${rb_file}" +grep -q '^auth\.authenticator=com\.example\.HalfWritten$' "${rb_file}" +grep -q '^auth\.token_secret=s3cr3t$' "${rb_file}" +grep -q '^unrelated=true$' "${rb_file}" +[[ -z "$(staged tmp)$(staged bak)" ]] || { + echo "a successful set left staging files behind: $(staged tmp) $(staged bak)" >&2 + exit 1 +} +# When the restore fails too there is nothing left to do but say so and +# point at the snapshot, because that snapshot is the only copy of a +# working config the operator has. +printf '%s\n' \ + 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ + 'auth.token_secret=s3cr3t' \ + 'unrelated=true' > "${rb_file}" +rb_out=$( + PATH="${failbin}:${PATH}" + FAKE_CAT_REAL="${real_cat}" + FAKE_BAK_FAIL=1 + export PATH FAKE_CAT_REAL FAKE_BAK_FAIL + set_prop 'auth.authenticator' 'com.example.HalfWritten' "${rb_file}" 2>&1 +) || true +rb_bak=$(staged bak) +[[ "${rb_out}" == *"${rb_file}.bak."* ]] || { + echo "props.awk must name the snapshot when the restore also fails, got [${rb_out}]" >&2 + exit 1 +} +# The damaged config keeps whatever the aborted copy left, and the +# snapshot still holds the last known good content. +[[ -n "${rb_bak}" ]] || { echo "the snapshot is gone" >&2; exit 1; } +cmp -s "${rb_bak}" "${rb_expect}" || { + echo "the snapshot must be a byte-for-byte copy of the original" >&2 + exit 1 +} + +# ── A config that cannot be written is refused before anything is staged ── +# The copy-back was the first write props.awk attempted, so a read-only config +# failed there and got the message written for a copy that reached the +# destination and then broke. The shell opens the destination for writing before +# cat has written a byte, so on EACCES nothing was truncated and the config is +# intact, yet the run called it damaged and sent the operator to restore a file +# that needed nothing -- while leaving the staged temp file, which holds the value +# being written, and the snapshot beside it. A container on a restart policy +# therefore gained one pair per restart, each temp file carrying the secret. +# Repeating the attempt three times is the point: the leak is per attempt. +# The group only means something on a host that stops a write to 0444, so it is +# gated on the probe that tries it rather than weakened into a pass anywhere. +ro_staged() { + find "${test_dir}" -maxdepth 1 -name "config-readonly.$1.*" | sort +} +if (( host_denies_write )); then + ro_file="${test_dir}/config-readonly" + ro_pristine="${test_dir}/config-readonly.pristine" + printf '%s\n' 'server.name=hugegraph' > "${ro_file}" + cp "${ro_file}" "${ro_pristine}" + chmod 444 "${ro_file}" + for ro_attempt in 1 2 3; do + if ro_out=$(set_prop 'auth.admin_pa' 's3cretVALUE' "${ro_file}" 2>&1); then + chmod 644 "${ro_file}" + echo "set_prop must refuse a config it cannot write" >&2 + exit 1 + fi + if [[ "${ro_out}" != *"not writable"* ]]; then + chmod 644 "${ro_file}" + echo "a refused set must say the config is not writable, got [${ro_out}]" >&2 + exit 1 + fi + if [[ "${ro_out}" == *"damaged"* ]]; then + chmod 644 "${ro_file}" + echo "a config nothing wrote to must not be called damaged, got [${ro_out}]" >&2 + exit 1 + fi + done + chmod 644 "${ro_file}" + cmp -s "${ro_file}" "${ro_pristine}" || { + echo "a refused set must leave the config byte-for-byte untouched" >&2 + exit 1 + } + [[ -z "$(ro_staged tmp)$(ro_staged bak)" ]] || { + echo "a refused set left staging files behind: $(ro_staged tmp) $(ro_staged bak)" >&2 + exit 1 + } + # Refusing to write a read-only config must not turn into refusing to read + # one, which is what the entrypoint does first on every boot. + [[ "$(get_prop_decoded 'server.name' "${ro_file}")" == "hugegraph" ]] || { + echo "get_prop must still answer from a read-only config" >&2 + exit 1 + } + # A write that cannot even begin is the same case arriving a different way: + # `test -w` can report a bind-mounted read-only file as writable, so the + # compare against the snapshot has to hold on its own. A `cat` that fails + # without writing simulates it, and an empty config is the shape where that + # really does leave the original intact, so this exercises the compare rather + # than re-testing the redirect above. + nowrite_bin="${test_dir}/fakebin-nowrite" + mkdir -p "${nowrite_bin}" + printf '%s\n' '#!/bin/sh' 'exit 1' > "${nowrite_bin}/cat" + chmod +x "${nowrite_bin}/cat" + nm_file="${test_dir}/config-notmodified" + : > "${nm_file}" + if nm_out=$( + PATH="${nowrite_bin}:${PATH}" + FAKE_CAT_REAL="${real_cat}" + export PATH FAKE_CAT_REAL + set_prop 'auth.token_secret' 's3cretVALUE' "${nm_file}" 2>&1 + ); then + echo "set_prop must fail when the copy-back cannot run" >&2 + exit 1 + fi + [[ "${nm_out}" == *"unchanged"* ]] || { + echo "a copy-back that never started must say the config is unchanged, got [${nm_out}]" >&2 + exit 1 + } + [[ -s "${nm_file}" ]] && { echo "the config gained content from a refused write" >&2; exit 1; } + [[ -z "$(find "${test_dir}" -maxdepth 1 -name "config-notmodified.*" | grep -v pristine)" ]] || { + echo "a refused copy-back that left the config intact must remove both staging files" >&2 + exit 1 + } +else + skip "the read-only-config group -- this host lets a 0444 file be written" +fi + +# A value whose encoded form ends in an odd number of backslashes must not be +# written at all. The entrypoint copies an existing secret between files with +# set_prop_encoded, replaying the raw bytes, and on disk `key=abc\` as the last +# line of a mounted config reads back as no property at all under +# commons-configuration2 (what HugeConfig extends). Written into a file where +# it is no longer last, it turns the following line into a continuation of the +# secret: the server then sees neither the secret nor that property, and the +# entrypoint has published a credential nothing will read. +bs_file="${test_dir}/config-trailing-backslash" +bs_pristine="${test_dir}/config-trailing-backslash.pristine" +printf '%s\n' 'unrelated=true' > "${bs_file}" +cp "${bs_file}" "${bs_pristine}" +if set_prop_encoded 'auth.token_secret' 'abc\' "${bs_file}" 2>/dev/null; then + echo "props.awk must refuse a value ending in an odd number of backslashes" >&2 + exit 1 +fi +cmp -s "${bs_file}" "${bs_pristine}" || { + echo "a refused set must leave the config byte-for-byte untouched" >&2 + exit 1 +} + +# An escaped backslash — two of them — is not a continuation, so it stays +# writable and replays byte for byte. Built from parts because a doubled +# backslash inside one literal is easy to write and hard to read back. +bs='\' +two_bs="abc${bs}${bs}" +set_prop_encoded 'auth.token_secret' "${two_bs}" "${bs_file}" +[[ "$(get_prop_encoded 'auth.token_secret' "${bs_file}")" == "${two_bs}" ]] +assert_line_count 1 '^unrelated=true$' "${bs_file}" + +# ── CR-only line terminators ────────────────────────────────────────── +# java.util.Properties ends a line at a bare CR as well, so a config written +# that way holds one property per CR-separated chunk. Reading it with a +# \n-only split made the entire file one record: only the first key was ever +# seen, and rewriting that key replaced the record with a single line, which +# silently deleted every property after it -- including auth.authenticator, so +# the file the server then read had no authentication configured at all. +if (( awk_sees_lone_cr )); then + cr_file="${test_dir}/config-cr" + printf 'graph=a\rpd.peers=b\rauth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator\r' \ + > "${cr_file}" + + [[ "$(get_prop_encoded 'graph' "${cr_file}")" == "a" ]] + [[ "$(get_prop_encoded 'pd.peers' "${cr_file}")" == "b" ]] + [[ "$(get_prop_encoded 'auth.authenticator' "${cr_file}")" == \ + "org.apache.hugegraph.auth.StandardAuthenticator" ]] + + cp "${cr_file}" "${cr_file}.before" + set_prop 'graph' 'org.apache.hugegraph.auth.HugeFactoryAuthProxy' "${cr_file}" + + # Every key that was there before is still there afterwards, with the + # values the rewrite was not about. + [[ "$(get_prop_encoded 'pd.peers' "${cr_file}")" == "b" ]] || { + echo "a CR-only config lost pd.peers when an unrelated key was rewritten" >&2 + exit 1 + } + [[ "$(get_prop_encoded 'auth.authenticator' "${cr_file}")" == \ + "org.apache.hugegraph.auth.StandardAuthenticator" ]] || { + echo "a CR-only config lost auth.authenticator when an unrelated key was rewritten" >&2 + exit 1 + } + [[ "$(get_prop_encoded 'graph' "${cr_file}")" == \ + "org.apache.hugegraph.auth.HugeFactoryAuthProxy" ]] + # One definition per key, so the rewrite replaced rather than appended. + # Counted on CR folded to LF because grep only ever starts a new line at + # LF, and a CR-only file is a single line to it. + count_records() { + local pattern="$1" file="$2" + # grep exits 1 on a zero count, which errexit would take as the + # interesting failure; the printed number is the answer here. + tr '\r' '\n' < "${file}" | grep -Ec "${pattern}" || true + } + [[ "$(count_records '^graph=' "${cr_file}")" == "1" ]] || { + echo "a CR-only rewrite must leave exactly one graph definition" >&2 + exit 1 + } + [[ "$(count_records '^auth\.authenticator=' "${cr_file}")" == "1" ]] || { + echo "a CR-only rewrite must leave exactly one auth.authenticator definition" >&2 + exit 1 + } + + # Mixed terminators in one file, the state an upgraded mounted volume + # actually reaches: CRLF from a Windows edit, CR from an old store(), LF + # from the image. + mix_file="${test_dir}/config-mixed-eol" + printf 'graph=a\rpd.peers=b\nauth.authenticator=c\r\nunrelated=d\n' > "${mix_file}" + [[ "$(get_prop_encoded 'graph' "${mix_file}")" == "a" ]] + [[ "$(get_prop_encoded 'pd.peers' "${mix_file}")" == "b" ]] + [[ "$(get_prop_encoded 'auth.authenticator' "${mix_file}")" == "c" ]] + [[ "$(get_prop_encoded 'unrelated' "${mix_file}")" == "d" ]] +fi + +# ── Form feed is separator whitespace to Java ───────────────────────── +# java.util.Properties counts \f as whitespace on both sides of the key/value +# boundary, so `auth.authenticator=...` is that property. Recognising only +# space and tab parsed the form feed into the key name instead, and a mounted +# config written that way read as unconfigured -- which the guards then answered +# by appending a second, competing definition. +ff_file="${test_dir}/config-formfeed" +printf 'auth.authenticator\fs=org.apache.hugegraph.auth.StandardAuthenticator\n' > "${ff_file}" +[[ "$(get_prop_encoded 'auth.authenticator' "${ff_file}")" == \ + "s=org.apache.hugegraph.auth.StandardAuthenticator" ]] || { + echo "a form feed before the separator must end the key, as it does in Java" >&2 + exit 1 +} +printf 'auth.authenticator\forg.apache.hugegraph.auth.X\n' > "${ff_file}" +[[ "$(get_prop_encoded 'auth.authenticator' "${ff_file}")" == \ + "org.apache.hugegraph.auth.X" ]] +printf 'auth.authenticator=\f1\n' > "${ff_file}" +[[ "$(get_prop_encoded 'auth.authenticator' "${ff_file}")" == "1" ]] +printf '\fauth.authenticator=1\n' > "${ff_file}" +[[ "$(get_prop_encoded 'auth.authenticator' "${ff_file}")" == "1" ]] +# A line that is only form feed whitespace is blank to Java, not a property. +printf '\f\f\ngraph=a\n' > "${ff_file}" +[[ "$(get_prop_encoded 'graph' "${ff_file}")" == "a" ]] +assert_line_count 1 '^graph=a$' "${ff_file}" + +# get answers absence with empty output at exit 0, and an empty definition +# reads back the same way -- which is why ensure_rest_prop tests the value +# rather than a presence status. The one nonzero case is a file the reader +# cannot answer a question about, and it must not be read as "absent": a guard +# wearing errexit has to stop rather than append a default over a file it could +# not read. +get_status_file="${test_dir}/config-get-status" +printf 'auth.authenticator=\n' > "${get_status_file}" +[[ -z "$(get_prop_encoded 'auth.authenticator' "${get_status_file}")" ]] || { + echo "an empty definition must read back empty" >&2 + exit 1 +} +if ! get_prop_encoded 'auth.graph_store' "${get_status_file}" >/dev/null; then + echo "an absent key in a readable file must exit 0" >&2 + exit 1 +fi +status=0 +get_prop_encoded 'k' "${test_dir}/no-such-file" >/dev/null 2>&1 || status=$? +if (( status != 2 )); then + echo "get must exit 2 for an unreadable file, got ${status}" >&2 + exit 1 +fi + +# get with PROPS_DECODED=1 hands back the value as the server would see it, +# which is what a guard that compares a class name needs. +dec_file="${test_dir}/config-decoded" +printf 'gremlin\\u002egraph=org.apache.hugegraph.HugeFactory\n' > "${dec_file}" +[[ "$(PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY='gremlin.graph' \ + PROPS_FILE="${dec_file}" awk -f "${PROPS_AWK}" /dev/null)" == \ + "org.apache.hugegraph.HugeFactory" ]] +[[ "$(PROPS_MODE=get PROPS_KEY='gremlin\u002egraph' PROPS_FILE="${dec_file}" \ + awk -f "${PROPS_AWK}" /dev/null)" == "" ]] + +# ── gremlin.graph spelled with a Unicode escape still gets wrapped ────── +# \u002e is a dot to java.util.Properties, so this is the plain HugeFactory and +# enable-auth.sh has to route authentication through it. The grep/sed pair +# matched only a literal or backslash-escaped dot, missed this one, and left the +# graph factory unwrapped while both servers had been told authentication was +# on -- the one remaining path where the REST side was configured and the graph +# behind it was not. +u2e_dir="${test_dir}/u2e-wrap" +mkdir -p "${u2e_dir}/conf/graphs" +install_enable_auth "${u2e_dir}" +: > "${u2e_dir}/conf/rest-server.properties" +: > "${u2e_dir}/conf/gremlin-server.yaml" +printf '%s\n' 'gremlin\u002egraph=org.apache.hugegraph.HugeFactory' \ + > "${u2e_dir}/conf/graphs/hugegraph.properties" +( + cd "${u2e_dir}" || exit 1 + unset AUTHENTICATOR_CLASS + ./bin/enable-auth.sh + if [[ "$(PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY='gremlin.graph' \ + PROPS_FILE=./conf/graphs/hugegraph.properties \ + awk -f "${PROPS_AWK}" /dev/null)" != \ + "org.apache.hugegraph.auth.HugeFactoryAuthProxy" ]]; then + echo "a gremlin.graph key written as \\u002e must still be wrapped" >&2 + exit 1 + fi + # One definition, not the original left behind plus a new one. + if [[ "$(grep -c 'HugeFactory' ./conf/graphs/hugegraph.properties)" != "1" ]]; then + echo "wrapping a \\u002e-escaped key must not leave the old definition" >&2 + exit 1 + fi +) + +# A CR-only graph config wraps too, and keeps the keys around it. +if (( awk_sees_lone_cr )); then + crwrap_dir="${test_dir}/cr-wrap" + mkdir -p "${crwrap_dir}/conf/graphs" + install_enable_auth "${crwrap_dir}" + : > "${crwrap_dir}/conf/rest-server.properties" + : > "${crwrap_dir}/conf/gremlin-server.yaml" + printf 'gremlin.graph=org.apache.hugegraph.HugeFactory\rbackend=rocksdb\r' \ + > "${crwrap_dir}/conf/graphs/hugegraph.properties" + ( + cd "${crwrap_dir}" || exit 1 + unset AUTHENTICATOR_CLASS + ./bin/enable-auth.sh + [[ "$(get_prop_encoded 'backend' ./conf/graphs/hugegraph.properties)" == \ + "rocksdb" ]] || { + echo "wrapping a CR-only graph config dropped a later key" >&2 + exit 1 + } + [[ "$(get_prop_encoded 'gremlin.graph' ./conf/graphs/hugegraph.properties)" == \ + "org.apache.hugegraph.auth.HugeFactoryAuthProxy" ]] + ) +fi + +# ── A failed append must fail the script ────────────────────────────── +# The entrypoint runs enable-auth.sh and trusts its exit status, so a run that +# configures REST and then cannot write the yaml has to say so. Without +# errexit and per-write checks it exited 0 on exactly that half-done tree: the +# mounted read-only gremlin-server.yaml made the yaml append fail while both +# rest-server.properties appends succeeded. +ro_dir="${test_dir}/read-only-yaml" +mkdir -p "${ro_dir}/conf/graphs" +install_enable_auth "${ro_dir}" +: > "${ro_dir}/conf/rest-server.properties" +printf 'host: 8182\n' > "${ro_dir}/conf/gremlin-server.yaml" +printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > "${ro_dir}/conf/graphs/hugegraph.properties" +( + cd "${ro_dir}" || exit 1 + unset AUTHENTICATOR_CLASS + chmod 444 conf/gremlin-server.yaml + status=0 + ./bin/enable-auth.sh 2>/dev/null || status=$? + chmod 644 conf/gremlin-server.yaml + if (( status == 0 )); then + echo "enable-auth.sh must exit nonzero when a config append fails" >&2 + exit 1 + fi + if grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' conf/gremlin-server.yaml; then + echo "the unwritable yaml file must not have been changed" >&2 + exit 1 + fi +) + +# ── A refused read has to precede every write ────────────────────────── +# The block above covers a write that fails; this covers a read that fails. +# A rest-server.properties carrying commons-configuration's `include` directive +# is a file props.awk refuses, and the script used to reach it only at +# ensure_rest_prop -- one statement after the yaml block had been appended. Its +# guard also tested `[[ -n "$(props_get ...)" ]]`, and `[[` keeps the text of a +# command substitution while discarding its status, so props_get's refusal only +# exited the subshell and the REST write went ahead too. The run left +# gremlin-server.yaml naming StandardAuthenticator beside a rest-server.properties +# with no authenticator at all, and in the release tarball that does not repair +# itself: the appended block reads as a mapping, so the rerun after the operator +# removes the include line stops on the unverifiable branch and the yaml has to +# be edited by hand. graphs/hugegraph.properties was read last of all, so an +# include directive there let the run write both auth sides and leave the factory +# unwrapped. Every value the script decides on is now read before the first +# write, so a refusal leaves the tree exactly as the operator left it, and one +# run after the repair arms both sides. +refused_read_tree() { + local dir="$1" with_scan="$2" unreadable="$3" + mkdir -p "${dir}/conf/graphs" + install_enable_auth "${dir}" + if [[ "${with_scan}" == "no-yamlscan" ]]; then + rm -f "${dir}/yamlscan.awk" + fi + printf '%s\n' 'server.name=hugegraph' > "${dir}/conf/rest-server.properties" + printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > "${dir}/conf/graphs/hugegraph.properties" + printf 'host: 8182\n' > "${dir}/conf/gremlin-server.yaml" + printf '%s\n' 'include=other.properties' >> "${dir}/conf/${unreadable}" +} + +refused_read() { + local dir="$1" desc="$2" unreadable="$3" + ( + cd "${dir}" || exit 1 + keep="${dir}.found" + mkdir -p "${keep}/graphs" + cp conf/gremlin-server.yaml conf/rest-server.properties "${keep}/" + cp conf/graphs/hugegraph.properties "${keep}/graphs/" + if ./bin/enable-auth.sh; then + echo "${desc}: enable-auth.sh must refuse a config props.awk cannot read" >&2 + exit 1 + fi + for kept in gremlin-server.yaml rest-server.properties; do + if ! cmp -s "conf/${kept}" "${keep}/${kept}"; then + echo "${desc}: a refused read still edited ${kept}" >&2 + exit 1 + fi + done + if ! cmp -s conf/graphs/hugegraph.properties \ + "${keep}/graphs/hugegraph.properties"; then + echo "${desc}: a refused read still edited graphs/hugegraph.properties" >&2 + exit 1 + fi + # The operator's repair, then one run over a tree that is back to the + # shipped shape. Rewritten rather than filtered so no CR can enter the + # fixture on this host. + if [[ "${unreadable}" == "rest-server.properties" ]]; then + printf '%s\n' 'server.name=hugegraph' > conf/rest-server.properties + else + printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > conf/graphs/hugegraph.properties + fi + if ! ./bin/enable-auth.sh; then + echo "${desc}: enable-auth.sh failed on the repaired tree" >&2 + exit 1 + fi + if ! grep -q \ + '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ + conf/rest-server.properties; then + echo "${desc}: the repaired run wrote no class to REST" >&2 + exit 1 + fi + if ! grep -q \ + '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \ + conf/gremlin-server.yaml; then + echo "${desc}: the repaired run named no class in the yaml" >&2 + exit 1 + fi + if ! grep -q '^gremlin\.graph=org\.apache\.hugegraph\.auth\.HugeFactoryAuthProxy$' \ + conf/graphs/hugegraph.properties; then + echo "${desc}: the repaired run left the graph factory unwrapped" >&2 + exit 1 + fi + ) +} + +for scan in yes no-yamlscan; do + case "${scan}" in + yes) scan_desc="image layout (yamlscan.awk present)" ;; + *) scan_desc="release tarball (no yamlscan.awk)" ;; + esac + for unreadable in rest-server.properties graphs/hugegraph.properties; do + dir="${test_dir}/refused-read-${scan}-${unreadable##*/}" + refused_read_tree "${dir}" "${scan}" "${unreadable}" + refused_read "${dir}" "${scan_desc}, ${unreadable} unreadable" "${unreadable}" + done +done + +# ── A config that cannot be written has to precede every write too ────── +# The group above covers a read that fails; this covers a write that cannot +# start. The read-only gremlin-server.yaml already had a case, but the mirror +# image did not: append_lines guards only the file it appends to, so a +# rest-server.properties that can be read and not written let the yaml gain the +# authentication block, props_set then failed on the REST side, and the run +# exited 1 on the one-sided tree the comment above append_lines says must not +# happen -- which is precisely what gremlin-server.yaml naming +# StandardAuthenticator beside a REST config with no auth.authenticator is. +# graphs/hugegraph.properties had the same hole one side further along: both +# auth sides got written and the factory stayed unwrapped. In the release +# tarball the tree did not repair itself either, because the appended block reads +# as a mapping, so the rerun after the operator restored write access stopped on +# the unverifiable branch and the yaml had to be edited by hand. So each +# permutation must refuse with every byte of every config where it was, leave no +# staging file behind, and arm both sides and wrap the factory once write access +# returns. +refused_write_tree() { + local dir="$1" with_scan="$2" unwritable="$3" + mkdir -p "${dir}/conf/graphs" + install_enable_auth "${dir}" + if [[ "${with_scan}" == "no-yamlscan" ]]; then + rm -f "${dir}/yamlscan.awk" + fi + printf '%s\n' 'server.name=hugegraph' > "${dir}/conf/rest-server.properties" + printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > "${dir}/conf/graphs/hugegraph.properties" + printf 'host: 8182\n' > "${dir}/conf/gremlin-server.yaml" + chmod 444 "${dir}/conf/${unwritable}" +} + +refused_write() { + local dir="$1" desc="$2" unwritable="$3" + ( + cd "${dir}" || exit 1 + keep="${dir}.kept" + mkdir -p "${keep}/graphs" + cp conf/gremlin-server.yaml conf/rest-server.properties "${keep}/" + cp conf/graphs/hugegraph.properties "${keep}/graphs/" + if ./bin/enable-auth.sh; then + echo "${desc}: enable-auth.sh must refuse a config it cannot write" >&2 + exit 1 + fi + for kept in gremlin-server.yaml rest-server.properties; do + if ! cmp -s "conf/${kept}" "${keep}/${kept}"; then + echo "${desc}: a refused write still edited ${kept}" >&2 + exit 1 + fi + done + if ! cmp -s conf/graphs/hugegraph.properties \ + "${keep}/graphs/hugegraph.properties"; then + echo "${desc}: a refused write still edited graphs/hugegraph.properties" >&2 + exit 1 + fi + # The staged temp file holds the value being written, so a refusal that + # leaves it in a mounted conf directory is a leak, not a diagnostic. + if find conf -name '*.tmp.*' -o -name '*.bak.*' | grep -q .; then + echo "${desc}: a refused write left staging files behind" >&2 + find conf -name '*.tmp.*' -o -name '*.bak.*' >&2 + exit 1 + fi + # The operator's repair, then one run over the tree that was never + # touched. This is what the refused case buys: the yaml cannot have a + # half-written mapping for the rerun to misread. + chmod 644 "conf/${unwritable}" + if ! ./bin/enable-auth.sh; then + echo "${desc}: enable-auth.sh failed once the config was writable" >&2 + exit 1 + fi + if ! grep -q \ + '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ + conf/rest-server.properties; then + echo "${desc}: the repaired run wrote no class to REST" >&2 + exit 1 + fi + if ! grep -q \ + '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \ + conf/gremlin-server.yaml; then + echo "${desc}: the repaired run named no class in the yaml" >&2 + exit 1 + fi + if ! grep -q '^gremlin\.graph=org\.apache\.hugegraph\.auth\.HugeFactoryAuthProxy$' \ + conf/graphs/hugegraph.properties; then + echo "${desc}: the repaired run left the graph factory unwrapped" >&2 + exit 1 + fi + ) +} + +if (( host_denies_write )); then + for scan in yes no-yamlscan; do + case "${scan}" in + yes) scan_desc="image layout (yamlscan.awk present)" ;; + *) scan_desc="release tarball (no yamlscan.awk)" ;; + esac + for unwritable in rest-server.properties graphs/hugegraph.properties; do + dir="${test_dir}/refused-write-${scan}-${unwritable##*/}" + refused_write_tree "${dir}" "${scan}" "${unwritable}" + refused_write "${dir}" "${scan_desc}, ${unwritable} read-only" "${unwritable}" + done + done +else + skip "the read-only enable-auth.sh group -- this host lets a 0444 file be written" +fi + +# ── yaml_auth_state answers about the mapping, not about the text ─────── +# Each case below is a mounted gremlin-server.yaml that a grep-shaped reader +# calls named while the Gremlin server runs without an authenticator. Reported +# parity on such a file is how REST ends up enforcing and Gremlin open, so the +# reader follows the mapping structure instead of the substring. +yaml_case() { + local want="$1" desc="$2" dir + shift 2 + dir="${test_dir}/yaml-$(printf '%s' "${desc}" | tr -c 'A-Za-z0-9' '-')" + mkdir -p "${dir}/conf" + printf '%s\n' "$@" > "${dir}/conf/gremlin-server.yaml" + ( + cd "${dir}" || exit 1 + got=$(yaml_auth_state) + if [[ "${got}" != "${want}" ]]; then + echo "yaml_auth_state: ${desc}: got ${got}, want ${want}" >&2 + exit 1 + fi + ) +} + +# A flow mapping that names nothing, with a commented-out authenticator behind +# it: the text is there, the key is not. +yaml_case nameless "flow empty with authenticator in a comment" \ + 'authentication: {} # authenticator: org.apache.hugegraph.auth.StandardAuthenticator' +# A comment line inside the mapping is not the end of it, so a valid +# deployment with a note between the keys must not be refused. +yaml_case named "column-zero comment inside the mapping" \ + 'authentication:' \ + '# configured by the operator' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' +# config is its own map, so an authenticator under it is the token store +# configuration and not the server authenticator. +yaml_case nameless "authenticator nested under config" \ + 'authentication:' \ + ' config:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' +yaml_case nameless "authenticator nested inside a flow config" \ + 'authentication: {config: {authenticator: org.apache.hugegraph.auth.StandardAuthenticator}}' +# The positive cases a wrong reader must keep accepting. +yaml_case named "plain block child" \ + 'authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' +yaml_case named "direct flow child with siblings" \ + 'authentication: {config: {tokens: conf/rest-server.properties}, authenticator: org.apache.hugegraph.auth.StandardAuthenticator}' +yaml_case named "quoted key" \ + 'authentication:' \ + ' "authenticator": org.apache.hugegraph.auth.StandardAuthenticator' +# An authenticator key that names no class leaves the server on +# AllowAllAuthenticator, so it is the nameless case. +yaml_case nameless "direct authenticator with no value" \ + 'authentication:' \ + ' authenticator:' +yaml_case nameless "direct authenticator set to null" \ + 'authentication:' \ + ' authenticator: null' +# An `authentication:` belonging to another mapping is not the server's. +yaml_case none "authentication nested under another key" \ + 'server:' \ + ' authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' +yaml_case none "no authentication anywhere" \ + 'host: 8182' \ + 'port: 1' +# A sibling key at column zero closes the mapping; an authenticator after it +# belongs to the sibling, not to authentication. +yaml_case nameless "sibling key closes the mapping" \ + 'authentication:' \ + ' handler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + 'metrics:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' +# What snakeyaml resolves to null names no class, and it resolves null +# case-insensitively, so NULL, Null and nUll are the refusal case just as null +# is. An explicit !!null says it outright, and an empty quoted scalar is the +# empty string, for which loadAuthenticator returns null. Reporting any of +# these as named is the one direction that cannot be forgiven: REST would +# enforce while Gremlin ran on AllowAllAuthenticator. +for nullish in 'null' 'NULL' 'Null' 'nUll' '~' '!!null' '!!null ~' '""' "''"; do + yaml_case nameless "authenticator set to the null spelling [${nullish}]" \ + 'authentication:' \ + " authenticator: ${nullish}" +done +# The same values in a flow mapping, where the reader has to reach the value at +# all: a quoted class name used to arrive empty, which refused a valid mounted +# config before the server started. +yaml_case named "unquoted class in a flow mapping" \ + 'authentication: {authenticator: org.apache.hugegraph.auth.StandardAuthenticator}' +yaml_case named "double quoted class in a flow mapping" \ + 'authentication: {authenticator: "org.apache.hugegraph.auth.StandardAuthenticator"}' +yaml_case named "single quoted class in a flow mapping" \ + "authentication: {authenticator: 'org.apache.hugegraph.auth.StandardAuthenticator'}" +yaml_case named "double quoted class between flow siblings" \ + 'authentication: {config: {tokens: conf/rest-server.properties}, authenticator: "org.apache.hugegraph.auth.StandardAuthenticator"}' +# Quoting a scalar makes it a string rather than the null node, so "null" names +# a class the server fails to load loudly at startup; that is not the silent +# no-authenticator state the plain spellings above are. Pinned so a later +# tightening of the null rules cannot move it without saying so here. +yaml_case named "quoted null is a string, not the null node" \ + 'authentication:' \ + ' authenticator: "null"' +# A nested mapping is still the config map even when the value inside it is +# quoted, and an empty quoted scalar is the empty string the server reads as no +# authenticator. +yaml_case nameless "class nested under a flow config, quoted" \ + 'authentication: {config: {authenticator: "org.apache.hugegraph.auth.StandardAuthenticator"}}' +yaml_case nameless "flow value that is an empty quoted string" \ + 'authentication: {authenticator: ""}' +# A tag, not the text, decides the type of a scalar, and a type this scanner +# cannot resolve is refused rather than guessed at. +yaml_case nameless "explicit str tag, a type this scanner cannot resolve" \ + 'authentication:' \ + ' authenticator: !!str org.apache.hugegraph.auth.StandardAuthenticator' +# Two top-level authentication mappings: Settings.read() resolves the LAST one, +# so the first must not decide the answer and neither may the file as a whole be +# refused for carrying two. The base enable-auth.sh appended a block every time +# conf-bak/ was missing, and conf-bak/ is not on the mounted volume, so a +# bind-mounted conf/ holds two or three identical ones (#3133, the bug this +# entrypoint fixes) while TinkerPop 3.5.1 boots that file authenticating. +# Refusing it stops containers that work; only a LAST mapping that names no +# class is the unsafe direction, and that still reads nameless here. +yaml_case nameless "duplicate root authentication mappings" \ + 'authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \ + 'metrics:' \ + ' tokens: conf/tokens' \ + 'authentication:' \ + ' handler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' +yaml_case named "two identical root mappings name the class" \ + 'authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + 'authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' +# The empty mapping as the last one is still the mapping the server loads, and +# it leaves Gremlin on AllowAllAuthenticator beside a configured REST: refused. +yaml_case nameless "duplicate roots whose last mapping is an empty flow" \ + 'authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \ + 'authentication: {}' +# YAML folds the following deeper line into the scalar, so a key line carrying +# no value is not the null node: Settings.read() hands over the class, and +# answering nameless for it stops a container that boots. +yaml_case named "authenticator value on the following deeper line" \ + 'authentication:' \ + ' authenticator:' \ + ' org.apache.hugegraph.auth.StandardAuthenticator' +yaml_case named "quoted authenticator value on the following deeper line" \ + 'authentication:' \ + ' authenticator:' \ + ' "org.apache.hugegraph.auth.StandardAuthenticator"' +# A sibling at the child indentation is not that value, so the key really is +# empty here and the server reads no authenticator. +yaml_case nameless "authenticator left empty with a sibling below it" \ + 'authentication:' \ + ' authenticator:' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' +# A mapping or collection in that position names no class, and reading one as a +# class name would arm REST beside a server that dies on the shape. +yaml_case nameless "authenticator value that is a nested mapping" \ + 'authentication:' \ + ' authenticator:' \ + ' tokens: conf/rest-server.properties' +# Settings.read() builds the `? authentication` line and its `: ...` value line +# into the authentication mapping, so answering `none` for that file would start +# REST open beside a Gremlin that authenticates. The reader does not walk +# explicit keys, so it refuses rather than guess. +yaml_case nameless "explicit key root mapping is refused" \ + '? authentication' \ + ': authenticator: org.apache.hugegraph.auth.StandardAuthenticator' +# A byte order mark frames the stream; it is not part of the first key, and +# SnakeYAML resolves `authentication` to the root mapping. Compared +# byte-for-byte the mark made the key unknown, so the file that does +# authenticate was reported as having no mapping at all. +yaml_case named "byte order mark before the root key is not part of it" \ + $'\xef\xbb\xbf''authentication: {authenticator: com.example.BomAuth}' +# A root key preceded by a tag, an anchor or an alias still resolves to +# `authentication` for SnakeYAML, so the server does build the mapping. This +# reader resolves no node properties, and answering `none` for such a file is +# the one-sided direction, so it is refused the way an explicit key is. +yaml_case nameless "tagged root key is refused, not called unauthenticated" \ + '!!str authentication: {authenticator: com.example.TaggedAuth}' +yaml_case nameless "anchored root key is refused, not called unauthenticated" \ + '&k authentication: {authenticator: com.example.AnchoredAuth}' +yaml_case nameless "aliased root key is refused, not called unauthenticated" \ + '*a authentication: {authenticator: com.example.AliasedAuth}' +# Refusing a file because of a node property has a cost, so the refusal covers +# only the one key this reader is asked about. A property in front of a +# different root key is an ordinary sibling, and a deployment that carries one +# beside a working authentication mapping must still be read as named. +yaml_case named "node property on a different root key stays an ordinary sibling" \ + '!!str host: 0.0.0.0' \ + 'authentication:' \ + ' authenticator: com.example.SiblingAuth' +yaml_case nameless "tag and anchor together on the root key are refused" \ + '!!str &k authentication: {authenticator: com.example.TaggedAuth}' +# A root mapping written indented below a document marker is still the root to +# Settings.read(). Reporting `none` for it is the opposite mismatch: REST would +# start open beside a Gremlin that authenticates. +yaml_case named "indented root mapping after a document marker" \ + '---' \ + ' authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' +# ...but an `authentication:` nested under a real root key belongs to that key, +# not to the server: the root is where the document opens, at column 0 here, so +# the indented one stays invisible exactly as before. +yaml_case none "nested authentication is not the root even when indented" \ + 'host: 8182' \ + 'someFeature:' \ + ' authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' + +# ── Mounted one-sided config is refused with no PASSWORD ─────────────── +# check_auth_sides used to run only inside the PASSWORD branch, so a mounted +# rest-server.properties that already carried auth.authenticator and a yaml +# without a matching mapping was never validated at all: the entrypoint skipped +# the check, never called enable-auth.sh, and started the server with REST +# enforcing and Gremlin open. The parity check now runs on every start. +mounted_dir="${test_dir}/mounted-one-sided" +mkdir -p "${mounted_dir}/conf/graphs" +( + cd "${mounted_dir}" || exit 1 + # check_auth_sides reads these two paths, which the entrypoint sets at the + # top of a run; this block calls the guard directly, as the other unit + # groups here do. + REST_SERVER_CONF="./conf/rest-server.properties" + GRAPH_CONF="./conf/graphs/hugegraph.properties" + printf '%s\n' \ + 'restserver.url=http://127.0.0.1:8080' \ + 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ + > conf/rest-server.properties + printf '%s\n' 'host: 8182' > conf/gremlin-server.yaml + printf '%s\n' 'backend=rocksdb' > conf/graphs/hugegraph.properties + if check_auth_sides; then + echo "check_auth_sides must refuse REST configured with yaml not" >&2 + exit 1 + fi + # And it accepts the two balanced states, so this is not just a refusal: + printf '%s\n' \ + 'authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \ + > conf/gremlin-server.yaml + check_auth_sides + printf '%s\n' 'host: 8182' > conf/gremlin-server.yaml + printf '%s\n' \ + 'restserver.url=http://127.0.0.1:8080' \ + > conf/rest-server.properties + check_auth_sides +) + +# ── Trees the entrypoint hands to enable-auth.sh ─────────────────────── +# check_auth_sides and enable-auth.sh answer one question from two files, so +# they have to answer it the same way. Every case below is a tree that +# check_auth_sides ACCEPTS -- which is why the entrypoint goes on to run +# enable-auth.sh -- and where the old guard here wrote only one side: it asked +# whether a key or a block was present, while the entrypoint asks whether a +# value names a class. Those disagree for an `authentication:` nested under +# another feature and for a defined-but-empty `auth.authenticator`, and the +# result was REST enforcing StandardAuthenticator beside a Gremlin left on +# TinkerPop's AllowAllAuthenticator. +parity_dir="${test_dir}/enable-auth-parity" + +# bootstrap : the layout the script ships in, with props.awk beside it. +bootstrap_tree() { + local dir="$1" + rm -rf "${dir}" + mkdir -p "${dir}/conf/graphs" + install_enable_auth "${dir}" + printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > "${dir}/conf/graphs/hugegraph.properties" + : > "${dir}/conf/gremlin-server.yaml" + : > "${dir}/conf/rest-server.properties" +} + +# The class the server would read, through the same reader rather than through +# grep: an appended second definition looks correct to grep and is invisible +# here, which is the failure these cases are about. +rest_class() { + PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY=auth.authenticator \ + PROPS_FILE="$1/conf/rest-server.properties" awk -f "${PROPS_AWK}" /dev/null +} + +gremlin_state() { + ( cd "$1" && yaml_auth_state ) +} + +# accepted_then_both_sides -- refuse to test a tree the +# entrypoint would never run the script on, then require both sides named. +# REST_SERVER_CONF is a top-level assignment in docker-entrypoint.sh and this +# group evals only the functions, so each call has to carry it: unset, the REST +# side reads as unconfigured whatever the file says, and a one-sided tree would +# be waved through the very guard being asserted. +sides_agree() { + ( cd "$1" && REST_SERVER_CONF="./conf/rest-server.properties" check_auth_sides ) +} + +accepted_then_both_sides() { + local desc="$1" dir="$2" state class + if ! sides_agree "${dir}" >/dev/null 2>&1; then + echo "${desc}: check_auth_sides refused this tree, so enable-auth.sh + is never reached -- the case no longer tests what it was written for" >&2 + exit 1 + fi + ( cd "${dir}" && unset AUTHENTICATOR_CLASS && ./bin/enable-auth.sh ) || { + echo "${desc}: enable-auth.sh failed" >&2 + exit 1 + } + state=$(gremlin_state "${dir}") + if [[ "${state}" != "named" ]]; then + echo "${desc}: gremlin-server.yaml is ${state}, not named" >&2 + exit 1 + fi + class=$(rest_class "${dir}") + if [[ "${class}" != "org.apache.hugegraph.auth.StandardAuthenticator" ]]; then + echo "${desc}: rest-server.properties reads back [${class}]" >&2 + exit 1 + fi + if [[ "$(grep -c '^auth\.authenticator' "${dir}/conf/rest-server.properties")" != "1" ]]; then + echo "${desc}: auth.authenticator has more than one definition" >&2 + exit 1 + fi + # Parity has to survive the run, not just the files: a tree the script + # leaves one-sided must not still pass the guard that let it through. + if ! sides_agree "${dir}"; then + echo "${desc}: check_auth_sides rejects the tree enable-auth.sh left" >&2 + exit 1 + fi +} + +# An `authentication:` that belongs to another mapping is not the server's, so +# the script owns the whole of the Gremlin side and has to write it. +bootstrap_tree "${parity_dir}/nested" +printf '%s\n' 'host: 0.0.0.0' 'someFeature:' ' authentication:' \ + ' authenticator: com.example.Nested' \ + > "${parity_dir}/nested/conf/gremlin-server.yaml" +printf '%s\n' 'restserver.url=http://127.0.0.1:8080' \ + > "${parity_dir}/nested/conf/rest-server.properties" +accepted_then_both_sides "nested authentication mapping" "${parity_dir}/nested" +# The other feature keeps its own block untouched, and the block written for +# the server is the one at column 0. +grep -q '^authentication: {$' "${parity_dir}/nested/conf/gremlin-server.yaml" +grep -q '^ authentication:$' "${parity_dir}/nested/conf/gremlin-server.yaml" +grep -q '^ authenticator: com\.example\.Nested$' \ + "${parity_dir}/nested/conf/gremlin-server.yaml" + +# Both empty spellings, plus a whitespace value: each parses to the empty +# string, so each is the unconfigured side and has to be filled in place. +for empty in 'auth.authenticator=' 'auth.authenticator' 'auth.authenticator= '; do + bootstrap_tree "${parity_dir}/empty" + printf '%s\n' 'host: 0.0.0.0' > "${parity_dir}/empty/conf/gremlin-server.yaml" + printf '%s\n' "${empty}" 'unrelated=true' \ + > "${parity_dir}/empty/conf/rest-server.properties" + accepted_then_both_sides "empty definition [${empty}]" "${parity_dir}/empty" + # The placeholder is rewritten where it stood; unrelated content is kept. + grep -q '^unrelated=true$' "${parity_dir}/empty/conf/rest-server.properties" + [[ "$(head -1 "${parity_dir}/empty/conf/rest-server.properties")" == \ + 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' ]] +done + +# A value the operator did write is never a default's target. This tree is +# accepted because both sides already name the same class, and the script has +# to leave it alone rather than replace it with StandardAuthenticator. +bootstrap_tree "${parity_dir}/operator" +printf '%s\n' 'authentication:' ' authenticator: com.example.OperatorAuth' \ + > "${parity_dir}/operator/conf/gremlin-server.yaml" +printf '%s\n' 'auth.authenticator=com.example.OperatorAuth' \ + > "${parity_dir}/operator/conf/rest-server.properties" +if ! sides_agree "${parity_dir}/operator" >/dev/null 2>&1; then + echo "operator class tree: check_auth_sides refused" >&2 + exit 1 +fi +( cd "${parity_dir}/operator" && unset AUTHENTICATOR_CLASS && ./bin/enable-auth.sh ) +if [[ "$(rest_class "${parity_dir}/operator")" != "com.example.OperatorAuth" ]]; then + echo "operator class must survive the default write: got [$(rest_class "${parity_dir}/operator")]" >&2 + exit 1 +fi +if ! sides_agree "${parity_dir}/operator"; then + echo "operator class tree lost parity" >&2 + exit 1 +fi + +# ── The Gremlin guard and check_auth_sides have to read the same key ──── +# enable-auth.sh skips the yaml append when the file already carries a +# top-level authentication mapping. A grep that only knew the bare spelling +# called an operator's "authentication": block absent and appended a second +# one beside it, after which the two servers resolve the key in opposite +# directions while REST keeps the authenticator the operator named. +top_level_auth_keys() { + local file="$1" sq="'" + grep -Ec "^[\"${sq}]?authentication[\"${sq}]?[[:blank:]]*:" "${file}" +} + +quoted_key_tree() { # + local dir="$1" with_scan="$2" + bootstrap_tree "${dir}" + [[ "${with_scan}" == "yes" ]] || rm -f "${dir}/yamlscan.awk" + printf '%s\n' 'host: 0.0.0.0' '"authentication":' \ + ' authenticator: com.example.OperatorAuth' > "${dir}/conf/gremlin-server.yaml" + printf '%s\n' 'restserver.url=http://127.0.0.1:8080' \ + 'auth.authenticator=com.example.OperatorAuth' \ + > "${dir}/conf/rest-server.properties" + if ! sides_agree "${dir}" >/dev/null 2>&1; then + echo "quoted top-level key (${with_scan}): check_auth_sides refused" >&2 + exit 1 + fi + ( cd "${dir}" && unset AUTHENTICATOR_CLASS && ./bin/enable-auth.sh ) || { + echo "quoted top-level key (${with_scan}): enable-auth.sh failed" >&2 + exit 1 + } + if [[ "$(top_level_auth_keys "${dir}/conf/gremlin-server.yaml")" != "1" ]]; then + echo "quoted top-level key (${with_scan}): the append duplicated the" \ + "operator block, got $(top_level_auth_keys "${dir}/conf/gremlin-server.yaml")" >&2 + cat "${dir}/conf/gremlin-server.yaml" >&2 + exit 1 + fi + if [[ "$(gremlin_state "${dir}")" != "named" ]]; then + echo "quoted top-level key (${with_scan}): yaml is no longer named" >&2 + exit 1 + fi + if [[ "$(rest_class "${dir}")" != "com.example.OperatorAuth" ]]; then + echo "quoted top-level key (${with_scan}): REST lost the operator class" >&2 + exit 1 + fi +} + +# The image layout, where yamlscan.awk sits in the install home, so the guard +# asks the same reader the entrypoint does. +quoted_key_tree "${parity_dir}/quoted-key" yes +# The plain release tarball carries no yamlscan.awk; the fallback has to keep +# the same answer for the question it can honestly settle on its own. +quoted_key_tree "${parity_dir}/quoted-key-tarball" no + +# ── A value compared to a literal is the decoded value, not the bytes ─── +# wait-partition.sh is skipped unless ACTUAL_BACKEND reads hstore. The JVM +# resolves `backend=h\u0073tore` to hstore, so a reader that hands back the +# on-disk escaping does not see the backend that is actually running, and +# startup continues before the partitions are assigned. +escaped_backend="${test_dir}/backend-escape.properties" +# %s, not the format string: printf resolves \u0073 in a format itself and would +# write the decoded word, which is the very thing this case has to hand the +# reader. The next assertion is the guard rail against that happening silently. +printf '%s\n' 'backend=h\u0073tore' > "${escaped_backend}" +if [[ "$(tr -d '\n' < "${escaped_backend}")" != 'backend=h\u0073tore' ]]; then + echo "fixture must hold the escaped bytes on disk, got [$(cat "${escaped_backend}")]" >&2 + exit 1 +fi +if [[ "$(get_prop_encoded backend "${escaped_backend}")" == "hstore" ]]; then + echo "the encoded reader is expected to report the on-disk escaping" >&2 + exit 1 +fi +if [[ "$(get_prop_decoded backend "${escaped_backend}")" != "hstore" ]]; then + echo "decoded read of an escaped backend gave" \ + "[$(get_prop_decoded backend "${escaped_backend}")]" >&2 + exit 1 +fi +# The ordinary spelling is unaffected, so this is not decode-instead-of-read. +plain_backend="${test_dir}/backend-plain.properties" +printf 'backend=hstore\n' > "${plain_backend}" +[[ "$(get_prop_decoded backend "${plain_backend}")" == "hstore" ]] +[[ "$(get_prop_encoded backend "${plain_backend}")" == "hstore" ]] + +# ── The value is judged as the YAML node, not as the bytes after the colon ── +# An anchor label is not part of the value it names, so `authenticator: &noAuth +# null` is a mapping whose authenticator resolves to null; an alias points at a +# node this scanner does not resolve. Both read as a class name if only the +# first byte is looked at, and the direction that error moves the server in is +# REST enforcing StandardAuthenticator over a Gremlin left on TinkerPop's +# AllowAllAuthenticator -- check_auth_sides reports parity and never asks again. +# An anchor in front of a real class still has to count, or a valid mounted +# config gets refused before startup. +anchor_dir="${test_dir}/yaml-anchor" +mkdir -p "${anchor_dir}/conf" +( + cd "${anchor_dir}" || exit 1 + state_file="conf/gremlin-server.yaml" + want_yaml() { + if [[ "$1" != "$2" ]]; then + echo "expected yaml state '$1', got '$2'" >&2 + exit 1 + fi + } + + printf '%s\n' 'authentication:' ' authenticator: &noAuth null' > "${state_file}" + want_yaml nameless "$(yaml_auth_state)" + printf '%s\n' 'authentication:' ' authenticator: &anchorOnly' > "${state_file}" + want_yaml nameless "$(yaml_auth_state)" + printf '%s\n' 'authentication:' ' authenticator: *noAuth' > "${state_file}" + want_yaml nameless "$(yaml_auth_state)" + printf '%s\n' 'authentication: {authenticator: &noAuth null}' > "${state_file}" + want_yaml nameless "$(yaml_auth_state)" + printf '%s\n' 'authentication:' ' authenticator: &cls com.example.Anchored' > "${state_file}" + want_yaml named "$(yaml_auth_state)" +) + +# One mapping is answered only once it has been read to the end. Resolving the +# first direct `authenticator` seen is not a question the scanner can keep: the +# server takes the last value of a repeated key, and current snakeyaml rejects +# the document outright instead. `authenticator: com.example.First` followed by +# `authenticator: null` answered `named` on the first row, which is the config +# that boots with no Gremlin authenticator while REST has one, in block and in +# flow form alike. +dup_dir="${test_dir}/yaml-duplicate" +mkdir -p "${dup_dir}/conf" +( + cd "${dup_dir}" || exit 1 + state_file="conf/gremlin-server.yaml" + want_yaml() { + if [[ "$1" != "$2" ]]; then + echo "expected yaml state '$1', got '$2'" >&2 + exit 1 + fi + } + + printf '%s\n' 'authentication:' ' authenticator: com.example.First' \ + ' authenticator: null' > "${state_file}" + want_yaml nameless "$(yaml_auth_state)" + printf '%s\n' 'authentication:' ' authenticator: com.example.First' \ + ' authenticator: com.example.Second' > "${state_file}" + want_yaml nameless "$(yaml_auth_state)" + printf '%s\n' 'authentication: {authenticator: com.example.A, authenticator: null}' > "${state_file}" + want_yaml nameless "$(yaml_auth_state)" + # A duplicate under a different key, or one indented into a nested mapping, + # is not a second definition of the authenticator and must not refuse a + # config the server reads as one clean mapping. + printf '%s\n' 'authentication:' ' authenticator: com.example.Only' \ + ' config: {authenticator: com.example.Nested}' > "${state_file}" + want_yaml named "$(yaml_auth_state)" + # A single entry is still answered by its own value, however far down the + # mapping it sits, so this is last-wins rather than give-up. + printf '%s\n' 'authentication:' ' authenticationHandler: org.X' \ + ' config: {tokens: conf/rest-server.properties}' \ + ' authenticator: com.example.Late' > "${state_file}" + want_yaml named "$(yaml_auth_state)" + printf '%s\n' 'authentication:' ' authenticator: com.example.First' \ + 'other: x' ' authenticator: null' > "${state_file}" + want_yaml named "$(yaml_auth_state)" +) + +# A Gremlin config saved with CRLF breaks its lines at CR too: YAML ends a line +# at CR, LF or CRLF. Carrying the CR into the parse made `authentication:\r` +# fail the split, so a mapping that does name an authenticator was reported as +# absent -- and with REST holding no authenticator either, check_auth_sides saw +# two sides agreeing and started a server that authenticates on Gremlin and +# leaves REST open. +# +# The fixture writes one CR for the host: where awk drops the CR of a CRLF pair +# in text mode, two are written so that exactly one reaches the record, and the +# probe below refuses to run the group rather than let it pass on a fixture that +# quietly became plain LF. +yaml_cr=$'\r\n' +if ! (( awk_sees_crlf_cr )); then + yaml_cr=$'\r\r\n' +fi +crlf_yaml_dir="${test_dir}/yaml-crlf" +mkdir -p "${crlf_yaml_dir}/conf" +( + cd "${crlf_yaml_dir}" || exit 1 + state_file="conf/gremlin-server.yaml" + REST_SERVER_CONF="./conf/rest-server.properties" + + if [[ "$(printf "probe${yaml_cr}" | awk 'NR == 1 { print length($0) }')" != "6" ]]; then + skip "the CRLF gremlin-server.yaml check" + exit 0 + fi + + printf "authentication:%s authenticator: com.example.CrlfAuth%shost: 0.0.0.0%s" \ + "${yaml_cr}" "${yaml_cr}" "${yaml_cr}" > "${state_file}" + [[ "$(yaml_auth_state)" == "named" ]] || { + echo "a CRLF mapping that names a class read as [$(yaml_auth_state)]" >&2 + exit 1 + } + + printf "authentication:%s tokens: conf/rest-server.properties%s" \ + "${yaml_cr}" "${yaml_cr}" > "${state_file}" + [[ "$(yaml_auth_state)" == "nameless" ]] || { + echo "a CRLF mapping without one read as [$(yaml_auth_state)]" >&2 + exit 1 + } + + # An empty line never ends a block scalar, and on a CRLF file the record + # splits at the CR and leaves exactly such an empty segment after every + # line. Closing the scalar there read `authenticator: >-\r` as an empty + # value, so a Windows-saved config that does name a class stopped its own + # boot while the same file written with LF read `named`. + printf "host: 0.0.0.0%sauthentication:%s authenticator: >-%s com.example.CrlfBlockAuth%s" \ + "${yaml_cr}" "${yaml_cr}" "${yaml_cr}" "${yaml_cr}" > "${state_file}" + [[ "$(yaml_auth_state)" == "named" ]] || { + echo "a CRLF block-scalar authenticator read as [$(yaml_auth_state)]" >&2 + exit 1 + } + + # The one-sided direction, end to end: Gremlin authenticates, REST does not. + printf "authentication:%s authenticator: com.example.CrlfAuth%s" \ + "${yaml_cr}" "${yaml_cr}" > "${state_file}" + : > "${REST_SERVER_CONF}" + if check_auth_sides; then + echo "check_auth_sides must refuse a CRLF yaml that authenticates alone" >&2 + exit 1 + fi +) + +# ── props.awk refuses a file it cannot answer a question about ────────── +# commons-configuration splices an `include` file into the one being read, so +# `auth.authenticator` can be defined over there and be nowhere in the bytes +# here. Answering "absent" for it is what starts a REST-open server beside a +# Gremlin that requires authentication, and the spliced order also decides which +# of the two definitions wins, so even a key this file does carry cannot be +# called the effective one. Neither of those is a question this reader can +# answer, so it stops rather than guessing. +include_dir="${test_dir}/props-include" +mkdir -p "${include_dir}/conf" +( + cd "${include_dir}" || exit 1 + REST_SERVER_CONF="./conf/rest-server.properties" + printf '%s\n' 'include=conf/rest-auth.properties' \ + 'restserver.url=http://127.0.0.1:8080' > "${REST_SERVER_CONF}" + printf '%s\n' 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ + > conf/rest-auth.properties + printf '%s\n' 'authentication:' ' authenticator: com.example.IncludedAuth' \ + > conf/gremlin-server.yaml + + if get_prop_encoded restserver.url "${REST_SERVER_CONF}" >/dev/null 2>&1; then + echo "a read of a file with an include must refuse, not answer" >&2 + exit 1 + fi + # A refused write leaves the config exactly as it stood: no second + # definition is appended beside one the server may resolve the other way. + if set_prop auth.authenticator com.example.Written "${REST_SERVER_CONF}" 2>/dev/null; then + echo "a set must refuse to write into an including file" >&2 + exit 1 + fi + grep -Fxq 'include=conf/rest-auth.properties' "${REST_SERVER_CONF}" + grep -Fxq 'restserver.url=http://127.0.0.1:8080' "${REST_SERVER_CONF}" + + # The operator has to be told which question could not be read, rather than + # being sent to the other side of the parity check. + if check_auth_sides 2>/dev/null; then + echo "check_auth_sides must not boot on an unreadable side" >&2 + exit 1 + fi + # Captured rather than piped: pipefail makes a refused check the status of + # the pipeline no matter what grep matched, so the message would have to be + # asserted through a variable. + inc_out=$(check_auth_sides 2>&1 || true) + case "${inc_out}" in + *"cannot read auth.authenticator"*) ;; + *) echo "check_auth_sides must say the REST side could not be read, got [${inc_out}]" >&2 + exit 1 ;; + esac + + # Commons configuration 2 matches the directive name case-insensitively and + # carries a second spelling, `includeOptional`, that splices a file in the + # same way. Refusing only the exact lowercase `include` let `INCLUDE=` or + # `includeOptional=` through as an ordinary property, so the entrypoint read + # and rewrote a file whose effective authenticator lived over there -- the + # same wrong direction the plain include is refused for. + for directive in 'include' 'INCLUDE' 'Include' \ + 'includeOptional' 'includeoptional' 'IncludeOptional' 'INCLUDEOPTIONAL'; do + printf '%s\n' "${directive}=conf/rest-auth.properties" \ + 'restserver.url=http://127.0.0.1:8080' > "${REST_SERVER_CONF}" + if get_prop_encoded restserver.url "${REST_SERVER_CONF}" >/dev/null 2>&1; then + echo "a read must refuse the include spelling [${directive}]" >&2 + exit 1 + fi + if PROPS_MODE=set PROPS_KEY=auth.authenticator \ + PROPS_VALUE_ENCODED=com.example.Written PROPS_FILE="${REST_SERVER_CONF}" \ + awk -f "${PROPS_AWK}" /dev/null 2>/dev/null; then + echo "a set must refuse the include spelling [${directive}]" >&2 + exit 1 + fi + done + + # Controls: `include` is the whole key, and only a live directive counts. + printf '%s\n' 'included.filter=1' 'auth.authenticator=com.example.Plain' \ + > "${REST_SERVER_CONF}" + [[ "$(get_prop_encoded auth.authenticator "${REST_SERVER_CONF}")" == \ + "com.example.Plain" ]] + printf '%s\n' '#include=conf/rest-auth.properties' \ + 'auth.authenticator=com.example.Comment' > "${REST_SERVER_CONF}" + [[ "$(get_prop_encoded auth.authenticator "${REST_SERVER_CONF}")" == \ + "com.example.Comment" ]] +) + +# ── Temporary files are private and cannot be arranged in advance ─────── +# A predictable `.tmp` is a name anyone with write access to a mounted +# conf directory can use first, and neither the pre-creating redirection nor +# awk's `>` checks what is behind it: run as root in the default image, the +# copy-back would write auth.admin_pa through a planted symlink into whatever +# file that link named. The same holds for the `.bak` snapshot. Both names now +# come from an exclusive create, so there is nothing to arrange and nothing to +# follow. +planted_dir="${test_dir}/planted-temps" +mkdir -p "${planted_dir}" +planted="${planted_dir}/rest-server.properties" +printf '%s\n' 'auth.authenticator=com.example.Old' > "${planted}" +printf 'NOT-YOURS-TMP\n' > "${planted}.tmp" +printf 'NOT-YOURS-BAK\n' > "${planted}.bak" +set_prop auth.authenticator com.example.New "${planted}" +grep -Fxq 'auth.authenticator=com.example.New' "${planted}" +grep -Fxq 'NOT-YOURS-TMP' "${planted}.tmp" || { + echo "a file already named .tmp was written through" >&2 + exit 1 +} +grep -Fxq 'NOT-YOURS-BAK' "${planted}.bak" || { + echo "a file already named .bak was written through" >&2 + exit 1 +} +if [[ -n "$(find "${planted_dir}" \( -name '*.tmp.*' -o -name '*.bak.*' \) 2>/dev/null)" ]]; then + echo "the staged rewrite left a temporary file behind" >&2 + exit 1 +fi +if (( host_keeps_symlink )); then + victim="${planted_dir}/victim.txt" + printf 'VICTIM\n' > "${victim}" + rm -f "${planted}.tmp" + ln -s "${victim}" "${planted}.tmp" + set_prop auth.authenticator com.example.Linked "${planted}" + grep -Fxq 'VICTIM' "${victim}" || { + echo "the entrypoint wrote credentials through a symlinked temp file" >&2 + exit 1 + } + grep -Fxq 'auth.authenticator=com.example.Linked' "${planted}" +else + skip "the symlinked-temp-file check" +fi + +# ── A trailing blank on gremlin.graph must not leave the graph unwrapped ── +# commons-configuration trims the line before it resolves the class, so a +# mounted `gremlin.graph=org.apache.hugegraph.HugeFactory ` opens the graph +# through the plain factory exactly as the same line without the blanks does. +# Comparing the untrimmed bytes answered "not HugeFactory" and left +# HugeFactoryAuthProxy out of an otherwise fully authenticated tree, which +# GraphManager only warns about. (java.util.Properties by itself keeps the +# blanks -- measured against JDK 17 -- which is why the reader hands them back.) +run_enable_auth() { + local dir="$1" graph_line="$2" + + mkdir -p "${dir}/bin" "${dir}/conf/graphs" + install_enable_auth "${dir}" + printf '%s\n' 'host: 0.0.0.0' > "${dir}/conf/gremlin-server.yaml" + printf '%s\n' 'restserver.url=http://127.0.0.1:8080' > "${dir}/conf/rest-server.properties" + printf '%s\n' "${graph_line}" > "${dir}/conf/graphs/hugegraph.properties" + if ! ( cd "${dir}" && ./bin/enable-auth.sh ); then + echo "enable-auth.sh failed for [${graph_line}]" >&2 + exit 1 + fi +} +for blank in ' ' '\ '; do + blank_dir="${test_dir}/factory-blank-${blank//\\/esc}" + run_enable_auth "${blank_dir}" "gremlin.graph=org.apache.hugegraph.HugeFactory${blank}" + grep -q '^gremlin\.graph=org\.apache\.hugegraph\.auth\.HugeFactoryAuthProxy$' \ + "${blank_dir}/conf/graphs/hugegraph.properties" || { + echo "a trailing blank (${blank}) left the graph outside the auth proxy" >&2 + exit 1 + } +done +# A factory that is not HugeFactory is left exactly as mounted, blanks and all. +foreign_dir="${test_dir}/factory-foreign" +run_enable_auth "${foreign_dir}" 'gremlin.graph=com.example.OtherFactory ' +if grep -q 'HugeFactoryAuthProxy' "${foreign_dir}/conf/graphs/hugegraph.properties"; then + echo "enable-auth.sh rewrote a factory it does not own" >&2 + exit 1 +fi + +# ── A written value must not end where a trim turns it into a continuation ─ +# encode_prop_value wrote a space as `\ `. Read back by java.util.Properties +# that is a space, but commons-configuration right-trims the physical line first +# and then asks whether it continues, so `abc\ ` became `abc\` and swallowed the +# line under it -- a password ending in a space ate the `auth.authenticator` +# written below it, and the guards reported a config that was already broken. +# \u0020 decodes to the same space in both readers and leaves nothing to trim. +space_file="${test_dir}/encoded-trailing-space" +printf '%s\n' 'auth.admin_pa=placeholder' \ + 'auth.authenticator=com.example.Below' > "${space_file}" +[[ "$(encode_prop_value 'abc ')" == 'abc\u0020' ]] || { + echo "a space is still encoded in a form a trim can cut: [$(encode_prop_value 'abc ')]" >&2 + exit 1 +} +set_prop auth.admin_pa 'abc ' "${space_file}" +grep -Fxq 'auth.admin_pa=abc\u0020' "${space_file}" || { + echo "written on disk as [$(sed -n 's/^auth\.admin_pa=//p' "${space_file}")]" >&2 + exit 1 +} +[[ "$(get_prop_decoded auth.admin_pa "${space_file}")" == "abc " ]] +# The property under a value that ends in a space is still its own property. +[[ "$(get_prop_decoded auth.authenticator "${space_file}")" == "com.example.Below" ]] +grep -Fxq 'auth.authenticator=com.example.Below' "${space_file}" +# The guard judges the line the way the server sees it, so an encoded value +# built somewhere else cannot carry the hazard in through the back door. +if set_prop_encoded auth.token_secret 'abc\ ' "${space_file}" 2>/dev/null; then + echo "a value ending in backslash+blank would swallow the next line" >&2 + exit 1 +fi +if set_prop_encoded auth.token_secret 'abc\\\ ' "${space_file}" 2>/dev/null; then + echo "a value ending in an odd run of backslashes before a blank was accepted" >&2 + exit 1 +fi +# An even run is a literal backslash and continues nothing. +set_prop_encoded auth.token_secret 'abc\\ ' "${space_file}" +[[ "$(get_prop_decoded auth.token_secret "${space_file}")" == 'abc\ ' ]] +grep -Fxq 'auth.admin_pa=abc\u0020' "${space_file}" + +# ── No helper hands chmod an argument it means as an option ───────────── +# `chmod 600 -- file` is GNU-only: BSD chmod reads `--` as the file name after +# the mode and fails, which on macOS left props.awk unable to back up the config +# it was about to rewrite. Nothing needs the separator here -- every path goes +# through shquote, so an argument can only start at a quote byte -- and the two +# calls it was written for are gone now that the staged files are created 0600 +# by mktemp. +dash_dir="${test_dir}/dash-named-config" +mkdir -p "${dash_dir}" +printf '%s\n' 'auth.authenticator=com.example.Old' > "${dash_dir}/-config.properties" +set_prop auth.authenticator com.example.New "${dash_dir}/-config.properties" +grep -Fxq 'auth.authenticator=com.example.New' "${dash_dir}/-config.properties" +[[ "$(get_prop_encoded auth.authenticator "${dash_dir}/-config.properties")" == \ + "com.example.New" ]] +if grep -Eq 'chmod[^#]*--' "${PROPS_AWK}"; then + echo "props.awk still passes -- to chmod, which BSD chmod reads as a file" >&2 + grep -En 'chmod[^#]*--' "${PROPS_AWK}" >&2 + exit 1 +fi diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk new file mode 100644 index 0000000000..107b94ca68 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -0,0 +1,794 @@ +# 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. +# +# yamlscan.awk -- does the top-level `authentication` mapping of a Gremlin +# server YAML file name an authenticator? Prints exactly one of: +# +# none there is no top-level authentication mapping +# nameless the mapping exists but names no authenticator class +# named the mapping names an authenticator class +# +# The entrypoint asks this one question to decide whether +# rest-server.properties and gremlin-server.yaml configure authentication +# together. Getting it wrong toward "named" is how REST ends up enforcing +# StandardAuthenticator while Gremlin silently falls back to TinkerPop +# AllowAllAuthenticator, so the answer has to follow the same structure +# snakeyaml hands to the server, within the subset of YAML that shipped and +# mounted configs use: +# +# 1. the mapping must sit at the indentation of the document root -- an +# `authentication:` nested under some other key belongs to that feature, +# not to the Gremlin server, while a root mapping written indented below a +# document marker still counts because the server reads it as the root; +# 2. only a direct child `authenticator` counts -- a class reached through +# `authentication.config`, or through any other nested mapping, is not +# the server authenticator, because TinkerPop keeps `config` as its own +# map; +# 3. `#` outside quotes starts a comment: text behind one is not content, +# and a comment-only line is neither a child nor the end of the mapping; +# 4. in a flow mapping the key must sit at depth one between the braces, so +# `{authenticator: X}` names a class while `{config: {authenticator: X}}` +# does not; +# 5. a direct `authenticator` whose value is empty, `null` or `~` names no +# class -- the server reads the key, gets nothing and leaves +# authentication off, which is the nameless case that must be refused; +# 6. a mapping is read to its end before it is answered, because the server +# sees the whole node: a direct `authenticator` defined twice is refused +# rather than settled by whoever met it first; +# 6b. the file is read to its end, not to the first mapping, because two +# top-level `authentication` mappings resolve to the LAST one. The +# answer therefore comes from the last mapping, which is the mapping the +# server loads: the base enable-auth.sh appended a block every time +# conf-bak/ was missing, so a bind-mounted conf/ carries two or three +# identical ones (#3133, the bug this entrypoint fixes) and boots as an +# authenticating server. Refusing that file would stop a container that +# works, while an empty or class-less LAST mapping is still `nameless` and +# still stops the boot, which is the direction that matters; +# 7. YAML ends a line at CR, LF or CRLF, so a CR that has survived the +# comment being stripped is line noise, not part of a key or a value. +# Reading `authentication:\r` as no key at all reported a Gremlin mapping +# that names a class as absent, which is the direction that leaves REST +# open while Gremlin authenticates. +# 8. a child value may open a flow collection or a quoted scalar that its own +# line does not close, and flow content ignores indentation -- so every +# line until it closes belongs to the nested node rather than to the +# authentication mapping. Counting one of those continuation lines as a +# direct child answered `named` for a file whose authenticator sits inside +# `authentication.config`, which is the direction that leaves REST +# enforcing beside a Gremlin on AllowAllAuthenticator; +# 8b. the mapping itself may be a flow collection that opens behind an anchor +# (`authentication: &auth {authenticator: X}`) or on its first child line +# rather than on the key line. Settings.read() loads both, so answering +# `nameless` for them stops a boot that works. +# 9. a child key may carry no value on its own line and leave the scalar to +# the following, deeper line, which YAML folds into that value. Settings +# reads +# authenticator: +# org.apache.hugegraph.auth.StandardAuthenticator +# as a class name, so answering `nameless` for that spelling stops a +# container that boots. A value there that is a nested mapping or +# collection names no class and is refused instead of read as one. +# 10. an explicit key (`? authentication` with its `: ...` value line) is a +# root mapping this reader does not walk. Settings.read() still builds +# the authentication mapping from it, so answering `none` would start REST +# open beside a Gremlin that authenticates; it is refused. +# 11. a UTF-8 byte order mark before the first line is stream framing, not +# part of the key: SnakeYAML skips it, so `authentication` is the +# root mapping. Carrying the mark into the key comparison missed it and +# answered `none`, which is the unsafe direction, so it is dropped from the +# first record. +# 12. a root key preceded by a node property (`!!str authentication`, `&k +# authentication`, `*a authentication`) resolves to `authentication` for +# SnakeYAML and does open the Gremlin mapping. Resolving node properties +# is not what this reader does, so like rule 10 it is refused rather than +# called unauthenticated. Only a property in front of that one key is +# refused: `&defaults handler_pool:` or `!!str host:` is an ordinary root +# sibling whose own mapping the server never reads as authentication, and +# refusing a file because of one would stop a container that boots. +# 13. a block scalar is not ended by an empty line -- YAML keeps an empty line +# inside it as content. Splitting a CRLF record at the CR (rule 7) leaves +# an empty segment behind, and closing the scalar there read +# `authenticator: >-\r` as an empty value, so a Windows-saved config that +# does name a class stopped its own boot. +# +# Quote characters come from sprintf so this file holds no literal apostrophe: +# an awk program written into a single-quoted shell string breaks on one, and +# that has cost this repo twice already. + +function apos() { return sprintf("%c", 39) } +function dquo() { return sprintf("%c", 34) } + +function ltrim(s) { sub(/^[ \t]+/, "", s); return s } +function rtrim(s) { sub(/[ \t]+$/, "", s); return s } +function trim(s) { return rtrim(ltrim(s)) } + +function is_quote(c) { return c == apos() || c == dquo() } + +# Drop a leading UTF-8 byte order mark from a record. A multibyte-aware awk +# hands the mark over as the single character U+FEFF and a byte-oriented one as +# the three bytes EF BB BF, so both spellings are tried and only the one this +# reader actually produced can match -- the guards on length keep a reader that +# produced neither from stripping a byte off a legitimate first key. +function strip_bom(s, mark) { + mark = sprintf("%c%c%c", 239, 187, 191) + if (length(mark) == 3 && substr(s, 1, 3) == mark) return substr(s, 4) + mark = sprintf("%c", 65279) + if (length(mark) == 1 && substr(s, 1, 1) == mark) return substr(s, 2) + return s +} + +# Remove an unquoted trailing comment together with the whitespace that has to +# precede the `#` for it to be a comment rather than part of a scalar. +function strip_comment(s, i, n, c, q, prev) { + q = "" + prev = "" + n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (q != "") { + if (c == q) q = "" + } else if (is_quote(c)) { + q = c + } else if (c == "#" && (prev == "" || prev == " " || prev == "\t")) { + return rtrim(substr(s, 1, i - 1)) + } + prev = c + } + return s +} + +# How many whitespace characters open the line, i.e. its block nesting level. +function indent_of(s, i, n, c) { + n = length(s) + i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c != " " && c != "\t") break + i++ + } + return i - 1 +} + +# Hex without strtonum, which is not POSIX awk. +function hexval(h, i, n, c, v) { + v = 0 + n = length(h) + for (i = 1; i <= n; i++) { + c = substr(h, i, 1) + if (c >= "0" && c <= "9") v = v * 16 + (c - 0) + else if (c == "a" || c == "A") v = v * 16 + 10 + else if (c == "b" || c == "B") v = v * 16 + 11 + else if (c == "c" || c == "C") v = v * 16 + 12 + else if (c == "d" || c == "D") v = v * 16 + 13 + else if (c == "e" || c == "E") v = v * 16 + 14 + else if (c == "f" || c == "F") v = v * 16 + 15 + else return -1 + } + return v +} + +# Resolve the escapes a double quoted scalar carries, which snakeyaml does +# before the text ever becomes a key. `"\u0061uthentication"` is the +# authentication key, and `authentic\u0061tion` is the same key spelled out. +# An escape this cannot resolve sets UNRESOLVED instead of being skipped: being +# wrong about a key toward "absent" is what leaves REST open beside an +# authenticating Gremlin, so an unresolved form has to be refused. +function unescape(s, out, i, n, c, h, k, v) { + n = length(s) + out = "" + i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c != "\\") { out = out c; i++; continue } + i++ + if (i > n) { UNRESOLVED = 1; return s } + c = substr(s, i, 1) + if (c == "u" || c == "U") k = (c == "u" ? 4 : 8) + else if (c == "x") k = 2 + else k = 0 + if (k > 0) { + h = substr(s, i + 1, k) + v = (length(h) == k ? hexval(h) : -1) + if (v < 0) { UNRESOLVED = 1; return s } + out = out sprintf("%c", v) + i = i + k + 1 + continue + } + if (c == "0") { out = out sprintf("%c", 0); i++; continue } + if (c == "a") { out = out sprintf("%c", 7); i++; continue } + if (c == "e") { out = out sprintf("%c", 27); i++; continue } + if (c == "N") { out = out sprintf("%c", 133); i++; continue } + if (c == "L") { out = out sprintf("%c", 8232); i++; continue } + if (c == "P") { out = out sprintf("%c", 8233); i++; continue } + if (c == "b") { out = out "\b"; i++; continue } + if (c == "t") { out = out "\t"; i++; continue } + if (c == "n") { out = out "\n"; i++; continue } + if (c == "v") { out = out "\v"; i++; continue } + if (c == "f") { out = out "\f"; i++; continue } + if (c == "r") { out = out "\r"; i++; continue } + if (c == " " || c == dquo() || c == "\\" || c == "/") { + out = out c + i++ + continue + } + UNRESOLVED = 1 + return s + } + return out +} + +# One layer of matching quotes off a key or scalar. +function unquote(s, f, body) { + s = trim(s) + if (length(s) >= 2) { + f = substr(s, 1, 1) + if ((f == apos() || f == dquo()) && substr(s, length(s), 1) == f) { + body = substr(s, 2, length(s) - 2) + return (f == dquo() ? unescape(body) : body) + } + } + return s +} + +# Split `name: value` at the first colon outside quotes that is followed by end +# of line or a space, which is what makes a colon inside `http://host` part of +# the scalar. Results go to K_TXT / V_TXT because awk returns one value. +function split_pair(s, i, n, c, q) { + q = "" + n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (q != "") { + if (c == q) q = "" + continue + } + if (is_quote(c)) { q = c; continue } + if (c != ":") continue + if (i == n || substr(s, i + 1, 1) ~ /^[ \t]/) { + K_TXT = rtrim(substr(s, 1, i - 1)) + V_TXT = ltrim(substr(s, i + 1)) + return 1 + } + } + return 0 +} + +function names_authenticator(k) { return unquote(k) == "authenticator" } + +# An authenticator entry only counts when it actually names a class, and the +# answer has to be what snakeyaml hands the server rather than what the bytes +# look like. The unsafe direction is `named` for a config that leaves Gremlin +# on AllowAllAuthenticator while REST enforces, so anything this scanner cannot +# resolve to a class is refused instead of guessed at: +# +# - a plain scalar that resolves to null in any spelling, and YAML resolves +# null case-insensitively (null, Null, NULL, nUll) as well as to ~, names +# no class; +# - a leading `!` makes the tag, not the text, decide the type: !!null is the +# explicit spelling of empty and every other tag is a type not resolvable +# here, so neither counts; +# - a quoted scalar is a string and never null, but `""` and the empty single +# quoted form are the empty string, and loadAuthenticator("") returns null, +# which is the same no-authenticator state; +# - `&label value` is an anchor: the label is not part of the value, so the +# text after it decides, and `&label` alone anchors an empty node, which is +# the explicit spelling of null; +# - `*label` is an alias whose class lives in another node. This scanner +# does not resolve nodes, so an alias is refused rather than read as a +# class name -- `authenticator: &noAuth null` is a valid document whose +# value is null, and calling it named is the exact mistake this guards. +# - an unterminated quote is not a scalar at all. +function names_class(v, first, last, body, rest) { + v = trim(v) + if (v == "") return 0 + first = substr(v, 1, 1) + if (first == "!") return 0 + if (first == "*") return 0 + if (first == "&") { + rest = trim(substr(v, 2)) + sub(/^[^ \t]*/, "", rest) + return names_class(trim(rest)) + } + if (first == apos() || first == dquo()) { + if (length(v) < 2) return 0 + last = substr(v, length(v), 1) + if (last != first) return 0 + body = trim(substr(v, 2, length(v) - 2)) + return body != "" + } + if (v == "~") return 0 + if (tolower(v) == "null") return 0 + return 1 +} + +# The answer for the mapping read so far, for both the block and the flow form. +# AUTH_SEEN counts direct `authenticator` children and AUTH_NAMED remembers +# whether the last one named a class. Both describe the LAST top-level +# `authentication` mapping, because handle_line() clears them when a later one +# opens and that is the node Settings.read() loads. A key defined twice inside +# that mapping has no answer this scanner can give honestly: snakeyaml either +# keeps the last value or, with unique keys enforced, rejects the document and +# the server never starts. Either way the operator has to be told which line to +# fix, so the duplicate is reported on stderr and the mapping is refused through +# the nameless state, which check_auth_sides stops the boot on and +# enable-auth.sh will not append beside. +function auth_state( msg) { + if (AUTH_SEEN > 1) { + msg = "yamlscan.awk: a mapping with " AUTH_SEEN " direct authenticator entries" + print msg > "/dev/stderr" + print "cannot be answered here: the server takes the last one, or rejects the file." > "/dev/stderr" + print "Remove the duplicate authenticator entry from gremlin-server.yaml." > "/dev/stderr" + return "nameless" + } + if (AUTH_SEEN == 1 && AUTH_NAMED) return "named" + # A class-less mapping is normally a hand edit, but the last of several + # duplicated root mappings is what the base enable-auth.sh leaves behind, + # and there "add an authenticator entry" is the wrong advice: the fix is to + # drop the extra mapping, so say which one was read. + if (AUTH_BLOCKS > 1) { + msg = "yamlscan.awk: " AUTH_BLOCKS " top-level authentication mappings; read the last" + print msg > "/dev/stderr" + print "one, and it names no authenticator. Keep a single mapping that does." > "/dev/stderr" + } + return "nameless" +} + +# Feed one line of a flow collection to the brace scanner. DEPTH counts open +# collections; keys and values are only read at depth one, which is what makes +# a nested mapping under `config` invisible to it. A direct authenticator seen +# at depth one is recorded for auth_state. Returns 1 once the outermost +# collection has closed. +function scan_flow(s, i, n, c, q, esc) { + n = length(s) + q = "" + esc = 0 + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (q != "") { + # Every byte inside the quotes belongs to the scalar, delimiters + # included; unquote and names_class take the quotes off. Dropping + # the value here is what made {authenticator: "org.A"} read as + # nameless and refuse a valid mounted config. A backslash escapes + # the next byte in a double quoted scalar only -- in a single + # quoted one the way out is a doubled quote, which this loop + # already gets right because the first one closes and the next + # reopens, and the pair still counts as content. + if (FST == "key") CUR = CUR c + else if (FST == "val") CUR_VAL = CUR_VAL c + if (esc) esc = 0 + else if (q == dquo() && c == "\\") esc = 1 + else if (c == q) q = "" + continue + } + if (is_quote(c)) { + q = c + if (FST == "key") CUR = CUR c + else if (FST == "val") CUR_VAL = CUR_VAL c + continue + } + if (c == "{" || c == "[") { + DEPTH++ + CUR = "" + # Past depth one the whole entry is nested content and is skipped, + # including an authenticator key inside it. + FST = (DEPTH == 1 ? "key" : "skip") + continue + } + if (c == "}" || c == "]") { + if (DEPTH == 1 && FST == "val") commit_val() + DEPTH-- + CUR = "" + if (DEPTH == 0) { FST = "key"; return 1 } + FST = "skip" + continue + } + if (c == "\r") continue + if (DEPTH != 1) continue + if (c == ":") { + if (FST == "key") { + CUR_KEY = CUR + CUR_VAL = "" + FST = "val" + } + CUR = "" + continue + } + if (c == ",") { + if (FST == "val") commit_val() + FST = "key" + CUR = "" + continue + } + if (c == " " || c == "\t") { + # A space ends an unquoted key but never carries a value byte. + continue + } + if (FST == "key") CUR = CUR c + else if (FST == "val") CUR_VAL = CUR_VAL c + } + return 0 +} + +# Close out the depth-one entry that was being read when a `,` or `}` arrived. +function commit_val( k) { + k = CUR_KEY + if (names_authenticator(k)) { + AUTH_SEEN++ + AUTH_NAMED = names_class(CUR_VAL) + } +} + +# Refuse a document whose shape this reader cannot resolve, through the same +# nameless state that makes check_auth_sides stop the boot and that +# duplicate_root() uses above. Guessing `none` here is the unsafe answer: it +# tells the entrypoint that Gremlin configures nothing, so an operator whose +# mounted file does authenticate gets REST started open beside it. +function refuse(what, msg) { + msg = "yamlscan.awk: " what + print msg > "/dev/stderr" + print "cannot be classified by this reader, so it is refused rather than" > "/dev/stderr" + print "called unauthenticated. Rewrite gremlin-server.yaml in the plain" > "/dev/stderr" + print "block form, or fix the spelling above, then restart." > "/dev/stderr" + return "nameless" +} + +# `|` and `>` open a block scalar, whose content is on the following, deeper +# lines rather than on the key line. Reading the indicator itself as the value +# answered `named` for `authenticator: |` with nothing behind it, and snakeyaml +# hands the server an empty string there, which is no authenticator at all. +function is_block_scalar(v) { + v = trim(v) + if (v == "") return 0 + if (substr(v, 1, 1) != "|" && substr(v, 1, 1) != ">") return 0 + return substr(v, 2) ~ /^[0-9]*[-+]?$/ +} + +# `&label` in front of a value is an anchor and not part of the value, so a +# flow mapping written `authentication: &auth {authenticator: X}` opens with the +# brace exactly as the unanchored spelling does. names_class() already reads +# anchors this way on a scalar. +function unanchor(s) { + s = trim(s) + if (substr(s, 1, 1) != "&") return s + sub(/^&[^ \t]*/, "", s) + return trim(s) +} + +# One or more node properties may open a key -- `!!str`, `&label`, `*alias` -- +# and SnakeYAML resolves them off the key rather than reading them as part of its +# text, so `&k authentication` is the authentication key. Stripping them lets +# the root comparison below say which key the server is actually building. +function strip_node_props(s, f) { + s = trim(s) + while (1) { + f = substr(s, 1, 1) + if (f != "!" && f != "&" && f != "*") break + sub(/^[^ \t]+[ \t]*/, "", s) + } + return s +} + +# Walk the bytes of a line that sits inside a flow collection or a quoted +# scalar opened on an earlier line, updating SP (collections still open) and SQ +# (the quote still open). Braces inside a quoted scalar are text, and a +# backslash escapes the next byte of a double quoted scalar only. +function flow_span(s, i, n, c) { + n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (SQ != "") { + if (ESC) ESC = 0 + else if (SQ == dquo() && c == "\\") ESC = 1 + else if (c == SQ) SQ = "" + continue + } + if (is_quote(c)) { SQ = c; continue } + if (c == "{") SP++ + else if (c == "[") SP++ + else if (c == "}") SP-- + else if (c == "]") SP-- + } +} + +# A child value that begins with a brace or bracket, or with a quote this line +# never closes, carries on below rather than ending here. Only an opening byte +# at the very start counts: a plain scalar may hold a brace anywhere else in it +# and still be complete on its own line. +function opens_child_span(v, s, first) { + s = unanchor(v) + if (s == "") return + first = substr(s, 1, 1) + if (first != "{" && first != "[" && !is_quote(first)) return + flow_span(s) +} + +BEGIN { + DEPTH = 0 + FST = "key" + CUR = "" + CUR_KEY = "" + CUR_VAL = "" + AUTH_SEEN = 0 + AUTH_NAMED = 0 + ROOT_IND = -1 + AUTH_BLOCKS = 0 + in_auth = 0 + child = -1 + flow = 0 + RESULT = "" + UNRESOLVED = 0 + ROOT_FLOW = 0 + BLOCK = 0 + BLOCK_IND = 0 + BLOCK_TXT = "" + SP = 0 + SQ = "" + ESC = 0 + PENDING = 0 + PENDING_IND = 0 + EXPLICIT = 0 + ROOT_PROP = 0 + NESTED_VAL = 0 +} + +# Close out the block scalar whose lines were being collected. +function finish_block() { + BLOCK = 0 + AUTH_NAMED = names_class(BLOCK_TXT) + BLOCK_TXT = "" +} + +function handle_line(raw, line, ind, v, t, f) { + if (BLOCK) { + # An empty line is content inside a block scalar, never its end. On a + # CRLF file the record splits at the CR and leaves exactly such an empty + # segment after every line, so closing here ended `authenticator: >-` + # before the class line below it had been read and a config that does + # name a class was refused. + if (trim(raw) == "") return + # Deeper than the key means the line is still scalar content; anything + # else ends the scalar and is ordinary content again. + if (indent_of(raw) > BLOCK_IND) { + if (BLOCK_TXT != "") BLOCK_TXT = BLOCK_TXT " " + BLOCK_TXT = BLOCK_TXT trim(strip_comment(raw)) + return + } + finish_block() + } + + # A CR that survived the comment being stripped is line noise, not part of + # a key or a value. + line = strip_comment(raw) + sub(/[ \t]+$/, "", line) + if (trim(line) == "") return + + ind = indent_of(line) + + # A child value that opened a flow collection or a quoted scalar has not + # ended: flow and quoted content ignore indentation, so every line until it + # closes is nested content of that value and never a direct child of the + # authentication mapping. Reading one as a child is what answered `named` + # for a class that only ever reaches `authentication.config`. + if (SP > 0 || SQ != "") { + flow_span(line) + return + } + + # `authenticator:` can carry no value on its own line and leave the scalar + # to the following, deeper one, which YAML folds into that value and + # Settings.read() hands over as the class. Comment and blank lines are not + # content, so this state survives them; the first line that is not deeper + # ends it and the key stays the valueless one it looked like. + if (PENDING) { + PENDING = 0 + if (ind > PENDING_IND) { + t = trim(line) + f = substr(t, 1, 1) + # A nested mapping or collection in that position is not a class + # name. Calling it one would arm REST beside a server that either + # dies on the shape or finds no authenticator, so refuse it. + if (split_pair(t) || f == "{" || f == "[" || f == "-" || f == "?") + NESTED_VAL = 1 + else + AUTH_NAMED = names_class(t) + return + } + } + + # An explicit key at the document root is a mapping this reader does not + # walk, and Settings.read() still builds `? authentication` together with + # its `: ...` value line into the authentication mapping. The key search + # below meets no `key: value` pair on those lines and would answer `none`, + # which starts REST open beside a Gremlin that authenticates, so refuse. + t = trim(line) + if ((ROOT_IND < 0 || ind == ROOT_IND) && + (t == "?" || substr(t, 1, 2) == "? ")) { + EXPLICIT = 1 + return + } + + # A root key preceded by a tag, an anchor or an alias is the authentication + # key to SnakeYAML -- `!!str authentication` and `&k authentication` both + # resolve to it and the server builds the mapping -- but resolving node + # properties is outside this reader and the sibling line below it would set + # the root indentation, so the mapping went unnoticed and the answer came + # out `none`. That is the direction which starts REST open beside a Gremlin + # that authenticates, so it is refused the way an explicit key is. A + # property in front of a different key is an ordinary root sibling, so it + # falls through and keeps setting the indentation and closing mappings the + # way an untagged one does. + if ((ROOT_IND < 0 || ind == ROOT_IND) && split_pair(line) && + substr(unquote(K_TXT), 1, 1) ~ /^[*&!]/ && + strip_node_props(unquote(K_TXT)) == "authentication") { + ROOT_PROP = 1 + return + } + + # The indentation of the first real content line is the root indentation. + # A document marker or a stray scalar opens no mapping, so keep looking + # until a key:value line is met. Every comparison below is against that + # indentation rather than column 0, so a root mapping written indented -- + # valid to Settings.read() -- is recognized, while an `authentication:` + # nested under some other key is still not mistaken for the Gremlin one. + if (ROOT_IND < 0) { + # A document written as one flow mapping is a shape this reader does not + # walk, and its authenticator sits behind a root key rather than at the + # root indentation. Answering `none` for it is what left REST open + # beside a Gremlin that authenticates, so it is refused. + if (substr(trim(line), 1, 1) == "{") { + ROOT_FLOW = 1 + return + } + if (!split_pair(line)) return + ROOT_IND = ind + } else if (ind == ROOT_IND && in_auth && !flow) { + # A root-level sibling closes the mapping being read -- but not while a + # flow collection is still open, or the closing brace of a flow mapping + # spread over several lines was taken for a sibling and the direct + # authenticator it did name was never committed. + in_auth = 0 + flow = 0 + child = -1 + } + + # A top-level authentication key opens a mapping. Count them and read to + # EOF rather than exiting at the first, and clear the child state when a + # later one opens: two top-level mappings resolve to the LAST one, so the + # answer has to describe that node and not the first the scanner met. + if (ind == ROOT_IND && split_pair(line) && + unquote(K_TXT) == "authentication") { + AUTH_BLOCKS++ + if (AUTH_BLOCKS > 1) { + AUTH_SEEN = 0 + AUTH_NAMED = 0 + # A shape the earlier mapping left unresolved says nothing about the + # mapping the server actually loads, so it goes with the reset. + NESTED_VAL = 0 + } + in_auth = 1 + child = -1 + v = unanchor(V_TXT) + if (substr(v, 1, 1) == "{") { + # A flow mapping is the value whether or not an anchor sits in + # front of the brace, and it may stay open past this line. + flow = 1 + if (scan_flow(v)) { + in_auth = 0 + flow = 0 + } + } + # Anything else on the key line -- a scalar, a sequence, nothing -- is + # not a mapping that names a class. Reading `authentication: some.Name` + # as named would accept a config the server cannot use. + return + } + + if (!in_auth) return + + if (flow) { + if (scan_flow(line)) { + in_auth = 0 + flow = 0 + } + return + } + + # Inside a block mapping: the first child sets the child indentation, and + # only a direct child at that indentation counts. A line reaching here is + # never at the root indentation (the sibling case above consumed those), + # so `child` is always deeper than the root, as a real child must be. + if (child < 0) { + # The mapping may be a flow collection that opens on the first child + # line instead of on the key line. Its braces hold the direct entries, + # so the flow reader has to be the one that sees them. + v = unanchor(line) + if (substr(v, 1, 1) == "{") { + child = ind + flow = 1 + if (scan_flow(v)) { + in_auth = 0 + flow = 0 + } + return + } + } + if (!split_pair(line)) { + opens_child_span(line) + return + } + if (child < 0) child = ind + # Every child is checked for a collection it leaves open, at any + # indentation, so that a nested one swallows its own continuation lines + # before they can be counted as a direct child. + opens_child_span(V_TXT) + if (SP > 0 || SQ != "") return + if (ind != child) return + if (names_authenticator(K_TXT)) { + AUTH_SEEN++ + if (is_block_scalar(V_TXT)) { + # The class, if this names one at all, is on the deeper lines that + # follow rather than on the key line. + BLOCK = 1 + BLOCK_IND = ind + BLOCK_TXT = "" + return + } + if (trim(V_TXT) == "") { + # `authenticator:` with nothing behind it: the value may still be + # the next deeper line rather than the empty node this line shows. + PENDING = 1 + PENDING_IND = ind + return + } + AUTH_NAMED = names_class(V_TXT) + } +} + +{ + # YAML ends a line at CR, LF or CRLF, but awk splits records on LF alone, so + # a file written with bare CR terminators arrives as one long record whose + # root `authentication:` key is never met. Splitting each record on CR + # gives every spelling its own line; the CR that a Linux reader leaves at + # the end of a CRLF record simply yields the empty segment that the blank + # check drops. + # A byte order mark belongs to the stream, not to the first key, so it is + # dropped before the record is split; see rule 11 above. + rec = $0 + if (NR == 1) rec = strip_bom(rec) + seg_n = split(rec, seg, /\r/) + for (seg_i = 1; seg_i <= seg_n; seg_i++) handle_line(seg[seg_i]) +} + +END { + if (BLOCK) finish_block() + if (ROOT_FLOW) RESULT = refuse("a document written as a root flow mapping") + else if (UNRESOLVED) + RESULT = refuse("a quoted key or value carrying an escape that is not resolvable here") + # A collection or quote that never closed is a file the server rejects, so + # no answer here can be right; stopping the boot is the safe one. + else if (SP > 0 || SQ != "") + RESULT = refuse("a flow collection or quoted scalar left open in the authentication mapping") + else if (EXPLICIT) + RESULT = refuse("an explicit key, question mark then space, opening a root mapping") + else if (ROOT_PROP) + RESULT = refuse("the authentication key preceded by a tag, an anchor or an alias") + else if (NESTED_VAL) + RESULT = refuse("an authenticator whose value is a nested mapping or collection") + else if (AUTH_BLOCKS == 0) RESULT = "none" + # The last top-level mapping is the node Settings.read() loads, whether or + # not there were others above it, so one answer covers both files. + else RESULT = auth_state() + print RESULT +} diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh index fcdadd906f..f13a453e4c 100644 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh @@ -16,6 +16,8 @@ # limitations under the License. # +set -euo pipefail + function abs_path() { SOURCE="${BASH_SOURCE[0]}" while [[ -h "$SOURCE" ]]; do @@ -34,23 +36,295 @@ GREMLIN_SERVER_CONF="gremlin-server.yaml" REST_SERVER_CONF="rest-server.properties" GRAPH_CONF="hugegraph.properties" +fail() { + echo "enable-auth.sh: $*" >&2 + exit 1 +} + +# Reading and writing .properties files goes through props.awk, the same helper +# the docker entrypoint uses, because the keys below can be spelled in every way +# java.util.Properties accepts: `=`/`:`/bare-whitespace separators, a form feed +# as whitespace, `\.` or `\u002e` for the dots, and LF, CRLF or CR line +# terminators. grep and sed see a different file. A legal +# `gremlin\u002egraph=org.apache.hugegraph.HugeFactory` matched no pattern at +# all, so the factory was never wrapped for auth even though both servers were +# told authentication was on -- and the CR byte that the previous pattern had +# to be handed a carriage return for is now handled by the reader itself. +# +# props.awk is packaged in this same bin/ directory by the release assembly, so +# it is present in the tarball and in the image. PROPS_AWK lets a caller point +# at a different copy; the entrypoint reads that same variable for itself. +for candidate in "${PROPS_AWK:-}" "${BIN}/props.awk" "${TOP}/props.awk"; do + if [[ -n "${candidate}" && -f "${candidate}" ]]; then + PROPS_AWK="${candidate}" + break + fi +done +[[ -n "${PROPS_AWK:-}" ]] || fail "props.awk not found beside this script" + +# The Gremlin half of the decision below has to be made by the same reader the +# entrypoint uses. yamlscan.awk is not in bin/: the Dockerfile places it in the +# install home, one above this script, which is where the image layout is +# mirrored in the test tree; PROPS_AWK and YAMLSCAN_AWK cover a caller that +# keeps it elsewhere. The release tarball carries no copy at all, so the +# fallback below has to answer on its own. +YAMLSCAN="" +for candidate in "${YAMLSCAN_AWK:-}" "${TOP}/yamlscan.awk" "${BIN}/yamlscan.awk"; do + if [[ -n "${candidate}" && -f "${candidate}" ]]; then + YAMLSCAN="${candidate}" + break + fi +done + +# props_get is the only reader used here, and it treats any nonzero status from +# props.awk as an error: 2 means the file could not be read at all, which must +# not be mistaken for "the key is not there" and answered with a write. +props_get() { + local status=0 value + value=$(PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY="$1" PROPS_FILE="$2" \ + awk -f "${PROPS_AWK}" /dev/null) || status=$? + if (( status > 0 )); then + fail "cannot read $2" + fi + printf '%s' "${value}" +} + +# The character check for every class this script writes. Java class names need +# no properties escaping; anything else would have to go through the entrypoint's +# encoder first. It is one function rather than a pattern at each call site so +# the early refusal below and the write cannot drift apart. +check_class_name() { + case "$1" in + *[!A-Za-z0-9_\.\$]*) fail "refusing to write an unescaped value: $1" ;; + esac +} + +props_set() { + check_class_name "$2" + PROPS_MODE=set PROPS_KEY="$1" PROPS_VALUE_ENCODED="$2" PROPS_FILE="$3" \ + awk -f "${PROPS_AWK}" /dev/null || fail "cannot update $3" +} + +# Give `$3` its default `$2` for key `$1`, in place, unless it already has a +# value. Guarding on the key being present and appending was not the same +# question: +# `auth.authenticator=` and a bare `auth.authenticator` line both parse to the +# empty string (measured against java.util.Properties, which also strips the +# trailing blanks of `auth.authenticator= `), so a presence check reported +# them as +# answered and the append was skipped -- while the entrypoint's +# check_auth_sides, which asks for the value rather than the key, counted the +# same file as unconfigured. `loadAuthenticator("")` returns null, so REST then +# served without authentication next to a Gremlin that required it. +# +# props_set covers both shapes the guard had to split: with a definition +# present it replaces the first one where it stands (no duplicate for +# first-definition-wins to bury), with none present it appends. +# +# `$4` is the key's current value, read before the first write. Reading it +# here instead -- `[[ -n "$(props_get "$1" "$3")" ]]` -- discarded the status of +# the command substitution, because `[[` only looks at the text, so props_get's +# refusal of a file props.awk cannot read (an `include` directive) exited the +# subshell, the guard saw an empty string, and props_set then tried to write +# that same unreadable file. +ensure_rest_prop() { + [[ -n "$4" ]] && return 0 + props_set "$1" "$2" "$3" +} + # make a backup BAK_CONF="$TOP/conf-bak" if [ ! -d "$BAK_CONF" ]; then - mkdir -p "$BAK_CONF" - cp "${CONF}/${GREMLIN_SERVER_CONF}" "${BAK_CONF}/${GREMLIN_SERVER_CONF}.bak" - cp "${CONF}/${REST_SERVER_CONF}" "${BAK_CONF}/${REST_SERVER_CONF}.bak" - cp "${CONF}/graphs/${GRAPH_CONF}" "${BAK_CONF}/${GRAPH_CONF}.bak" + mkdir -p "$BAK_CONF" || fail "cannot create ${BAK_CONF}" + cp "${CONF}/${GREMLIN_SERVER_CONF}" "${BAK_CONF}/${GREMLIN_SERVER_CONF}.bak" || + fail "cannot back up ${GREMLIN_SERVER_CONF}" + cp "${CONF}/${REST_SERVER_CONF}" "${BAK_CONF}/${REST_SERVER_CONF}.bak" || + fail "cannot back up ${REST_SERVER_CONF}" + cp "${CONF}/graphs/${GRAPH_CONF}" "${BAK_CONF}/${GRAPH_CONF}.bak" || + fail "cannot back up ${GRAPH_CONF}" +fi + +# Both writes below skip a side that already carries a real value, so they are +# no-ops on a mounted config or a re-run. Appending unconditionally used to +# create duplicate definitions that the properties parser (first definition +# wins) and the yaml parser (last wins) resolved in opposite directions, leaving +# Gremlin and REST on different authenticators. That is why the REST side goes +# through ensure_rest_prop rather than a presence guard plus an append: a +# presence guard also lets a defined-but-empty key count as answered, and the +# appended default would then be the definition the server never reads. +# +# Appended with `>>` rather than `sed -i '$a\...'`: GNU sed's `$` address never +# matches when the file has no lines, so on an empty mounted config every append +# silently did nothing. `sed -i '$a'` also closed the previous last line for us, +# which `>>` does not, so a file without a trailing newline gets one first. +# +# Every write here has to be seen to succeed. The docker entrypoint runs this +# script and trusts its exit status, and a partially updated tree -- REST +# configured, yaml append refused by a read-only mounted file -- is exactly the +# one-sided state the entrypoint refuses to start with. Without errexit and +# these checks the script exited 0 on that half-done job. +append_lines() { + local file="$1" + shift + if [[ ! -w "${file}" ]]; then + fail "cannot append to ${file}: not writable" + fi + if [[ -s "${file}" && -n "$(tail -c 1 "${file}")" ]]; then + printf '\n' >> "${file}" || fail "cannot append to ${file}" + fi + printf '%s\n' "$@" >> "${file}" || fail "cannot append to ${file}" +} + +AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" + +# Refused here, before the first write, rather than at props_set's check when the +# value reaches rest-server.properties. That check ran one statement after the +# yaml block below carried the same class into gremlin-server.yaml, so a value +# with a space in it left the yaml naming a class no server can load beside an +# untouched REST config -- the one-sided tree the entrypoint's check_auth_sides +# then stops the next boot on. It did not self-repair either: once the yaml +# reads as `named` the append is skipped, so re-running with the variable fixed +# or unset wrote the default to REST only, exited 0, and left the two servers +# authenticating to different classes. +check_class_name "${AUTHENTICATOR_CLASS}" + +# Does the Gremlin config carry a top-level `authentication` mapping, and does +# that mapping name an authenticator? This is the same question +# check_auth_sides answers, so it has to go to the same reader: a mapping is +# the server's only at column 0, comment text is not content, and the key may +# be quoted. grep asks it differently -- it sees only the bare spelling, so an +# operator's `"authentication":` block read as absent and a second default +# block was appended beside it, after which the two servers can resolve the key +# in opposite directions while REST keeps its existing authenticator. +# +# The answer lands in GREMLIN_AUTH, and there are three of them because two +# were not enough: whether a mapping exists says nothing about whether it +# names a class, and only the second one decides what is safe to write. +# none no mapping, so the default block below is ours to append; +# named the operator's mapping names a class; +# nameless a mapping that names none, or a document the reader refuses +# rather than guess about -- yamlscan.awk reports both as +# nameless and check_auth_sides stops the boot on them. +# unverifiable only the tarball fallback can produce this: grep saw the key +# but has no reader to tell the three cases apart. +GREMLIN_AUTH="" +gremlin_auth_state() { + local file="$1" + GREMLIN_AUTH="none" + [[ -f "${file}" ]] || return 0 + if [[ -n "${YAMLSCAN}" ]]; then + GREMLIN_AUTH=$(awk -f "${YAMLSCAN}" "${file}") || fail "cannot read ${file}" + return 0 + fi + # No parser in this layout (the plain release tarball). Match what grep can + # honestly answer here: a column-0 key in either quote style or none. The + # nested-mapping and comment cases are the ones that need the real reader, + # and the image, where the entrypoint runs this script, always has it. + if grep -Eq "^[\"']?authentication[\"']?[[:blank:]]*:" "${file}"; then + GREMLIN_AUTH="unverifiable" + fi +} + +# Writing `auth.authenticator` is the one-way door: REST starts enforcing on +# the next boot, and TinkerPop 3.5.1 resolves a mapping that names no +# authenticator to AllowAllAuthenticator, so Gremlin keeps answering without +# credentials. That is the same one-sided state this script exists to avoid, +# arrived at by a route the entrypoint does not guard -- enable-auth.sh ships +# in the release tarball, where nothing calls check_auth_sides first, so the +# refusal has to live here rather than lean on the caller. +gremlin_auth_state "${CONF}/${GREMLIN_SERVER_CONF}" + +if [[ "${GREMLIN_AUTH}" == "nameless" ]]; then + fail "${GREMLIN_SERVER_CONF} carries an authentication mapping that names no authenticator, or a shape the reader refuses; writing ${REST_SERVER_CONF} beside it would enforce on REST and leave Gremlin on its default. Name authentication.authenticator in that mapping, or drop the mapping and let this script write both sides." +fi +# Every value the decisions below act on is read here, before the first write. +# A plain assignment is what makes the read fatal: errexit sees the status of a +# command substitution on an assignment statement, while `[[ -n "$(props_get +# ...)" ]]` looked only at the text and discarded it. Reading rest-server.properties +# from ensure_rest_prop instead discovered an unreadable file one statement after +# the yaml block had been appended, leaving the yaml naming StandardAuthenticator +# beside a REST config with no `auth.authenticator` -- the one-sided tree this +# script exists to prevent, which in the tarball layout then refuses to repair +# itself: the appended block reads as a mapping, so a rerun after the operator +# fixes the include line stops on the unverifiable branch below. +# graphs/hugegraph.properties was read last of all, so an unreadable graph config +# let the run write both auth sides and leave the factory unwrapped. +REST_AUTHENTICATOR=$(props_get "auth.authenticator" "${CONF}/${REST_SERVER_CONF}") +REST_GRAPH_STORE=$(props_get "auth.graph_store" "${CONF}/${REST_SERVER_CONF}") +GRAPH_FACTORY=$(props_get "gremlin.graph" "${CONF}/graphs/${GRAPH_CONF}") - sed -i -e '$a\authentication: {' \ - -e '$a\ authenticator: org.apache.hugegraph.auth.StandardAuthenticator,' \ - -e '$a\ authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler,' \ - -e '$a\ config: {tokens: conf/rest-server.properties}' \ - -e '$a\}' ${CONF}/${GREMLIN_SERVER_CONF} +# Wrap the graph factory only when it really is the plain HugeFactory, which is +# a question about the decoded value, so it goes through the same reader. +# +# The trailing blanks come off before the comparison because the server reads +# the trimmed line: commons-configuration right-trims a property line before it +# resolves the class, so a mounted `gremlin.graph=org.apache.hugegraph.HugeFactory ` +# opens the graph through the plain factory exactly as if it carried no blanks. +# java.util.Properties by itself keeps them (measured against JDK 17), which is +# why the reader hands the value back verbatim. Comparing the untrimmed bytes +# left such a config unwrapped: authentication on both servers, and no +# HugeFactoryAuthProxy in front of the graph, which GraphManager only warns +# about. A factory that is not HugeFactory stays untouched either way. +# +# The trim happens here rather than next to the write below because the +# writability guard under it asks the same question, and two copies of it would +# be free to disagree. +while [[ "${GRAPH_FACTORY}" =~ [[:space:]]$ ]]; do + GRAPH_FACTORY="${GRAPH_FACTORY%?}" +done + +# A config the script can read but not write has to be refused here, before the +# yaml block below, for the same reason the reads moved up. `append_lines` +# guards only the file it appends to, so with a read-only rest-server.properties +# the yaml gained the authentication mapping, props_set then failed on the REST +# side, and the run exited 1 leaving the one-sided tree the comment above +# `append_lines` says must not happen -- and in the release tarball it does not +# repair itself: the appended block reads as a mapping, so the rerun after the +# operator restores write access stops on the unverifiable branch and the yaml +# has to be edited by hand. Only a file this run actually owes a write to is +# guarded, so a read-only config that already answers every key still passes. +# `-w` asks the shell rather than reading the mode because root can write a 0444 +# file, and refusing that would break the image it is meant to protect. +if [[ -z "${REST_AUTHENTICATOR}" || -z "${REST_GRAPH_STORE}" ]] && + ! [[ -w "${CONF}/${REST_SERVER_CONF}" ]]; then + fail "${REST_SERVER_CONF} is not writable and does not answer auth.authenticator and auth.graph_store, so this run owes it a write it cannot make; nothing was written" +fi +if [[ "${GRAPH_FACTORY}" == "org.apache.hugegraph.HugeFactory" ]] && + ! [[ -w "${CONF}/graphs/${GRAPH_CONF}" ]]; then + fail "graphs/${GRAPH_CONF} is not writable and gremlin.graph is the plain HugeFactory, so this run owes it a write it cannot make; nothing was written" +fi + +if [[ "${GREMLIN_AUTH}" == "unverifiable" ]] && [[ -z "${REST_AUTHENTICATOR}" ]]; then + # The operator already naming a class on the REST side is the one answer + # this layout can act on without a reader: ensure_rest_prop then has + # nothing to write, so both sides stay as the operator left them. + fail "${GREMLIN_SERVER_CONF} has a top-level authentication mapping and this layout has no yaml reader to tell whether it names an authenticator, while ${REST_SERVER_CONF} names none. Set auth.authenticator there yourself, or run this from the server image, which ships the reader." +fi + +# Only a column-0 `authentication` mapping is the Gremlin server's, which is the +# rule yamlscan.awk applies to decide the same thing for check_auth_sides. With +# a guard that disagreed on nesting, the entrypoint read the file as `none`, so +# parity held and it called this script, but the guard saw the nested key and +# skipped the append, writing the REST side only -- StandardAuthenticator on +# REST, TinkerPop's AllowAllAuthenticator on Gremlin. +if [[ "${GREMLIN_AUTH}" == "none" ]]; then + append_lines "${CONF}/${GREMLIN_SERVER_CONF}" \ + 'authentication: {' \ + " authenticator: ${AUTHENTICATOR_CLASS}," \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler,' \ + ' config: {tokens: conf/rest-server.properties}' \ + '}' +fi - sed -i -e '$a\auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ - -e '$a\auth.graph_store=hugegraph' ${CONF}/${REST_SERVER_CONF} +ensure_rest_prop "auth.authenticator" "${AUTHENTICATOR_CLASS}" \ + "${CONF}/${REST_SERVER_CONF}" "${REST_AUTHENTICATOR}" +ensure_rest_prop "auth.graph_store" "hugegraph" \ + "${CONF}/${REST_SERVER_CONF}" "${REST_GRAPH_STORE}" - sed -i 's/gremlin.graph=org.apache.hugegraph.HugeFactory/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/g' ${CONF}/graphs/${GRAPH_CONF} +# The value was trimmed, and the writability of this file was refused before the +# yaml append, both next to the reads that decided them. +if [[ "${GRAPH_FACTORY}" == "org.apache.hugegraph.HugeFactory" ]]; then + props_set "gremlin.graph" "org.apache.hugegraph.auth.HugeFactoryAuthProxy" \ + "${CONF}/graphs/${GRAPH_CONF}" fi diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk new file mode 100644 index 0000000000..22049aa8d0 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk @@ -0,0 +1,437 @@ +# 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. +# +# props.awk — read and rewrite Java ".properties" files with the grammar +# HugeConfig (commons-configuration over JDK Properties) applies, so the +# entrypoint and the server agree on what a mounted file means. grep/sed +# rewrites do not: they see `\`-escaped keys, `:` separators, continuation +# lines and duplicate definitions differently, which is how a mounted +# config ends up with two definitions of one key. +# +# One invocation, selected with the `PROPS_MODE` environment variable: +# +# PROPS_MODE=get PROPS_KEY=K PROPS_FILE=F +# print the value of K's first logical definition, in the on-disk +# escaped form; with PROPS_DECODED=1 print it as java.util.Properties +# would hand it to the server. Exits 0 when K has no definition, which +# prints nothing, and 2 when the file cannot be read or uses an include +# directive. The guards that append a default -- `check_auth_sides` and +# `props_get` -- rely on that nonzero status to refuse rather than treat +# an unreadable file as an absent key. +# PROPS_MODE=set PROPS_KEY=K PROPS_FILE=F +# replace K's first definition in place, drop every other +# definition of K, append one when the file has none. The new +# value arrives pre-encoded in PROPS_VALUE_ENCODED (an environment +# variable, so secrets never appear in `ps` output or in awk's +# argv), and -v is not used for it so awk cannot mangle its +# backslash escapes. A file that cannot be written exits 2 as +# well, before anything is staged, so a read-only config neither +# loses a secret into a leftover temp file nor reads as damaged. +# +# Grammar implemented (java.util.Properties line reader + the +# first-definition-wins rule Configuration.getString applies): +# - physical lines end at \r\n, \n or a bare \r, as in java.util.Properties +# - '#' / '!' comments and blank lines +# - '=' / ':' / whitespace separators, where the whitespace Java counts is +# space, tab and form feed, with whitespace then an optional single '=' or +# ':' accepted as one separator +# - continuations: a physical line ending in an odd number of +# backslashes joins the next line (its leading whitespace stripped) +# - backslash escapes in keys and values, including \uXXXX +# - duplicate logical keys resolve to the first definition +# +# Rewrites keep every untouched line byte-for-byte (comments, blank +# lines, unrelated entries), and replace the first definition where it +# stands, so mounted configs stay reviewable in git diffs. +# +# A file that carries commons-configuration's `include` directive is refused in +# every mode, with exit status 2: the directive splices another file into this +# one, so "this key is absent" is a question that cannot be answered from this +# file alone, and writing into it could bury the definition the server reads. +# Rewrites stage through two exclusively created 0600 files (`mktemp`) rather +# than a predictable `.tmp` / `.bak`, which a writer in a mounted +# conf directory could have arranged as a symlink before the entrypoint, running +# as root, opened either of them. + +function die(msg) { + printf "props.awk: %s\n", msg > "/dev/stderr" + # 2 for an error. An absent key answers with empty output at exit 0, so an + # error needs a status of its own: a caller that read any nonzero as "not + # there" would append a definition on top of one it failed to read. + exit 2 +} + +function hex_digit(c) { + return index("0123456789abcdef", tolower(c)) - 1 +} + +# \uXXXX is a UTF-16 code unit in Java. Values here are effectively +# ISO-8859-1, so codes above 0xFF are kept as their literal escape text +# rather than being mangled through a single-byte sprintf. +function unescape(s, out, i, n, c, code, j, d, ok) { + out = "" + n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c != "\\") { out = out c; continue } + if (i == n) break + i++ + c = substr(s, i, 1) + if (c == "u" && i + 4 <= n) { + code = 0 + ok = 1 + for (j = 1; j <= 4; j++) { + d = hex_digit(substr(s, i + j, 1)) + if (d < 0) { ok = 0; break } + code = code * 16 + d + } + if (ok) { + i += 4 + if (code <= 255) out = out sprintf("%c", code) + else out = out substr(s, i - 5, 6) + continue + } + } + if (c == "t") out = out "\t" + else if (c == "n") out = out "\n" + else if (c == "r") out = out "\r" + else if (c == "f") out = out "\f" + else out = out c + } + return out +} + +# A physical line is continued when it ends in an odd number of +# backslashes (an even count escapes itself). +function trailing_backslashes(s, n, k) { + n = length(s) + k = 0 + while (k < n && substr(s, n - k, 1) == "\\") k++ + return k +} + +function is_skipped(raw) { + return raw ~ /^[ \t\f]*([#!]|$)/ +} + +# Split a logical line into its raw (still-escaped) key and value parts. +# Results land in K_RAW / V_RAW because awk returns one value. +# Java treats form feed as whitespace on both sides of the separator, so +# `auth.authenticator=...` is one property here too; reading it as part of +# the key name made a valid mounted configuration invisible to the guards. +function split_kv(s, n, i, c, esc, sep_at, rest) { + n = length(s) + esc = 0 + sep_at = 0 + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (esc) { esc = 0; continue } + if (c == "\\") { esc = 1; continue } + if (c == "=" || c == ":" || c == " " || c == "\t" || c == "\f") { sep_at = i; break } + } + if (sep_at == 0) { + K_RAW = s + V_RAW = "" + return + } + K_RAW = substr(s, 1, sep_at - 1) + rest = substr(s, sep_at) + c = substr(rest, 1, 1) + if (c == "=" || c == ":") { + rest = substr(rest, 2) + } else { + sub(/^[ \t\f]+/, "", rest) + c = substr(rest, 1, 1) + if (c == "=" || c == ":") rest = substr(rest, 2) + } + sub(/^[ \t\f]+/, "", rest) + V_RAW = rest +} + +function shquote(s) { + gsub(/'/, "'\\''", s) + return "'" s "'" +} + +# A private temporary file, created exclusively, beside `file`. +# +# The name has to come from mktemp. With a fixed `.tmp` anyone able to +# write in a mounted conf directory could leave that name as a symlink to a +# file elsewhere in the container, and neither the shell redirection that +# pre-created it nor awk's own `>` checks for that: both follow it, so the +# entrypoint, running as root by default, would write auth.admin_pa or +# auth.token_secret through the link and into whatever it points at. An +# exclusive create of an unpredictable name cannot be pre-arranged, and mktemp +# makes the file 0600 whatever the umask says, which is the reason no chmod +# follows it here. +# +# The template is quoted, which is also why no `--` is passed: the argument +# starts at a quote byte, so it can never read as an option. +function make_temp(file, kind, cmd, path) { + path = "" + cmd = "umask 077 && mktemp " shquote(file) "." kind ".XXXXXX" + if ((cmd | getline path) <= 0 || path == "") { + close(cmd) + die("cannot create a private " kind " file beside " file) + } + close(cmd) + return path +} + +# java.util.Properties ends a physical line at \r\n, \n or a bare \r, but +# getline splits on \n alone. A properties file saved with CR-only endings -- +# which java.util.Properties writes for a lone `store()` on some platforms, and +# which a mounted config can arrive with -- therefore reached the parser as one +# enormous record: only its first key was ever seen, and rewriting that key +# replaced the whole record and dropped every later entry, including +# auth.authenticator. So the file is re-scanned for terminators here. +# +# RAW[] keeps the exact bytes of each line and RAWTERM[] its terminator, so a +# rewrite still replays untouched lines byte-for-byte. A file whose last line +# carries no terminator gets a \n, which is what the replay did before. +function scan_records(s, i, n, c, start, term, len, cnt) { + n = length(s) + cnt = 0 + start = 1 + i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c != "\r" && c != "\n") { i++; continue } + if (c == "\r" && substr(s, i + 1, 1) == "\n") { + term = "\r\n" + len = 2 + } else { + term = c + len = 1 + } + cnt++ + RAW[cnt] = substr(s, start, i - start) + RAWTERM[cnt] = term + start = i + len + i = start + } + if (start <= n) { + cnt++ + RAW[cnt] = substr(s, start) + RAWTERM[cnt] = "" + } + return cnt +} + +# Load `file` into per-block arrays: one block per comment/blank line or +# logical entry, spanning exactly the physical lines it occupies. +function props_load(file, raw, rc, content, nl, stripped, next_raw, start, logical, inc) { + content = "" + while ((rc = (getline raw < file)) > 0) + content = content raw "\n" + if (rc == -1) + die("cannot read " file) + close(file) + + NLINES = scan_records(content) + + NBLOCK = 0 + for (nl = 1; nl <= NLINES; nl++) { + # RAW[] holds one java.util.Properties physical line with its terminator + # already removed, so no CR stripping is needed here. + stripped = RAW[nl] + if (is_skipped(stripped)) { + NBLOCK++ + BTYPE[NBLOCK] = "skip" + BFIRST[NBLOCK] = nl + BLAST[NBLOCK] = nl + continue + } + start = nl + logical = stripped + while (trailing_backslashes(logical) % 2 == 1 && nl < NLINES) { + logical = substr(logical, 1, length(logical) - 1) + nl++ + next_raw = RAW[nl] + sub(/^[ \t\f]+/, "", next_raw) + logical = logical next_raw + } + # java.util.Properties ignores whitespace before the key; strip it + # so split_kv's separator scan agrees (an indented key used to be + # read as a key whose name started with a space, and a set then + # appended a second definition of the real key). + sub(/^[ \t\f]+/, "", logical) + split_kv(logical) + NBLOCK++ + BTYPE[NBLOCK] = "entry" + BFIRST[NBLOCK] = start + BLAST[NBLOCK] = nl + BKEY[NBLOCK] = unescape(K_RAW) + # An include directive is not an ordinary property to the server: commons + # configuration splices the named file into this one at this point, so + # auth.authenticator can be defined over there and be invisible from + # here, and which of the two definitions wins follows the spliced + # order rather than the order of this file. Answering that needs the + # parser the server uses, and answering it wrong is how a mounted + # config boots with REST open and Gremlin protected. Commons + # configuration 2 treats both `include` and `includeOptional` as + # directives, and matches the property name case-insensitively, so the + # guard below rejects every spelling a real loader would honour -- not + # just the exact lowercase `include` this first refused. A file that + # uses any of them is refused in every mode and nothing is written. + inc = tolower(BKEY[NBLOCK]) + if (inc == "include" || inc == "includeoptional") + die("refusing to read or rewrite " file ": it uses an include directive (line " start "), which this helper cannot resolve") + # Values stay in their on-disk escaped form. get Prop callers feed + # the result straight back into set, which would corrupt a decoded + # value by re-writing its backslashes as literals; keys are + # unescaped because they are matched against plain names. + BVAL[NBLOCK] = V_RAW + } +} + +function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs, tail) { + props_load(file) + # A value whose written form leaves an odd number of backslashes at the end + # of the physical line turns the line after it into a continuation of that + # value. The line has to be judged as the server sees it: commons + # configuration trims the line before it looks for the continuation, so + # `abc\ ` -- the spelling encode_prop_value used to give a trailing space -- + # reaches the server as `abc\` and swallows whatever follows it. Measured + # against commons-configuration2 (what HugeConfig extends), the same input + # read back yields no property at all, so a secret written this way never + # reaches the server that is supposed to authenticate with it. The + # entrypoint has to refuse instead of guessing a target. + tail = enc_val + sub(/[ \t\f\r]+$/, "", tail) + nbs = 0 + while (nbs < length(tail) && substr(tail, length(tail) - nbs, 1) == "\\") + nbs++ + if (nbs % 2 == 1) + die("refusing to write " key ": trimmed of its trailing blanks the value ends in a backslash, which would swallow the next line") + # Refuse a destination that cannot be written before staging anything, not + # after. Without this the copy-back below is the first write attempted, so + # a read-only config produced the failure message at the end of this + # function -- and that message is only true of a copy that got partway. + # The shell opens the destination for writing before cat has written a byte, + # so EACCES leaves the original untouched while the staged temp file, which + # carries the value being written, stays beside it. A container on a restart + # policy therefore adds one temp/snapshot pair per restart, each holding the + # secret. `test -w` is the cheap answer and it is not the whole answer: root + # can write a 0444 file, and access() reports a bind-mounted read-only file + # as writable, so the compare on copy-back failure below still has to hold. + if (system("test -w " shquote(file)) != 0) + die(file " is not writable; nothing written") + first = 0 + for (b = 1; b <= NBLOCK; b++) { + if (BTYPE[b] == "entry" && BKEY[b] == key) { + if (first == 0) first = b + else BDROP[b] = 1 + } + } + # Staged rewrite: everything lands in a private temp file first, so a + # failure before the copy-back leaves the original untouched. The temp file + # holds secrets, so it is created 0600 and exclusively (see make_temp): a + # reused, predictable name is both a disclosure risk under the process umask + # and a path someone else can have arranged already. + tmp = make_temp(file, "tmp") + for (b = 1; b <= NBLOCK; b++) { + if (BDROP[b]) continue + if (b == first) { + printf "%s=%s\n", key, enc_val > tmp + } else { + for (ln = BFIRST[b]; ln <= BLAST[b]; ln++) { + # Replay the line with the terminator it was read with, so a + # CRLF or CR-only config keeps its endings on lines the + # rewrite does not touch. + msg = RAWTERM[ln] + if (msg == "") msg = "\n" + printf "%s%s", RAW[ln], msg > tmp + } + } + } + if (first == 0) + printf "%s=%s\n", key, enc_val > tmp + close(tmp) + # Copy the completed temp file back onto the original instead of + # renaming it: a rename replaces the inode, which would lose the + # file's permissions (a 0600 config holding secrets would come back + # umask-world-readable), turn a symlinked config into a regular file, + # and fail with EBUSY on a config bind-mounted as a single file — the + # mounted case this path exists for. The copy keeps the inode, mode, + # symlink and mount point. + # + # The copy itself is not atomic and the shell's `>` truncates the + # destination before cat writes a byte, so an ENOSPC or I/O error + # mid-copy used to leave a truncated config on disk — a truncated + # rest-server.properties loses `auth.authenticator` and boots the + # server with authentication off. Snapshot the original first, into a + # second exclusively created 0600 file for the same reason as the temp, + # and put it back when the copy fails. + bak = make_temp(file, "bak") + cmd = "cp -- " shquote(file) " " shquote(bak) + if (system(cmd) != 0) + die("cannot back up " file " before the copy-back") + cmd = "cat -- " shquote(tmp) " > " shquote(file) + if (system(cmd) != 0) { + msg = "cannot copy " tmp " over " file + # Only a copy that reached the destination and then failed can damage + # it. A failure to open the destination leaves the original + # byte-for-byte as it was, so asking the snapshot is what separates the + # two: reporting an untouched config as damaged sends the operator to + # restore a file that never needed restoring, and leaves the staged + # value -- the secret being written -- sitting in the conf directory. + if (system("cmp -s -- " shquote(file) " " shquote(bak)) == 0) { + if (system("rm -f -- " shquote(tmp) " " shquote(bak)) != 0) + die(msg "; " file " is unchanged but " tmp " and " bak " could not be removed") + die(msg "; " file " is unchanged and nothing was written") + } + # Best effort: the destination really is damaged, so restoring it from + # the snapshot comes first, and the temp file is kept for an operator + # who wants to inspect what was being written. + cmd = "cat -- " shquote(bak) " > " shquote(file) + if (system(cmd) == 0) die(msg "; the previous content is restored") + die(msg "; " file " is damaged, previous content is in " bak) + } + if (system("rm -f -- " shquote(tmp) " " shquote(bak)) != 0) + die("cannot remove " tmp " and " bak " after the copy-back") +} + +function props_get(file, key, decoded, b) { + props_load(file) + for (b = 1; b <= NBLOCK; b++) { + if (BTYPE[b] == "entry" && BKEY[b] == key) { + if (decoded) print unescape(BVAL[b]) + else print BVAL[b] + return + } + } + # Absence prints nothing and is NOT an exit status: callers assign from + # command substitution (`rest=$(get_prop ...)`) under a shell with errexit + # on, where a nonzero status would abort the entrypoint over a merely + # missing property. A nonzero status therefore only ever means the file + # could not be read or was refused, which is what the guards act on. +} + +BEGIN { + mode = ENVIRON["PROPS_MODE"] + key = ENVIRON["PROPS_KEY"] + file = ENVIRON["PROPS_FILE"] + decoded = (ENVIRON["PROPS_DECODED"] == "1") + if (file == "" || key == "") + die("PROPS_FILE and PROPS_KEY must be set") + if (mode == "get") { + props_get(file, key, decoded) + } else if (mode == "set") { + props_set(file, key, ENVIRON["PROPS_VALUE_ENCODED"]) + } else { + die("PROPS_MODE must be get or set") + } +}