23 KiB
FluxIP
FluxIP 是一个使用 Python、FastAPI 和 SQLite 开发的 AWS Lightsail Static IP 轮换控制器。它可以管理多台 Lightsail 实例,把每台实例与一个 Cloudflare A 记录 组成一个托管目标,再通过实例组统一设置轮换周期。A 记录默认使用 DNS only,也可按 实例选择橙云模式。
轮换过程中不会停止或重启 Lightsail。FluxIP 会分配新的 Static IP,解绑旧地址, 把新地址附加到仍在运行的实例,验证 SOCKS5 或 HTTP 代理与 Cloudflare DNS,等待释放宽限期, 最后从 AWS 账号释放旧 Static IP。
配置、账号资料、任务状态和历史记录保存在 SQLite。可以分别维护多套 AWS 与 Cloudflare 账号,每个托管实例独立选择要使用的两套账号。AWS 密钥与 Cloudflare API Token 以及代理用户名、密码使用本机主密钥加密后存储,API 和 WebUI 不会返回密钥明文。
重要边界
- 每台 Lightsail 实例只能附加一个 Static IP。地址切换不是双 IP 并行切流。
- 解绑旧地址和附加新地址之间会有短暂切换窗口。实例进程不会退出,但已有 SOCKS5 TCP 连接会断开,需要客户端重连。
- DNS only 模式下 Cloudflare 只负责 DNS 解析。DNS 缓存仍可能在 TTL 内返回旧地址, FluxIP 不能提供零中断或连接迁移。
- 普通 Cloudflare 橙云不能转发 SOCKS5、通用 HTTP CONNECT 或 10808 等任意 TCP
端口。只有已经单独配置 Cloudflare Spectrum 等 TCP 产品时才应开启橙云;FluxIP
只修改 DNS 记录的
proxied状态,不会创建 Spectrum 应用。 - 橙云记录的 API 回读只能证明配置已经写入,不能证明所有 Cloudflare 边缘节点已经 切换源站;默认释放宽限期也不是 Spectrum 端到端可用性的保证。
- 旧 Static IP 一旦执行
ReleaseStaticIp就不能找回。FluxIP 只会在新地址、DNS 和 可选代理认证与 CONNECT 检查通过后释放它。 - AWS 默认每个区域最多 5 个 Static IP,配额可以申请提高。安全轮换采用 “先分配新地址、后释放旧地址”,因此同一区域需要至少一个空余配额。
- 调度器运行在 Web 进程内,只支持一个 Uvicorn worker 和一个应用副本。
- 生产环境建议把控制器部署到独立的长期在线主机。虽然 Static IP 切换不会关闭 目标实例,但同机部署会使 WebUI 管理连接也受地址切换影响。
资源模型
一个托管实例包含:
- 显示名称;
- AWS 账号、从内置中文名称与图标目录选择的 Lightsail 区域,以及 Lightsail 实例名称;
- Cloudflare 账号、Zone ID、唯一的完整 A 记录名和橙云开关;
- SOCKS5/HTTP 代理链接、端口、健康检查开关和超时;
- 旧地址释放宽限期;
- 是否允许参与手动或分组轮换。
一个实例组包含若干托管实例、启用状态和轮换间隔。到期时,组内已启用实例按照 成员顺序逐台轮换。串行执行只额外占用一个 Static IP 配额,也避免同时切断多台 代理。下次执行时间从整个批次结束后重新计算,两个批次之间至少间隔一个完整周期。 当前系统全局只允许一个轮换批次或 DNS 同步任务执行。
AWS 与 Cloudflare 账号分别建档,同一账号可以由多个实例复用,不同实例也可以选择 完全独立的账号组合。数据库会阻止以下冲突:
- 同一个 AWS 账号下的同一区域和 Lightsail 实例被重复管理;
- 同一个 Cloudflare 账号下的同一记录被多个托管实例使用;
- 同一个实例同时加入多个实例组。
账号的新增、修改和删除与实例配置共用轮换写锁。轮换批次或 DNS 同步任务执行期间 通常不能写入账号;任务进入待处理或待清理后,只允许替换当前运行项所绑定账号的 恢复密钥,不能改名、切换认证模式或操作其他账号。仍被活动实例引用的账号不能删除, 必须先把实例改绑到其他账号或删除相关实例。
轮换流程
一次实例轮换按以下顺序执行:
- 预检:确认 Lightsail 正在运行且有公网 IPv4;读取当前附加的 Static IP; 确认 Cloudflare A 记录的 IP、代理模式和 TTL 与实例配置一致。
- 分配:使用确定性任务资源名调用
AllocateStaticIp,轮询 AWS operation, 再读取资源确认新 Static IP 已存在且属于正确区域。 - 解绑:如果实例原本已有 Static IP,调用
DetachStaticIp并回读确认旧地址 已解绑。实例原本使用动态 IP 时,这一步用于首次 Static IP 纳管,不存在旧静态 资源需要删除。 - 附加:调用
AttachStaticIp把新地址附加到目标实例,并同时核对 Static IP 的attachedTo、实例publicIpAddress和isStaticIp。 - 健康检查:直连新的 Static IP。SOCKS5 会完成方法协商、RFC 1929 用户密码认证 和外部 CONNECT;HTTP 代理会发送带 Basic 认证的 CONNECT。粘贴链接中的域名不会 用于 DNS 切换前的源站检查,避免误测到旧 IP。
- 更新 DNS:把 Cloudflare A 记录切换到新地址;灰云写入
proxied=false、TTL 60, 橙云写入proxied=true、TTL Automatic,然后回读验证。 - 释放宽限:至少等待 60 秒,默认 75 秒。旧地址此时已解绑,宽限期不会让旧 客户端继续访问,但可以避免旧地址在 DNS 缓存期立即被 AWS 分配给其他用户。
- 删除旧地址:再次验证新地址仍附加、DNS 仍指向新地址,并按配置复查代理;
此时同时探测新 Static IP 和实际 A 记录域名,确保 DNS only 或 Spectrum 入口真正
可用后再调用
ReleaseStaticIp,以GetStaticIp返回不存在作为删除成功依据。
AWS 写操作是异步的。FluxIP 会保存任务阶段并轮询 GetOperation;进程意外退出后,
会根据 SQLite 状态和 AWS/Cloudflare 实际状态续跑,而不是仅根据上次 HTTP 响应猜测。
预检失败会直接结束任务;无法安全自动恢复的切换异常进入“待处理”;旧地址或回滚
新地址释放失败进入“待清理”。管理员应先核对云端状态,再更新凭据、继续或放弃任务。
如果旧 Static IP 仍存在、DNS 仍为旧地址且没有被外部修改,FluxIP 会在新地址附加、 健康检查或 DNS 更新失败后自动执行持久化回滚:解绑新地址、重新附加旧地址、验证旧 代理与 DNS 路由,再删除本次分配的新 Static IP。回滚过程中每一步都会写入 SQLite, 进程重启后可以幂等续跑。DNS 已经确认切到新地址时会继续向前验证,不会制造新旧 DNS 缓存分裂;DNS 指向第三方地址、记录缺失或代理模式被外部改变时则停止自动操作。首次从动态 IP 纳管没有可重新附加的旧静态资源,因此不能自动回滚,只能保留现场并进入待处理。
运行要求
- Python 3.11 或更高版本;
- 可访问 AWS Lightsail API 和 Cloudflare API;
- 目标 Lightsail 的 SOCKS5 或 HTTP 代理服务已经运行,端口和防火墙规则正确;
- Cloudflare 中使用独占的 A 记录;普通代理服务使用 DNS only,橙云需要已配置 Spectrum;
- AWS Static IP 配额具有轮换余量。
不允许使用多 worker:
--workers 1
不要运行多个 Compose 副本、多个 systemd 实例或多个指向同一数据库的控制器。
直接运行源码
Windows PowerShell
cd "D:\project\S5-IP自动轮换"
py -3.11 -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip setuptools
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
if (-not (Test-Path .env)) { Copy-Item .env.example .env }
.\.venv\Scripts\python.exe -m app
打开 http://127.0.0.1:8787。关闭服务时在运行窗口按 Ctrl+C。
Linux 或 macOS
cd /path/to/fluxip
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip setuptools
.venv/bin/python -m pip install -r requirements.txt
[ -f .env ] || cp .env.example .env
.venv/bin/python -m app
python -m app 会读取 .env 中的 ROTATOR_HOST、ROTATOR_PORT 和日志配置,并固定
使用一个 worker。需要临时覆盖时也可以继续使用显式的 Uvicorn 命令。
默认数据文件为:
data/fluxip.db
data/master.key
首次启动会创建数据库和主密钥。数据库存在而主密钥丢失时,程序会拒绝启动,避免 用新密钥覆盖后无法解密原凭据。
从只支持一套全局凭据的旧版本升级时,已配置的 AWS 和 Cloudflare 凭据会在启动迁移 后分别导入为 legacy 账号,旧实例会自动绑定对应账号。导入过程使用现有主密钥解密并 重新加密,不会把密钥明文写入数据库或日志。
管理员账号
FluxIP 没有默认管理员账号或默认密码。
第一次打开 WebUI 时自行创建管理员,密码至少 10 位。未配置
ROTATOR_BOOTSTRAP_TOKEN 时,首次创建管理员只允许来自控制器回环地址;容器、远程
主机或公网反向代理部署应提前配置至少 32 字节高熵 Bootstrap Token。
生成示例:
python -c "import secrets; print(secrets.token_urlsafe(32))"
管理员创建完成后,可以从环境配置中删除 Bootstrap Token 并重启服务。不要把 Bootstrap Token、云端密钥或数据库主密钥提交到 Git。
WebUI 首次配置
- 创建管理员并登录。
- 打开“账号”,新增 AWS 账号:选择默认凭据链,或填写专用 Access Key ID、Secret Access Key;临时凭据还需要 Session Token。
- 继续新增 Cloudflare 账号并填写 API Token。可以为不同 AWS 或 Cloudflare 租户创建 多个独立账号资料。
- 新建托管实例,选择已保存的 AWS 账号和 Cloudflare 账号;从带图标和中文名称的
区域目录选择 AWS Region,再填写 Lightsail 实例名称、Zone ID 和完整 A 记录名。
代理测活链接可直接粘贴以下格式,协议、端口和认证会自动解析:
socks5://用户:密码@域名:端口、http://用户:密码@域名:端口、socks5://域名:端口@用户:密码。用户名或密码包含@、:时应使用 URL 编码。 链接里的域名必须与该实例的 A 记录完整域名一致。 - 执行实例连接测试,确认所选 AWS 账号可以读取实例公网地址,所选 Cloudflare 账号 可以读取目标记录。
- 如 DNS 尚未指向实例当前地址,先执行 DNS 同步。
- 对单个实例执行一次手动轮换,核对新 Static IP、代理 CONNECT 和 DNS。
- 创建实例组、添加成员、设置轮换间隔并启用计划。
配置更新使用版本号进行并发检查。如果页面数据已被另一会话修改,刷新后再提交。 账号、实例和实例组的删除是软删除,不会自动删除 Lightsail 实例或 Cloudflare Zone。
轮换批次或 DNS 同步执行中禁止新增、删除账号,也禁止修改实例或实例组。任务因凭据 失效进入待处理或待清理后,可以只替换当前运行项所绑定账号的恢复密钥;账号名称和 认证模式仍被锁定。此时也可以为当前实例重新粘贴相同域名、协议和端口的代理链接, 只替换加密的代理用户名与密码;其他实例配置仍被锁定。审计日志不记录密钥内容。
AWS 最小权限
建议使用专用 IAM 身份或角色,不要使用 AWS 根账号密钥。FluxIP 当前需要以下 Lightsail API:
GetInstanceGetStaticIpGetStaticIpsGetOperationAllocateStaticIpAttachStaticIpDetachStaticIpReleaseStaticIp
这些调用包含新资源分配、全区域静态地址查询和异步 operation 查询,不能全部稳定地
约束到一个预先存在的资源 ARN。下面策略使用精确 Action 列表和区域条件,避免授予
lightsail:*:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FluxIPStaticIpRotation",
"Effect": "Allow",
"Action": [
"lightsail:GetInstance",
"lightsail:GetStaticIp",
"lightsail:GetStaticIps",
"lightsail:GetOperation",
"lightsail:AllocateStaticIp",
"lightsail:AttachStaticIp",
"lightsail:DetachStaticIp",
"lightsail:ReleaseStaticIp"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestedRegion": [
"us-east-1",
"ap-southeast-1"
]
}
}
}
]
}
把区域列表改成实际使用的区域。单区域部署只保留一个值。组织若使用 SCP 或权限边界, 还需在相应层级允许这些 Action。权限不足时不要临时改成管理员权限,应根据错误信息 补充缺失 Action 或区域。
Static IP 配额
AWS 默认每个账号、每个 Lightsail 区域最多 5 个 Static IP,配额可申请提高。假设同一
区域已有 N 台托管实例各占一个 Static IP,安全轮换至少需要 N + 1 的配额。实例组
逐台执行,因此通常只需要一个临时余量。
如果 5 个地址已经全部占用,FluxIP 不会先删除旧地址来腾配额。那样会失去恢复余地, 也可能重新获得相同地址。应先在 AWS 配额页面申请提高限制,或减少该区域的现用地址。 未附加的 Static IP 可能产生费用;待处理和待清理任务应尽快核对。
Cloudflare 最小权限
创建专用 API Token,不要使用 Global API Key。Token 只包含托管目标所在 Zone,并授予:
Zone / Zone / ReadZone / DNS / Edit
多个实例跨多个 Zone 时,把这些特定 Zone 都加入同一 Token,或改用覆盖所需 Zone 的 受控 Token。所有目标记录必须满足:
- 类型为 A;
- Proxy status 与实例里的橙云开关一致;
- 同名没有 CNAME、AAAA 或第二条 A 记录;
- 记录由 FluxIP 独占,不由其他自动化同时修改。
默认应使用 DNS only(灰云)。Cloudflare 普通橙云不代理任意 SOCKS5、通用 HTTP CONNECT 或 10808 等任意 TCP 端口。只有先在 Cloudflare 配置并开通 Spectrum 等对应 TCP 服务后才开启 FluxIP 的橙云开关;这个开关本身不会创建 Spectrum 应用。
FluxIP 写入前会核对当前记录,写入后再回读,但 Cloudflare 常规 DNS 接口不提供跨 AWS 与 DNS 的原子事务。需要人工接管时,先禁用相关实例组计划,并等待当前任务完成 或在任务详情明确处理异常现场。
环境变量
应用读取项目根目录 .env,变量统一使用 ROTATOR_ 前缀。
| 变量 | 默认值 | 说明 |
|---|---|---|
ROTATOR_ENVIRONMENT |
development |
production 会关闭 API 文档 |
ROTATOR_HOST |
127.0.0.1 |
服务监听地址 |
ROTATOR_PORT |
8787 |
服务端口 |
ROTATOR_DATABASE_PATH |
data/fluxip.db |
SQLite 文件 |
ROTATOR_MASTER_KEY_FILE |
data/master.key |
凭据加密主密钥文件 |
ROTATOR_MASTER_KEY |
空 | 外部注入的 URL-safe Base64 主密钥,优先于文件 |
ROTATOR_BOOTSTRAP_TOKEN |
空 | 远程首次初始化令牌 |
ROTATOR_COOKIE_SECURE |
false |
HTTPS 生产环境应设为 true |
ROTATOR_SESSION_DAYS |
7 |
管理员会话有效期 |
ROTATOR_ALLOWED_ORIGINS |
本机 8787 | 允许的完整 Origin,逗号分隔 |
ROTATOR_TRUSTED_HOSTS |
localhost,127.0.0.1 |
接受的 Host,逗号分隔 |
ROTATOR_TRUSTED_PROXIES |
127.0.0.1,::1 |
可提交转发头的代理 IP/CIDR |
ROTATOR_SCHEDULER_ENABLED |
true |
是否执行实例组到期扫描 |
ROTATOR_SCHEDULER_TICK_SECONDS |
10 |
扫描间隔,2 到 60 秒 |
ROTATOR_LOG_LEVEL |
INFO |
日志级别 |
ROTATOR_EXTERNAL_TIMEOUT_SECONDS |
20 |
外部请求基础超时,5 到 60 秒 |
生产示例见 deploy/fluxip.env.example。不要把生产密钥直接写进仓库内的示例文件。
Docker Compose
Docker Compose 默认把 WebUI 发布到 127.0.0.1:8787,并使用项目目录下三个独立的
宿主机目录保存数据库、主密钥和备份:
data/fluxip-data/
data/fluxip-key/
data/fluxip-backups/
部署需要 Docker Compose v2(使用 docker compose 命令)。启动时,一次性的
fluxip-init 服务会拒绝挂载目录中的符号链接或特殊文件,把目录权限收紧并交给
UID/GID 10001 后退出;长期运行的 fluxip 服务仍是非 root 用户。已有数据库、
主密钥和备份不会被初始化服务删除或改写内容。
cp deploy/fluxip.env.example .env
# 编辑 .env,至少设置高熵 ROTATOR_BOOTSTRAP_TOKEN
docker compose up -d --build
docker compose ps -a
curl http://127.0.0.1:8787/readyz
Windows PowerShell:
Copy-Item deploy\fluxip.env.example .env
docker compose up -d --build
Invoke-RestMethod http://127.0.0.1:8787/readyz
常用命令:
docker compose logs -f --tail=200 fluxip
docker compose restart fluxip
docker compose down
docker compose down 不删除上述宿主机目录。不要手工删除 data/fluxip-key/master.key;
数据库存在但主密钥丢失时,已保存的云端凭据将无法解密。
Linux systemd 与 HTTPS
示例假定代码位于 /opt/fluxip:
sudo useradd --system --home /var/lib/fluxip --shell /usr/sbin/nologin fluxip
sudo install -d -o root -g root -m 0755 /opt/fluxip /etc/fluxip
sudo cp -a app migrations deploy requirements.txt pyproject.toml README.md /opt/fluxip/
sudo python3 -m venv /opt/fluxip/.venv
sudo /opt/fluxip/.venv/bin/python -m pip install -r /opt/fluxip/requirements.txt
sudo install -o root -g root -m 0600 deploy/fluxip.env.example /etc/fluxip/fluxip.env
sudo editor /etc/fluxip/fluxip.env
sudo install -o root -g root -m 0644 deploy/fluxip.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now fluxip.service
curl http://127.0.0.1:8787/readyz
查看日志:
sudo journalctl -u fluxip.service -n 200 --no-pager
deploy/nginx-fluxip.conf.example 是本机 Nginx HTTPS 反向代理示例。启用后设置:
ROTATOR_COOKIE_SECURE=true
ROTATOR_ALLOWED_ORIGINS=https://controller.example.com
ROTATOR_TRUSTED_HOSTS=controller.example.com,127.0.0.1
ROTATOR_TRUSTED_PROXIES=127.0.0.1,::1
若代理不在本机,只填写代理的精确 IP/CIDR,并同步限制 Uvicorn
--forwarded-allow-ips 和防火墙。不要信任 0.0.0.0/0 或 ::/0 的转发头。
备份与恢复
数据库和主密钥必须成对恢复,但不应默认放进同一个未加密归档:
- 数据库包含管理员、实例、实例组、任务历史和加密凭据;
master.key可以解密云端凭据,必须单独保存在密码库或加密介质;- 丢失主密钥无法恢复凭据,泄露主密钥会使数据库密文失去保护;
- 不要提交数据库、主密钥、
.env或备份包。
在线备份只打包 SQLite:
python3 deploy/backup.py \
--database /var/lib/fluxip/fluxip.db \
--output-dir /var/backups/fluxip \
--keep-last 30
Docker:
docker compose exec -T fluxip python /app/deploy/backup.py \
--database /app/data/fluxip.db \
--output-dir /app/backups \
--keep-last 30
docker compose cp fluxip:/app/backups/. ./fluxip-backups
systemd 定时备份示例为 deploy/fluxip-backup.service 和
deploy/fluxip-backup.timer。备份后应验证同名 .sha256,并定期在隔离环境实际恢复。
只有目标目录本身已经加密时,才使用 --allow-plaintext-key-archive 创建包含主密钥的
完整归档。恢复时先停止 FluxIP,同时恢复匹配的数据库和主密钥,再启动并验证
/readyz、管理员登录、账号资料状态、实例组计划和云端连接。
健康检查与排错
GET /healthz:进程存活状态,返回status和应用版本;不探测 SQLite 或云端账号。GET /readyz:检查 SQLite 和已启用调度器的运行状态;就绪时返回 HTTP 200,数据库 异常或已启用调度器意外退出时返回 HTTP 503 和degraded。响应中的scheduler会显示开关、运行状态、最近扫描/派发/异常时间;该接口不会调用 AWS 或 Cloudflare。- 到期后没有自动轮换:先检查
/readyz的scheduler.enabled和running。系统环境 变量优先于.env;请删除意外继承的ROTATOR_SCHEDULER_ENABLED=false,或明确设为true后重启。已逾期的实例组会在调度器启动后的首轮扫描立即执行。 - 没有默认管理员:首次打开页面自行创建;远程初始化需 Bootstrap Token。
- 账号连接测试失败:确认实例选中了正确类型的 AWS 与 Cloudflare 账号,账号密钥 完整,并且 Region、Zone ID 和记录名属于对应账号。
- AWS AccessDenied:核对所选 AWS 账号的上述 8 个 Lightsail Action 和目标区域, 不要直接授予管理员。
- Static IP 配额不足:提高该区域配额;不要先释放正在使用的旧地址。
- 找不到附加的旧地址:确认实例、Static IP 和区域一致,避免同时在控制台人工修改。
- 代理检查失败:确认服务监听公网接口、端口开放、链接协议与服务一致,并重新粘贴
正确的用户名、密码。探测会实际建立到
1.1.1.1:443的 CONNECT。 - DNS 冲突:删除同名 CNAME、AAAA 或多余 A 记录,并让橙云状态与实例配置一致。
- 任务待处理:先核对新旧 Static IP 的真实附加状态和 DNS,再更新恢复凭据或继续。
- 任务待清理:新地址通常已经生效,但旧地址尚未成功释放,应尽快处理以避免费用。
- 客户端短暂掉线:Static IP 替换会终止原 TCP 连接,这是当前架构的预期边界。
- 主密钥缺失:恢复与数据库匹配的主密钥,不要删除数据库让程序生成新密钥。
- Docker 启动提示
/app/secrets/master.key无权限:拉取最新代码后执行docker compose down,再执行docker compose up -d --build --force-recreate,确认docker compose ps -a中fluxip-init为Exited (0)。这两个命令不会删除持久化 目录。旧版本可先停止服务,再将data/fluxip-data、data/fluxip-key、data/fluxip-backups的所有者递归改为10001:10001;不要删除或单独替换已有主密钥。
安全清单
- Uvicorn 始终使用单 worker、单副本。
- WebUI 只开放给受信网络,公网入口使用 HTTPS。
- AWS 使用专用最小权限身份,Cloudflare Token 只授权必要 Zone。
- 每个 Cloudflare A 记录由一个托管实例独占;普通 SOCKS5/HTTP CONNECT 使用 DNS only。
- 代理服务启用身份认证并限制入站来源。
- 为 Static IP 配额预留至少一个安全轮换余量。
- 定期检查待处理、待清理任务和未附加 Static IP 费用。
- SQLite 与主密钥成对备份,备份异机存放并加密。
- 不在日志、截图、工单、Git 或最终交付文档中发送真实密钥。