|
#!/usr/bin/env bash |
|
# remnawave-node-config-get — Extract and clean XRay config from a Remnawave node container |
|
# https://gist.github.com/Kenya-West/7f87b991028d904c59cc1332963c033a… |
|
set -euo pipefail |
|
|
|
VERSION="1.0.0" |
|
|
|
# ── Colors (stderr only, disabled when not a terminal) ─────────────────────── |
|
if [[ -t 2 ]]; then |
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
CYAN='\033[0;36m' |
|
BOLD='\033[1m' |
|
DIM='\033[2m' |
|
NC='\033[0m' |
|
else |
|
RED='' GREEN='' CYAN='' BOLD='' DIM='' NC='' |
|
fi |
|
|
|
# ── Defaults ───────────────────────────────────────────────────────────────── |
|
CONTAINER="" |
|
OUTPUT="" |
|
STRIP_REMNAWAVE=true |
|
SUMMARIZE_SNI=true |
|
SUMMARIZE_CLIENTS=true |
|
REDACT=false |
|
QUIET=false |
|
|
|
# ── Functions ──────────────────────────────────────────────────────────────── |
|
err() { printf "${RED}error:${NC} %s\n" "$*" >&2; } |
|
info() { printf "${CYAN}%s${NC}\n" "$*" >&2; } |
|
|
|
usage() { |
|
cat >&2 <<EOF |
|
${BOLD}remnawave-node-config-get${NC} v${VERSION} |
|
Extract and clean XRay config from a Remnawave node Docker container. |
|
|
|
${BOLD}USAGE${NC} |
|
remnawave-node-config-get.sh [OPTIONS] |
|
|
|
${BOLD}OPTIONS${NC} |
|
-c, --container NAME Container name (default: auto-detect remnanode → remna) |
|
-o, --output FILE Write JSON to FILE instead of stdout |
|
-R, --keep-remnawave Keep Remnawave service entities (api, stats, policy, |
|
REMNAWAVE_API_INBOUND inbound & routing rule) |
|
-S, --keep-servernames Keep serverNames arrays as-is (default: summarize) |
|
-C, --keep-clients Keep clients arrays as-is (default: summarize) |
|
-r, --redact Replace sensitive values with <REDACTED> |
|
-q, --quiet Output only the JSON, suppress info banner |
|
-h, --help Show this help |
|
-V, --version Show version |
|
|
|
${BOLD}EXAMPLES${NC} |
|
# Auto-detect container, print cleaned config to stdout |
|
./remnawave-node-config-get.sh |
|
|
|
# Save to file, redact secrets |
|
./remnawave-node-config-get.sh -r -o config.json |
|
|
|
# Use specific container, keep everything, quiet mode |
|
./remnawave-node-config-get.sh -c mynode -R -S -C -q |
|
|
|
# Pipe-friendly: quiet + redact, pipe to another tool |
|
./remnawave-node-config-get.sh -q -r | less |
|
EOF |
|
} |
|
|
|
# ── Argument parsing ───────────────────────────────────────────────────────── |
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
-c|--container) |
|
[[ -z "${2:-}" ]] && { err "Option $1 requires a value"; exit 1; } |
|
CONTAINER="$2"; shift 2 ;; |
|
-o|--output) |
|
[[ -z "${2:-}" ]] && { err "Option $1 requires a value"; exit 1; } |
|
OUTPUT="$2"; shift 2 ;; |
|
-R|--keep-remnawave) STRIP_REMNAWAVE=false; shift ;; |
|
-S|--keep-servernames) SUMMARIZE_SNI=false; shift ;; |
|
-C|--keep-clients) SUMMARIZE_CLIENTS=false; shift ;; |
|
-r|--redact) REDACT=true; shift ;; |
|
-q|--quiet) QUIET=true; shift ;; |
|
-h|--help) usage; exit 0 ;; |
|
-V|--version) echo "$VERSION"; exit 0 ;; |
|
-*) err "Unknown option: $1"; usage; exit 1 ;; |
|
*) err "Unexpected argument: $1"; usage; exit 1 ;; |
|
esac |
|
done |
|
|
|
# ── Dependency check ───────────────────────────────────────────────────────── |
|
missing=() |
|
for cmd in docker jq perl; do |
|
command -v "$cmd" &>/dev/null || missing+=("$cmd") |
|
done |
|
if [[ ${#missing[@]} -gt 0 ]]; then |
|
err "Missing required tools: ${missing[*]}" |
|
exit 1 |
|
fi |
|
|
|
# ── Container detection ────────────────────────────────────────────────────── |
|
CONTAINER_SOURCE="specified" |
|
if [[ -z "$CONTAINER" ]]; then |
|
CONTAINER_SOURCE="auto-detected" |
|
if docker inspect --format='{{.State.Running}}' remnanode 2>/dev/null | grep -q true; then |
|
CONTAINER="remnanode" |
|
elif docker inspect --format='{{.State.Running}}' remna 2>/dev/null | grep -q true; then |
|
CONTAINER="remna" |
|
else |
|
err "No running Remnawave container found (tried 'remnanode', 'remna')" |
|
err "Specify one with: -c <container-name>" |
|
exit 2 |
|
fi |
|
else |
|
if ! docker inspect --format='{{.State.Running}}' "$CONTAINER" 2>/dev/null | grep -q true; then |
|
err "Container '$CONTAINER' is not running or does not exist" |
|
exit 2 |
|
fi |
|
fi |
|
|
|
# ── Banner ─────────────────────────────────────────────────────────────────── |
|
print_banner() { |
|
local yn_strip yn_sni yn_clients yn_redact out_target |
|
yn_strip=$( [[ "$STRIP_REMNAWAVE" == true ]] && echo "yes" || echo "no") |
|
yn_sni=$( [[ "$SUMMARIZE_SNI" == true ]] && echo "yes" || echo "no") |
|
yn_clients=$( [[ "$SUMMARIZE_CLIENTS" == true ]] && echo "yes" || echo "no") |
|
yn_redact=$([[ "$REDACT" == true ]] && echo "yes" || echo "no") |
|
out_target=$([[ -n "$OUTPUT" ]] && echo "$OUTPUT" || echo "stdout") |
|
|
|
printf >&2 "\n" |
|
printf >&2 "${BOLD}remnawave-node-config-get${NC} v%s\n" "$VERSION" |
|
printf >&2 "${DIM}──────────────────────────────────────────────${NC}\n" |
|
printf >&2 " Container: ${BOLD}%s${NC} (%s)\n" "$CONTAINER" "$CONTAINER_SOURCE" |
|
printf >&2 " Strip Remnawave: ${BOLD}%-3s${NC} ${DIM}-R to keep${NC}\n" "$yn_strip" |
|
printf >&2 " Summarize SNI: ${BOLD}%-3s${NC} ${DIM}-S to keep${NC}\n" "$yn_sni" |
|
printf >&2 " Summarize Clients: ${BOLD}%-3s${NC} ${DIM}-C to keep${NC}\n" "$yn_clients" |
|
printf >&2 " Redact secrets: ${BOLD}%-3s${NC} ${DIM}-r to enable${NC}\n" "$yn_redact" |
|
printf >&2 " Output: ${BOLD}%s${NC}${DIM}%s${NC}\n" "$out_target" "${OUTPUT:+ -o <file>}" |
|
printf >&2 "${DIM}──────────────────────────────────────────────${NC}\n" |
|
} |
|
|
|
if [[ "$QUIET" != true ]]; then |
|
print_banner |
|
fi |
|
|
|
# ── Extract raw config ─────────────────────────────────────────────────────── |
|
RAW_OUTPUT=$(docker exec -e NO_COLOR=1 "$CONTAINER" cli --dump-config 2>/dev/null </dev/null) || { |
|
err "Failed to run 'cli --dump-config' in container '$CONTAINER'" |
|
exit 3 |
|
} |
|
|
|
JSON=$(printf '%s' "$RAW_OUTPUT" \ |
|
| perl -CSDA -pe 's/\r/\n/g; s/\e\[[0-9;?]*[ -\/]*[@-~]//g' \ |
|
| perl -0777 -ne 'if (/(\{.*\})/s) { print $1 }') |
|
|
|
if [[ -z "$JSON" ]]; then |
|
err "No JSON object found in command output" |
|
exit 3 |
|
fi |
|
|
|
if ! printf '%s' "$JSON" | jq empty 2>/dev/null; then |
|
err "Extracted data is not valid JSON" |
|
exit 3 |
|
fi |
|
|
|
# ── Build jq filter ────────────────────────────────────────────────────────── |
|
JQ_FILTER="." |
|
|
|
if [[ "$STRIP_REMNAWAVE" == true ]]; then |
|
JQ_FILTER+=' |
|
| (if .routing.rules then |
|
.routing.rules |= [.[] | select( |
|
(.inboundTag // []) | index("REMNAWAVE_API_INBOUND") | not |
|
)] |
|
else . end) |
|
| (if .inbounds then |
|
.inbounds |= [.[] | select(.tag != "REMNAWAVE_API_INBOUND")] |
|
else . end) |
|
| del(.api) | del(.stats) | del(.policy)' |
|
fi |
|
|
|
if [[ "$SUMMARIZE_SNI" == true ]]; then |
|
JQ_FILTER+=' |
|
| walk( |
|
if type == "object" and has("serverNames") and (.serverNames | type) == "array" |
|
then .serverNames = ["<\(.serverNames | length) entries hidden>"] |
|
else . end |
|
)' |
|
fi |
|
|
|
if [[ "$SUMMARIZE_CLIENTS" == true ]]; then |
|
JQ_FILTER+=' |
|
| (if .inbounds then |
|
.inbounds |= [.[] | |
|
if .settings.clients and (.settings.clients | type) == "array" |
|
then .settings.clients = ["<\(.settings.clients | length) clients>"] |
|
else . end |
|
] |
|
else . end)' |
|
fi |
|
|
|
if [[ "$REDACT" == true ]]; then |
|
JQ_FILTER+=' |
|
| def redact_host: |
|
if type != "string" then . |
|
elif . == "" or . == "0.0.0.0" or . == "127.0.0.1" or . == "localhost" then . |
|
elif test("^[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+(:[0-9]+)?$") then |
|
split(":") as $p | |
|
($p[0] | split(".") | .[0:2] + ["XXX","XXX"] | join(".")) |
|
+ (if ($p | length) > 1 then ":" + $p[1] else "" end) |
|
elif test("[.]") then |
|
split(":") as $p | |
|
($p[0] | split(".") |
|
| if length <= 2 then ["example","tld"] |
|
else .[:-2] + ["example","tld"] end |
|
| join(".")) |
|
+ (if ($p | length) > 1 then ":" + $p[1] else "" end) |
|
else . end; |
|
walk( |
|
if type == "object" then |
|
(if has("privateKey") and (.privateKey | type) == "string" |
|
then .privateKey = "<REDACTED>" else . end) |
|
| (if has("password") and (.password | type) == "string" |
|
then .password = "<REDACTED>" else . end) |
|
| (if has("key") and (.key | type) == "array" |
|
then .key = ["<REDACTED>"] else . end) |
|
| (if has("certificate") and (.certificate | type) == "array" |
|
then .certificate = ["<REDACTED>"] else . end) |
|
| (if has("shortIds") and (.shortIds | type) == "array" |
|
then .shortIds = ["<REDACTED>"] else . end) |
|
| (if has("id") and (.id | type) == "string" and (.id | test("^[0-9a-f]{8}-[0-9a-f]{4}-")) |
|
then .id = "<REDACTED>" else . end) |
|
| (if has("email") and (.email | type) == "string" |
|
then .email = "<REDACTED>" else . end) |
|
| (if has("address") and (.address | type) == "string" |
|
then .address |= redact_host else . end) |
|
| (if has("serverName") and (.serverName | type) == "string" |
|
then .serverName |= redact_host else . end) |
|
| (if has("host") and (.host | type) == "string" |
|
then .host |= redact_host else . end) |
|
| (if has("target") and (.target | type) == "string" |
|
then .target |= redact_host else . end) |
|
| (if has("serverNames") and (.serverNames | type) == "array" |
|
then .serverNames |= map(if type == "string" then redact_host else . end) |
|
else . end) |
|
else . end |
|
)' |
|
fi |
|
|
|
# ── Apply filter ───────────────────────────────────────────────────────────── |
|
RESULT=$(printf '%s' "$JSON" | jq "$JQ_FILTER") || { |
|
err "jq processing failed" |
|
exit 4 |
|
} |
|
|
|
# ── Output ─────────────────────────────────────────────────────────────────── |
|
if [[ -n "$OUTPUT" ]]; then |
|
printf '%s\n' "$RESULT" > "$OUTPUT" || { |
|
err "Failed to write to '$OUTPUT'" |
|
exit 5 |
|
} |
|
if [[ "$QUIET" != true ]]; then |
|
printf >&2 "\n${GREEN}Config written to ${BOLD}%s${NC}\n" "$OUTPUT" |
|
fi |
|
else |
|
printf '%s\n' "$RESULT" |
|
fi |