cloudflared-manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2727 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_NAME="$(basename "$0")"
5
+ SETTINGS_FILE="${CLOUDFLARED_MANAGER_SETTINGS:-$HOME/.cloudflared-manager.env}"
6
+ DEFAULT_LOGIN_CERT="$HOME/.cloudflared/cert.pem"
7
+ DEFAULT_MANAGER_HOME="$HOME/.cloudflared-manager"
8
+
9
+ CLOUDFLARED_BIN="${CLOUDFLARED_BIN:-$(command -v cloudflared || true)}"
10
+
11
+ CLI_PROFILE_DIR=""
12
+ CLI_DEFAULT_CONFIG_FILE=""
13
+ CLI_ORIGIN_CERT=""
14
+ CLI_MANAGER_ROOT=""
15
+ SAVE_PROFILE=0
16
+
17
+ SETTINGS_PROFILE_DIR=""
18
+ SETTINGS_DEFAULT_CONFIG_FILE=""
19
+ SETTINGS_ORIGIN_CERT=""
20
+ SETTINGS_MANAGER_ROOT=""
21
+
22
+ PROFILE_DIR=""
23
+ DEFAULT_CONFIG_FILE=""
24
+ ORIGIN_CERT=""
25
+ MANAGER_ROOT=""
26
+ APPS_DIR=""
27
+ ARCHIVE_DIR=""
28
+
29
+ META_NAME=""
30
+ META_TUNNEL_NAME=""
31
+ META_TUNNEL_ID=""
32
+ META_HOSTNAME=""
33
+ META_SERVICE=""
34
+ META_APP_DIR=""
35
+ META_CONFIG_FILE=""
36
+ META_CREDENTIALS_FILE=""
37
+ META_INGRESS_FILE=""
38
+ META_PID_FILE=""
39
+ META_LOG_FILE=""
40
+ META_META_FILE=""
41
+ META_CREATED_AT=""
42
+ META_UPDATED_AT=""
43
+
44
+ INGRESS_HOSTS=()
45
+ INGRESS_SERVICES=()
46
+ PARSED_INGRESS_HOSTNAME=""
47
+ PARSED_INGRESS_SERVICE=""
48
+ PARSED_MODIFY_INDEX=""
49
+ PARSED_MODIFY_HOSTNAME=""
50
+ PARSED_MODIFY_SERVICE=""
51
+
52
+ REMOTE_FOUND=0
53
+ REMOTE_TUNNEL_ID=""
54
+ REMOTE_TUNNEL_NAME=""
55
+ REMOTE_TUNNEL_CREATED_AT=""
56
+ REMOTE_TUNNEL_CONNECTIONS="0"
57
+
58
+ # 返回当前 UTC 时间,统一用于元数据时间戳。
59
+ utc_now() {
60
+ date -u +"%Y-%m-%dT%H:%M:%SZ"
61
+ }
62
+
63
+ # 向标准错误输出文本。
64
+ eprint() {
65
+ printf '%s\n' "$*" >&2
66
+ }
67
+
68
+ # 向标准输出打印普通信息。
69
+ info() {
70
+ printf '%s\n' "$*"
71
+ }
72
+
73
+ # 输出警告信息。
74
+ warn() {
75
+ eprint "警告: $*"
76
+ }
77
+
78
+ # 输出错误信息并退出。
79
+ die() {
80
+ eprint "错误: $*"
81
+ exit 1
82
+ }
83
+
84
+ # 展开形如 ~ 或 ~/path 的用户目录写法。
85
+ expand_tilde() {
86
+ local value="$1"
87
+ if [[ "$value" == "~" ]]; then
88
+ printf '%s\n' "$HOME"
89
+ elif [[ "$value" == "~/"* ]]; then
90
+ printf '%s/%s\n' "$HOME" "${value#~/}"
91
+ else
92
+ printf '%s\n' "$value"
93
+ fi
94
+ }
95
+
96
+ # 将相对路径转换为绝对路径,但不要求目标必须存在。
97
+ normalize_path() {
98
+ local value
99
+ value="$(expand_tilde "$1")"
100
+ if [[ -z "$value" ]]; then
101
+ printf '\n'
102
+ elif [[ "$value" == /* ]]; then
103
+ printf '%s\n' "$value"
104
+ else
105
+ printf '%s/%s\n' "$PWD" "$value"
106
+ fi
107
+ }
108
+
109
+ # 在目标存在时尽量返回规范路径,否则返回归一化后的绝对路径。
110
+ canonical_path() {
111
+ local value dir base
112
+ value="$(expand_tilde "$1")"
113
+ if [[ -d "$value" ]]; then
114
+ (
115
+ cd "$value"
116
+ pwd -P
117
+ )
118
+ return
119
+ fi
120
+ if [[ -e "$value" ]]; then
121
+ dir="$(dirname "$value")"
122
+ base="$(basename "$value")"
123
+ (
124
+ cd "$dir"
125
+ printf '%s/%s\n' "$(pwd -P)" "$base"
126
+ )
127
+ return
128
+ fi
129
+ normalize_path "$value"
130
+ }
131
+
132
+ # 确保目录存在。
133
+ ensure_dir() {
134
+ mkdir -p "$1"
135
+ }
136
+
137
+ # 生成兼容 macOS 的临时文件路径。
138
+ portable_mktemp() {
139
+ local prefix="$1"
140
+ mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX"
141
+ }
142
+
143
+ # 返回路径的父目录。
144
+ path_parent() {
145
+ dirname "$1"
146
+ }
147
+
148
+ # 返回路径的文件名部分。
149
+ path_basename() {
150
+ basename "$1"
151
+ }
152
+
153
+ # 判断文件是否存在。
154
+ file_exists() {
155
+ [[ -f "$1" ]]
156
+ }
157
+
158
+ # 判断目录是否存在。
159
+ dir_exists() {
160
+ [[ -d "$1" ]]
161
+ }
162
+
163
+ # 判断命令是否可用。
164
+ command_exists() {
165
+ command -v "$1" >/dev/null 2>&1
166
+ }
167
+
168
+ # 生成备份文件路径。
169
+ backup_path() {
170
+ local original="$1"
171
+ local stamp
172
+ stamp="$(date +"%Y%m%d-%H%M%S")"
173
+ printf '%s/%s.bak-%s\n' "$(dirname "$original")" "$(basename "$original")" "$stamp"
174
+ }
175
+
176
+ # 备份文件,若源文件不存在则直接返回。
177
+ backup_file() {
178
+ local original="$1"
179
+ local backup=""
180
+ if ! file_exists "$original"; then
181
+ return 0
182
+ fi
183
+ backup="$(backup_path "$original")"
184
+ cp "$original" "$backup"
185
+ printf '%s\n' "$backup"
186
+ }
187
+
188
+ # 将键值对写入制表符分隔的简单配置文件。
189
+ write_kv_file() {
190
+ local file="$1"
191
+ shift
192
+ ensure_dir "$(dirname "$file")"
193
+ : >"$file"
194
+ while [[ $# -gt 1 ]]; do
195
+ printf '%s\t%s\n' "$1" "$2" >>"$file"
196
+ shift 2
197
+ done
198
+ }
199
+
200
+ # 读取用户保存的默认路径设置。
201
+ load_settings() {
202
+ SETTINGS_PROFILE_DIR=""
203
+ SETTINGS_DEFAULT_CONFIG_FILE=""
204
+ SETTINGS_ORIGIN_CERT=""
205
+ SETTINGS_MANAGER_ROOT=""
206
+ if ! file_exists "$SETTINGS_FILE"; then
207
+ return 0
208
+ fi
209
+ while IFS=$'\t' read -r key value; do
210
+ case "$key" in
211
+ profile_dir) SETTINGS_PROFILE_DIR="$value" ;;
212
+ default_config_file) SETTINGS_DEFAULT_CONFIG_FILE="$value" ;;
213
+ origin_cert) SETTINGS_ORIGIN_CERT="$value" ;;
214
+ manager_root) SETTINGS_MANAGER_ROOT="$value" ;;
215
+ esac
216
+ done <"$SETTINGS_FILE"
217
+ }
218
+
219
+ # 保存当前解析出的路径设置。
220
+ save_settings() {
221
+ write_kv_file "$SETTINGS_FILE" \
222
+ profile_dir "${PROFILE_DIR:-}" \
223
+ default_config_file "${DEFAULT_CONFIG_FILE:-}" \
224
+ origin_cert "${ORIGIN_CERT:-}" \
225
+ manager_root "${MANAGER_ROOT:-}"
226
+ }
227
+
228
+ # 在常见目录中自动发现一个可用的 profile 目录。
229
+ discover_profile_dir() {
230
+ local candidates=()
231
+ local candidate=""
232
+ if [[ -n "${SETTINGS_PROFILE_DIR:-}" ]]; then
233
+ candidates+=("${SETTINGS_PROFILE_DIR}")
234
+ fi
235
+ if [[ -n "${CLOUDFLARED_PROFILE_DIR:-}" ]]; then
236
+ candidates+=("${CLOUDFLARED_PROFILE_DIR}")
237
+ fi
238
+ if [[ -n "${CLOUDFLARED_DIR:-}" ]]; then
239
+ candidates+=("${CLOUDFLARED_DIR}")
240
+ fi
241
+ candidates+=(
242
+ "$PWD/.cloudflared"
243
+ "$PWD/cloudflared"
244
+ "$HOME/.cloudflared"
245
+ "$HOME/cloudflared"
246
+ )
247
+ for candidate in "${candidates[@]}"; do
248
+ [[ -z "$candidate" ]] && continue
249
+ candidate="$(canonical_path "$candidate")"
250
+ if dir_exists "$candidate" && { file_exists "$candidate/config.yml" || file_exists "$candidate/cert.pem"; }; then
251
+ printf '%s\n' "$candidate"
252
+ return 0
253
+ fi
254
+ done
255
+ return 1
256
+ }
257
+
258
+ # 解析 profile、config、证书和管理目录的最终路径。
259
+ resolve_paths() {
260
+ local anchor_dir=""
261
+ local discovered=""
262
+
263
+ load_settings
264
+
265
+ PROFILE_DIR="${CLI_PROFILE_DIR:-${CLOUDFLARED_PROFILE_DIR:-${CLOUDFLARED_DIR:-${SETTINGS_PROFILE_DIR:-}}}}"
266
+ DEFAULT_CONFIG_FILE="${CLI_DEFAULT_CONFIG_FILE:-${CLOUDFLARED_CONFIG_FILE:-${SETTINGS_DEFAULT_CONFIG_FILE:-}}}"
267
+ ORIGIN_CERT="${CLI_ORIGIN_CERT:-${CLOUDFLARED_ORIGIN_CERT:-${TUNNEL_ORIGIN_CERT:-${SETTINGS_ORIGIN_CERT:-}}}}"
268
+ MANAGER_ROOT="${CLI_MANAGER_ROOT:-${CLOUDFLARED_MANAGER_ROOT:-${SETTINGS_MANAGER_ROOT:-}}}"
269
+
270
+ if [[ -z "$PROFILE_DIR" && -z "$DEFAULT_CONFIG_FILE" && -z "$ORIGIN_CERT" ]]; then
271
+ discovered="$(discover_profile_dir || true)"
272
+ PROFILE_DIR="$discovered"
273
+ fi
274
+
275
+ if [[ -n "$PROFILE_DIR" ]]; then
276
+ PROFILE_DIR="$(canonical_path "$PROFILE_DIR")"
277
+ fi
278
+
279
+ if [[ -z "$DEFAULT_CONFIG_FILE" && -n "$PROFILE_DIR" ]]; then
280
+ DEFAULT_CONFIG_FILE="$PROFILE_DIR/config.yml"
281
+ fi
282
+ if [[ -n "$DEFAULT_CONFIG_FILE" ]]; then
283
+ DEFAULT_CONFIG_FILE="$(canonical_path "$DEFAULT_CONFIG_FILE")"
284
+ fi
285
+
286
+ if [[ -z "$ORIGIN_CERT" ]]; then
287
+ if [[ -n "$PROFILE_DIR" ]]; then
288
+ ORIGIN_CERT="$PROFILE_DIR/cert.pem"
289
+ elif file_exists "$DEFAULT_LOGIN_CERT"; then
290
+ ORIGIN_CERT="$DEFAULT_LOGIN_CERT"
291
+ fi
292
+ fi
293
+ if [[ -n "$ORIGIN_CERT" ]]; then
294
+ ORIGIN_CERT="$(canonical_path "$ORIGIN_CERT")"
295
+ fi
296
+
297
+ if [[ -z "$MANAGER_ROOT" ]]; then
298
+ if [[ -n "$PROFILE_DIR" ]]; then
299
+ anchor_dir="$PROFILE_DIR"
300
+ elif [[ -n "$DEFAULT_CONFIG_FILE" ]]; then
301
+ anchor_dir="$(dirname "$DEFAULT_CONFIG_FILE")"
302
+ elif [[ -n "$ORIGIN_CERT" ]]; then
303
+ anchor_dir="$(dirname "$ORIGIN_CERT")"
304
+ else
305
+ anchor_dir="$DEFAULT_MANAGER_HOME"
306
+ fi
307
+ MANAGER_ROOT="$anchor_dir/manager"
308
+ if [[ "$anchor_dir" == "$DEFAULT_MANAGER_HOME" ]]; then
309
+ MANAGER_ROOT="$DEFAULT_MANAGER_HOME"
310
+ fi
311
+ fi
312
+ MANAGER_ROOT="$(canonical_path "$MANAGER_ROOT")"
313
+ APPS_DIR="$MANAGER_ROOT/apps"
314
+ ARCHIVE_DIR="$MANAGER_ROOT/archive"
315
+ }
316
+
317
+ # 确保管理目录结构存在。
318
+ ensure_manager_dirs() {
319
+ ensure_dir "$APPS_DIR"
320
+ ensure_dir "$ARCHIVE_DIR"
321
+ }
322
+
323
+ # 确保系统已经安装 cloudflared。
324
+ ensure_cloudflared() {
325
+ if [[ -z "$CLOUDFLARED_BIN" ]]; then
326
+ die "cloudflared 未安装,请先执行 install。"
327
+ fi
328
+ }
329
+
330
+ # 如目标证书不存在,则尝试从默认登录位置自动导入。
331
+ maybe_import_default_login_cert() {
332
+ if [[ -z "${ORIGIN_CERT:-}" ]]; then
333
+ return 0
334
+ fi
335
+ if file_exists "$ORIGIN_CERT"; then
336
+ return 0
337
+ fi
338
+ if ! file_exists "$DEFAULT_LOGIN_CERT"; then
339
+ return 0
340
+ fi
341
+ if [[ "$ORIGIN_CERT" == "$DEFAULT_LOGIN_CERT" ]]; then
342
+ return 0
343
+ fi
344
+ ensure_dir "$(dirname "$ORIGIN_CERT")"
345
+ cp "$DEFAULT_LOGIN_CERT" "$ORIGIN_CERT"
346
+ info "已自动导入登录证书到: $ORIGIN_CERT"
347
+ }
348
+
349
+ # 确保当前环境拥有可用于远端管理的 Origin 证书。
350
+ ensure_logged_in() {
351
+ maybe_import_default_login_cert
352
+ if [[ -z "${ORIGIN_CERT:-}" ]]; then
353
+ die "未配置 Origin 证书路径,请使用 --origincert、--profile-dir 或执行 init/use。"
354
+ fi
355
+ if ! file_exists "$ORIGIN_CERT"; then
356
+ die "找不到 Origin 证书: $ORIGIN_CERT。请先执行 login 或 import-cert。"
357
+ fi
358
+ }
359
+
360
+ # 统一封装依赖 Origin 证书的 cloudflared tunnel 命令。
361
+ remote_cmd() {
362
+ ensure_cloudflared
363
+ ensure_logged_in
364
+ "$CLOUDFLARED_BIN" tunnel --origincert "$ORIGIN_CERT" "$@"
365
+ }
366
+
367
+ # 校验应用名称是否符合目录和标识要求。
368
+ validate_app_name() {
369
+ local name="$1"
370
+ if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]]; then
371
+ die "应用名非法。只允许字母、数字、点、下划线、短横线,且必须以字母或数字开头。"
372
+ fi
373
+ }
374
+
375
+ # 规范化并校验公网 hostname。
376
+ normalize_hostname() {
377
+ local value="$1"
378
+ value="${value%%.}"
379
+ value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
380
+ if [[ ! "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]]; then
381
+ die "主机名非法: $1"
382
+ fi
383
+ printf '%s\n' "$value"
384
+ }
385
+
386
+ # 规范化 service 地址,兼容 host:port 与多种协议写法。
387
+ normalize_service() {
388
+ local value="$1"
389
+ local scheme=""
390
+ local host=""
391
+
392
+ if [[ "$value" == "hello_world" || "$value" == http_status:* ]]; then
393
+ printf '%s\n' "$value"
394
+ return 0
395
+ fi
396
+
397
+ if [[ "$value" == unix:* ]]; then
398
+ printf '%s\n' "$value"
399
+ return 0
400
+ fi
401
+
402
+ if [[ "$value" != *"://"* ]]; then
403
+ if [[ "$value" == *:* ]]; then
404
+ value="http://$value"
405
+ else
406
+ die "服务地址必须包含协议,或使用 host:port 形式,例如 http://localhost:5173"
407
+ fi
408
+ fi
409
+
410
+ if [[ "$value" =~ ^([A-Za-z][A-Za-z0-9+.-]*)://(\[[^]]+\]|[^/:?#]+) ]]; then
411
+ scheme="${BASH_REMATCH[1]}"
412
+ host="${BASH_REMATCH[2]}"
413
+ else
414
+ die "服务地址格式非法: $1"
415
+ fi
416
+
417
+ case "$scheme" in
418
+ http|https|tcp|ssh|rdp) ;;
419
+ *) die "不支持的服务协议: $scheme。支持 http、https、tcp、ssh、rdp、unix:、hello_world、http_status:*" ;;
420
+ esac
421
+
422
+ if [[ -z "$host" ]]; then
423
+ die "服务地址缺少主机名: $1"
424
+ fi
425
+
426
+ printf '%s\n' "$value"
427
+ }
428
+
429
+ # 从 service URL 中提取协议、主机和端口,供连通性检查复用。
430
+ parse_service_url() {
431
+ local service="$1"
432
+ PARSED_SCHEME=""
433
+ PARSED_HOST=""
434
+ PARSED_PORT=""
435
+
436
+ if [[ "$service" =~ ^([A-Za-z][A-Za-z0-9+.-]*)://(\[[^]]+\]|[^/:?#]+)(:([0-9]+))? ]]; then
437
+ PARSED_SCHEME="${BASH_REMATCH[1]}"
438
+ PARSED_HOST="${BASH_REMATCH[2]}"
439
+ PARSED_PORT="${BASH_REMATCH[4]}"
440
+ PARSED_HOST="${PARSED_HOST#[}"
441
+ PARSED_HOST="${PARSED_HOST%]}"
442
+ fi
443
+ }
444
+
445
+ # 校验本地 service 是否可达,避免 tunnel 启动后立刻失败。
446
+ local_target_reachable() {
447
+ local service="$1"
448
+ local timeout="${2:-2}"
449
+ local host=""
450
+ local port=""
451
+
452
+ if [[ "$service" == "hello_world" || "$service" == http_status:* ]]; then
453
+ return 0
454
+ fi
455
+
456
+ if [[ "$service" == unix:* ]]; then
457
+ local socket_path="${service#unix:}"
458
+ if ! file_exists "$socket_path"; then
459
+ die "Unix socket 不存在: $socket_path"
460
+ fi
461
+ return 0
462
+ fi
463
+
464
+ parse_service_url "$service"
465
+ host="$PARSED_HOST"
466
+ port="$PARSED_PORT"
467
+ if [[ -z "$host" ]]; then
468
+ die "服务地址格式非法: $service"
469
+ fi
470
+ case "$host" in
471
+ localhost|127.0.0.1|::1) ;;
472
+ *) return 0 ;;
473
+ esac
474
+
475
+ if [[ -z "$port" ]]; then
476
+ case "$PARSED_SCHEME" in
477
+ http) port="80" ;;
478
+ https) port="443" ;;
479
+ ssh) port="22" ;;
480
+ rdp) port="3389" ;;
481
+ *) die "服务地址缺少端口: $service" ;;
482
+ esac
483
+ fi
484
+
485
+ if ! command_exists nc; then
486
+ warn "系统没有 nc,跳过本地服务连通性检查。"
487
+ return 0
488
+ fi
489
+
490
+ if ! nc -z -G "$timeout" "$host" "$port" >/dev/null 2>&1; then
491
+ die "本地服务无法连接: $service"
492
+ fi
493
+ }
494
+
495
+ # 返回指定应用的根目录。
496
+ app_dir() {
497
+ printf '%s/%s\n' "$APPS_DIR" "$1"
498
+ }
499
+
500
+ # 返回指定应用的元数据文件路径。
501
+ meta_path() {
502
+ printf '%s/meta.env\n' "$(app_dir "$1")"
503
+ }
504
+
505
+ # 返回指定应用的配置文件路径。
506
+ config_path() {
507
+ printf '%s/config.yml\n' "$(app_dir "$1")"
508
+ }
509
+
510
+ # 返回指定应用的凭据文件路径。
511
+ credentials_path() {
512
+ printf '%s/credentials.json\n' "$(app_dir "$1")"
513
+ }
514
+
515
+ # 返回指定应用的 ingress 规则文件路径。
516
+ ingress_path() {
517
+ printf '%s/ingress.tsv\n' "$(app_dir "$1")"
518
+ }
519
+
520
+ # 返回指定应用的 PID 文件路径。
521
+ pid_path() {
522
+ printf '%s/run/cloudflared.pid\n' "$(app_dir "$1")"
523
+ }
524
+
525
+ # 返回指定应用的日志文件路径。
526
+ log_path() {
527
+ printf '%s/logs/cloudflared.log\n' "$(app_dir "$1")"
528
+ }
529
+
530
+ # 返回指定应用的备份目录路径。
531
+ backups_dir() {
532
+ printf '%s/backups\n' "$(app_dir "$1")"
533
+ }
534
+
535
+ # 从 config.yml 读取顶层字段值。
536
+ read_top_level_config_value() {
537
+ local config_file="$1"
538
+ local key="$2"
539
+ awk -F': ' -v wanted="$key" '
540
+ $1 == wanted {
541
+ sub(/^[^:]+:[[:space:]]*/, "", $0)
542
+ print
543
+ exit
544
+ }
545
+ ' "$config_file"
546
+ }
547
+
548
+ # 从 config.yml 中读取第一个 ingress hostname。
549
+ read_first_ingress_hostname() {
550
+ local config_file="$1"
551
+ awk '
552
+ /^[[:space:]]*-[[:space:]]*hostname:[[:space:]]*/ {
553
+ sub(/^[[:space:]]*-[[:space:]]*hostname:[[:space:]]*/, "", $0)
554
+ print
555
+ exit
556
+ }
557
+ ' "$config_file"
558
+ }
559
+
560
+ # 从 config.yml 中读取第一个 ingress service。
561
+ read_first_ingress_service() {
562
+ local config_file="$1"
563
+ awk '
564
+ /^[[:space:]]*service:[[:space:]]*/ {
565
+ sub(/^[[:space:]]*service:[[:space:]]*/, "", $0)
566
+ print
567
+ exit
568
+ }
569
+ ' "$config_file"
570
+ }
571
+
572
+ # 从 credentials JSON 中提取 TunnelID。
573
+ read_tunnel_id_from_credentials() {
574
+ local credentials_file="$1"
575
+ plutil -extract TunnelID raw -o - "$credentials_file" 2>/dev/null || true
576
+ }
577
+
578
+ # 清空当前进程内缓存的 ingress 规则。
579
+ reset_ingress_rules() {
580
+ INGRESS_HOSTS=()
581
+ INGRESS_SERVICES=()
582
+ }
583
+
584
+ # 返回当前缓存中的 ingress 规则数量。
585
+ ingress_rule_count() {
586
+ printf '%s\n' "${#INGRESS_HOSTS[@]}"
587
+ }
588
+
589
+ # 查找某个 hostname 在 ingress 规则中的位置,可选跳过指定序号。
590
+ find_ingress_rule_index_by_hostname() {
591
+ local wanted="$1"
592
+ local skip_index="${2:--1}"
593
+ local i=0
594
+ while [[ "$i" -lt "${#INGRESS_HOSTS[@]}" ]]; do
595
+ if [[ "$i" != "$skip_index" && "${INGRESS_HOSTS[$i]}" == "$wanted" ]]; then
596
+ printf '%s\n' "$i"
597
+ return 0
598
+ fi
599
+ i=$((i + 1))
600
+ done
601
+ return 1
602
+ }
603
+
604
+ # 解析形如 hostname=service 的 ingress 简写。
605
+ parse_ingress_spec() {
606
+ local spec="$1"
607
+ local hostname=""
608
+ local service=""
609
+
610
+ if [[ "$spec" != *=* ]]; then
611
+ die "ingress 规则格式非法: $spec。请使用 --ingress hostname=service"
612
+ fi
613
+
614
+ hostname="${spec%%=*}"
615
+ service="${spec#*=}"
616
+ [[ -n "$hostname" ]] || die "ingress 规则缺少 hostname: $spec"
617
+ [[ -n "$service" ]] || die "ingress 规则缺少 service: $spec"
618
+
619
+ PARSED_INGRESS_HOSTNAME="$(normalize_hostname "$hostname")"
620
+ PARSED_INGRESS_SERVICE="$(normalize_service "$service")"
621
+ }
622
+
623
+ # 解析形如 2:hostname=service 的批量修改简写。
624
+ parse_modify_spec() {
625
+ local spec="$1"
626
+ local index_part=""
627
+ local ingress_part=""
628
+
629
+ if [[ "$spec" != *:* || "$spec" != *=* ]]; then
630
+ die "批量修改格式非法: $spec。请使用 --set N:hostname=service"
631
+ fi
632
+
633
+ index_part="${spec%%:*}"
634
+ ingress_part="${spec#*:}"
635
+ [[ "$index_part" =~ ^[1-9][0-9]*$ ]] || die "批量修改序号非法: $spec"
636
+
637
+ parse_ingress_spec "$ingress_part"
638
+ PARSED_MODIFY_INDEX="$index_part"
639
+ PARSED_MODIFY_HOSTNAME="$PARSED_INGRESS_HOSTNAME"
640
+ PARSED_MODIFY_SERVICE="$PARSED_INGRESS_SERVICE"
641
+ }
642
+
643
+ # 向当前缓存追加一条 ingress 规则,并确保 hostname 不重复。
644
+ append_ingress_rule() {
645
+ local hostname="$1"
646
+ local service="$2"
647
+
648
+ if find_ingress_rule_index_by_hostname "$hostname" >/dev/null 2>&1; then
649
+ die "同一个 Tunnel 下不能重复使用主机名: $hostname"
650
+ fi
651
+
652
+ INGRESS_HOSTS+=("$hostname")
653
+ INGRESS_SERVICES+=("$service")
654
+ }
655
+
656
+ # 根据命令行参数组装本次要写入的 ingress 规则集合。
657
+ build_requested_ingress_rules() {
658
+ local hostname="${1:-}"
659
+ local service="${2:-}"
660
+ shift 2 || true
661
+ local spec=""
662
+
663
+ reset_ingress_rules
664
+
665
+ if [[ -n "$hostname" || -n "$service" ]]; then
666
+ [[ -n "$hostname" ]] || die "缺少 --hostname"
667
+ [[ -n "$service" ]] || die "缺少 --service"
668
+ append_ingress_rule "$(normalize_hostname "$hostname")" "$(normalize_service "$service")"
669
+ fi
670
+
671
+ for spec in "$@"; do
672
+ parse_ingress_spec "$spec"
673
+ append_ingress_rule "$PARSED_INGRESS_HOSTNAME" "$PARSED_INGRESS_SERVICE"
674
+ done
675
+
676
+ if [[ "$(ingress_rule_count)" == "0" ]]; then
677
+ die "至少需要提供一条 ingress 规则。可使用 --hostname/--service,或重复传 --ingress hostname=service"
678
+ fi
679
+ }
680
+
681
+ # 从 ingress.tsv 读取全部规则到当前缓存。
682
+ load_ingress_rules_file() {
683
+ local file="$1"
684
+ local hostname=""
685
+ local service=""
686
+
687
+ reset_ingress_rules
688
+ while IFS=$'\t' read -r hostname service; do
689
+ [[ -z "$hostname" ]] && continue
690
+ [[ -z "$service" ]] && continue
691
+ append_ingress_rule "$hostname" "$service"
692
+ done <"$file"
693
+ }
694
+
695
+ # 从当前缓存同步主规则,兼容旧版单规则字段。
696
+ sync_primary_ingress_to_meta() {
697
+ if [[ "${#INGRESS_HOSTS[@]}" -gt 0 ]]; then
698
+ META_HOSTNAME="${INGRESS_HOSTS[0]}"
699
+ META_SERVICE="${INGRESS_SERVICES[0]}"
700
+ else
701
+ META_HOSTNAME=""
702
+ META_SERVICE=""
703
+ fi
704
+ }
705
+
706
+ # 将当前缓存中的 ingress 规则写回 ingress.tsv。
707
+ save_ingress_rules() {
708
+ local i=0
709
+ ensure_dir "$(dirname "$META_INGRESS_FILE")"
710
+ : >"$META_INGRESS_FILE"
711
+ while [[ "$i" -lt "${#INGRESS_HOSTS[@]}" ]]; do
712
+ printf '%s\t%s\n' "${INGRESS_HOSTS[$i]}" "${INGRESS_SERVICES[$i]}" >>"$META_INGRESS_FILE"
713
+ i=$((i + 1))
714
+ done
715
+ sync_primary_ingress_to_meta
716
+ }
717
+
718
+ # 确保当前应用拥有 ingress 规则文件,旧数据会自动迁移。
719
+ ensure_ingress_rules_loaded() {
720
+ if [[ -z "$META_INGRESS_FILE" && -n "$META_NAME" ]]; then
721
+ META_INGRESS_FILE="$(canonical_path "$(ingress_path "$META_NAME")")"
722
+ fi
723
+
724
+ if [[ -n "$META_INGRESS_FILE" && -f "$META_INGRESS_FILE" ]]; then
725
+ load_ingress_rules_file "$META_INGRESS_FILE"
726
+ sync_primary_ingress_to_meta
727
+ return 0
728
+ fi
729
+
730
+ reset_ingress_rules
731
+ if [[ -n "$META_HOSTNAME" && -n "$META_SERVICE" ]]; then
732
+ append_ingress_rule "$(normalize_hostname "$META_HOSTNAME")" "$(normalize_service "$META_SERVICE")"
733
+ sync_primary_ingress_to_meta
734
+ return 0
735
+ fi
736
+
737
+ die "找不到 ingress 规则文件: $META_INGRESS_FILE"
738
+ }
739
+
740
+ # 从现有 config.yml 中读取全部 hostname/service ingress 规则。
741
+ load_ingress_rules_from_config() {
742
+ local config_file="$1"
743
+ local hostname=""
744
+ local service=""
745
+
746
+ reset_ingress_rules
747
+ while IFS=$'\t' read -r hostname service; do
748
+ [[ -z "$hostname" ]] && continue
749
+ [[ -z "$service" ]] && continue
750
+ append_ingress_rule "$(normalize_hostname "$hostname")" "$(normalize_service "$service")"
751
+ done < <(
752
+ awk '
753
+ /^[[:space:]]*-[[:space:]]*hostname:[[:space:]]*/ {
754
+ line = $0
755
+ sub(/^[[:space:]]*-[[:space:]]*hostname:[[:space:]]*/, "", line)
756
+ host = line
757
+ next
758
+ }
759
+ /^[[:space:]]*hostname:[[:space:]]*/ {
760
+ line = $0
761
+ sub(/^[[:space:]]*hostname:[[:space:]]*/, "", line)
762
+ host = line
763
+ next
764
+ }
765
+ /^[[:space:]]*service:[[:space:]]*/ {
766
+ line = $0
767
+ sub(/^[[:space:]]*service:[[:space:]]*/, "", line)
768
+ if (host != "") {
769
+ print host "\t" line
770
+ host = ""
771
+ }
772
+ next
773
+ }
774
+ /^[[:space:]]*-[[:space:]]*service:[[:space:]]*/ {
775
+ host = ""
776
+ next
777
+ }
778
+ ' "$config_file"
779
+ )
780
+
781
+ [[ "$(ingress_rule_count)" != "0" ]]
782
+ }
783
+
784
+ # 校验当前缓存中的全部本地服务是否可达。
785
+ check_all_ingress_targets() {
786
+ local i=0
787
+ while [[ "$i" -lt "${#INGRESS_SERVICES[@]}" ]]; do
788
+ local_target_reachable "${INGRESS_SERVICES[$i]}"
789
+ i=$((i + 1))
790
+ done
791
+ }
792
+
793
+ # 为当前缓存中的全部 hostname 建立 DNS 路由。
794
+ route_all_dns() {
795
+ local tunnel_name="$1"
796
+ local overwrite="${2:-0}"
797
+ local i=0
798
+ while [[ "$i" -lt "${#INGRESS_HOSTS[@]}" ]]; do
799
+ route_dns "$tunnel_name" "${INGRESS_HOSTS[$i]}" "$overwrite"
800
+ i=$((i + 1))
801
+ done
802
+ }
803
+
804
+ # 复制现有 config.yml,并把 credentials-file 改写到受管目录。
805
+ copy_config_with_credentials_file() {
806
+ local source_config="$1"
807
+ local target_config="$2"
808
+ local target_credentials="$3"
809
+ ensure_dir "$(dirname "$target_config")"
810
+ awk -v credentials="$target_credentials" '
811
+ BEGIN { replaced = 0 }
812
+ /^credentials-file:[[:space:]]*/ {
813
+ print "credentials-file: " credentials
814
+ replaced = 1
815
+ next
816
+ }
817
+ { print }
818
+ END {
819
+ if (!replaced) {
820
+ print "credentials-file: " credentials
821
+ }
822
+ }
823
+ ' "$source_config" >"$target_config"
824
+ }
825
+
826
+ # 初始化某个应用的目录结构。
827
+ init_app_dirs() {
828
+ local name="$1"
829
+ ensure_manager_dirs
830
+ ensure_dir "$(app_dir "$name")"
831
+ ensure_dir "$(dirname "$(pid_path "$name")")"
832
+ ensure_dir "$(dirname "$(log_path "$name")")"
833
+ ensure_dir "$(backups_dir "$name")"
834
+ }
835
+
836
+ # 判断某个应用是否已被接管。
837
+ app_exists() {
838
+ file_exists "$(meta_path "$1")"
839
+ }
840
+
841
+ # 清空内存中的元数据变量,避免旧数据串用。
842
+ reset_meta() {
843
+ META_NAME=""
844
+ META_TUNNEL_NAME=""
845
+ META_TUNNEL_ID=""
846
+ META_HOSTNAME=""
847
+ META_SERVICE=""
848
+ META_APP_DIR=""
849
+ META_CONFIG_FILE=""
850
+ META_CREDENTIALS_FILE=""
851
+ META_INGRESS_FILE=""
852
+ META_PID_FILE=""
853
+ META_LOG_FILE=""
854
+ META_META_FILE=""
855
+ META_CREATED_AT=""
856
+ META_UPDATED_AT=""
857
+ reset_ingress_rules
858
+ }
859
+
860
+ # 尝试把当前默认 config.yml 和现有凭据自动接管成受管应用。
861
+ try_import_current_default_app() {
862
+ local name="$1"
863
+ local tunnel_name=""
864
+ local source_credentials=""
865
+ local tunnel_id=""
866
+
867
+ if [[ -z "${DEFAULT_CONFIG_FILE:-}" ]] || ! file_exists "$DEFAULT_CONFIG_FILE"; then
868
+ return 1
869
+ fi
870
+
871
+ tunnel_name="$(read_top_level_config_value "$DEFAULT_CONFIG_FILE" "tunnel")"
872
+ if [[ -z "$tunnel_name" || "$tunnel_name" != "$name" ]]; then
873
+ return 1
874
+ fi
875
+
876
+ source_credentials="$(read_top_level_config_value "$DEFAULT_CONFIG_FILE" "credentials-file")"
877
+
878
+ if [[ -z "$source_credentials" ]]; then
879
+ return 1
880
+ fi
881
+
882
+ if ! load_ingress_rules_from_config "$DEFAULT_CONFIG_FILE"; then
883
+ return 1
884
+ fi
885
+ if [[ "$(ingress_rule_count)" == "0" ]]; then
886
+ return 1
887
+ fi
888
+
889
+ source_credentials="$(canonical_path "$source_credentials")"
890
+ if ! file_exists "$source_credentials"; then
891
+ return 1
892
+ fi
893
+
894
+ tunnel_id="$(read_tunnel_id_from_credentials "$source_credentials")"
895
+ if [[ -z "$tunnel_id" && -n "${ORIGIN_CERT:-}" ]] && file_exists "$ORIGIN_CERT"; then
896
+ remote_lookup_by_name "$tunnel_name"
897
+ if [[ "$REMOTE_FOUND" == "1" ]]; then
898
+ tunnel_id="$REMOTE_TUNNEL_ID"
899
+ fi
900
+ fi
901
+
902
+ build_meta "$name" "$tunnel_name" "$tunnel_id" "${INGRESS_HOSTS[0]}" "${INGRESS_SERVICES[0]}"
903
+ cp "$source_credentials" "$META_CREDENTIALS_FILE"
904
+ save_ingress_rules
905
+ copy_config_with_credentials_file "$DEFAULT_CONFIG_FILE" "$META_CONFIG_FILE" "$META_CREDENTIALS_FILE"
906
+ save_meta
907
+ info "已自动接管现有默认配置为受管应用: $name"
908
+ return 0
909
+ }
910
+
911
+ # 从 meta.env 读取应用元数据到当前进程变量。
912
+ load_meta() {
913
+ local name="$1"
914
+ local file
915
+ file="$(meta_path "$name")"
916
+ reset_meta
917
+ if ! file_exists "$file"; then
918
+ if ! try_import_current_default_app "$name"; then
919
+ die "未找到受管应用: $name。远端 Tunnel 存在,不代表已经被本脚本接管。请执行 adopt,或让默认 config.yml 指向该 Tunnel 后重试。"
920
+ fi
921
+ file="$(meta_path "$name")"
922
+ fi
923
+ while IFS=$'\t' read -r key value; do
924
+ case "$key" in
925
+ name) META_NAME="$value" ;;
926
+ tunnel_name) META_TUNNEL_NAME="$value" ;;
927
+ tunnel_id) META_TUNNEL_ID="$value" ;;
928
+ hostname) META_HOSTNAME="$value" ;;
929
+ service) META_SERVICE="$value" ;;
930
+ app_dir) META_APP_DIR="$value" ;;
931
+ config_file) META_CONFIG_FILE="$value" ;;
932
+ credentials_file) META_CREDENTIALS_FILE="$value" ;;
933
+ ingress_file) META_INGRESS_FILE="$value" ;;
934
+ pid_file) META_PID_FILE="$value" ;;
935
+ log_file) META_LOG_FILE="$value" ;;
936
+ meta_file) META_META_FILE="$value" ;;
937
+ created_at) META_CREATED_AT="$value" ;;
938
+ updated_at) META_UPDATED_AT="$value" ;;
939
+ esac
940
+ done <"$file"
941
+ if [[ -z "$META_INGRESS_FILE" ]]; then
942
+ META_INGRESS_FILE="$(canonical_path "$(ingress_path "$name")")"
943
+ fi
944
+ ensure_ingress_rules_loaded
945
+ }
946
+
947
+ # 将当前元数据变量写回 meta.env。
948
+ save_meta() {
949
+ sync_primary_ingress_to_meta
950
+ META_UPDATED_AT="$(utc_now)"
951
+ write_kv_file "$META_META_FILE" \
952
+ name "$META_NAME" \
953
+ tunnel_name "$META_TUNNEL_NAME" \
954
+ tunnel_id "$META_TUNNEL_ID" \
955
+ hostname "$META_HOSTNAME" \
956
+ service "$META_SERVICE" \
957
+ app_dir "$META_APP_DIR" \
958
+ config_file "$META_CONFIG_FILE" \
959
+ credentials_file "$META_CREDENTIALS_FILE" \
960
+ ingress_file "$META_INGRESS_FILE" \
961
+ pid_file "$META_PID_FILE" \
962
+ log_file "$META_LOG_FILE" \
963
+ meta_file "$META_META_FILE" \
964
+ created_at "$META_CREATED_AT" \
965
+ updated_at "$META_UPDATED_AT"
966
+ }
967
+
968
+ # 根据输入参数构造一份完整的应用元数据。
969
+ build_meta() {
970
+ local name="$1"
971
+ local tunnel_name="$2"
972
+ local tunnel_id="$3"
973
+ local hostname="$4"
974
+ local service="$5"
975
+ init_app_dirs "$name"
976
+ META_NAME="$name"
977
+ META_TUNNEL_NAME="$tunnel_name"
978
+ META_TUNNEL_ID="$tunnel_id"
979
+ META_HOSTNAME="$hostname"
980
+ META_SERVICE="$service"
981
+ META_APP_DIR="$(canonical_path "$(app_dir "$name")")"
982
+ META_CONFIG_FILE="$(canonical_path "$(config_path "$name")")"
983
+ META_CREDENTIALS_FILE="$(canonical_path "$(credentials_path "$name")")"
984
+ META_INGRESS_FILE="$(canonical_path "$(ingress_path "$name")")"
985
+ META_PID_FILE="$(canonical_path "$(pid_path "$name")")"
986
+ META_LOG_FILE="$(canonical_path "$(log_path "$name")")"
987
+ META_META_FILE="$(canonical_path "$(meta_path "$name")")"
988
+ META_CREATED_AT="$(utc_now)"
989
+ META_UPDATED_AT="$META_CREATED_AT"
990
+ }
991
+
992
+ # 渲染 cloudflared 所需的 config.yml 内容。
993
+ render_config() {
994
+ local i=0
995
+
996
+ if [[ "$(ingress_rule_count)" == "0" ]]; then
997
+ die "当前应用没有可用的 ingress 规则,无法生成 config.yml"
998
+ fi
999
+
1000
+ cat <<EOF
1001
+ tunnel: $META_TUNNEL_NAME
1002
+ credentials-file: $META_CREDENTIALS_FILE
1003
+
1004
+ ingress:
1005
+ EOF
1006
+ while [[ "$i" -lt "${#INGRESS_HOSTS[@]}" ]]; do
1007
+ cat <<EOF
1008
+ - hostname: ${INGRESS_HOSTS[$i]}
1009
+ service: ${INGRESS_SERVICES[$i]}
1010
+ EOF
1011
+ i=$((i + 1))
1012
+ done
1013
+ cat <<EOF
1014
+ - service: http_status:404
1015
+ EOF
1016
+ }
1017
+
1018
+ # 写入应用配置,可选先备份旧文件。
1019
+ write_config() {
1020
+ local do_backup="${1:-1}"
1021
+ if [[ "$do_backup" == "1" ]]; then
1022
+ backup_file "$META_CONFIG_FILE" >/dev/null || true
1023
+ fi
1024
+ ensure_dir "$(dirname "$META_CONFIG_FILE")"
1025
+ render_config >"$META_CONFIG_FILE"
1026
+ }
1027
+
1028
+ # 使用 cloudflared 自带校验能力检查 config.yml 是否有效。
1029
+ validate_config() {
1030
+ ensure_cloudflared
1031
+ "$CLOUDFLARED_BIN" tunnel --config "$1" ingress validate >/dev/null
1032
+ }
1033
+
1034
+ # 读取 PID 文件中的进程号。
1035
+ read_pid() {
1036
+ local pid_file="$1"
1037
+ if ! file_exists "$pid_file"; then
1038
+ return 1
1039
+ fi
1040
+ tr -d '[:space:]' <"$pid_file"
1041
+ }
1042
+
1043
+ # 查询指定 PID 对应的命令行。
1044
+ process_command() {
1045
+ local pid="$1"
1046
+ ps -p "$pid" -o command= 2>/dev/null || true
1047
+ }
1048
+
1049
+ # 判断某个 PID 是否仍在运行,并可选校验其命令行特征。
1050
+ is_pid_running() {
1051
+ local pid="$1"
1052
+ local expected_hint="${2:-}"
1053
+ local cmdline=""
1054
+ if ! kill -0 "$pid" >/dev/null 2>&1; then
1055
+ return 1
1056
+ fi
1057
+ if [[ -z "$expected_hint" ]]; then
1058
+ return 0
1059
+ fi
1060
+ cmdline="$(process_command "$pid")"
1061
+ if [[ -z "$cmdline" ]]; then
1062
+ return 0
1063
+ fi
1064
+ [[ "$cmdline" == *"$expected_hint"* ]]
1065
+ }
1066
+
1067
+ # 通过 pidfile 和 pgrep 双重方式定位当前应用的运行进程。
1068
+ find_running_pid() {
1069
+ local pid=""
1070
+ if pid="$(read_pid "$META_PID_FILE" 2>/dev/null || true)"; then
1071
+ if [[ -n "$pid" ]] && is_pid_running "$pid" "$META_CONFIG_FILE"; then
1072
+ printf '%s\n' "$pid"
1073
+ return 0
1074
+ fi
1075
+ fi
1076
+ rm -f "$META_PID_FILE"
1077
+ while IFS= read -r pid; do
1078
+ [[ -z "$pid" ]] && continue
1079
+ if is_pid_running "$pid" "$META_CONFIG_FILE"; then
1080
+ ensure_dir "$(dirname "$META_PID_FILE")"
1081
+ printf '%s\n' "$pid" >"$META_PID_FILE"
1082
+ printf '%s\n' "$pid"
1083
+ return 0
1084
+ fi
1085
+ done < <(pgrep -f "$META_CONFIG_FILE" 2>/dev/null || true)
1086
+ return 1
1087
+ }
1088
+
1089
+ # 读取日志文件尾部若干行。
1090
+ read_log_tail() {
1091
+ local log_file="$1"
1092
+ local lines="$2"
1093
+ if ! file_exists "$log_file"; then
1094
+ return 0
1095
+ fi
1096
+ tail -n "$lines" "$log_file"
1097
+ }
1098
+
1099
+ # 为指定 tunnel 拉取并写入凭据文件。
1100
+ issue_credentials() {
1101
+ local tunnel_name="$1"
1102
+ local output_file="$2"
1103
+ ensure_dir "$(dirname "$output_file")"
1104
+ remote_cmd token --cred-file "$output_file" "$tunnel_name" >/dev/null
1105
+ if ! file_exists "$output_file"; then
1106
+ die "写入凭据文件失败: $output_file"
1107
+ fi
1108
+ }
1109
+
1110
+ # 创建远端 tunnel,并尽量把凭据直接落到受管目录。
1111
+ create_remote_tunnel() {
1112
+ local tunnel_name="$1"
1113
+ local output_file="$2"
1114
+ remote_lookup_by_name "$tunnel_name"
1115
+ if [[ "$REMOTE_FOUND" == "1" ]]; then
1116
+ die "远端 tunnel 已存在: $tunnel_name。请使用 adopt 或 add --use-existing。"
1117
+ fi
1118
+ ensure_dir "$(dirname "$output_file")"
1119
+ remote_cmd create --cred-file "$output_file" "$tunnel_name" >/dev/null
1120
+ if ! file_exists "$output_file"; then
1121
+ warn "创建 tunnel 时未直接生成凭据文件,将尝试补发 token。"
1122
+ issue_credentials "$tunnel_name" "$output_file"
1123
+ fi
1124
+ remote_lookup_by_name "$tunnel_name"
1125
+ if [[ "$REMOTE_FOUND" != "1" ]]; then
1126
+ die "Tunnel 已创建,但无法再次查询到: $tunnel_name"
1127
+ fi
1128
+ }
1129
+
1130
+ # 为 tunnel 绑定或覆盖 DNS 记录。
1131
+ route_dns() {
1132
+ local tunnel_name="$1"
1133
+ local hostname="$2"
1134
+ local overwrite="${3:-0}"
1135
+ if [[ "$overwrite" == "1" ]]; then
1136
+ remote_cmd route dns --overwrite-dns "$tunnel_name" "$hostname" >/dev/null
1137
+ else
1138
+ remote_cmd route dns "$tunnel_name" "$hostname" >/dev/null
1139
+ fi
1140
+ }
1141
+
1142
+ # 将当前应用配置复制为默认 config.yml。
1143
+ activate_default_config() {
1144
+ local backup=""
1145
+ if [[ -z "${DEFAULT_CONFIG_FILE:-}" ]]; then
1146
+ die "未配置默认配置文件路径,请使用 --config-file、--profile-dir 或 init/use。"
1147
+ fi
1148
+ ensure_dir "$(dirname "$DEFAULT_CONFIG_FILE")"
1149
+ backup="$(backup_file "$DEFAULT_CONFIG_FILE" || true)"
1150
+ cp "$META_CONFIG_FILE" "$DEFAULT_CONFIG_FILE"
1151
+ if [[ -n "$backup" ]]; then
1152
+ info "已备份默认配置: $backup"
1153
+ fi
1154
+ }
1155
+
1156
+ # 将应用目录归档到 archive 目录,便于后续追溯。
1157
+ archive_app_dir() {
1158
+ local src="$1"
1159
+ local stamp
1160
+ stamp="$(date +"%Y%m%d-%H%M%S")"
1161
+ ensure_manager_dirs
1162
+ local dest="$ARCHIVE_DIR/$(basename "$src")-$stamp"
1163
+ mv "$src" "$dest"
1164
+ printf '%s\n' "$dest"
1165
+ }
1166
+
1167
+ # 列出当前管理目录下的全部受管应用名称。
1168
+ managed_apps() {
1169
+ if ! dir_exists "$APPS_DIR"; then
1170
+ return 0
1171
+ fi
1172
+ local found=0
1173
+ local entry=""
1174
+ for entry in "$APPS_DIR"/*; do
1175
+ [[ ! -d "$entry" ]] && continue
1176
+ if file_exists "$entry/meta.env"; then
1177
+ basename "$entry"
1178
+ found=1
1179
+ fi
1180
+ done | sort
1181
+ if [[ "$found" == "0" ]]; then
1182
+ return 0
1183
+ fi
1184
+ }
1185
+
1186
+ # 统计 JSON 数组的元素个数。
1187
+ json_index_count() {
1188
+ local file="$1"
1189
+ local i=0
1190
+ while plutil -type "$i" "$file" >/dev/null 2>&1; do
1191
+ i=$((i + 1))
1192
+ done
1193
+ printf '%s\n' "$i"
1194
+ }
1195
+
1196
+ # 从 JSON 中提取原始值。
1197
+ json_raw() {
1198
+ plutil -extract "$2" raw -o - "$1" 2>/dev/null
1199
+ }
1200
+
1201
+ # 按 tunnel 名称查询远端 tunnel 信息并写入缓存变量。
1202
+ remote_lookup_by_name() {
1203
+ local tunnel_name="$1"
1204
+ local tmp_json
1205
+ local count=0
1206
+ local i=0
1207
+ local current_name=""
1208
+ REMOTE_FOUND=0
1209
+ REMOTE_TUNNEL_ID=""
1210
+ REMOTE_TUNNEL_NAME=""
1211
+ REMOTE_TUNNEL_CREATED_AT=""
1212
+ REMOTE_TUNNEL_CONNECTIONS="0"
1213
+
1214
+ tmp_json="$(portable_mktemp "cloudflared-manager-remote")"
1215
+ remote_cmd list --name "$tunnel_name" -o json >"$tmp_json"
1216
+ count="$(json_index_count "$tmp_json")"
1217
+ while [[ "$i" -lt "$count" ]]; do
1218
+ current_name="$(json_raw "$tmp_json" "$i.name" || true)"
1219
+ if [[ "$current_name" == "$tunnel_name" ]]; then
1220
+ REMOTE_FOUND=1
1221
+ REMOTE_TUNNEL_NAME="$current_name"
1222
+ REMOTE_TUNNEL_ID="$(json_raw "$tmp_json" "$i.id" || true)"
1223
+ REMOTE_TUNNEL_CREATED_AT="$(json_raw "$tmp_json" "$i.created_at" || true)"
1224
+ REMOTE_TUNNEL_CONNECTIONS="$(json_raw "$tmp_json" "$i.connections" || printf '0')"
1225
+ rm -f "$tmp_json"
1226
+ return 0
1227
+ fi
1228
+ i=$((i + 1))
1229
+ done
1230
+ rm -f "$tmp_json"
1231
+ }
1232
+
1233
+ # 生成单个应用的一行摘要信息。
1234
+ app_summary() {
1235
+ local pid=""
1236
+ local state="未运行"
1237
+ local connections="0"
1238
+ local count="0"
1239
+
1240
+ if pid="$(find_running_pid 2>/dev/null || true)"; then
1241
+ [[ -n "$pid" ]] && state="运行中"
1242
+ fi
1243
+
1244
+ if [[ -n "$CLOUDFLARED_BIN" && -n "${ORIGIN_CERT:-}" ]] && file_exists "$ORIGIN_CERT"; then
1245
+ remote_lookup_by_name "$META_TUNNEL_NAME"
1246
+ if [[ "$REMOTE_FOUND" == "1" ]]; then
1247
+ connections="$REMOTE_TUNNEL_CONNECTIONS"
1248
+ fi
1249
+ fi
1250
+
1251
+ count="$(ingress_rule_count)"
1252
+ printf '%s: 状态=%s,Tunnel=%s,主入口=%s -> %s,ingress=%s 条,连接数=%s\n' \
1253
+ "$META_NAME" "$state" "$META_TUNNEL_NAME" "$META_HOSTNAME" "$META_SERVICE" "$count" "$connections"
1254
+ }
1255
+
1256
+ # 确保运行所需的凭据文件存在,缺失时自动补发。
1257
+ ensure_credentials_present() {
1258
+ if file_exists "$META_CREDENTIALS_FILE"; then
1259
+ return 0
1260
+ fi
1261
+ if [[ -n "$CLOUDFLARED_BIN" && -n "${ORIGIN_CERT:-}" ]] && file_exists "$ORIGIN_CERT"; then
1262
+ info "凭据文件不存在,尝试自动补发 token: $META_TUNNEL_NAME"
1263
+ issue_credentials "$META_TUNNEL_NAME" "$META_CREDENTIALS_FILE"
1264
+ return 0
1265
+ fi
1266
+ die "找不到凭据文件: $META_CREDENTIALS_FILE"
1267
+ }
1268
+
1269
+ # 启动当前应用对应的 cloudflared 进程,支持前台和后台模式。
1270
+ start_app() {
1271
+ local foreground="${1:-0}"
1272
+ local skip_check="${2:-0}"
1273
+ local pid=""
1274
+ local cmd=()
1275
+
1276
+ if pid="$(find_running_pid 2>/dev/null || true)"; then
1277
+ if [[ -n "$pid" ]]; then
1278
+ die "应用已在运行: $META_NAME"
1279
+ fi
1280
+ fi
1281
+
1282
+ if [[ "$skip_check" == "0" ]]; then
1283
+ check_all_ingress_targets
1284
+ fi
1285
+
1286
+ if ! file_exists "$META_CONFIG_FILE"; then
1287
+ die "找不到配置文件: $META_CONFIG_FILE"
1288
+ fi
1289
+
1290
+ ensure_credentials_present
1291
+ validate_config "$META_CONFIG_FILE"
1292
+
1293
+ cmd=(
1294
+ "$CLOUDFLARED_BIN"
1295
+ tunnel
1296
+ --config "$META_CONFIG_FILE"
1297
+ --no-autoupdate
1298
+ --pidfile "$META_PID_FILE"
1299
+ run "$META_TUNNEL_NAME"
1300
+ )
1301
+
1302
+ if [[ "$foreground" == "1" ]]; then
1303
+ "${cmd[@]}"
1304
+ return 0
1305
+ fi
1306
+
1307
+ ensure_dir "$(dirname "$META_LOG_FILE")"
1308
+ nohup "${cmd[@]}" >>"$META_LOG_FILE" 2>&1 &
1309
+ printf '%s\n' "$!" >"$META_PID_FILE"
1310
+ sleep 2
1311
+ if ! pid="$(find_running_pid 2>/dev/null || true)"; then
1312
+ local tail_output=""
1313
+ tail_output="$(read_log_tail "$META_LOG_FILE" 50 || true)"
1314
+ die "cloudflared 启动后立即退出。最近日志如下:\n${tail_output:-"(暂无日志输出)"}"
1315
+ fi
1316
+ }
1317
+
1318
+ # 停止当前应用对应的 cloudflared 进程,可选强制结束。
1319
+ stop_app() {
1320
+ local force="${1:-0}"
1321
+ local pid=""
1322
+ local deadline=0
1323
+
1324
+ if ! pid="$(find_running_pid 2>/dev/null || true)"; then
1325
+ info "应用未运行: $META_NAME"
1326
+ return 0
1327
+ fi
1328
+ if [[ -z "$pid" ]]; then
1329
+ info "应用未运行: $META_NAME"
1330
+ return 0
1331
+ fi
1332
+
1333
+ kill "$pid" >/dev/null 2>&1 || true
1334
+ deadline=$((SECONDS + 20))
1335
+ while [[ "$SECONDS" -lt "$deadline" ]]; do
1336
+ if ! is_pid_running "$pid" "$META_CONFIG_FILE"; then
1337
+ rm -f "$META_PID_FILE"
1338
+ info "已停止: $META_NAME"
1339
+ return 0
1340
+ fi
1341
+ sleep 1
1342
+ done
1343
+
1344
+ if [[ "$force" == "1" ]]; then
1345
+ kill -9 "$pid" >/dev/null 2>&1 || true
1346
+ rm -f "$META_PID_FILE"
1347
+ info "已强制停止: $META_NAME"
1348
+ return 0
1349
+ fi
1350
+
1351
+ die "停止超时: $META_NAME。请重试 stop --force。"
1352
+ }
1353
+
1354
+ # 打印命令行帮助。
1355
+ print_usage() {
1356
+ cat <<EOF
1357
+ 用法:
1358
+ $SCRIPT_NAME [全局选项] <command> [command options]
1359
+
1360
+ 全局选项:
1361
+ --profile-dir DIR
1362
+ 指定配置档目录。目录名不要求叫 .cloudflared。
1363
+ 如果同时不显式传 --config-file 和 --origincert,则默认会推导为:
1364
+ DIR/config.yml 和 DIR/cert.pem
1365
+
1366
+ --cloudflared-dir DIR
1367
+ --profile-dir 的兼容别名
1368
+
1369
+ --config-file FILE
1370
+ 指定默认 config.yml 的目标路径
1371
+
1372
+ --origincert FILE
1373
+ 指定 Origin 证书路径
1374
+
1375
+ --manager-root DIR
1376
+ 指定受管应用元数据目录
1377
+ 默认会落在 <profile-dir>/manager 或推导出的锚点目录下
1378
+
1379
+ --save-profile
1380
+ 将当前解析出来的路径保存到:
1381
+ $SETTINGS_FILE
1382
+
1383
+ 命令:
1384
+ doctor
1385
+ 查看当前脚本解析到的配置档目录、默认配置文件、Origin 证书、
1386
+ 管理目录、cloudflared 版本,以及远端 Tunnel 数量
1387
+
1388
+ init [--install] [--login]
1389
+ 初始化管理目录并保存当前路径设置
1390
+ 可选顺带安装 cloudflared、执行 tunnel login
1391
+
1392
+ use
1393
+ 仅保存当前路径设置,不做远端操作
1394
+
1395
+ install [--sudo-install]
1396
+ 在 macOS 上下载安装 cloudflared
1397
+ 不带 --sudo-install 时会直接打开 pkg 安装包
1398
+
1399
+ login
1400
+ 执行 cloudflared tunnel login
1401
+ 登录后会尝试把默认证书同步到当前 Origin 证书目标路径
1402
+
1403
+ import-cert [FILE]
1404
+ 把已有证书导入到当前 Origin 证书路径
1405
+ 默认来源为: $DEFAULT_LOGIN_CERT
1406
+
1407
+ tunnels
1408
+ 列出当前 Cloudflare 账号下的远端 Tunnel
1409
+ 这里只是远端列表,不代表已经被本脚本接管
1410
+
1411
+ list
1412
+ 列出已经被本脚本接管的本地受管应用
1413
+
1414
+ show NAME
1415
+ 查看某个受管应用的元数据和 config.yml 内容
1416
+
1417
+ add NAME [--hostname HOST --service URL] [--ingress HOST=URL ...] [其他参数]
1418
+ 创建新的远端 Tunnel,并在本地生成受管目录、配置、凭据和元数据
1419
+ 支持“一个 Tunnel + 多条 ingress 规则”
1420
+ 适合“远端还没创建”的场景
1421
+
1422
+ adopt NAME --tunnel-name NAME [--hostname HOST --service URL] [--ingress HOST=URL ...] [其他参数]
1423
+ 接管一个已经存在的远端 Tunnel
1424
+ 支持把多个 hostname/service 一次性纳入同一个 Tunnel
1425
+ 适合“脚本外已经创建过 Tunnel”的场景
1426
+
1427
+ modify NAME [--index N] [--hostname HOST] [--service URL] [--set N:HOST=URL ...] [其他参数]
1428
+ 修改某个受管应用的一条或多条 ingress 规则
1429
+ 如果应用当前在运行,默认会自动重启
1430
+
1431
+ ingress-list NAME
1432
+ 列出某个受管应用下的全部 ingress 规则
1433
+
1434
+ ingress-add NAME [--hostname HOST --service URL] [--ingress HOST=URL ...] [其他参数]
1435
+ 为某个 Tunnel 追加一条或多条 ingress 规则,并自动写入 DNS 路由
1436
+
1437
+ ingress-remove NAME [--hostname HOST ...] [--index N ...] [其他参数]
1438
+ 从某个 Tunnel 中移除一条或多条 ingress 规则
1439
+ 只会改本地配置,不会自动删除 Cloudflare 上旧 DNS 记录
1440
+
1441
+ start NAME
1442
+ 启动某个已经接管的应用
1443
+
1444
+ stop NAME
1445
+ 停止某个已经接管的应用
1446
+
1447
+ restart NAME
1448
+ 重启某个已经接管的应用
1449
+
1450
+ status [NAME]
1451
+ 查看一个或全部受管应用的状态、PID、配置文件、日志文件
1452
+
1453
+ logs NAME [-f]
1454
+ 查看日志,或持续跟随日志输出
1455
+
1456
+ activate NAME
1457
+ 将某个受管应用的 config.yml 复制为默认 config.yml
1458
+
1459
+ delete NAME [--delete-tunnel]
1460
+ 归档本地受管目录
1461
+ 可选顺带删除远端 Tunnel
1462
+
1463
+ help
1464
+ 显示本帮助
1465
+
1466
+ 常见场景示例:
1467
+
1468
+ 1. 初始化一个新的配置档目录,并完成登录
1469
+ $SCRIPT_NAME --profile-dir /opt/cf-profile init --login
1470
+
1471
+ 2. 只检查当前脚本实际使用了哪些路径
1472
+ $SCRIPT_NAME doctor
1473
+
1474
+ 3. 使用完全拆开的路径,而不是依赖某个 .cloudflared 目录名
1475
+ $SCRIPT_NAME \\
1476
+ --config-file /srv/cloudflared/config.yml \\
1477
+ --origincert /srv/cloudflared/cert.pem \\
1478
+ --manager-root /srv/cloudflared/manager \\
1479
+ doctor
1480
+
1481
+ 4. 创建一个新的远端 Tunnel,并立即后台启动
1482
+ $SCRIPT_NAME add admin-dev \\
1483
+ --hostname admin.example.com \\
1484
+ --service http://localhost:5173 \\
1485
+ --start \\
1486
+ --activate
1487
+
1488
+ 5. 创建一个 Tunnel,同时挂多个域名/服务
1489
+ $SCRIPT_NAME add wchros-main \\
1490
+ --ingress admin.wchros.cn=http://localhost:8080 \\
1491
+ --ingress api.wchros.cn=http://localhost:9000 \\
1492
+ --ingress ssh.wchros.cn=ssh://localhost:22 \\
1493
+ --start
1494
+
1495
+ 6. 接管一个已经存在的远端 Tunnel
1496
+ $SCRIPT_NAME adopt admin-dev \\
1497
+ --tunnel-name wchros-admin-dev \\
1498
+ --hostname admin.wchros.cn \\
1499
+ --service http://localhost:8080 \\
1500
+ --activate
1501
+
1502
+ 7. 给现有 Tunnel 继续追加入口规则
1503
+ $SCRIPT_NAME ingress-add wchros-main \\
1504
+ --ingress static.wchros.cn=http://localhost:7000 \\
1505
+ --ingress blog.wchros.cn=http://localhost:7100
1506
+
1507
+ 8. 查看某个 Tunnel 下面有哪些入口规则
1508
+ $SCRIPT_NAME ingress-list wchros-main
1509
+
1510
+ 9. 查看远端 Tunnel 列表
1511
+ $SCRIPT_NAME tunnels
1512
+
1513
+ 10. 查看已经被脚本接管的本地应用
1514
+ $SCRIPT_NAME list
1515
+
1516
+ 11. 启动、查看状态和日志
1517
+ $SCRIPT_NAME start admin-dev
1518
+ $SCRIPT_NAME status admin-dev
1519
+ $SCRIPT_NAME logs admin-dev -f
1520
+
1521
+ 12. 一次修改多条 ingress 规则并自动重启
1522
+ $SCRIPT_NAME modify wchros-main \\
1523
+ --set 2:api.wchros.cn=http://localhost:9001 \\
1524
+ --set 3:ssh.wchros.cn=ssh://localhost:2222
1525
+
1526
+ 13. 一次删除多条 ingress 规则
1527
+ $SCRIPT_NAME ingress-remove wchros-main --index 3 --hostname blog.wchros.cn
1528
+
1529
+ 14. 删除本地接管记录,并顺带删除远端 Tunnel
1530
+ $SCRIPT_NAME delete admin-dev --delete-tunnel
1531
+
1532
+ 说明:
1533
+ - tunnels 看到的是“远端存在”
1534
+ - list / start / stop / status 操作的是“本地已接管”
1535
+ - 一个受管应用默认对应一个 Tunnel,但这个 Tunnel 下面可以有多条 ingress
1536
+ - 如果远端 Tunnel 已存在,但本地未接管,请先用 adopt
1537
+ EOF
1538
+ }
1539
+
1540
+ # 输出当前环境、路径解析和远端连接情况。
1541
+ cmd_doctor() {
1542
+ local remote_count="未知"
1543
+ printf '设置文件: %s\n' "$SETTINGS_FILE"
1544
+ printf 'Profile 目录: %s\n' "${PROFILE_DIR:-"(未设置)"}"
1545
+ printf '默认配置文件: %s\n' "${DEFAULT_CONFIG_FILE:-"(未设置)"}"
1546
+ printf 'Origin 证书: %s\n' "${ORIGIN_CERT:-"(未设置)"}"
1547
+ printf '管理根目录: %s\n' "$MANAGER_ROOT"
1548
+ printf '管理根目录存在: %s\n' "$(dir_exists "$MANAGER_ROOT" && printf 是 || printf 否)"
1549
+ printf '应用目录: %s\n' "$APPS_DIR"
1550
+ printf '归档目录: %s\n' "$ARCHIVE_DIR"
1551
+ if [[ -n "$CLOUDFLARED_BIN" ]]; then
1552
+ printf 'cloudflared 路径: %s\n' "$CLOUDFLARED_BIN"
1553
+ printf 'cloudflared 版本: %s\n' "$("$CLOUDFLARED_BIN" --version 2>/dev/null || true)"
1554
+ else
1555
+ printf 'cloudflared: 未安装\n'
1556
+ fi
1557
+ printf 'Origin 证书存在: %s\n' "$(file_exists "${ORIGIN_CERT:-/nonexistent}" && printf 是 || printf 否)"
1558
+ printf '默认登录证书存在: %s\n' "$(file_exists "$DEFAULT_LOGIN_CERT" && printf 是 || printf 否)"
1559
+ printf '受管应用数量: %s\n' "$(managed_apps | wc -l | tr -d '[:space:]')"
1560
+ if [[ -n "$CLOUDFLARED_BIN" && -n "${ORIGIN_CERT:-}" ]] && file_exists "$ORIGIN_CERT"; then
1561
+ local tmp_json
1562
+ tmp_json="$(portable_mktemp "cloudflared-manager-doctor")"
1563
+ if remote_cmd list -o json >"$tmp_json" 2>/dev/null; then
1564
+ remote_count="$(json_index_count "$tmp_json")"
1565
+ fi
1566
+ rm -f "$tmp_json"
1567
+ fi
1568
+ printf '远端 Tunnel 数量: %s\n' "$remote_count"
1569
+ }
1570
+
1571
+ # 保存当前 profile 到设置文件。
1572
+ cmd_use() {
1573
+ ensure_manager_dirs
1574
+ save_settings
1575
+ info "已保存当前配置档。"
1576
+ info "配置档目录: ${PROFILE_DIR:-"(未设置)"}"
1577
+ info "默认配置文件: ${DEFAULT_CONFIG_FILE:-"(未设置)"}"
1578
+ info "Origin 证书: ${ORIGIN_CERT:-"(未设置)"}"
1579
+ info "管理根目录: $MANAGER_ROOT"
1580
+ }
1581
+
1582
+ # 初始化管理目录,可选顺带安装和登录。
1583
+ cmd_init() {
1584
+ local do_install=0
1585
+ local do_login=0
1586
+ while [[ $# -gt 0 ]]; do
1587
+ case "$1" in
1588
+ --install) do_install=1; shift ;;
1589
+ --login) do_login=1; shift ;;
1590
+ -h|--help)
1591
+ cat <<EOF
1592
+ 用法:
1593
+ $SCRIPT_NAME [全局选项] init [--install] [--login]
1594
+ EOF
1595
+ return 0
1596
+ ;;
1597
+ *)
1598
+ die "init 不支持的参数: $1"
1599
+ ;;
1600
+ esac
1601
+ done
1602
+
1603
+ ensure_manager_dirs
1604
+ if [[ -n "${DEFAULT_CONFIG_FILE:-}" ]]; then
1605
+ ensure_dir "$(dirname "$DEFAULT_CONFIG_FILE")"
1606
+ fi
1607
+ if [[ -n "${ORIGIN_CERT:-}" ]]; then
1608
+ ensure_dir "$(dirname "$ORIGIN_CERT")"
1609
+ fi
1610
+ if [[ "$do_install" == "1" ]]; then
1611
+ cmd_install --sudo-install
1612
+ fi
1613
+ save_settings
1614
+ if [[ "$do_login" == "1" ]]; then
1615
+ cmd_login
1616
+ fi
1617
+ info "初始化完成。"
1618
+ }
1619
+
1620
+ # 在 macOS 上下载安装 cloudflared。
1621
+ cmd_install() {
1622
+ local sudo_install=0
1623
+ while [[ $# -gt 0 ]]; do
1624
+ case "$1" in
1625
+ --sudo-install) sudo_install=1; shift ;;
1626
+ -h|--help)
1627
+ cat <<EOF
1628
+ 用法:
1629
+ $SCRIPT_NAME install [--sudo-install]
1630
+ EOF
1631
+ return 0
1632
+ ;;
1633
+ *)
1634
+ die "install 不支持的参数: $1"
1635
+ ;;
1636
+ esac
1637
+ done
1638
+
1639
+ if [[ -n "$CLOUDFLARED_BIN" ]]; then
1640
+ info "cloudflared 已安装:$("$CLOUDFLARED_BIN" --version)"
1641
+ return 0
1642
+ fi
1643
+ if [[ "$(uname -s)" != "Darwin" ]]; then
1644
+ die "自动安装目前只支持 macOS。"
1645
+ fi
1646
+
1647
+ local arch pkg_name url pkg_path
1648
+ arch="$(uname -m)"
1649
+ case "$arch" in
1650
+ x86_64) pkg_name="cloudflared-amd64.pkg" ;;
1651
+ arm64) pkg_name="cloudflared-arm64.pkg" ;;
1652
+ *) die "不支持的 macOS 架构: $arch" ;;
1653
+ esac
1654
+ url="https://github.com/cloudflare/cloudflared/releases/latest/download/$pkg_name"
1655
+ pkg_path="/tmp/$pkg_name"
1656
+ curl -L --fail -o "$pkg_path" "$url"
1657
+ if [[ "$sudo_install" == "1" ]]; then
1658
+ sudo installer -pkg "$pkg_path" -target /
1659
+ else
1660
+ open "$pkg_path"
1661
+ info "已打开安装包: $pkg_path"
1662
+ fi
1663
+ }
1664
+
1665
+ # 执行 cloudflared tunnel login,并同步默认证书到目标位置。
1666
+ cmd_login() {
1667
+ ensure_cloudflared
1668
+ "$CLOUDFLARED_BIN" tunnel login
1669
+ maybe_import_default_login_cert
1670
+ if [[ -n "${ORIGIN_CERT:-}" ]] && file_exists "$ORIGIN_CERT"; then
1671
+ info "当前 Origin 证书: $ORIGIN_CERT"
1672
+ else
1673
+ warn "登录完成,但尚未在目标位置发现证书。默认登录证书可能仍在: $DEFAULT_LOGIN_CERT"
1674
+ fi
1675
+ save_settings
1676
+ }
1677
+
1678
+ # 从现有证书文件导入一份 Origin 证书到当前目标位置。
1679
+ cmd_import_cert() {
1680
+ local source="${1:-$DEFAULT_LOGIN_CERT}"
1681
+ source="$(canonical_path "$source")"
1682
+ if [[ -z "${ORIGIN_CERT:-}" ]]; then
1683
+ die "当前没有目标 Origin 证书路径,请先通过 --origincert、--profile-dir 或 init/use 指定。"
1684
+ fi
1685
+ if ! file_exists "$source"; then
1686
+ die "来源证书不存在: $source"
1687
+ fi
1688
+ ensure_dir "$(dirname "$ORIGIN_CERT")"
1689
+ cp "$source" "$ORIGIN_CERT"
1690
+ info "已导入证书: $ORIGIN_CERT"
1691
+ }
1692
+
1693
+ # 列出全部受管应用。
1694
+ cmd_list() {
1695
+ local name=""
1696
+ local any=0
1697
+ while IFS= read -r name; do
1698
+ [[ -z "$name" ]] && continue
1699
+ any=1
1700
+ load_meta "$name"
1701
+ app_summary
1702
+ done < <(managed_apps)
1703
+ if [[ "$any" == "0" ]]; then
1704
+ info "没有受管应用。"
1705
+ fi
1706
+ }
1707
+
1708
+ # 展示单个应用的元数据和配置内容。
1709
+ cmd_show() {
1710
+ local name="${1:-}"
1711
+ local i=0
1712
+ [[ -z "$name" ]] && die "show 需要 NAME"
1713
+ load_meta "$name"
1714
+ cat <<EOF
1715
+ 应用名称: $META_NAME
1716
+ Tunnel 名称: $META_TUNNEL_NAME
1717
+ Tunnel ID: $META_TUNNEL_ID
1718
+ 主入口主机名: $META_HOSTNAME
1719
+ 主入口服务地址: $META_SERVICE
1720
+ ingress 规则数: $(ingress_rule_count)
1721
+ 应用目录: $META_APP_DIR
1722
+ 配置文件: $META_CONFIG_FILE
1723
+ 凭据文件: $META_CREDENTIALS_FILE
1724
+ ingress 文件: $META_INGRESS_FILE
1725
+ PID 文件: $META_PID_FILE
1726
+ 日志文件: $META_LOG_FILE
1727
+ 元数据文件: $META_META_FILE
1728
+ 创建时间: $META_CREATED_AT
1729
+ 更新时间: $META_UPDATED_AT
1730
+
1731
+ # ingress 规则
1732
+ EOF
1733
+ while [[ "$i" -lt "${#INGRESS_HOSTS[@]}" ]]; do
1734
+ printf ' %s. %s -> %s\n' "$((i + 1))" "${INGRESS_HOSTS[$i]}" "${INGRESS_SERVICES[$i]}"
1735
+ i=$((i + 1))
1736
+ done
1737
+ cat <<EOF
1738
+
1739
+ # config.yml
1740
+ EOF
1741
+ cat "$META_CONFIG_FILE"
1742
+ }
1743
+
1744
+ # 列出当前账号下的远端 tunnels。
1745
+ cmd_tunnels() {
1746
+ local tmp_json count=0 i=0 name="" id="" created_at="" connections=""
1747
+ tmp_json="$(portable_mktemp "cloudflared-manager-tunnels")"
1748
+ remote_cmd list -o json >"$tmp_json"
1749
+ count="$(json_index_count "$tmp_json")"
1750
+ if [[ "$count" == "0" ]]; then
1751
+ info "没有远端 tunnels。"
1752
+ rm -f "$tmp_json"
1753
+ return 0
1754
+ fi
1755
+ while [[ "$i" -lt "$count" ]]; do
1756
+ name="$(json_raw "$tmp_json" "$i.name" || true)"
1757
+ id="$(json_raw "$tmp_json" "$i.id" || true)"
1758
+ created_at="$(json_raw "$tmp_json" "$i.created_at" || true)"
1759
+ connections="$(json_raw "$tmp_json" "$i.connections" || printf '0')"
1760
+ printf '%s | ID=%s | 连接数=%s | 创建时间=%s\n' "$name" "$id" "$connections" "$created_at"
1761
+ i=$((i + 1))
1762
+ done
1763
+ rm -f "$tmp_json"
1764
+ }
1765
+
1766
+ # 创建新的远端 tunnel,并接管到本地管理目录。
1767
+ cmd_add() {
1768
+ local name="${1:-}"
1769
+ local hostname=""
1770
+ local service=""
1771
+ local tunnel_name=""
1772
+ local ingress_specs=()
1773
+ local use_existing=0
1774
+ local overwrite_dns=0
1775
+ local skip_check=0
1776
+ local do_start=0
1777
+ local foreground=0
1778
+ local activate=0
1779
+
1780
+ [[ -z "$name" ]] && die "add 需要 NAME"
1781
+ shift
1782
+
1783
+ while [[ $# -gt 0 ]]; do
1784
+ case "$1" in
1785
+ --hostname) hostname="${2:-}"; shift 2 ;;
1786
+ --service) service="${2:-}"; shift 2 ;;
1787
+ --ingress) ingress_specs+=("${2:-}"); shift 2 ;;
1788
+ --tunnel-name) tunnel_name="${2:-}"; shift 2 ;;
1789
+ --use-existing) use_existing=1; shift ;;
1790
+ --overwrite-dns) overwrite_dns=1; shift ;;
1791
+ --skip-check) skip_check=1; shift ;;
1792
+ --start) do_start=1; shift ;;
1793
+ --foreground) foreground=1; shift ;;
1794
+ --activate) activate=1; shift ;;
1795
+ -h|--help)
1796
+ cat <<EOF
1797
+ 用法:
1798
+ $SCRIPT_NAME add NAME [--hostname HOST --service URL] [--ingress HOST=URL ...] [--tunnel-name NAME] [--use-existing] [--overwrite-dns] [--skip-check] [--start] [--foreground] [--activate]
1799
+
1800
+ 说明:
1801
+ - 兼容旧写法:使用一组 --hostname 和 --service
1802
+ - 多 ingress 写法:重复传 --ingress hostname=service
1803
+ - 可以混用,首条规则会作为“主入口”展示
1804
+ EOF
1805
+ return 0
1806
+ ;;
1807
+ *)
1808
+ die "add 不支持的参数: $1"
1809
+ ;;
1810
+ esac
1811
+ done
1812
+
1813
+ validate_app_name "$name"
1814
+ ensure_cloudflared
1815
+ ensure_logged_in
1816
+
1817
+ if app_exists "$name"; then
1818
+ die "受管应用已存在: $name"
1819
+ fi
1820
+
1821
+ build_requested_ingress_rules "$hostname" "$service" "${ingress_specs[@]}"
1822
+ if [[ "$skip_check" == "0" ]]; then
1823
+ check_all_ingress_targets
1824
+ fi
1825
+ [[ -z "$tunnel_name" ]] && tunnel_name="$name"
1826
+
1827
+ build_meta "$name" "$tunnel_name" "" "${INGRESS_HOSTS[0]}" "${INGRESS_SERVICES[0]}"
1828
+ remote_lookup_by_name "$tunnel_name"
1829
+ if [[ "$REMOTE_FOUND" == "1" ]]; then
1830
+ if [[ "$use_existing" != "1" ]]; then
1831
+ die "远端 tunnel 已存在: $tunnel_name。请使用 adopt 或 add --use-existing。"
1832
+ fi
1833
+ META_TUNNEL_ID="$REMOTE_TUNNEL_ID"
1834
+ issue_credentials "$tunnel_name" "$META_CREDENTIALS_FILE"
1835
+ else
1836
+ create_remote_tunnel "$tunnel_name" "$META_CREDENTIALS_FILE"
1837
+ META_TUNNEL_ID="$REMOTE_TUNNEL_ID"
1838
+ fi
1839
+
1840
+ route_all_dns "$tunnel_name" "$overwrite_dns"
1841
+ save_ingress_rules
1842
+ write_config 0
1843
+ save_meta
1844
+
1845
+ if [[ "$activate" == "1" ]]; then
1846
+ activate_default_config
1847
+ fi
1848
+ if [[ "$do_start" == "1" ]]; then
1849
+ start_app "$foreground" "$skip_check"
1850
+ fi
1851
+ info "已添加受管应用: $name"
1852
+ }
1853
+
1854
+ # 接管已有的远端 tunnel。
1855
+ cmd_adopt() {
1856
+ local name="${1:-}"
1857
+ local tunnel_name=""
1858
+ local hostname=""
1859
+ local service=""
1860
+ local ingress_specs=()
1861
+ local ensure_dns=0
1862
+ local overwrite_dns=0
1863
+ local skip_check=0
1864
+ local activate=0
1865
+
1866
+ [[ -z "$name" ]] && die "adopt 需要 NAME"
1867
+ shift
1868
+
1869
+ while [[ $# -gt 0 ]]; do
1870
+ case "$1" in
1871
+ --tunnel-name) tunnel_name="${2:-}"; shift 2 ;;
1872
+ --hostname) hostname="${2:-}"; shift 2 ;;
1873
+ --service) service="${2:-}"; shift 2 ;;
1874
+ --ingress) ingress_specs+=("${2:-}"); shift 2 ;;
1875
+ --ensure-dns) ensure_dns=1; shift ;;
1876
+ --overwrite-dns) overwrite_dns=1; shift ;;
1877
+ --skip-check) skip_check=1; shift ;;
1878
+ --activate) activate=1; shift ;;
1879
+ -h|--help)
1880
+ cat <<EOF
1881
+ 用法:
1882
+ $SCRIPT_NAME adopt NAME --tunnel-name NAME [--hostname HOST --service URL] [--ingress HOST=URL ...] [--ensure-dns] [--overwrite-dns] [--skip-check] [--activate]
1883
+
1884
+ 说明:
1885
+ - 至少要提供一条 ingress 规则
1886
+ - 如果远端 DNS 已经配好,可不传 --ensure-dns
1887
+ EOF
1888
+ return 0
1889
+ ;;
1890
+ *)
1891
+ die "adopt 不支持的参数: $1"
1892
+ ;;
1893
+ esac
1894
+ done
1895
+
1896
+ [[ -z "$tunnel_name" ]] && die "adopt 缺少 --tunnel-name"
1897
+
1898
+ validate_app_name "$name"
1899
+ ensure_cloudflared
1900
+ ensure_logged_in
1901
+
1902
+ if app_exists "$name"; then
1903
+ die "受管应用已存在: $name"
1904
+ fi
1905
+
1906
+ build_requested_ingress_rules "$hostname" "$service" "${ingress_specs[@]}"
1907
+ if [[ "$skip_check" == "0" ]]; then
1908
+ check_all_ingress_targets
1909
+ fi
1910
+
1911
+ remote_lookup_by_name "$tunnel_name"
1912
+ if [[ "$REMOTE_FOUND" != "1" ]]; then
1913
+ die "远端 tunnel 不存在: $tunnel_name"
1914
+ fi
1915
+
1916
+ build_meta "$name" "$tunnel_name" "$REMOTE_TUNNEL_ID" "${INGRESS_HOSTS[0]}" "${INGRESS_SERVICES[0]}"
1917
+ issue_credentials "$tunnel_name" "$META_CREDENTIALS_FILE"
1918
+ if [[ "$ensure_dns" == "1" ]]; then
1919
+ route_all_dns "$tunnel_name" "$overwrite_dns"
1920
+ fi
1921
+ save_ingress_rules
1922
+ write_config 0
1923
+ save_meta
1924
+
1925
+ if [[ "$activate" == "1" ]]; then
1926
+ activate_default_config
1927
+ fi
1928
+ info "已接管远端 tunnel: $name"
1929
+ }
1930
+
1931
+ # 修改既有应用的主机名或服务地址,并按需自动重启。
1932
+ cmd_modify() {
1933
+ local name="${1:-}"
1934
+ local new_hostname=""
1935
+ local new_service=""
1936
+ local index="1"
1937
+ local modify_specs=()
1938
+ local overwrite_dns=0
1939
+ local skip_check=0
1940
+ local no_restart=0
1941
+ local activate=0
1942
+ local was_running=0
1943
+ local pid=""
1944
+ local current_index=0
1945
+ local current_hostname=""
1946
+ local current_service=""
1947
+ local help_text=""
1948
+ local pending_hosts=()
1949
+ local pending_services=()
1950
+ local changed_indices=()
1951
+ local changed_count=0
1952
+ local spec=""
1953
+ local changed_index=""
1954
+ local candidate_index=0
1955
+ local candidate_hostname=""
1956
+ local candidate_service=""
1957
+ local planned_modify_flags=()
1958
+ local i=0
1959
+ local j=0
1960
+ local has_hostname_change=0
1961
+
1962
+ help_text="$(cat <<EOF
1963
+ 用法:
1964
+ $SCRIPT_NAME modify NAME [--index N] [--hostname HOST] [--service URL] [--set N:HOST=URL ...] [--overwrite-dns] [--skip-check] [--no-restart] [--activate]
1965
+
1966
+ 说明:
1967
+ - 旧写法仍可用:默认修改第 1 条,或配合 --index 修改单条
1968
+ - 批量写法:重复传 --set N:hostname=service
1969
+ - 使用 --set 时,不要再混用 --index / --hostname / --service
1970
+ EOF
1971
+ )"
1972
+
1973
+ if [[ "$name" == "-h" || "$name" == "--help" ]]; then
1974
+ printf '%s\n' "$help_text"
1975
+ return 0
1976
+ fi
1977
+ [[ -z "$name" ]] && die "modify 需要 NAME"
1978
+ shift
1979
+
1980
+ while [[ $# -gt 0 ]]; do
1981
+ case "$1" in
1982
+ --hostname) new_hostname="${2:-}"; shift 2 ;;
1983
+ --service) new_service="${2:-}"; shift 2 ;;
1984
+ --index) index="${2:-}"; shift 2 ;;
1985
+ --set) modify_specs+=("${2:-}"); shift 2 ;;
1986
+ --overwrite-dns) overwrite_dns=1; shift ;;
1987
+ --skip-check) skip_check=1; shift ;;
1988
+ --no-restart) no_restart=1; shift ;;
1989
+ --activate) activate=1; shift ;;
1990
+ -h|--help)
1991
+ printf '%s\n' "$help_text"
1992
+ return 0
1993
+ ;;
1994
+ *)
1995
+ die "modify 不支持的参数: $1"
1996
+ ;;
1997
+ esac
1998
+ done
1999
+
2000
+ load_meta "$name"
2001
+ if pid="$(find_running_pid 2>/dev/null || true)"; then
2002
+ [[ -n "$pid" ]] && was_running=1
2003
+ fi
2004
+
2005
+ if [[ "${#modify_specs[@]}" -gt 0 ]]; then
2006
+ if [[ -n "$new_hostname" || -n "$new_service" || "$index" != "1" ]]; then
2007
+ die "使用 --set 批量修改时,不要再混用 --index / --hostname / --service"
2008
+ fi
2009
+
2010
+ pending_hosts=("${INGRESS_HOSTS[@]}")
2011
+ pending_services=("${INGRESS_SERVICES[@]}")
2012
+ i=0
2013
+ while [[ "$i" -lt "${#INGRESS_HOSTS[@]}" ]]; do
2014
+ planned_modify_flags+=(0)
2015
+ i=$((i + 1))
2016
+ done
2017
+
2018
+ for spec in "${modify_specs[@]}"; do
2019
+ parse_modify_spec "$spec"
2020
+ candidate_index=$((PARSED_MODIFY_INDEX - 1))
2021
+ if [[ "$candidate_index" -lt 0 || "$candidate_index" -ge "${#INGRESS_HOSTS[@]}" ]]; then
2022
+ die "批量修改序号超出范围: $PARSED_MODIFY_INDEX。当前共有 $(ingress_rule_count) 条规则。"
2023
+ fi
2024
+ if [[ "${planned_modify_flags[$candidate_index]}" == "1" ]]; then
2025
+ die "同一条 ingress 不能在一次批量修改里重复指定: $PARSED_MODIFY_INDEX"
2026
+ fi
2027
+ planned_modify_flags[$candidate_index]=1
2028
+ if [[ "$skip_check" == "0" ]]; then
2029
+ local_target_reachable "$PARSED_MODIFY_SERVICE"
2030
+ fi
2031
+ pending_hosts[$candidate_index]="$PARSED_MODIFY_HOSTNAME"
2032
+ pending_services[$candidate_index]="$PARSED_MODIFY_SERVICE"
2033
+ done
2034
+
2035
+ i=0
2036
+ while [[ "$i" -lt "${#pending_hosts[@]}" ]]; do
2037
+ j=$((i + 1))
2038
+ while [[ "$j" -lt "${#pending_hosts[@]}" ]]; do
2039
+ if [[ "${pending_hosts[$i]}" == "${pending_hosts[$j]}" ]]; then
2040
+ die "批量修改后出现重复主机名: ${pending_hosts[$i]}"
2041
+ fi
2042
+ j=$((j + 1))
2043
+ done
2044
+ i=$((i + 1))
2045
+ done
2046
+
2047
+ i=0
2048
+ while [[ "$i" -lt "${#INGRESS_HOSTS[@]}" ]]; do
2049
+ if [[ "${INGRESS_HOSTS[$i]}" != "${pending_hosts[$i]}" || "${INGRESS_SERVICES[$i]}" != "${pending_services[$i]}" ]]; then
2050
+ changed_indices+=("$i")
2051
+ fi
2052
+ i=$((i + 1))
2053
+ done
2054
+
2055
+ changed_count="${#changed_indices[@]}"
2056
+ if [[ "$changed_count" == "0" ]]; then
2057
+ info "没有变更。"
2058
+ return 0
2059
+ fi
2060
+
2061
+ for changed_index in "${changed_indices[@]}"; do
2062
+ candidate_hostname="${pending_hosts[$changed_index]}"
2063
+ current_hostname="${INGRESS_HOSTS[$changed_index]}"
2064
+ if [[ "$candidate_hostname" != "$current_hostname" ]]; then
2065
+ route_dns "$META_TUNNEL_NAME" "$candidate_hostname" "$overwrite_dns"
2066
+ has_hostname_change=1
2067
+ fi
2068
+ done
2069
+
2070
+ backup_file "$META_META_FILE" >/dev/null || true
2071
+ backup_file "$META_CONFIG_FILE" >/dev/null || true
2072
+ backup_file "$META_INGRESS_FILE" >/dev/null || true
2073
+ INGRESS_HOSTS=("${pending_hosts[@]}")
2074
+ INGRESS_SERVICES=("${pending_services[@]}")
2075
+ else
2076
+ [[ "$index" =~ ^[1-9][0-9]*$ ]] || die "--index 必须是大于等于 1 的整数"
2077
+ current_index=$((index - 1))
2078
+ if [[ "$current_index" -ge "${#INGRESS_HOSTS[@]}" ]]; then
2079
+ die "ingress 序号超出范围: $index。当前共有 $(ingress_rule_count) 条规则。"
2080
+ fi
2081
+ current_hostname="${INGRESS_HOSTS[$current_index]}"
2082
+ current_service="${INGRESS_SERVICES[$current_index]}"
2083
+
2084
+ if [[ -n "$new_hostname" ]]; then
2085
+ new_hostname="$(normalize_hostname "$new_hostname")"
2086
+ else
2087
+ new_hostname="$current_hostname"
2088
+ fi
2089
+ if [[ -n "$new_service" ]]; then
2090
+ new_service="$(normalize_service "$new_service")"
2091
+ else
2092
+ new_service="$current_service"
2093
+ fi
2094
+
2095
+ if [[ "$skip_check" == "0" ]]; then
2096
+ local_target_reachable "$new_service"
2097
+ fi
2098
+
2099
+ if [[ "$new_hostname" == "$current_hostname" && "$new_service" == "$current_service" ]]; then
2100
+ info "没有变更。"
2101
+ return 0
2102
+ fi
2103
+
2104
+ if [[ "$new_hostname" != "$current_hostname" ]] && find_ingress_rule_index_by_hostname "$new_hostname" "$current_index" >/dev/null 2>&1; then
2105
+ die "同一个 Tunnel 下已存在该主机名: $new_hostname"
2106
+ fi
2107
+
2108
+ if [[ "$new_hostname" != "$current_hostname" ]]; then
2109
+ route_dns "$META_TUNNEL_NAME" "$new_hostname" "$overwrite_dns"
2110
+ has_hostname_change=1
2111
+ fi
2112
+
2113
+ backup_file "$META_META_FILE" >/dev/null || true
2114
+ backup_file "$META_CONFIG_FILE" >/dev/null || true
2115
+ backup_file "$META_INGRESS_FILE" >/dev/null || true
2116
+ INGRESS_HOSTS[$current_index]="$new_hostname"
2117
+ INGRESS_SERVICES[$current_index]="$new_service"
2118
+ changed_count=1
2119
+ fi
2120
+
2121
+ if [[ "$has_hostname_change" == "1" ]]; then
2122
+ warn "cloudflared CLI 不会删除旧 DNS 记录。如果旧 hostname 不再使用,请到 Cloudflare 手工清理。"
2123
+ fi
2124
+
2125
+ save_ingress_rules
2126
+ write_config 0
2127
+ save_meta
2128
+
2129
+ if [[ "$activate" == "1" ]]; then
2130
+ activate_default_config
2131
+ fi
2132
+ if [[ "$was_running" == "1" && "$no_restart" == "0" ]]; then
2133
+ stop_app 0
2134
+ start_app 0 "$skip_check"
2135
+ if [[ "${#modify_specs[@]}" -gt 0 ]]; then
2136
+ info "已批量修改 ${changed_count} 条 ingress 并重启: $META_NAME"
2137
+ else
2138
+ info "已修改并重启: $META_NAME"
2139
+ fi
2140
+ else
2141
+ if [[ "${#modify_specs[@]}" -gt 0 ]]; then
2142
+ info "已批量修改 ingress 规则数量: ${changed_count}"
2143
+ else
2144
+ info "已修改: $META_NAME"
2145
+ fi
2146
+ fi
2147
+ }
2148
+
2149
+ # 列出指定应用的全部 ingress 规则。
2150
+ cmd_ingress_list() {
2151
+ local name="${1:-}"
2152
+ local i=0
2153
+ [[ -z "$name" ]] && die "ingress-list 需要 NAME"
2154
+
2155
+ load_meta "$name"
2156
+ printf '应用名称: %s\n' "$META_NAME"
2157
+ printf 'Tunnel 名称: %s\n' "$META_TUNNEL_NAME"
2158
+ printf 'ingress 规则数: %s\n' "$(ingress_rule_count)"
2159
+ while [[ "$i" -lt "${#INGRESS_HOSTS[@]}" ]]; do
2160
+ printf ' %s. %s -> %s\n' "$((i + 1))" "${INGRESS_HOSTS[$i]}" "${INGRESS_SERVICES[$i]}"
2161
+ i=$((i + 1))
2162
+ done
2163
+ }
2164
+
2165
+ # 为指定应用追加一批新的 ingress 规则。
2166
+ cmd_ingress_add() {
2167
+ local name="${1:-}"
2168
+ local hostname=""
2169
+ local service=""
2170
+ local ingress_specs=()
2171
+ local overwrite_dns=0
2172
+ local skip_check=0
2173
+ local no_restart=0
2174
+ local activate=0
2175
+ local was_running=0
2176
+ local pid=""
2177
+ local requested_hosts=()
2178
+ local requested_services=()
2179
+ local i=0
2180
+ local candidate_hostname=""
2181
+ local candidate_service=""
2182
+
2183
+ if [[ "$name" == "-h" || "$name" == "--help" ]]; then
2184
+ cat <<EOF
2185
+ 用法:
2186
+ $SCRIPT_NAME ingress-add NAME [--hostname HOST --service URL] [--ingress HOST=URL ...] [--overwrite-dns] [--skip-check] [--no-restart] [--activate]
2187
+
2188
+ 说明:
2189
+ - 兼容旧写法:使用一组 --hostname 和 --service
2190
+ - 批量写法:重复传 --ingress hostname=service
2191
+ - 可以混用,但同一个 hostname 不能重复
2192
+ EOF
2193
+ return 0
2194
+ fi
2195
+ [[ -z "$name" ]] && die "ingress-add 需要 NAME"
2196
+ shift
2197
+
2198
+ while [[ $# -gt 0 ]]; do
2199
+ case "$1" in
2200
+ --hostname) hostname="${2:-}"; shift 2 ;;
2201
+ --service) service="${2:-}"; shift 2 ;;
2202
+ --ingress) ingress_specs+=("${2:-}"); shift 2 ;;
2203
+ --overwrite-dns) overwrite_dns=1; shift ;;
2204
+ --skip-check) skip_check=1; shift ;;
2205
+ --no-restart) no_restart=1; shift ;;
2206
+ --activate) activate=1; shift ;;
2207
+ -h|--help)
2208
+ cmd_ingress_add --help
2209
+ return 0
2210
+ ;;
2211
+ *)
2212
+ die "ingress-add 不支持的参数: $1"
2213
+ ;;
2214
+ esac
2215
+ done
2216
+
2217
+ load_meta "$name"
2218
+ if pid="$(find_running_pid 2>/dev/null || true)"; then
2219
+ [[ -n "$pid" ]] && was_running=1
2220
+ fi
2221
+
2222
+ if [[ -n "$hostname" || -n "$service" ]]; then
2223
+ [[ -n "$hostname" ]] || die "ingress-add 缺少 --hostname"
2224
+ [[ -n "$service" ]] || die "ingress-add 缺少 --service"
2225
+ requested_hosts+=("$(normalize_hostname "$hostname")")
2226
+ requested_services+=("$(normalize_service "$service")")
2227
+ fi
2228
+
2229
+ for candidate_service in "${ingress_specs[@]}"; do
2230
+ parse_ingress_spec "$candidate_service"
2231
+ requested_hosts+=("$PARSED_INGRESS_HOSTNAME")
2232
+ requested_services+=("$PARSED_INGRESS_SERVICE")
2233
+ done
2234
+
2235
+ if [[ "${#requested_hosts[@]}" == "0" ]]; then
2236
+ die "至少需要提供一条 ingress 规则。可使用 --hostname/--service,或重复传 --ingress hostname=service"
2237
+ fi
2238
+
2239
+ i=0
2240
+ while [[ "$i" -lt "${#requested_hosts[@]}" ]]; do
2241
+ candidate_hostname="${requested_hosts[$i]}"
2242
+ candidate_service="${requested_services[$i]}"
2243
+
2244
+ if find_ingress_rule_index_by_hostname "$candidate_hostname" >/dev/null 2>&1; then
2245
+ die "同一个 Tunnel 下已存在该主机名: $candidate_hostname"
2246
+ fi
2247
+ if [[ "$i" -gt 0 ]]; then
2248
+ local prior=0
2249
+ while [[ "$prior" -lt "$i" ]]; do
2250
+ if [[ "${requested_hosts[$prior]}" == "$candidate_hostname" ]]; then
2251
+ die "本次批量新增中出现重复主机名: $candidate_hostname"
2252
+ fi
2253
+ prior=$((prior + 1))
2254
+ done
2255
+ fi
2256
+ if [[ "$skip_check" == "0" ]]; then
2257
+ local_target_reachable "$candidate_service"
2258
+ fi
2259
+ i=$((i + 1))
2260
+ done
2261
+
2262
+ i=0
2263
+ while [[ "$i" -lt "${#requested_hosts[@]}" ]]; do
2264
+ route_dns "$META_TUNNEL_NAME" "${requested_hosts[$i]}" "$overwrite_dns"
2265
+ i=$((i + 1))
2266
+ done
2267
+
2268
+ backup_file "$META_META_FILE" >/dev/null || true
2269
+ backup_file "$META_CONFIG_FILE" >/dev/null || true
2270
+ backup_file "$META_INGRESS_FILE" >/dev/null || true
2271
+ i=0
2272
+ while [[ "$i" -lt "${#requested_hosts[@]}" ]]; do
2273
+ append_ingress_rule "${requested_hosts[$i]}" "${requested_services[$i]}"
2274
+ i=$((i + 1))
2275
+ done
2276
+ save_ingress_rules
2277
+ write_config 0
2278
+ save_meta
2279
+
2280
+ if [[ "$activate" == "1" ]]; then
2281
+ activate_default_config
2282
+ fi
2283
+ if [[ "$was_running" == "1" && "$no_restart" == "0" ]]; then
2284
+ stop_app 0
2285
+ start_app 0 "$skip_check"
2286
+ info "已新增 ingress 并重启: $META_NAME"
2287
+ else
2288
+ info "已新增 ingress 规则数量: ${#requested_hosts[@]}"
2289
+ fi
2290
+ }
2291
+
2292
+ # 从指定应用批量移除 ingress 规则。
2293
+ cmd_ingress_remove() {
2294
+ local name="${1:-}"
2295
+ local hostnames=()
2296
+ local indices=()
2297
+ local no_restart=0
2298
+ local activate=0
2299
+ local was_running=0
2300
+ local pid=""
2301
+ local remove_flags=()
2302
+ local remaining_hosts=()
2303
+ local remaining_services=()
2304
+ local removed_hosts=()
2305
+ local total_rules=0
2306
+ local remove_count=0
2307
+ local i=0
2308
+ local current_index=0
2309
+ local hostname=""
2310
+ local index=""
2311
+
2312
+ if [[ "$name" == "-h" || "$name" == "--help" ]]; then
2313
+ cat <<EOF
2314
+ 用法:
2315
+ $SCRIPT_NAME ingress-remove NAME [--hostname HOST ...] [--index N ...] [--no-restart] [--activate]
2316
+
2317
+ 说明:
2318
+ - 可重复传 --hostname
2319
+ - 可重复传 --index
2320
+ - 两种写法可以混用,脚本会自动去重
2321
+ EOF
2322
+ return 0
2323
+ fi
2324
+ [[ -z "$name" ]] && die "ingress-remove 需要 NAME"
2325
+ shift
2326
+
2327
+ while [[ $# -gt 0 ]]; do
2328
+ case "$1" in
2329
+ --hostname) hostnames+=("${2:-}"); shift 2 ;;
2330
+ --index) indices+=("${2:-}"); shift 2 ;;
2331
+ --no-restart) no_restart=1; shift ;;
2332
+ --activate) activate=1; shift ;;
2333
+ -h|--help)
2334
+ cmd_ingress_remove --help
2335
+ return 0
2336
+ ;;
2337
+ *)
2338
+ die "ingress-remove 不支持的参数: $1"
2339
+ ;;
2340
+ esac
2341
+ done
2342
+
2343
+ if [[ "${#hostnames[@]}" == "0" && "${#indices[@]}" == "0" ]]; then
2344
+ die "ingress-remove 至少需要一个 --hostname 或 --index"
2345
+ fi
2346
+
2347
+ load_meta "$name"
2348
+ if [[ "$(ingress_rule_count)" == "1" ]]; then
2349
+ die "至少需要保留一条 ingress 规则。请直接删除整个应用,或先新增其他规则。"
2350
+ fi
2351
+ if pid="$(find_running_pid 2>/dev/null || true)"; then
2352
+ [[ -n "$pid" ]] && was_running=1
2353
+ fi
2354
+
2355
+ total_rules="${#INGRESS_HOSTS[@]}"
2356
+ i=0
2357
+ while [[ "$i" -lt "$total_rules" ]]; do
2358
+ remove_flags+=(0)
2359
+ i=$((i + 1))
2360
+ done
2361
+
2362
+ for hostname in "${hostnames[@]}"; do
2363
+ hostname="$(normalize_hostname "$hostname")"
2364
+ current_index="$(find_ingress_rule_index_by_hostname "$hostname" 2>/dev/null || true)"
2365
+ [[ -n "$current_index" ]] || die "未找到要删除的 ingress 主机名: $hostname"
2366
+ remove_flags[$current_index]=1
2367
+ done
2368
+
2369
+ for index in "${indices[@]}"; do
2370
+ [[ "$index" =~ ^[1-9][0-9]*$ ]] || die "--index 必须是大于等于 1 的整数"
2371
+ current_index=$((index - 1))
2372
+ if [[ "$current_index" -lt 0 || "$current_index" -ge "$total_rules" ]]; then
2373
+ die "要删除的 ingress 序号超出范围: $index"
2374
+ fi
2375
+ remove_flags[$current_index]=1
2376
+ done
2377
+
2378
+ i=0
2379
+ while [[ "$i" -lt "$total_rules" ]]; do
2380
+ if [[ "${remove_flags[$i]}" == "1" ]]; then
2381
+ removed_hosts+=("${INGRESS_HOSTS[$i]}")
2382
+ remove_count=$((remove_count + 1))
2383
+ else
2384
+ remaining_hosts+=("${INGRESS_HOSTS[$i]}")
2385
+ remaining_services+=("${INGRESS_SERVICES[$i]}")
2386
+ fi
2387
+ i=$((i + 1))
2388
+ done
2389
+
2390
+ if [[ "$remove_count" == "0" ]]; then
2391
+ die "未找到要删除的 ingress 规则。"
2392
+ fi
2393
+ if [[ "${#remaining_hosts[@]}" == "0" ]]; then
2394
+ die "至少需要保留一条 ingress 规则。当前删除范围会把全部规则删空。"
2395
+ fi
2396
+
2397
+ backup_file "$META_META_FILE" >/dev/null || true
2398
+ backup_file "$META_CONFIG_FILE" >/dev/null || true
2399
+ backup_file "$META_INGRESS_FILE" >/dev/null || true
2400
+ INGRESS_HOSTS=("${remaining_hosts[@]}")
2401
+ INGRESS_SERVICES=("${remaining_services[@]}")
2402
+ save_ingress_rules
2403
+ write_config 0
2404
+ save_meta
2405
+ warn "已从配置中移除 ${#removed_hosts[@]} 条 ingress。Cloudflare 上旧 DNS 记录不会自动删除,如已弃用请手工清理。"
2406
+
2407
+ if [[ "$activate" == "1" ]]; then
2408
+ activate_default_config
2409
+ fi
2410
+ if [[ "$was_running" == "1" && "$no_restart" == "0" ]]; then
2411
+ stop_app 0
2412
+ start_app 0 0
2413
+ info "已移除 ingress 并重启: $META_NAME"
2414
+ else
2415
+ info "已移除 ingress 规则数量: ${#removed_hosts[@]}"
2416
+ fi
2417
+ }
2418
+
2419
+ # 删除本地受管应用,可选同步删除远端 tunnel。
2420
+ cmd_delete() {
2421
+ local name="${1:-}"
2422
+ local delete_tunnel=0
2423
+ local force=0
2424
+ local archived=""
2425
+
2426
+ [[ -z "$name" ]] && die "delete 需要 NAME"
2427
+ shift
2428
+
2429
+ while [[ $# -gt 0 ]]; do
2430
+ case "$1" in
2431
+ --delete-tunnel) delete_tunnel=1; shift ;;
2432
+ --force) force=1; shift ;;
2433
+ -h|--help)
2434
+ cat <<EOF
2435
+ 用法:
2436
+ $SCRIPT_NAME delete NAME [--delete-tunnel] [--force]
2437
+ EOF
2438
+ return 0
2439
+ ;;
2440
+ *)
2441
+ die "delete 不支持的参数: $1"
2442
+ ;;
2443
+ esac
2444
+ done
2445
+
2446
+ load_meta "$name"
2447
+ if find_running_pid >/dev/null 2>&1; then
2448
+ stop_app "$force"
2449
+ fi
2450
+
2451
+ if [[ "$delete_tunnel" == "1" ]]; then
2452
+ if [[ "$force" == "1" ]]; then
2453
+ remote_cmd delete --force "$META_TUNNEL_NAME" >/dev/null
2454
+ else
2455
+ remote_cmd delete "$META_TUNNEL_NAME" >/dev/null
2456
+ fi
2457
+ warn "cloudflared tunnel delete 不会清理历史 DNS 记录。如果旧主机名已弃用,请到 Cloudflare 手工删除。"
2458
+ fi
2459
+
2460
+ archived="$(archive_app_dir "$META_APP_DIR")"
2461
+ info "已归档本地应用目录: $archived"
2462
+ }
2463
+
2464
+ # 启动指定应用。
2465
+ cmd_start() {
2466
+ local name="${1:-}"
2467
+ local foreground=0
2468
+ local skip_check=0
2469
+ [[ -z "$name" ]] && die "start 需要 NAME"
2470
+ shift
2471
+
2472
+ while [[ $# -gt 0 ]]; do
2473
+ case "$1" in
2474
+ --foreground) foreground=1; shift ;;
2475
+ --skip-check) skip_check=1; shift ;;
2476
+ -h|--help)
2477
+ cat <<EOF
2478
+ 用法:
2479
+ $SCRIPT_NAME start NAME [--foreground] [--skip-check]
2480
+ EOF
2481
+ return 0
2482
+ ;;
2483
+ *)
2484
+ die "start 不支持的参数: $1"
2485
+ ;;
2486
+ esac
2487
+ done
2488
+
2489
+ load_meta "$name"
2490
+ start_app "$foreground" "$skip_check"
2491
+ if [[ "$foreground" == "0" ]]; then
2492
+ info "已启动: $name"
2493
+ info "日志文件: $META_LOG_FILE"
2494
+ fi
2495
+ }
2496
+
2497
+ # 停止指定应用。
2498
+ cmd_stop() {
2499
+ local name="${1:-}"
2500
+ local force=0
2501
+ [[ -z "$name" ]] && die "stop 需要 NAME"
2502
+ shift
2503
+
2504
+ while [[ $# -gt 0 ]]; do
2505
+ case "$1" in
2506
+ --force) force=1; shift ;;
2507
+ -h|--help)
2508
+ cat <<EOF
2509
+ 用法:
2510
+ $SCRIPT_NAME stop NAME [--force]
2511
+ EOF
2512
+ return 0
2513
+ ;;
2514
+ *)
2515
+ die "stop 不支持的参数: $1"
2516
+ ;;
2517
+ esac
2518
+ done
2519
+
2520
+ load_meta "$name"
2521
+ stop_app "$force"
2522
+ }
2523
+
2524
+ # 重启指定应用。
2525
+ cmd_restart() {
2526
+ local name="${1:-}"
2527
+ local force=0
2528
+ local skip_check=0
2529
+ [[ -z "$name" ]] && die "restart 需要 NAME"
2530
+ shift
2531
+
2532
+ while [[ $# -gt 0 ]]; do
2533
+ case "$1" in
2534
+ --force) force=1; shift ;;
2535
+ --skip-check) skip_check=1; shift ;;
2536
+ -h|--help)
2537
+ cat <<EOF
2538
+ 用法:
2539
+ $SCRIPT_NAME restart NAME [--force] [--skip-check]
2540
+ EOF
2541
+ return 0
2542
+ ;;
2543
+ *)
2544
+ die "restart 不支持的参数: $1"
2545
+ ;;
2546
+ esac
2547
+ done
2548
+
2549
+ load_meta "$name"
2550
+ if find_running_pid >/dev/null 2>&1; then
2551
+ stop_app "$force"
2552
+ fi
2553
+ start_app 0 "$skip_check"
2554
+ info "已重启: $name"
2555
+ }
2556
+
2557
+ # 查看单个或全部应用状态。
2558
+ cmd_status() {
2559
+ local name="${1:-}"
2560
+ local pid=""
2561
+ if [[ -n "$name" ]]; then
2562
+ load_meta "$name"
2563
+ app_summary
2564
+ if pid="$(find_running_pid 2>/dev/null || true)"; then
2565
+ [[ -n "$pid" ]] && printf ' 进程 PID=%s\n' "$pid"
2566
+ fi
2567
+ printf ' 配置文件=%s\n' "$META_CONFIG_FILE"
2568
+ printf ' 日志文件=%s\n' "$META_LOG_FILE"
2569
+ return 0
2570
+ fi
2571
+
2572
+ local any=0
2573
+ while IFS= read -r name; do
2574
+ [[ -z "$name" ]] && continue
2575
+ any=1
2576
+ load_meta "$name"
2577
+ app_summary
2578
+ if pid="$(find_running_pid 2>/dev/null || true)"; then
2579
+ [[ -n "$pid" ]] && printf ' 进程 PID=%s\n' "$pid"
2580
+ fi
2581
+ printf ' 配置文件=%s\n' "$META_CONFIG_FILE"
2582
+ printf ' 日志文件=%s\n' "$META_LOG_FILE"
2583
+ done < <(managed_apps)
2584
+ if [[ "$any" == "0" ]]; then
2585
+ info "没有受管应用。"
2586
+ fi
2587
+ }
2588
+
2589
+ # 查看或持续跟随应用日志。
2590
+ cmd_logs() {
2591
+ local name="${1:-}"
2592
+ local follow=0
2593
+ local lines=100
2594
+ [[ -z "$name" ]] && die "logs 需要 NAME"
2595
+ shift
2596
+
2597
+ while [[ $# -gt 0 ]]; do
2598
+ case "$1" in
2599
+ -f|--follow) follow=1; shift ;;
2600
+ -n|--lines) lines="${2:-}"; shift 2 ;;
2601
+ -h|--help)
2602
+ cat <<EOF
2603
+ 用法:
2604
+ $SCRIPT_NAME logs NAME [-f|--follow] [-n|--lines N]
2605
+ EOF
2606
+ return 0
2607
+ ;;
2608
+ *)
2609
+ die "logs 不支持的参数: $1"
2610
+ ;;
2611
+ esac
2612
+ done
2613
+
2614
+ load_meta "$name"
2615
+ if [[ "$follow" == "1" ]]; then
2616
+ if ! file_exists "$META_LOG_FILE"; then
2617
+ die "日志文件不存在: $META_LOG_FILE"
2618
+ fi
2619
+ tail -f -n "$lines" "$META_LOG_FILE"
2620
+ return 0
2621
+ fi
2622
+ if ! file_exists "$META_LOG_FILE"; then
2623
+ info "(日志为空)"
2624
+ return 0
2625
+ fi
2626
+ tail -n "$lines" "$META_LOG_FILE"
2627
+ }
2628
+
2629
+ # 将某个应用配置复制到默认 config.yml。
2630
+ cmd_activate() {
2631
+ local name="${1:-}"
2632
+ [[ -z "$name" ]] && die "activate 需要 NAME"
2633
+ load_meta "$name"
2634
+ activate_default_config
2635
+ info "已将受管应用配置激活为默认配置: $META_NAME"
2636
+ }
2637
+
2638
+ # 解析全局命令行参数。
2639
+ parse_global_options() {
2640
+ while [[ $# -gt 0 ]]; do
2641
+ case "$1" in
2642
+ --profile-dir|--cloudflared-dir)
2643
+ CLI_PROFILE_DIR="${2:-}"
2644
+ [[ -z "$CLI_PROFILE_DIR" ]] && die "$1 需要参数"
2645
+ shift 2
2646
+ ;;
2647
+ --config-file|--default-config)
2648
+ CLI_DEFAULT_CONFIG_FILE="${2:-}"
2649
+ [[ -z "$CLI_DEFAULT_CONFIG_FILE" ]] && die "$1 需要参数"
2650
+ shift 2
2651
+ ;;
2652
+ --origincert)
2653
+ CLI_ORIGIN_CERT="${2:-}"
2654
+ [[ -z "$CLI_ORIGIN_CERT" ]] && die "$1 需要参数"
2655
+ shift 2
2656
+ ;;
2657
+ --manager-root)
2658
+ CLI_MANAGER_ROOT="${2:-}"
2659
+ [[ -z "$CLI_MANAGER_ROOT" ]] && die "$1 需要参数"
2660
+ shift 2
2661
+ ;;
2662
+ --save-profile)
2663
+ SAVE_PROFILE=1
2664
+ shift
2665
+ ;;
2666
+ -h|--help)
2667
+ print_usage
2668
+ exit 0
2669
+ ;;
2670
+ --)
2671
+ shift
2672
+ break
2673
+ ;;
2674
+ -*)
2675
+ die "未知全局参数: $1"
2676
+ ;;
2677
+ *)
2678
+ break
2679
+ ;;
2680
+ esac
2681
+ done
2682
+ REMAINING_ARGS=("$@")
2683
+ }
2684
+
2685
+ # 主入口:解析全局参数并分发到具体命令。
2686
+ main() {
2687
+ local cmd=""
2688
+ parse_global_options "$@"
2689
+ set -- "${REMAINING_ARGS[@]}"
2690
+ resolve_paths
2691
+ if [[ "$SAVE_PROFILE" == "1" ]]; then
2692
+ save_settings
2693
+ fi
2694
+
2695
+ cmd="${1:-help}"
2696
+ shift || true
2697
+ case "$cmd" in
2698
+ doctor) cmd_doctor "$@" ;;
2699
+ init) cmd_init "$@" ;;
2700
+ use) cmd_use "$@" ;;
2701
+ install) cmd_install "$@" ;;
2702
+ login) cmd_login "$@" ;;
2703
+ import-cert) cmd_import_cert "$@" ;;
2704
+ list) cmd_list "$@" ;;
2705
+ show) cmd_show "$@" ;;
2706
+ tunnels) cmd_tunnels "$@" ;;
2707
+ add) cmd_add "$@" ;;
2708
+ adopt) cmd_adopt "$@" ;;
2709
+ modify) cmd_modify "$@" ;;
2710
+ ingress-list) cmd_ingress_list "$@" ;;
2711
+ ingress-add) cmd_ingress_add "$@" ;;
2712
+ ingress-remove) cmd_ingress_remove "$@" ;;
2713
+ delete) cmd_delete "$@" ;;
2714
+ start) cmd_start "$@" ;;
2715
+ stop) cmd_stop "$@" ;;
2716
+ restart) cmd_restart "$@" ;;
2717
+ status) cmd_status "$@" ;;
2718
+ logs) cmd_logs "$@" ;;
2719
+ activate) cmd_activate "$@" ;;
2720
+ help) print_usage ;;
2721
+ *)
2722
+ die "未知命令: $cmd。可用 help 查看帮助。"
2723
+ ;;
2724
+ esac
2725
+ }
2726
+
2727
+ main "$@"