Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ on:
- 'emby_plugins/video_subtitle/**'
- 'plugins/alfred_remote_upload/**'
- 'sample/life_tools/**'
- 'sample/systemd/**'
push:
tags:
- 'v*'
Expand Down Expand Up @@ -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/"

(
Expand Down
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。
Expand Down Expand Up @@ -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/...`

测试优先使用:

Expand Down Expand Up @@ -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` 会实际修改文件名。默认把它当高风险工具处理。
Expand Down Expand Up @@ -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 二进制发布包。
Expand Down
4 changes: 3 additions & 1 deletion README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

自定义安装目录和配置目录:
Expand All @@ -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) |
Expand Down
5 changes: 5 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/bin/bash

set -e

mkdir -p output

# 文件批量重命名
Expand All @@ -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/...
67 changes: 67 additions & 0 deletions cli/cq_ddns_client/config.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions cli/cq_ddns_client/config_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
139 changes: 139 additions & 0 deletions cli/cq_ddns_client/ddns.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading
Loading