diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce02ae7..db0b3b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ on: - 'emby_plugins/video_subtitle/**' - 'plugins/alfred_remote_upload/**' - 'sample/life_tools/**' + - 'sample/systemd/**' push: tags: - 'v*' @@ -96,10 +97,12 @@ jobs: GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/retry_exec" ./cli/retry_exec/... GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/codex_hook_notify" ./cli/codex_hook_notify/... GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/file_share" ./cli/file_share/... + GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/cq_ddns_client" ./cli/cq_ddns_client/... GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$out/bin/codex_inspector" ./cli/codex_inspector/... cp README.MD install.sh "$out/" cp sample/life_tools/*.json "$out/sample/life_tools/" + cp -R sample/systemd "$out/sample/" cp -R docs/. "$out/docs/" ( diff --git a/AGENTS.md b/AGENTS.md index 04eeebb..78df30e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ - `cli/retry_exec/`:命令失败重试和失败通知工具。 - `cli/codex_hook_notify/`:Codex lifecycle hook 飞书提醒工具。 - `cli/file_share/`:临时 HTTP 文件分享工具。 +- `cli/cq_ddns_client/`:独立 Cloudflare DDNS 客户端,兼容旧 `home_client` YAML,详见 `docs/cli/cq_ddns_client.md`。 - `cli/codex_inspector/`:本机 Codex 历史会话、活跃度和记忆内容只读查看工具,当前是实验 CLI,不进默认安装清单。 - `cli/video_subtitle/`:单视频自动生成中文字幕工具,详细说明见 `docs/cli/video_subtitle.md`。 - `emby_plugins/video_subtitle/`:Emby Server 插件,后端调用 `video_subtitle` 生成字幕,详细说明见 `docs/plugins/emby_video_subtitle.md`。 @@ -47,6 +48,7 @@ - `./output/retry_exec` from `./cli/retry_exec/...` - `./output/codex_hook_notify` from `./cli/codex_hook_notify/...` - `./output/file_share` from `./cli/file_share/...` +- `./output/cq_ddns_client` from `./cli/cq_ddns_client/...` 测试优先使用: @@ -107,6 +109,14 @@ bash -n build.sh - 不为小工具引入新框架、新配置系统或复杂依赖。 - 不吞掉 `.Error` 或返回值错误;现在已有代码有粗糙处,新增代码不要继续扩大问题。 +## `cq_ddns_client` 规则 + +- 默认配置 `/etc/life_tools/cq_ddns_client.json`;保留 `-conf` 别名和 `.yaml/.yml` 读取兼容。 +- 不得记录 Cloudflare Token、完整配置或 SDK HTTP 调试报文。 +- Go 测试只使用 loopback HTTP 服务;真实 DNS 验证先运行 `-dry-run`,不把真实 Token 写进仓库。 +- DNS 记录必须按域名和类型匹配,更新时保留 TTL、Proxied;创建时沿用 TTL 300、Proxied false。 +- 修改工具时同步检查安装脚本、构建脚本、release workflow、示例配置、systemd unit 和 CLI 文档。 + ## `renameV1` 规则 `renameV1` 会实际修改文件名。默认把它当高风险工具处理。 @@ -194,7 +204,7 @@ emby_plugins/video_subtitle/install.sh --help - tag 触发规则保持 `v*`,避免普通分支 push 意外创建 Release;`pull_request` 只能做 dry-run,不能创建 Release。 - Go 测试放在 `.github/workflows/go-test.yml`,PR 时必须真实运行 `go test ./...`,不能做成只提醒不阻塞的 reminder。 - Python 单元测试放在 `.github/workflows/python-test.yml`,失败只写 GitHub warning 和 summary,不能阻塞 release workflow。 -- Go 二进制包包含稳定 CLI 工具:`renameV1`、`check_keywords`、`retry_exec`、`codex_hook_notify`、`file_share`;`codex_inspector` 作为实验工具参与 release 二进制产物,但仍不进入根目录 `install.sh` 默认安装清单。 +- Go 二进制包包含稳定 CLI 工具:`renameV1`、`check_keywords`、`retry_exec`、`codex_hook_notify`、`file_share`、`cq_ddns_client`;`codex_inspector` 作为实验工具参与 release 二进制产物,但仍不进入根目录 `install.sh` 默认安装清单。 - `video_subtitle` 只能按源码包发布,不能宣传成免依赖二进制;它仍依赖 Python、ffmpeg、TOS、ASR 和 LLM 配置。 - Emby 插件包只放 `LifeTools.Emby.VideoSubtitle.Emby.dll` 和文档,不要把 `MediaBrowser.*`、`Emby.*` 或核心库 DLL 打进插件发布包。 - `InterviewTimer` macOS App 只在 macOS runner 上构建,tag 发布包为未签名的 `InterviewTimer.app` zip,不要把它塞进根目录 `install.sh` 或 Go 二进制发布包。 diff --git a/README.MD b/README.MD index f0fc18f..a347ecc 100644 --- a/README.MD +++ b/README.MD @@ -18,7 +18,7 @@ ## 快速安装 -支持 Linux 和 macOS。仓库根目录执行下面命令,默认安装稳定命令行工具:`renameV1`、`check_keywords`、`retry_exec`、`codex_hook_notify`、`video_subtitle`、`file_share`。 +支持 Linux 和 macOS。仓库根目录执行下面命令,默认安装稳定命令行工具:`renameV1`、`check_keywords`、`retry_exec`、`codex_hook_notify`、`video_subtitle`、`file_share`、`cq_ddns_client`。 ```bash ./install.sh @@ -31,6 +31,7 @@ ./install.sh --tool renameV1 --tool check_keywords ./install.sh --tools retry_exec,codex_hook_notify ./install.sh --tool file_share +./install.sh --tool cq_ddns_client ``` 自定义安装目录和配置目录: @@ -56,6 +57,7 @@ | CLI | `retry_exec` | `cli/retry_exec/` | `retry_exec` | 给任意命令增加重试、退避和失败通知 | [docs/cli/retry_exec.md](docs/cli/retry_exec.md) | | CLI | `codex_hook_notify` | `cli/codex_hook_notify/` | `codex_hook_notify` | 接收 Codex hook 并推送飞书提醒 | [docs/cli/codex_hook_notify.md](docs/cli/codex_hook_notify.md) | | CLI | `file_share` | `cli/file_share/` | `file_share` | 临时启动只读 HTTP 文件分享服务 | [docs/cli/file_share.md](docs/cli/file_share.md) | +| CLI | `cq_ddns_client` | `cli/cq_ddns_client/` | `cq_ddns_client` | 独立更新 Cloudflare IPv4/IPv6 DNS,兼容旧 home_client 配置 | [docs/cli/cq_ddns_client.md](docs/cli/cq_ddns_client.md) | | CLI | `video_subtitle` | `cli/video_subtitle/` | `video_subtitle` | 为单个视频生成简体中文字幕 | [docs/cli/video_subtitle.md](docs/cli/video_subtitle.md) | | Experimental CLI | `codex_inspector` | `cli/codex_inspector/` | `codex_inspector` | 本机只读查看 Codex 会话、token 用量、活跃度和记忆内容 | [docs/cli/codex_inspector.md](docs/cli/codex_inspector.md) | | Plugin | Alfred Remote Upload | `plugins/alfred_remote_upload/` | `Remote Upload.alfredworkflow` | 将剪贴板图片或 Finder 单文件上传到 SSH 主机,并复制远端路径 | [docs/plugins/alfred_remote_upload.md](docs/plugins/alfred_remote_upload.md) | diff --git a/build.sh b/build.sh index 679c842..ba703b2 100755 --- a/build.sh +++ b/build.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e + mkdir -p output # 文件批量重命名 @@ -16,3 +18,6 @@ go build -v -o ./output/codex_hook_notify ./cli/codex_hook_notify/... # HTTP 文件快速分享 go build -v -o ./output/file_share ./cli/file_share/... + +# Cloudflare 动态 DNS 客户端 +go build -v -o ./output/cq_ddns_client ./cli/cq_ddns_client/... diff --git a/cli/cq_ddns_client/config.go b/cli/cq_ddns_client/config.go new file mode 100644 index 0000000..1e610c3 --- /dev/null +++ b/cli/cq_ddns_client/config.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/json-iterator/go" + "gopkg.in/yaml.v3" +) + +const DefaultConfigPath = "/etc/life_tools/cq_ddns_client.json" + +type Config struct { + DDNSConfig []DomainConfig `json:"ddns_config" yaml:"ddns_config"` + Cloudflare *CloudflareConfig `json:"cloudflare" yaml:"cloudflare"` +} + +type CloudflareConfig struct { + APIToken string `json:"api_token" yaml:"api_token"` + Zone string `json:"zone" yaml:"zone"` + Debug bool `json:"debug" yaml:"debug"` + Proxy string `json:"proxy" yaml:"proxy"` +} + +type DomainConfig struct { + Domain string `json:"domain" yaml:"domain"` + IPVersion string `json:"ip_version" yaml:"ip_version"` +} + +func LoadConfig(path string) (Config, error) { + var config Config + content, err := os.ReadFile(path) + if err != nil { + return config, fmt.Errorf("read config: %w", err) + } + // The old home_client configuration remains usable with -conf path/to/config.yaml. + switch strings.ToLower(filepath.Ext(path)) { + case ".yaml", ".yml": + err = yaml.Unmarshal(content, &config) + default: + err = jsoniter.Unmarshal(content, &config) + } + if err != nil { + // JSON decoders may include the input, including credentials, in their errors. + return Config{}, fmt.Errorf("invalid config syntax in %s", path) + } + if config.Cloudflare == nil || strings.TrimSpace(config.Cloudflare.APIToken) == "" || strings.TrimSpace(config.Cloudflare.Zone) == "" { + return Config{}, fmt.Errorf("cloudflare.api_token and cloudflare.zone are required") + } + if len(config.DDNSConfig) == 0 { + return Config{}, fmt.Errorf("ddns_config must contain at least one domain") + } + seen := make(map[[2]string]bool) + for i, domain := range config.DDNSConfig { + if strings.TrimSpace(domain.Domain) == "" || (domain.IPVersion != "ipv4" && domain.IPVersion != "ipv6") { + return Config{}, fmt.Errorf("ddns_config[%d] requires domain and ip_version ipv4 or ipv6", i) + } + key := [2]string{strings.ToLower(strings.TrimSuffix(domain.Domain, ".")), domain.IPVersion} + if seen[key] { + return Config{}, fmt.Errorf("ddns_config[%d] repeats a domain and IP version", i) + } + seen[key] = true + } + return config, nil +} diff --git a/cli/cq_ddns_client/config_test.go b/cli/cq_ddns_client/config_test.go new file mode 100644 index 0000000..94f3434 --- /dev/null +++ b/cli/cq_ddns_client/config_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadConfigJSONAndLegacyYAML(t *testing.T) { + for name, content := range map[string]string{ + "config.json": `{"cloudflare":{"api_token":"test-token","zone":"test-zone","proxy":"127.0.0.1:1080","debug":true},"ddns_config":[{"domain":"pi.example.org","ip_version":"ipv4"},{"domain":"pi6.example.org","ip_version":"ipv6"}]}`, + "client_config.yaml": "cloudflare:\n api_token: test-token\n zone: test-zone\n proxy: 127.0.0.1:1080\n debug: true\nddns_config:\n - domain: pi.example.org\n ip_version: ipv4\n - domain: pi6.example.org\n ip_version: ipv6\n", + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + config, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "test-token", config.Cloudflare.APIToken) + require.Equal(t, "test-zone", config.Cloudflare.Zone) + require.Equal(t, "127.0.0.1:1080", config.Cloudflare.Proxy) + require.True(t, config.Cloudflare.Debug) + require.Equal(t, []DomainConfig{{Domain: "pi.example.org", IPVersion: "ipv4"}, {Domain: "pi6.example.org", IPVersion: "ipv6"}}, config.DDNSConfig) + }) + } +} + +func TestRejectInvalidConfig(t *testing.T) { + for name, content := range map[string]string{ + "empty": `{}`, + "missing token": `{"cloudflare":{"zone":"test-zone"},"ddns_config":[{"domain":"a.example.org","ip_version":"ipv4"}]}`, + "missing zone": `{"cloudflare":{"api_token":"test-token"},"ddns_config":[{"domain":"a.example.org","ip_version":"ipv4"}]}`, + "missing domains": `{"cloudflare":{"api_token":"test-token","zone":"test-zone"}}`, + "unknown version": `{"cloudflare":{"api_token":"test-token","zone":"test-zone"},"ddns_config":[{"domain":"a.example.org","ip_version":"ipv5"}]}`, + "null domain": `{"cloudflare":{"api_token":"test-token","zone":"test-zone"},"ddns_config":[null]}`, + "invalid json": `{"cloudflare":`, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + _, err := LoadConfig(path) + require.Error(t, err) + require.NotContains(t, err.Error(), "test-token") + }) + } +} diff --git a/cli/cq_ddns_client/ddns.go b/cli/cq_ddns_client/ddns.go new file mode 100644 index 0000000..a6855d5 --- /dev/null +++ b/cli/cq_ddns_client/ddns.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/cloudflare/cloudflare-go" + "github.com/sirupsen/logrus" + "golang.org/x/net/proxy" +) + +type Client struct { + config Config + api *cloudflare.API + ipClient *http.Client + ipv4Services []string + ipv6Services []string +} + +func NewClient(config Config) (*Client, error) { + httpClient := &http.Client{Timeout: 20 * time.Second} + if config.Cloudflare.Proxy != "" { + dialer, err := proxy.SOCKS5("tcp", config.Cloudflare.Proxy, nil, proxy.Direct) + if err != nil { + return nil, fmt.Errorf("create Cloudflare SOCKS5 proxy: %w", err) + } + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("Cloudflare SOCKS5 proxy does not support context") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = contextDialer.DialContext + httpClient.Transport = transport + } + api, err := cloudflare.NewWithAPIToken(config.Cloudflare.APIToken, cloudflare.HTTPClient(httpClient)) + if err != nil { + return nil, err + } + // Do not enable SDK HTTP dumps: they include the Authorization header. + // Legacy debug controls application logging in main instead. + return &Client{ + config: config, api: api, ipClient: &http.Client{Timeout: 5 * time.Second}, + ipv4Services: []string{"https://4.ipw.cn", "https://api4.ipify.org", "https://ipv4.icanhazip.com", "https://v4.ident.me"}, + ipv6Services: []string{"https://6.ipw.cn", "https://api6.ipify.org", "https://ipv6.icanhazip.com", "https://v6.ident.me"}, + }, nil +} + +func (c *Client) Refresh(ctx context.Context, dryRun bool) (err error) { + defer recoverCloudflareError(ctx, &err) + logrus.Infof("DDNS refresh start: domains=%d dry_run=%t", len(c.config.DDNSConfig), dryRun) + records, err := c.api.DNSRecords(ctx, c.config.Cloudflare.Zone, cloudflare.DNSRecord{}) + if err != nil { + return fmt.Errorf("list DNS records: %w", err) + } + // A and AAAA may share a name. Multiple records of the same type are ambiguous. + byNameAndType := make(map[[2]string][]cloudflare.DNSRecord) + for _, record := range records { + key := [2]string{strings.ToLower(strings.TrimSuffix(record.Name, ".")), record.Type} + byNameAndType[key] = append(byNameAndType[key], record) + } + failed := 0 + for _, domain := range c.config.DDNSConfig { + if err := ctx.Err(); err != nil { + return err + } + typeName, services := "A", c.ipv4Services + if domain.IPVersion == "ipv6" { + typeName, services = "AAAA", c.ipv6Services + } + key := [2]string{strings.ToLower(strings.TrimSuffix(domain.Domain, ".")), typeName} + ip, err := getIPAddress(ctx, c.ipClient, services, domain.IPVersion) + if err == nil { + err = c.syncRecord(ctx, domain.Domain, typeName, ip, byNameAndType[key], dryRun) + } + if err != nil { + failed++ + logrus.WithError(err).Errorf("DDNS refresh failed: domain=%s type=%s", domain.Domain, typeName) + } + } + if failed != 0 { + return fmt.Errorf("DDNS refresh failed for %d of %d domains", failed, len(c.config.DDNSConfig)) + } + logrus.Info("DDNS refresh complete") + return nil +} + +func (c *Client) syncRecord(ctx context.Context, domain, typeName, ip string, records []cloudflare.DNSRecord, dryRun bool) (err error) { + defer recoverCloudflareError(ctx, &err) + if len(records) > 1 { + return fmt.Errorf("multiple DNS records match domain and type; refusing ambiguous update") + } + proxied := false + record := cloudflare.DNSRecord{Name: domain, Type: typeName, Content: ip, TTL: 300, Proxied: &proxied} + action := "create" + if len(records) == 1 { + record = records[0] + if net.ParseIP(ip).Equal(net.ParseIP(record.Content)) { + logrus.Infof("domain=%s type=%s ip=%s unchanged, skip update", domain, typeName, ip) + return nil + } + record.Content = ip + action = "update" + } + if dryRun { + logrus.Infof("dry-run: would %s domain=%s type=%s ip=%s", action, domain, typeName, ip) + return nil + } + if action == "create" { + resp, err := c.api.CreateDNSRecord(ctx, c.config.Cloudflare.Zone, record) + if err != nil { + return err + } + if resp == nil || !resp.Success { + return fmt.Errorf("Cloudflare did not confirm DNS record creation") + } + } else if err := c.api.UpdateDNSRecord(ctx, c.config.Cloudflare.Zone, record.ID, record); err != nil { + return err + } + logrus.Infof("%s success: domain=%s type=%s ip=%s", action, domain, typeName, ip) + return nil +} + +// cloudflare-go v0.47.1 dereferences a nil response on transport errors. +// Keep the old client's recovery boundary, returning errors so one failed write +// does not stop other domains and the daemon can retry on its next cycle. +func recoverCloudflareError(ctx context.Context, err *error) { + if recovered := recover(); recovered != nil { + if ctx.Err() != nil { + *err = ctx.Err() + } else { + *err = fmt.Errorf("Cloudflare SDK request panicked; check network and proxy connectivity") + } + } +} diff --git a/cli/cq_ddns_client/ddns_test.go b/cli/cq_ddns_client/ddns_test.go new file mode 100644 index 0000000..39284be --- /dev/null +++ b/cli/cq_ddns_client/ddns_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/cloudflare/cloudflare-go" + "github.com/stretchr/testify/require" +) + +// Exercise the real Cloudflare SDK over loopback; no token or public DNS is used. +func TestRefreshDNSRecords(t *testing.T) { + for _, mode := range []string{"create", "update", "unchanged", "dry-run", "list error", "write error", "ip error", "duplicate", "list transport error", "write transport error"} { + t.Run(mode, func(t *testing.T) { + var mu sync.Mutex + var writes []cloudflare.DNSRecord + var paths []string + proxied := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/ipv4" || r.URL.Path == "/ipv6" { + if mode == "ip error" && r.URL.Path == "/ipv4" { + w.WriteHeader(http.StatusBadGateway) + return + } + ip := "203.0.113.10" + if r.URL.Path == "/ipv6" { + ip = "2001:db8::10" + } + _, _ = w.Write([]byte(ip)) + return + } + if mode == "list transport error" && r.Method == http.MethodGet { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + conn.Close() + return + } + if r.Method == http.MethodGet { + if mode == "list error" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"success":false,"errors":[{"code":10000,"message":"denied"}]}`)) + return + } + records := []cloudflare.DNSRecord{{ID: "txt", Name: "pi.example.org", Type: "TXT", Content: "keep"}} + if mode != "create" { + v4, v6 := "203.0.113.1", "2001:db8::1" + if mode == "unchanged" { + v4, v6 = "203.0.113.10", "2001:0db8:0:0:0:0:0:10" + } + records = append(records, + cloudflare.DNSRecord{ID: "v4", Name: "pi.example.org", Type: "A", Content: v4, TTL: 600, Proxied: &proxied}, + cloudflare.DNSRecord{ID: "v6", Name: "pi.example.org", Type: "AAAA", Content: v6, TTL: 900, Proxied: &proxied}) + } + if mode == "duplicate" { + records = append(records, records[1]) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": records, "result_info": map[string]int{"page": 1, "total_pages": 1}}) + return + } + var record cloudflare.DNSRecord + if err := json.NewDecoder(r.Body).Decode(&record); err != nil { + t.Error(err) + } + writes = append(writes, record) + paths = append(paths, r.Method+" "+r.URL.Path) + if mode == "write transport error" { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + conn.Close() + return + } + if mode == "write error" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"success":false,"errors":[{"code":10000,"message":"denied"}]}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": record}) + })) + defer server.Close() + api, err := cloudflare.NewWithAPIToken("test-token", cloudflare.BaseURL(server.URL), cloudflare.HTTPClient(server.Client()), cloudflare.UsingRetryPolicy(0, 0, 0)) + require.NoError(t, err) + client := &Client{ + config: Config{Cloudflare: &CloudflareConfig{Zone: "test-zone"}, DDNSConfig: []DomainConfig{{Domain: "pi.example.org", IPVersion: "ipv4"}, {Domain: "pi.example.org", IPVersion: "ipv6"}}}, + api: api, ipClient: server.Client(), ipv4Services: []string{server.URL + "/ipv4"}, ipv6Services: []string{server.URL + "/ipv6"}, + } + require.NotPanics(t, func() { + err = client.Refresh(context.Background(), mode == "dry-run") + }) + mu.Lock() + defer mu.Unlock() + if mode == "list error" || mode == "write error" || mode == "ip error" || mode == "duplicate" || mode == "list transport error" || mode == "write transport error" { + require.Error(t, err) + } else { + require.NoError(t, err) + } + switch mode { + case "unchanged", "dry-run", "list error", "list transport error": + require.Empty(t, writes) + case "create": + require.Len(t, writes, 2) + require.Equal(t, []string{"POST /zones/test-zone/dns_records", "POST /zones/test-zone/dns_records"}, paths) + require.Equal(t, "A", writes[0].Type) + require.Equal(t, "AAAA", writes[1].Type) + require.Equal(t, 300, writes[0].TTL) + require.NotNil(t, writes[0].Proxied) + require.False(t, *writes[0].Proxied) + case "update", "write error", "write transport error": + require.Equal(t, []string{"PATCH /zones/test-zone/dns_records/v4", "PATCH /zones/test-zone/dns_records/v6"}, paths) + require.Equal(t, 600, writes[0].TTL) + require.True(t, *writes[0].Proxied) + require.Equal(t, "203.0.113.10", writes[0].Content) + require.Equal(t, "2001:db8::10", writes[1].Content) + case "ip error", "duplicate": + require.Equal(t, []string{"PATCH /zones/test-zone/dns_records/v6"}, paths) + } + }) + } +} + +func TestCloudflareCancellationAndNextRefresh(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/cancel/zones/test-zone/dns_records" { + cancel() + <-r.Context().Done() + return + } + _, _ = w.Write([]byte(`{"success":true,"result":[],"result_info":{"page":1,"total_pages":1}}`)) + })) + defer server.Close() + client, err := NewClient(Config{Cloudflare: &CloudflareConfig{APIToken: "test-token", Zone: "test-zone", Debug: true}}) + require.NoError(t, err) + require.False(t, client.api.Debug, "legacy debug must never expose the Authorization header") + client.api.BaseURL = server.URL + "/cancel" + // Direct cancellation must propagate rather than crash in the pinned SDK. + require.NotPanics(t, func() { + err = client.Refresh(ctx, false) + }) + require.ErrorIs(t, err, context.Canceled) + client.api.BaseURL = server.URL + require.NoError(t, client.Refresh(context.Background(), false)) + require.NoError(t, run(ctx, client, true, false), "shutdown is a normal exit") +} diff --git a/cli/cq_ddns_client/ip.go b/cli/cq_ddns_client/ip.go new file mode 100644 index 0000000..3d1f430 --- /dev/null +++ b/cli/cq_ddns_client/ip.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + + "github.com/sirupsen/logrus" +) + +// Query services in order, accepting only an address of the requested family. +// A service error page or a wrong-family response must never reach Cloudflare. +func getIPAddress(ctx context.Context, client *http.Client, urls []string, version string) (string, error) { + var lastErr error + for _, url := range urls { + if err := ctx.Err(); err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err == nil { + var body []byte + body, err = io.ReadAll(io.LimitReader(resp.Body, 1024)) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + err = fmt.Errorf("IP service returned HTTP %d", resp.StatusCode) + } + if err == nil { + address := strings.TrimSpace(string(body)) + ip := net.ParseIP(address) + if ip != nil && ((version == "ipv4" && ip.To4() != nil) || (version == "ipv6" && ip.To4() == nil)) { + return address, nil + } + err = fmt.Errorf("IP service did not return a valid %s address", version) + } + } + logrus.WithError(err).Warnf("IP service %s failed, trying next", url) + lastErr = err + } + if err := ctx.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("all %s query services failed: %w", version, lastErr) +} diff --git a/cli/cq_ddns_client/ip_test.go b/cli/cq_ddns_client/ip_test.go new file mode 100644 index 0000000..eeccef2 --- /dev/null +++ b/cli/cq_ddns_client/ip_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIPFallbackAndValidation(t *testing.T) { + var visited []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + visited = append(visited, r.URL.Path) + switch r.URL.Path { + case "/error": + w.WriteHeader(http.StatusBadGateway) + case "/html": + _, _ = w.Write([]byte("unavailable")) + case "/v6": + _, _ = w.Write([]byte("2001:db8::1\n")) + default: + _, _ = w.Write([]byte(" 203.0.113.10\n")) + } + })) + defer server.Close() + urls := []string{server.URL + "/error", server.URL + "/html", server.URL + "/v6", server.URL + "/v4"} + ip, err := getIPAddress(context.Background(), server.Client(), urls, "ipv4") + require.NoError(t, err) + require.Equal(t, "203.0.113.10", ip) + require.Equal(t, []string{"/error", "/html", "/v6", "/v4"}, visited) + _, err = getIPAddress(context.Background(), server.Client(), urls[:3], "ipv4") + require.Error(t, err) + ip, err = getIPAddress(context.Background(), server.Client(), []string{server.URL + "/v6"}, "ipv6") + require.NoError(t, err) + require.Equal(t, "2001:db8::1", ip) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = getIPAddress(ctx, server.Client(), urls, "ipv4") + require.ErrorIs(t, err, context.Canceled) +} diff --git a/cli/cq_ddns_client/main.go b/cli/cq_ddns_client/main.go new file mode 100644 index 0000000..6db2ac3 --- /dev/null +++ b/cli/cq_ddns_client/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "flag" + "os" + "os/signal" + "syscall" + "time" + + "github.com/sirupsen/logrus" +) + +func main() { + var configPath string + flag.StringVar(&configPath, "config", DefaultConfigPath, "配置文件路径(JSON,兼容 .yaml/.yml)") + flag.StringVar(&configPath, "conf", DefaultConfigPath, "旧 home_client 参数,等价于 -config") + once := flag.Bool("once", false, "刷新一次后退出;失败时返回非零退出码") + dryRun := flag.Bool("dry-run", false, "只读预检一次,显示计划变更,不写 DNS") + flag.Parse() + logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) + config, err := LoadConfig(configPath) + if err != nil { + logrus.WithError(err).Error("load config failed") + os.Exit(1) + } + if config.Cloudflare.Debug { + logrus.SetLevel(logrus.DebugLevel) + } + client, err := NewClient(config) + if err != nil { + logrus.WithError(err).Error("initialize DDNS client failed") + os.Exit(1) + } + logrus.Infof("cq_ddns_client started: config=%s domains=%d interval=120s", configPath, len(config.DDNSConfig)) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, client, *once || *dryRun, *dryRun); err != nil { + logrus.WithError(err).Error("DDNS run failed") + os.Exit(1) + } +} + +func run(ctx context.Context, client *Client, once, dryRun bool) error { + ticker := time.NewTicker(120 * time.Second) + defer ticker.Stop() + for { + err := client.Refresh(ctx, dryRun) + if ctx.Err() != nil { + logrus.Info("cq_ddns_client stopped") + return nil + } + if once { + return err + } + if err != nil { + logrus.WithError(err).Error("DDNS cycle failed; retrying on next tick") + } + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + } +} diff --git a/docs/cli/cq_ddns_client.md b/docs/cli/cq_ddns_client.md new file mode 100644 index 0000000..25727cc --- /dev/null +++ b/docs/cli/cq_ddns_client.md @@ -0,0 +1,167 @@ +# cq_ddns_client + +独立的 Cloudflare DDNS 客户端,从 `home_server/client` 迁移而来。运行时直接查询公网 IP 和 Cloudflare API,不需要启动 home_server,也不读取服务端配置。 + +## 安装和运行 + +在 life_tools 仓库根目录执行,Go 版本以 `go.mod` 为准: + +```bash +./install.sh --tool cq_ddns_client +``` + +安装后的命令为 `/usr/local/bin/cq_ddns_client`,默认配置为 `/etc/life_tools/cq_ddns_client.json`。安装只生成缺失的示例配置,权限为 `0600`,不会覆盖已有配置或自动启用服务。执行用户需要有配置读取权限。 + +```bash +# 编辑配置后先预检,只读取 Cloudflare 和公网 IP,输出计划变更 +cq_ddns_client -dry-run + +# 刷新一次,成功返回 0,任一域名失败返回 1 +cq_ddns_client -once + +# 常驻:立即刷新,之后每 120 秒刷新 +cq_ddns_client +``` + +没有系统目录写权限时,可以自定义安装位置;运行时显式传入配置路径: + +```bash +./install.sh --tool cq_ddns_client --prefix "$HOME/.local" --config-dir "$HOME/.config/life_tools" +"$HOME/.local/bin/cq_ddns_client" -config "$HOME/.config/life_tools/cq_ddns_client.json" -dry-run +``` + +## 配置 + +示例见 [cq_ddns_client.json](../../sample/life_tools/cq_ddns_client.json)。 + +| 字段 | 含义 | +|---|---| +| `cloudflare.api_token` | 对目标 Zone 有 DNS 读取、编辑权限的 API Token。 | +| `cloudflare.zone` | Cloudflare Zone ID,沿用旧字段名;不是域名。 | +| `cloudflare.proxy` | 可选 SOCKS5 地址,例如 `127.0.0.1:1080`,只用于 Cloudflare 请求。 | +| `cloudflare.debug` | 可选应用日志开关,默认 false;不会打开包含认证头的 SDK HTTP dump。 | +| `ddns_config[].domain` | 需要维护的完整域名。 | +| `ddns_config[].ip_version` | `ipv4` 对应 A,`ipv6` 对应 AAAA。 | + +同一个域名可以分别配置 IPv4 和 IPv6,同域名同 IP 版本不能重复。Token、Zone、域名或 IP 版本缺失/无效时,启动失败,不发送 DNS 请求。JSON 文件按 JSON 解析,`.yaml`、`.yml` 文件按旧 YAML 解析。 + +Cloudflare 请求每次最多等待 20 秒,保留原 SDK 重试策略;公网 IP 查询每个地址最多等待 5 秒,并依次尝试以下服务: + +| 地址类型 | 查询顺序 | +|---|---| +| IPv4 | `4.ipw.cn` → `api4.ipify.org` → `ipv4.icanhazip.com` → `v4.ident.me` | +| IPv6 | `6.ipw.cn` → `api6.ipify.org` → `ipv6.icanhazip.com` → `v6.ident.me` | + +原 `ip.mcoder.cc` 私有查询入口不再是依赖。只有有效且类型正确的 IP 才能用于 DNS 更新;非 200 响应、错误页面和错误类型的 IP 会触发下一个查询源。 + +## DNS 更新规则 + +每轮先读取 Zone 中的 DNS 记录,再依次处理配置中的域名。记录按“域名+A/AAAA 类型”匹配,避免相同域名的不同类型互相覆盖。 + +| 当前状态 | 行为 | +|---|---| +| 对应类型的记录不存在 | 创建记录,TTL 为 300 秒,关闭 Cloudflare 代理。 | +| IP 没有变化 | 跳过更新,包括 IPv6 等价写法。 | +| IP 发生变化 | 只变更地址,保留原 TTL、Proxied 等记录属性。 | +| 同域名同类型存在多条记录 | 该域名报错,不猜测应该更新哪条记录。 | +| 某个 IP 查询或更新失败 | 记录错误并继续其他域名;常驻模式下一轮重试。 | +| `-dry-run` | 检查一次并输出计划,不创建或更新 DNS,随后退出。 | +| SIGINT / SIGTERM | 取消正在执行的请求并正常退出。 | + +日志输出到 stderr,systemd 通过 journal 收集。不输出 Token、完整配置或全 Zone 的 DNS 内容。 + +## 从 home_client 迁移 + +先保留旧二进制、旧 YAML 和 `home_client.service`,用新客户端读取同一份配置预检: + +```bash +cq_ddns_client -conf /etc/home_server/client_config.yaml -dry-run +``` + +`-conf` 是 `-config` 的兼容别名。默认路径已改为 `/etc/life_tools/cq_ddns_client.json`,不会自动寻找旧配置。字段名和 IPv4/IPv6、SOCKS5 含义保持不变。 + +配置转换只改变 YAML/JSON 格式,保留所有字段。可以参照示例手工转换;下面命令需要 Python 3 和 PyYAML,并拒绝覆盖已有目标文件: + +```bash +sudo python3 - <<'PY' +import json +import os +import yaml + +with open('/etc/home_server/client_config.yaml') as source: + config = yaml.safe_load(source) +os.makedirs('/etc/life_tools', exist_ok=True) +dest = '/etc/life_tools/cq_ddns_client.json' +fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) +with os.fdopen(fd, 'w') as target: + json.dump(config, target, indent=2) + target.write('\n') +PY +``` + +如果安装脚本已生成示例 JSON,先确认它仍是未填写的示例并将它另存为备份,再转换;已有真实配置应直接核对使用。转换后验证: + +```bash +sudo cq_ddns_client -config /etc/life_tools/cq_ddns_client.json -dry-run +sudo cq_ddns_client -config /etc/life_tools/cq_ddns_client.json -once +``` + +## Linux 常驻服务和回滚 + +[systemd 示例](../../sample/systemd/cq_ddns_client.service) 需要 systemd 247+,使用 DynamicUser 和 LoadCredential:配置由 root 持有 `0600` 权限,systemd 将只读凭证副本交给动态服务用户。路径使用 [systemd v247 文档](https://raw.githubusercontent.com/systemd/systemd/v247/man/systemd.exec.xml) 中的 `${CREDENTIALS_DIRECTORY}` 写法。凭证中的配置必须为 JSON。 + +确认新配置可用后再切换。不要让两个客户端长期同时维护同一批记录: + +```bash +sudo chown root:root /etc/life_tools/cq_ddns_client.json +sudo chmod 600 /etc/life_tools/cq_ddns_client.json +sudo install -m 644 sample/systemd/cq_ddns_client.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl stop home_client.service +sudo systemctl start cq_ddns_client.service +sudo journalctl -u cq_ddns_client.service -n 60 --no-pager +``` + +检查首次刷新和至少一个 120 秒周期都成功后,再调整开机启动: + +```bash +sudo systemctl disable home_client.service +sudo systemctl enable cq_ddns_client.service +``` + +新服务启动或刷新失败时,立即回滚: + +```bash +sudo systemctl disable --now cq_ddns_client.service +sudo systemctl enable --now home_client.service +``` + +## 开发验证 + +在测试主机上的仓库副本中执行: + +```bash +go test ./... +./build.sh +go test -race ./cli/cq_ddns_client +``` + +自动化测试使用 loopback HTTP 服务,覆盖 JSON/YAML 配置、IP 查询兜底、A/AAAA 同名记录、创建、更新属性保留、跳过、预检不写入、失败和重复记录。实机测试使用当前配置执行 `-dry-run`、`-once`,再验证一个常驻周期。只读预检和 IP 未变化的单次执行不能证明 Cloudflare 实际写权限;创建/更新的真实网络验证应使用专门的测试域名。 + +### 2026-09-13 实机验证记录 + +目标为 SSH 别名 `pi`,实际运行 Ubuntu 24.04.4、Linux amd64、Go 1.21.12、systemd 255。此记录不代表在 ARM 硬件上运行过。 + +| 验证项 | 结果 | +|---|---| +| life_tools 全仓测试、根构建脚本 | 通过。 | +| 新工具发布目标构建 | Linux/macOS 的 amd64、arm64 共 4 个目标均通过;仅 Linux amd64 执行实机测试。 | +| DDNS race 检查 | 通过,包含 Cloudflare 传输断连、请求取消及后续刷新恢复。 | +| home_server 移除 client 后的构建 | 根构建脚本及 `go build ./...` 通过。 | +| 隔离安装两次 | 命令可执行,配置权限 `0600`,第二次不覆盖配置。 | +| 旧 YAML 与转换后的 JSON | 字段完全一致;真实配置的 YAML 预检和 JSON 单次刷新均返回 0。 | +| systemd 动态用户及 LoadCredential | 能读取 JSON 凭证;两轮均完成 IPv4、IPv6 检查。 | +| 120 秒刷新周期 | UTC+8 14:11:46、14:13:46 启动两轮,重启次数 0。 | +| 停止及日志 | 测试服务停止后 `Result=success`、退出码 0;Token 和认证头未出现在日志。 | + +部分 IP 查询源出现超时、拒绝连接或解析失败,后续查询源成功,真实配置中的两条 DNS 地址均未发生变化。因此真实网络验证覆盖读取、地址比对和常驻运行;DNS 创建、修改与失败分支由 loopback 集成用例验证,没有通过修改现有 DNS 记录验证写权限。旧常驻服务保持运行,临时测试服务已停止。 diff --git a/docs/install.md b/docs/install.md index 62ac636..0e72c71 100644 --- a/docs/install.md +++ b/docs/install.md @@ -25,6 +25,7 @@ | `codex_hook_notify` | `codex_hook_notify` | Go | 是 | `/etc/life_tools/codex_hook_notify.json` | | `video_subtitle` | `video_subtitle` | Python | 是 | `/etc/life_tools/video_subtitle.json` | | `file_share` | `file_share` | Go | 是 | `/etc/life_tools/file_share.json` | +| `cq_ddns_client` | `cq_ddns_client` | Go | 是 | `/etc/life_tools/cq_ddns_client.json` | | `codex_inspector` | `codex_inspector` | Go | 否 | 无配置文件,默认只读 `~/.codex` | | `InterviewTimer` | `InterviewTimer.app` | SwiftPM macOS App | 否 | `~/Library/Application Support/InterviewTimer/` | @@ -43,6 +44,7 @@ ./install.sh --tool renameV1 --tool check_keywords ./install.sh --tools retry_exec,codex_hook_notify ./install.sh --tool file_share +./install.sh --tool cq_ddns_client ``` 安装到自定义前缀: @@ -60,6 +62,17 @@ 这只改变安装脚本写入示例配置的位置。部分工具源码里的默认配置路径仍是 `/etc/life_tools`,运行时需要用命令参数指定自定义配置路径。 +## cq_ddns_client + +```bash +./install.sh --tool cq_ddns_client +cq_ddns_client -config /etc/life_tools/cq_ddns_client.json -dry-run +``` + +安装脚本将示例配置以 `0600` 权限安装,已有配置不覆盖。填入 Cloudflare Token、Zone ID 和域名后,`-dry-run` 只读预检一次,`-once` 刷新一次,省略两者则每 120 秒刷新。执行用户需要有配置读取权限。 + +安装命令只安装二进制和示例配置。常驻服务使用 `sample/systemd/cq_ddns_client.service`;配置转换、停用旧 `home_client` 和回滚说明见 [CLI 文档](cli/cq_ddns_client.md)。 + ## file_share 只安装 `file_share`: @@ -267,6 +280,7 @@ command -v retry_exec command -v codex_hook_notify command -v video_subtitle command -v file_share +command -v cq_ddns_client test -d "$HOME/Applications/InterviewTimer.app" ``` @@ -279,6 +293,7 @@ retry_exec --help codex_hook_notify -h video_subtitle --help file_share -h +cq_ddns_client -h open "$HOME/Applications/InterviewTimer.app" ``` diff --git a/docs/release.md b/docs/release.md index 8222a18..be1326f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -43,6 +43,7 @@ on: - 'emby_plugins/video_subtitle/**' - 'plugins/alfred_remote_upload/**' - 'sample/life_tools/**' + - 'sample/systemd/**' push: tags: - 'v*' @@ -103,9 +104,11 @@ bin/check_keywords bin/retry_exec bin/codex_hook_notify bin/file_share +bin/cq_ddns_client bin/codex_inspector install.sh sample/life_tools/*.json +sample/systemd/cq_ddns_client.service docs/** ``` diff --git a/go.mod b/go.mod index 08cbc72..73d4e4d 100644 --- a/go.mod +++ b/go.mod @@ -3,29 +3,37 @@ module life_tools go 1.21 require ( + github.com/cloudflare/cloudflare-go v0.47.1 github.com/google/uuid v1.6.0 github.com/json-iterator/go v1.1.12 github.com/mcoder2014/go_utils v0.0.6 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.8.1 + golang.org/x/net v0.0.0-20220722155237-a158d28d115b golang.org/x/sync v0.1.0 golang.org/x/term v0.7.0 + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.29.10 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.3.7 // indirect + golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.49.3 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index 05fb6a0..a5fe8e7 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,24 @@ +github.com/cloudflare/cloudflare-go v0.47.1 h1:+ckOY+iG4JZYHv56BkJpEwU5X3ODLPOq+dLYCWANtXw= +github.com/cloudflare/cloudflare-go v0.47.1/go.mod h1:NTQPvombbf52QYX6YdY/oA15F7u9GNzeBhY3c3H49vA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ= +github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -22,6 +33,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -31,6 +44,7 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -39,6 +53,8 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -47,8 +63,13 @@ golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs= +golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/install.sh b/install.sh index fd0f5ba..31d396f 100755 --- a/install.sh +++ b/install.sh @@ -8,8 +8,8 @@ CONFIG_DIR="${LIFE_TOOLS_CONFIG_DIR:-/etc/life_tools}" HOOKS_FILE="$HOME/.codex/hooks.json" OS_NAME="$(uname -s)" -STABLE_TOOLS=(renameV1 check_keywords retry_exec codex_hook_notify video_subtitle file_share) -ALL_TOOLS=(renameV1 check_keywords retry_exec codex_hook_notify video_subtitle file_share) +STABLE_TOOLS=(renameV1 check_keywords retry_exec codex_hook_notify video_subtitle file_share cq_ddns_client) +ALL_TOOLS=(renameV1 check_keywords retry_exec codex_hook_notify video_subtitle file_share cq_ddns_client) REQUESTED_TOOLS=() SELECTED_TOOLS=() INSTALL_ALL=0 @@ -35,10 +35,10 @@ Options: -h, --help Show this help. Stable tools installed by default: - renameV1, check_keywords, retry_exec, codex_hook_notify, video_subtitle, file_share + renameV1, check_keywords, retry_exec, codex_hook_notify, video_subtitle, file_share, cq_ddns_client All tool names: - renameV1, check_keywords, retry_exec, codex_hook_notify, video_subtitle, file_share + renameV1, check_keywords, retry_exec, codex_hook_notify, video_subtitle, file_share, cq_ddns_client Examples: ./install.sh @@ -47,6 +47,7 @@ Examples: ./install.sh --tool video_subtitle --with-python-deps ./install.sh --tool codex_hook_notify --install-codex-hook ./install.sh --tool file_share + ./install.sh --tool cq_ddns_client EOF } @@ -126,7 +127,7 @@ install_config_if_missing() { echo "config exists, skip overwrite: $dest" return fi - install_file "$sample" "$dest" 0644 + install_file "$sample" "$dest" "${3:-0644}" echo "installed sample config: $dest" } @@ -175,6 +176,9 @@ normalize_tool_name() { file|share|file-share|file_share) echo "file_share" ;; + cq_ddns_client|cq-ddns-client) + echo "cq_ddns_client" + ;; *) return 1 ;; @@ -312,7 +316,7 @@ needs_go() { local tool for tool in "${SELECTED_TOOLS[@]}"; do case "$tool" in - renameV1|check_keywords|retry_exec|codex_hook_notify|file_share) + renameV1|check_keywords|retry_exec|codex_hook_notify|file_share|cq_ddns_client) return 0 ;; esac @@ -558,6 +562,14 @@ EOF fi } +install_cq_ddns_client() { + install_go_tool cq_ddns_client cq_ddns_client ./cli/cq_ddns_client/... + install_config_if_missing "$ROOT_DIR/sample/life_tools/cq_ddns_client.json" "$CONFIG_DIR/cq_ddns_client.json" 0600 + echo "Edit $CONFIG_DIR/cq_ddns_client.json, then run:" + echo " cq_ddns_client -config $CONFIG_DIR/cq_ddns_client.json -dry-run" + echo "systemd setup and migration: docs/cli/cq_ddns_client.md" +} + install_selected_tool() { case "$1" in renameV1) @@ -578,6 +590,9 @@ install_selected_tool() { file_share) install_file_share ;; + cq_ddns_client) + install_cq_ddns_client + ;; *) echo "unsupported tool: $1" >&2 exit 1 diff --git a/sample/life_tools/cq_ddns_client.json b/sample/life_tools/cq_ddns_client.json new file mode 100644 index 0000000..207b0bc --- /dev/null +++ b/sample/life_tools/cq_ddns_client.json @@ -0,0 +1,12 @@ +{ + "cloudflare": { + "api_token": "", + "zone": "", + "debug": false, + "proxy": "" + }, + "ddns_config": [ + {"domain": "pi.example.org", "ip_version": "ipv4"}, + {"domain": "pi6.example.org", "ip_version": "ipv6"} + ] +} diff --git a/sample/systemd/cq_ddns_client.service b/sample/systemd/cq_ddns_client.service new file mode 100644 index 0000000..86ab11d --- /dev/null +++ b/sample/systemd/cq_ddns_client.service @@ -0,0 +1,19 @@ +[Unit] +Description=CQ Cloudflare DDNS client +Documentation=https://github.com/mcoder2014/life_tools/blob/master/docs/cli/cq_ddns_client.md +After=network-online.target nss-lookup.target +Wants=network-online.target + +[Service] +DynamicUser=true +LoadCredential=config:/etc/life_tools/cq_ddns_client.json +ExecStart=/usr/local/bin/cq_ddns_client -config ${CREDENTIALS_DIRECTORY}/config +Restart=on-failure +RestartSec=10 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target