소스 검색

fix: some bugs

Dew-OF-Aurora 1 주 전
부모
커밋
9433fbf787
4개의 변경된 파일138개의 추가작업 그리고 24개의 파일을 삭제
  1. 6 3
      config.server.json
  2. 61 13
      scripts/domain_updater.py
  3. 66 3
      scripts/install_debian.sh
  4. 5 5
      workflow.md

+ 6 - 3
config.server.json

@@ -102,10 +102,13 @@
   },
   "output": {
     "runtime_dir": "./runtime",
-    "current_domain_file": "current_domain.txt",
-    "current_domain_json": "current_domain.json",
+    "selected_value_file": "current_domain.txt",
+    "selected_value_json": "current_domain.json",
+    "selected_value_json_key": "domain",
     "state_file": "state.json",
-    "substore_vars_file": "substore_vars.json"
+    "state_last_good_key": "last_good_domain",
+    "export_vars_file": "substore_vars.json",
+    "substore_value_key": "AUTO_DOMAIN"
   },
   "notify": {
     "command": ""

+ 61 - 13
scripts/domain_updater.py

@@ -9,6 +9,7 @@ import os
 import re
 import subprocess
 import sys
+import tempfile
 import urllib.parse
 import urllib.request
 
@@ -33,20 +34,48 @@ def read_json_file(path, default=None):
         return default
 
 
+def _atomic_write(path, payload_bytes):
+    """Write `payload_bytes` to `path` atomically.
+
+    Strategy: write to a sibling temp file, fsync, then os.replace.
+    Concurrent readers either see the previous version or the new
+    one — never a truncated/empty file. Required for state.json,
+    which is the sole fallback source on the next run.
+    """
+    parent = os.path.dirname(path) or "."
+    os.makedirs(parent, exist_ok=True)
+    fd, tmp_path = tempfile.mkstemp(
+        prefix=".{}.".format(os.path.basename(path)),
+        suffix=".tmp",
+        dir=parent,
+    )
+    try:
+        with os.fdopen(fd, "wb") as f:
+            f.write(payload_bytes)
+            f.flush()
+            try:
+                os.fsync(f.fileno())
+            except OSError:
+                # fsync may fail on some filesystems (e.g. tmpfs without
+                # backing store); the rename below still gives atomicity
+                # within a single filesystem.
+                pass
+        os.replace(tmp_path, path)
+    except Exception:
+        try:
+            os.unlink(tmp_path)
+        except OSError:
+            pass
+        raise
+
+
 def write_json_file(path, data):
-    parent = os.path.dirname(path)
-    if parent:
-        os.makedirs(parent, exist_ok=True)
-    with open(path, "w", encoding="utf-8") as f:
-        json.dump(data, f, ensure_ascii=True, indent=2)
+    payload = json.dumps(data, ensure_ascii=True, indent=2)
+    _atomic_write(path, payload.encode("utf-8"))
 
 
 def write_text_file(path, data):
-    parent = os.path.dirname(path)
-    if parent:
-        os.makedirs(parent, exist_ok=True)
-    with open(path, "w", encoding="utf-8") as f:
-        f.write(data)
+    _atomic_write(path, data.encode("utf-8"))
 
 
 def build_url(base_url, params):
@@ -908,13 +937,32 @@ def choose_domain(filtered_domains, top_n, ranked_scored):
 
 
 def build_output_settings(output_cfg, config_path_abs):
+    # H3: legacy keys (current_domain_file/current_domain_json/substore_vars_file)
+    # were ambiguous and drifted between server/local configs. Refuse to start
+    # so operators migrate to the unified key set documented in CLAUDE.md.
+    legacy_key_map = {
+        "current_domain_file": "selected_value_file",
+        "current_domain_json": "selected_value_json",
+        "substore_vars_file": "export_vars_file",
+    }
+    legacy_in_use = [k for k in legacy_key_map if k in output_cfg]
+    if legacy_in_use:
+        renames = ", ".join(
+            "{} -> {}".format(k, legacy_key_map[k]) for k in legacy_in_use
+        )
+        raise ValueError(
+            "deprecated output keys in config: {}. Rename them: {}.".format(
+                ", ".join(legacy_in_use), renames
+            )
+        )
+
     runtime_dir_cfg = output_cfg.get("runtime_dir", "./runtime")
     runtime_dir = resolve_path(os.path.dirname(config_path_abs), runtime_dir_cfg)
 
-    selected_text_name = output_cfg.get("selected_value_file", output_cfg.get("current_domain_file", "current_domain.txt"))
-    selected_json_name = output_cfg.get("selected_value_json", output_cfg.get("current_domain_json", "current_domain.json"))
+    selected_text_name = output_cfg.get("selected_value_file", "current_domain.txt")
+    selected_json_name = output_cfg.get("selected_value_json", "current_domain.json")
     state_name = output_cfg.get("state_file", "state.json")
-    vars_name = output_cfg.get("export_vars_file", output_cfg.get("substore_vars_file", "substore_vars.json"))
+    vars_name = output_cfg.get("export_vars_file", "substore_vars.json")
 
     return {
         "runtime_dir": runtime_dir,

+ 66 - 3
scripts/install_debian.sh

@@ -76,6 +76,56 @@ run_as_service_user() {
 	runuser -u "$RUN_USER" -- env HOME="$RUN_HOME" "$@"
 }
 
+# --- H4: validators for inputs that flow into systemd unit files / shell ---
+# Refuse anything that could break unit syntax or inject directives.
+validate_ident() {
+	# user/group/service names: POSIX-portable charset only.
+	local label="$1" value="$2"
+	if [[ ! "$value" =~ ^[A-Za-z_][A-Za-z0-9_.-]*$ ]] || [[ ${#value} -gt 64 ]]; then
+		log_err "Error: invalid $label: '$value' (must match [A-Za-z_][A-Za-z0-9_.-]{0,63})"
+		exit 1
+	fi
+}
+
+validate_path() {
+	# Absolute path; no CR/LF; no characters that have meaning inside
+	# systemd unit-file value parsing or bash ExecStart parsing.
+	# (POSIX paths cannot contain NUL, so we don't need to test for it.)
+	local label="$1" value="$2"
+	if [[ "$value" != /* ]]; then
+		log_err "Error: $label must be an absolute path: '$value'"
+		exit 1
+	fi
+	if [[ "$value" == *$'\n'* ]] || [[ "$value" == *$'\r'* ]]; then
+		log_err "Error: $label contains a newline/CR byte"
+		exit 1
+	fi
+	case "$value" in
+		*'"'*|*'%'*|*'$'*|*'`'*|*'\\'*|*';'*|*'|'*|*'&'*|*'<'*|*'>'*|*' '*)
+			log_err "Error: $label contains a forbidden character (one of: space \" % \$ \\\` \\\\ ; | & < >): '$value'"
+			exit 1
+			;;
+	esac
+}
+
+validate_interval() {
+	local label="$1" value="$2"
+	if [[ ! "$value" =~ ^[0-9]+(min|m|h)$ ]]; then
+		log_err "Error: $label must look like '30min' / '5m' / '1h', got: '$value'"
+		exit 1
+	fi
+}
+
+validate_tz() {
+	local value="$1"
+	if [[ ! "$value" =~ ^[A-Za-z0-9_+/.-]+$ ]] || [[ ${#value} -gt 64 ]]; then
+		log_err "Error: invalid --peak-tz: '$value' (must match [A-Za-z0-9_+/.-]{1,64})"
+		exit 1
+	fi
+}
+
+validate_ident "service name" "$SERVICE_NAME"
+
 while [[ $# -gt 0 ]]; do
 	case "$1" in
 		--user)
@@ -207,6 +257,12 @@ if [[ -z "$RUN_GROUP" ]]; then
 	exit 1
 fi
 
+validate_ident "service user" "$RUN_USER"
+validate_ident "service group" "$RUN_GROUP"
+validate_tz "$PEAK_TZ"
+validate_path "APP_DIR" "$APP_DIR"
+validate_path "CONFIG_PATH" "$CONFIG_PATH"
+
 if [[ ! "$GIT_PUSH_ENABLED" =~ ^[01]$ ]]; then
 	log_err "Error: --git-push must be 0 or 1"
 	exit 1
@@ -259,11 +315,16 @@ to_minutes() {
 		echo "${BASH_REMATCH[1]}"
 	elif [[ "$val" =~ ^([0-9]+)h$ ]]; then
 		echo $(( ${BASH_REMATCH[1]} * 60 ))
+	elif [[ "$val" =~ ^[0-9]+$ ]]; then
+		echo "$val"
 	else
-		echo "$val" | tr -cd '0-9'
+		echo ""
 	fi
 }
 
+validate_interval "--peak-interval" "$PEAK_INTERVAL"
+validate_interval "--offpeak-interval" "$OFFPEAK_INTERVAL"
+
 PEAK_INTERVAL_MIN=$(to_minutes "$PEAK_INTERVAL")
 OFFPEAK_INTERVAL_MIN=$(to_minutes "$OFFPEAK_INTERVAL")
 
@@ -292,6 +353,8 @@ fi
 
 RUNTIME_DIR="$(/usr/bin/python3 "${APP_DIR}/scripts/domain_updater.py" --config "$CONFIG_PATH" --print-output-settings | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["runtime_dir"])')"
 
+validate_path "RUNTIME_DIR" "$RUNTIME_DIR"
+
 mkdir -p "$RUNTIME_DIR"
 chmod +x "$APP_DIR/scripts/run_update_and_commit.sh" || true
 chown -R "$RUN_USER:$RUN_GROUP" "$RUNTIME_DIR"
@@ -388,10 +451,10 @@ Wants=network-online.target
 Type=oneshot
 User=${RUN_USER}
 Group=${RUN_GROUP}
-WorkingDirectory=${APP_DIR}
+WorkingDirectory="${APP_DIR}"
 EnvironmentFile=-${ENV_FILE}
 UMask=0077
-ExecStart=/bin/bash ${APP_DIR}/scripts/run_update_and_commit.sh ${CONFIG_PATH}
+ExecStart=/bin/bash "${APP_DIR}/scripts/run_update_and_commit.sh" "${CONFIG_PATH}"
 EOF
 
 cat >"/etc/systemd/system/${SERVICE_NAME}.timer" <<EOF

+ 5 - 5
workflow.md

@@ -18,10 +18,10 @@ flowchart TD
       B7 --> B8[score and rank]
       B8 --> B9[select preferred domain]
       B9 --> B10[build top_candidates (sparse fields, unified semantics)]
-      B10 --> B11[write runtime/current_domain.txt]
-      B11 --> B12[write runtime/current_domain.json]
-      B12 --> B13[write runtime/substore_vars.json]
-      B13 --> B14[write runtime/state.json]
+      B10 --> B11[write configured selected text file]
+      B11 --> B12[write configured selected JSON file]
+      B12 --> B13[write configured substore_vars file]
+      B13 --> B14[write configured state file]
     end
 
     A2 --> C0{force-commit=1 OR<br/>in peak OR<br/>on offpeak boundary?}
@@ -108,7 +108,7 @@ flowchart TD
 
 ```mermaid
 flowchart TD
-    A1[runtime/current_domain.json] --> B1[Sub-Store operator]
+    A1[runtime/current_domain.json (configured)] --> B1[Sub-Store operator]
     A2[cfip_runtime/current_ip.json over LAN] --> B1
     B1 --> C1["cache lookup<br/>(key scoped per VALUE_SOURCE_MODE)"]
     C1 --> C2{cache hit?}