摘要
记录一次从零开始搭建个人云服务的经历,涵盖 Cloudflare Workers、DNS、GitHub Pages、Nginx、阿里云服务器与 Tailscale 的配置,以及其中遇到的各种问题与踩坑。本文也分享了服务器访问安全、SSH 防护和代理配置等实践。
前言
也就是昨天(还是前天),念及自己即将开启苦逼的高三一年,自己的 QQBot ,自己的什么什么,就这么再起不能, 似乎有些许的绝望。碰巧在阿里云看见一台 68CNY/y 的 轻量应用服务器 ,2c2g,当然是说不上好的, 但是于我而言他是足够的,所以还是租下了他,为避免浪费服务资源,我也决定稍微做点简单的云服务, 于是又拿下了一个首年 1CNY 的 .me 域名。
本文主要记录本人设置有关服务所经历和踩坑(中的可共享部分)。
小小的广告
如果您也对 QQBot 感兴趣,您不妨看看由本人参与贡献的如下项目:
初始计划
建立一些简单的服务并利用 Cloudflare Workers 做转发。
flowchart TD
A[用户访问] -->|api.harcic.is-a.dev ?| B(Cloudflare Workers)
B -->|签名| C[xxx.xxx.xxx.xxx ?]
A[用户访问] --> C
C --> D{签名了吗哥俩}
D -->|签了喵| E[好的喵]
D -->|没签喵| F[滚 401]
您或许可以看出,这个计划问题不是一般的多,不过我很快就要来纠正他们。
Workers 配置
Workers 配置本身是简单的,尤其是在这个 AI 时代,我们重点说明如下问题。
域名选用
[!NOTE]
为什么一定需要一个域名?
对于中国大陆的网络环境, Cloudflare Workers 并不友好,解析往往是失败的,导致在内地对 Workers 的访问不够简易。 幸运的是,我们只需要一个自定义域名就可以解决这个问题。
如你所见,我最初计划在我已有的 harcic.is-a.dev 的基础上申请 api.harcic.is-a.dev 的 nested domain. is-a.dev 完全免费,文档齐全,听起来很完美,但是就在我准备去PR的时候:
✔ proxy › Domains with proxy enabled must have at least one proxy-able record (297ms)
✔ proxy › Domains with specific records must not have proxy enabled
✘ [fail]: records › All files should have valid records api.harcic.json: CNAME cannot end with .workers.dev
✔ records › Root subdomains should have at least one usable record (134ms)
✔ json › All files should have valid required and optional fields (2s)
啊是的,is-a.dev 不允许 CNAME 指向 Workers.
[!TIP] CNAME 是 DNS 记录的一种,这种记录指向一个域名,例如利用 CANME 记录,我们可以做到:
harcic.is-a.dev -> harcicyang.github.io -> GitHub Pages我们还会见到以下常用记录类型:
- A : 记录 IPv4 的原始 IP 地址
- AAAA : 记录 IPv6 的原始 IP 地址
- MX : 指定该域名的邮件的接收服务器
- 等等
关于将 is-a.dev 指向 xxx.xxx.workers.dev 为什么被标记为不合法,我会在下文给出解释。
在郁闷了一段时间后,我在阿里云找到了首年仅仅 1CNY 的 .me 域名,没有注意续费价格就租下了。 (啊续费是一年135CNY)
现在,包装我们的 Workers 就是十分简单的了,我们只需要按照 Cloudflare 的要求更换名称服务器就可以让 Cloudflare 管理我们的域名。 然后我们就可以在 Workers 中选择自己的域名,然后设置子域名即可。
Workers 该怎么代理
最简单的想法:直接把服务器公网 IPv4 地址写进去不就好了?
嗯,我就这么做了,然后得到了一个让人困惑的 code 1003. 查阅资料后发现 Cloudflare Workers 的一项限制: Workers 不允许请求裸露的 IP 地址。
## Error 1003 Access Denied: Direct IP Access Not Allowed
This error indicates that direct access to a Cloudflare IP address is not allowed.
### Common cause
A client or browser directly accesses a [Cloudflare IP address ↗](https://www.cloudflare.com/ips).
### Resolution
Browse to the website domain name in your URL instead of the Cloudflare IP address.
最直接的解决方案当然是去搞个域名。但是如果我们不希望现在就用上域名,我们可以使用 sslip.io
只需要把 xxx.xxx.xxx.xxx 改写为 xxx.xxx.xxx.xxx.sslip.io 即可。
如果我立刻为我的服务器设置域名呢?
记得 不要在对源 IP 的 DNS 记录设置中开启 “代理”,也不要写不存在的 https.
服务端配置
本人使用的阿里云轻量应用服务器似乎不太支持安全组设置,于是我们需要通过防火墙来限制只允许 Cloudflare IP 的访问。
这是我使用的自动脚本:
点击展开
#!/usr/bin/env python3
"""把 Cloudflare 官方 IP 段自动同步到 轻量应用服务器(Lighthouse) 防火墙规则。
子命令:
--print 打印当前 CF IPv4 段
--check --instance-id <id> 只对比,有差异退出码 2
--apply --instance-id <id> 做差量收敛(默认 dry-run)
--apply --instance-id <id> --no-dry-run 真正执行(cron 用)
依赖(一次性):ECS 装 aliyun CLI + swas-open 插件 + aliyun configure
(RAM 子账号 AliyunSWASFullAccess,地域 ap-northeast-2)。
"""
import argparse
import json
import subprocess
import sys
import time
import urllib.request
CF_API = "https://api.cloudflare.com/client/v4/ips"
CF_FALLBACK = "https://www.cloudflare.com/ips-v4"
UA = {"User-Agent": "servicic-cf-ip-sync/1.0"}
DEFAULT_REGION = "ap-northeast-2"
REMARK = "cf-ip-sync"
CHUNK = 10 # 每次 create/delete 调用携带的规则数上限(保守)
def log(msg):
print(msg)
def fetch_cf_ips():
"""拉取 CF IPv4 段;主 API 失败时回退到静态页。"""
for url in (CF_API, CF_FALLBACK):
try:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=30) as r:
data = json.loads(r.read().decode())
return set(data["result"]["ipv4_cidrs"])
except Exception as e:
log(f"fetch failed ({url}): {e}")
sys.exit(1)
def run_aliyun(args):
"""执行 aliyun CLI;失败即退出。打印实际命令方便排查/复核。"""
cmd = ["aliyun"] + args
log("$ " + " ".join(cmd))
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0:
sys.stderr.write(p.stderr)
sys.exit(1)
if not p.stdout.strip():
return None
return json.loads(p.stdout)
def list_rules(instance_id, region):
"""列出实例全部防火墙规则,返回 [{port, source, remark, rule_id}]。
字段以实际 API 响应为准:RuleId(注意不是 FirewallRuleId)。
若一条规则都解析不到,dump 原始响应前 500 字符到 stderr 供自诊断。
"""
data = run_aliyun([
"swas-open", "list-firewall-rules",
"--instance-id", instance_id,
"--biz-region-id", region,
"--page-size", "100",
]) or {}
rules = []
def walk(x):
if isinstance(x, dict):
rid = x.get("RuleId") or x.get("FirewallRuleId")
if rid:
rules.append({
"rule_id": str(rid),
"port": str(x.get("Port") or ""),
"source": str(x.get("SourceCidrIp") or ""),
"remark": str(x.get("Remark") or ""),
})
for v in x.values():
walk(v)
elif isinstance(x, list):
for item in x:
walk(item)
walk(data)
if not rules and data:
sys.stderr.write("DEBUG: no firewall rules parsed; raw response head:\n"
+ json.dumps(data)[:500] + "\n")
return rules
def main():
ap = argparse.ArgumentParser(description="Sync Cloudflare IP ranges into Lighthouse firewall rules")
ap.add_argument("--print", action="store_true", help="print current CF IPv4 CIDRs")
ap.add_argument("--check", action="store_true", help="compare only; exit 2 if drift")
ap.add_argument("--apply", action="store_true", help="apply drift (dry-run unless --no-dry-run)")
ap.add_argument("--no-dry-run", action="store_true", help="really apply")
ap.add_argument("--instance-id", default=None, help="轻量应用服务器实例ID")
ap.add_argument("--ports", default="80,8080", help="受管端口,逗号分隔")
ap.add_argument("--region", default=DEFAULT_REGION)
args = ap.parse_args()
want = fetch_cf_ips()
if args.print:
for c in sorted(want):
print(c)
return
if not args.instance_id:
sys.stderr.write("--instance-id is required (except --print)\n")
sys.exit(1)
ports = [p.strip() for p in args.ports.split(",") if p.strip()]
rules = list_rules(args.instance_id, args.region)
# 只关心我们标记过的规则(其余一概不动)
by_key = {(r["port"], r["source"]): r["rule_id"]
for r in rules
if r["remark"] == REMARK and r["port"] in ports}
desired = {(p, c) for p in ports for c in want}
adds = sorted(desired - set(by_key))
removes = [by_key[k] for k in sorted(set(by_key) - desired)]
if not adds and not removes:
log("no drift")
return
log(f"drift: +{len(adds)} add, -{len(removes)} remove")
if args.check:
sys.exit(2)
def create_args(chunk):
out = ["swas-open", "create-firewall-rules",
"--instance-id", args.instance_id, "--biz-region-id", args.region]
for port, cidr in chunk:
out += ["--firewall-rules", f"Port={port}", f"Remark={REMARK}",
"RuleProtocol=TCP", f"SourceCidrIp={cidr}"]
return out
def delete_args(chunk):
return ["swas-open", "delete-firewall-rules",
"--instance-id", args.instance_id, "--biz-region-id", args.region,
"--rule-ids"] + chunk
if not args.no_dry_run:
log("dry-run:加 --no-dry-run 才会真正执行,实际命令如下:")
for i in range(0, len(adds), CHUNK):
log("$ " + " ".join(create_args(adds[i:i + CHUNK])))
for i in range(0, len(removes), CHUNK):
log("$ " + " ".join(delete_args(removes[i:i + CHUNK])))
return
for i in range(0, len(adds), CHUNK):
run_aliyun(create_args(adds[i:i + CHUNK]))
for i in range(0, len(removes), CHUNK):
run_aliyun(delete_args(removes[i:i + CHUNK]))
# 复查收敛
time.sleep(2)
rules2 = list_rules(args.instance_id, args.region)
managed2 = {(r["port"], r["source"]) for r in rules2
if r["remark"] == REMARK and r["port"] in ports}
missing = sorted(desired - managed2)
log("done" if not missing else f"warning: still missing {len(missing)} entries")
if missing:
sys.exit(3)
if __name__ == "__main__":
main()
这一脚本需要您配置阿里云的 RAM 账户并设置有关访问权限。
除此之外的 uvicorn 、 nginx 等配置在 AI 的帮助下应该不是困难的或者有明显障碍的, 不过这也确实需要您对于 Linux 的操作有一定的熟悉程度。
GitHub Pages
这是一项后来新增的计划。
如你所见,我的 GitHub Pages 早就绑定了 harcic.is-a.dev 这个域名,在我购得新的 .me 域名后,我期望通过这个新域名访问我的个人网站。
尝试一:粗暴 CNAME
我根据直觉尝试直接在 CF 的控制台设置 CNAME 记录到 harcic.is-a.dev,然后我在访问时得到:
## Error 1011: Access Denied (Hotlinking Denied)
This error indicates that access to the resource is denied due to hotlinking protection.
### Common cause
A request is made for a resource that
uses [Cloudflare hotlink protection](https://developers.cloudflare.com/waf/tools/scrape-shield/hotlink-protection/).
### Resolution
Notify the website owner of the blocking. If you cannot determine how to contact the website owner, lookup contact
information for the domain via the [Whois database ↗](https://lookup.icann.org/). Since the website owner performed the
blocking, Cloudflare support cannot override a customer's security settings.
嗯是的, Cloudflare 不允许将 CNAME 解析到被禁止 hotlink 的 Cloudflare 域名。
尝试二:还是粗暴 CNAME
我们还记得,在 is-a.dev 中,我们是通过一条 CNAME 记录配合 GitHub Pages 的 custom domain 设置,达成了从 harcic.is-a.dev 到 harcicyang.github.io 的访问。 那么我们能不能用一样的方法让我们的新域名也解析到 harcicyang.github.io 而不影响 harcic.is-a.dev 的访问呢?
答案是不能。 GitHub Pages 对访问的域名有较为严格的要求,如果访问的域名与 Pages settings 中的设置不符,GitHub 将直接返回 404.
尝试三:重定向
这是一个值得考虑的方案,我们确实可以做到两个域名最终呈现相同的内容。Cloudflare 中配置流程大致如下:
-
进入 Domains, 找的需要设置重定向的域名,进入,随后点击页面左侧边栏 DNS, 进入 DNS 记录的设置
-
为目标访问域名随意设置一个 DNS 记录
-
在左侧找到并进入 规则 -> 页面规则
-
点击 创建页面规则,输入/选择:
- URL: 你的目标访问 URL,如
your.domain.xyz/* - 选取设置: 转发 URL
- 选择状态代码: 301 和 302 均可
- 输入目标 URL: 你的目标URL,如
http(s)://des.domain.xyz/$1
注意 URL 的
/*后缀,不设置该后缀会导致不重定向全部页面 注意 输入目标 URL 的/$1后缀,不设置会导致重定向到错误的目标 - URL: 你的目标访问 URL,如
-
保存页面规则,然后启用
效果:访问新域名将自动重定向到原域名的对应页面。
最终方案:代理
重定向方案确实是方便的,但是他显然不能释放我们域名和云服务器的全部潜能(迫真),于是配置 nginx 转发是一个不错的方案了。
这里提供我使用的 nginx 配置文件:
点击展开
server {
listen 80;
listen [::]:80;
server_name YOUR_DOMAIN;
if ($http_x_forwarded_proto = "http") { # 强制使用 HTTPS
return 301 https://$host$request_uri;
}
location / {
proxy_pass https://DES_DOMAIN;
proxy_ssl_server_name on;
proxy_set_header Host DES_DOMAIN;
proxy_set_header Referer "https://DES_DOMAIN/"; # 防止 hotlink 问题
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_read_timeout 30s;
}
}
这样,我们不仅做到了对两个域名所访问的内容的统一,还让新域名的访问获得了一丝丝的加速(主要体现在中国大陆地区)
(玄学警告⚠️)。
访问安全
我们在 前文提到的自动化配置脚本 中已经将 80 和 8080 都送给了 Cloudflare,但是此时我们仍然有一大安全隐患: SSH.
在阿里云的默认配置中,22 端口允许任意网段的访问,这显然不是十分稳妥。 于是我们我们计划通过某种方式,让我们能够不通过公网访问 SSH,并结束 22 端口在公网中的暴露。
我个人较为认可的方式有二:
- Cloudflare Zero Trust + Cloudflare Tunnel
- Tailscale
由于尚没有可以绑定到 Cloudflare 的支付方式,我 被迫(?) 选择了后者。
Tailscale 的配置
Tailscale 的配置无非两部分,服务器端和我们手上的的终端。但是由于我们对于科学上网的特殊需要,我将稍微展开叙述。
Ts: 服务器配置
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up # 登陆
sudo tailscale set --ssh # 设置 SSH 访问
服务器上的配置基本就是如此。不过我们可能遇到奇奇怪怪的问题,例如:
$ git pull
ssh: Could not resolve hostname github.com: Name or service not known
fatal: Could not read from remote repository.
Please make sure you have the correct access rights
and the repository exists.
什么?你找不到 github.com ?
通过检查,cat /etc/resolv.conf 发现我们的 DNS 被 tailscale 覆写,怀疑 tailnet 对部分地址不能正确解析,于是我们设置 DNS
并刷新缓存:
resolvectl dns eth0 223.5.5.5 223.6.6.6 # 不要直接写个 eth0 ,根据 `ip -brief link` 判断你实际使用的设备, DNS 也不一定是用阿里的啦~
resolvectl flush-caches
问题解决。至此,我们可以转向其他的终端了。
Ts: iOS / iPadOS 配置
苹果设备的配置原本是极为简单的:我们只需要在 App Store 安装 Tailscale 然后按照要求配置即可。 但是由于我们对于科学上网的需要和 iOS 的平台限制,我们需要寻求一种可以在连接 tailnet 的同时保证科学上网的方式。
Shadowrocket
[!NOTE] 这一节内容摘自 zongyanglaobiao.github.io © 2024/08/06 - 2026 J.A
Shadowrocket 在 2.2.90 版本引入了内置的 Tailscale 支持,我们可以非常方便的完成配置。
准备工作:
- Shadowrocket >= 2.2.90
- Tailscale Auth Key
操作步骤:
- 进入 Tailscale 管理控制台,展开左侧 Settings 选项卡,点击 Key
- 在 Auth Key 一栏(注意不要找错哦)点击
Generate auth key,内容按需填写即可,然后保存并记下你的 auth key - 来到 Shadowrocket,进入设置,在 隧道 一节找到 Tailscale,在认证密钥一栏输入你的 auth key,然后启用。
完成上述步骤后,我们应当可以通过 Tailscale IP 访问我们的服务器、连接 SSH。
Ts: Linux 终端
由于本人电脑性能问题,本人几乎一切开发工作都在 Arch Linux 下而不是 Windows 进行,于是前者也成为我的日用系统, 所以这里出现了一份 Linux 介绍而不是 Windows 喵()
Linux 终端的初始配置和服务端基本相同,略。我们重点关注与代理工具的配合,此处以 Clash Verge 为例。
Clash Verge
[!NOTE] 这一节内容基本来自 jiz4oh.com,作者 jiz4oh。 原文也包含了对 Windows、 macOS、iOS、Android 的教程。
步骤大致如下:
- 修改
/etc/default/tailscaled,使 Tailscale 不再修改 iptables:
# Set the port to listen on for incoming VPN packets.
# Remote nodes will automatically be informed about the new port number,
# but you might want to configure this in order to set external firewall
# settings.
PORT="0"
# Extra flags you might want to pass to tailscaled.
FLAGS="--tun=userspace-networking --socks5-server=0.0.0.0:1099"
- 重启 Tailscale 并设置为 Exit 节点
sudo systemctl restart tailscaled
sudo tailscale set --advertise-exit-node
- 进入 Clash Verge 配置,添加代理和规则:
proxies:
...
- { name:
tailscale-socks, type:
socks5, server:
localhost, port: 1099 }
rules:
- IP-CIDR,100.64.0.0/10,tailscale-socks
- IP-CIDR,fd7a:115c:a1e0::/48,tailscale-socks
完成上述步骤后,我们应当可以通过 Tailscale IP 访问我们的服务器、连接 SSH。
写在最后
初次认真的配置一台服务器,怎么说呢,感觉还挺奇妙啊(
喵的 这得有 AI 一半功劳
行了,不说了,高考加油喵。加油喵。。。加油。喵。。。。。。