介绍

之前入手了一块树莓派 4B, 但一直放在角落里吃灰。最近看到 GitHub Copilot 开始收费,感觉自己就像在给巨硬打白工,于是心血来潮想要在树莓派上搭建一个 Gitea 和 Drone 服务。

这篇博客用来记录我的一些操作。由于截止我写博客的时候已经基本部署完成了,所以就想到什么写什么,中间可能有遗漏的地方。

安装和初始配置

为了节省资源,这次我安装的是 Raspberry Pi OS Lite (64-bit).

安装完成之后启动,执行 sudo raspi-config 配置网络和一些其它的系统选项。

接下来换国内源,执行 sudo apt update && sudo apt upgrade 更新一下。

为了省事,我写了一个自动更新脚本并用 crontab 定期执行更新任务。

/home/sainnhe/repo/dotfiles/scripts/crontab/debian-upgrade.sh:

1
2
3
4
5
#!/usr/bin/env sh

apt update
apt upgrade
apt autoremove

赋予可执行权限: chmod +x /home/sainnhe/repo/dotfiles/scripts/crontab/debian-upgrade.sh

编辑 crontab: sudo crontab -e

添加下面这一行:

1
0 13 * * 0    /home/sainnhe/repo/dotfiles/scripts/crontab/debian-upgrade.sh

这是每周星期天 13:00 执行更新脚本。

然后启用系统服务:

1
2
$ sudo systemctl enable --now cron.service
$ sudo systemctl restart cron.service

配置 SSH

安装并启用 SSH Server:

1
2
$ sudo apt install openssh-server
$ sudo systemctl enable --now ssh.service

在 PC 上生成 SSH 密钥:

1
2
3
4
$ ssh-keygen -t ecdsa -f ~/.ssh/id_ecdsa -C "sainnhe@gmail.com"
$ eval "$(ssh-agent -s)"
$ ssh-add ~/.ssh/id_ecdsa
$ kill <ssh-agent-pid>    # <ssh-agent-pid> 就是上上步打印的 PID

然后把 PC 上的 ~/.ssh/id_ecdsa.pub 里面的内容添加到树莓派的 ~/.ssh/authorized_keys

在树莓派上查看一下 IP 地址 ip addr

然后就可以 SSH 连接了。

1
$ ssh <user-name>@<ip-addr>

为了安全起见,禁用密码登录:

/etc/ssh/sshd_config:

1
PasswordAuthentication no

重启一下服务:

1
$ sudo systemctl restart ssh.service

我配置了自动连接 Wifi, 不过 IP 地址是通过 DHCP 动态分配的,下次启动可能就变了,所以我想要找到一种解决方案,不需要用 ip addr 查看 IP 就能 SSH 上去。

方案一:使用 DDNS

DDNS 即动态 DNS, 可以实时更新一条 DNS 记录。

我买了一个域名 sainnhe.dev ,托管在 Google Domains,然后我在 DNS 管理的页面添加一条 DDNS 记录 ssh.local.sainnhe.dev,添加完之后可以查看这条记录的用户名和密码。

接下来安装并启用 ddclient,这是一个 DDNS 客户端。

1
2
$ sudo apt install ddclient
$ sudo systemctl enable --now ddclient

编辑配置 /etc/ddclient.conf:

1
2
3
4
5
6
7
8
protocol=dyndns2
use=if
if=wlan0
server=google-domains.vercel.app
ssl=yes
login=<username>
password=<password>
ssh.local.sainnhe.dev

其中 <username><password> 分别是这条 DDNS 记录的用户名和密码。

这个配置的意思是,用 wlan0 这个 Interface 返回的 IP 地址作为 DDNS 记录的值,这其实就是 Wifi 连接的内网 IP 地址。

接下来用 <username><password> 作为登录凭证,将托管在 Google Domains 的 ssh.local.sainnhe.dev 这个域名的记录更新为内网 IP 地址。

由于原版的域名 domains.google.com 在国内无法直接访问,所以这里用的是我用 Vercel 部署的一个反代 google-domains.vercel.app

重启一下服务:

1
$ sudo systemctl restart ddclient.service

测试一下看看有没有更新成功:

1
$ dig @8.8.8.8 ssh.local.sainnhe.dev +short

如果返回的是内网 IP 地址,说明更新成功了。

接下来每次启动树莓派,它都会先自动连接 Wifi, 然后用自动将 ssh.local.sainnhe.dev 这条记录的值设置成内网 IP 地址。

现在就可以拔掉 HDMI 线和键盘线,把树莓派放到柜子里(隔音,风扇声还挺吵的),拉一条 Type-C 的电源线接上去,然后就可以用新的域名登录了:

1
$ ssh sainnhe@ssh.local.sainnhe.dev

方案二:使用脚本更新

如果域名托管在了不支持 DDNS 的服务商(比如 Cloudflare),那么可以考虑用脚本来自动更新 DNS 记录。

如果你家的网络有公网 IP, 那么可以考虑用 timothymiller/cloudflare-ddns

如果你和我一样只有局域网 IP, 那么可以用我的 Fork

这个 Fork 会把 wlan0 的 IPv4 地址更新到目标域名,详细使用方法参考 README 。

Nftables

接下来配置防火墙。这一步其实是可选的,因为我们最后会用内网穿透来把 Web 服务暴露到公网当中,而这并不需要开启某个端口。

但如果你想要在内网访问的话,就需要在防火墙中开启对应的 Web 服务的端口了。

这里我们以 Gitea 的 3000 端口为例,先禁用 iptables 并启用 nftables:

1
2
3
$ sudo systemctl disable --now iptables.service
$ sudo apt install nftables
$ sudo systemctl enable --now nftables.service

/etc/nftables.conf:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
#!/sbin/nft -f

#
# nftables.conf: nftables config for server firewall
#
# input chain
# -----------
# * accept all traffic related to established connections
# * accept all traffic on loopback iface and wireguard iface
# * accept icmp, https, and wireguard traffic on external iface
# * drop and count any other input traffic
#
# forward chain
# -------------
# * accept all forwarded traffic (TODO: lock this down more)
#
# output chain
# ------------
# * accept and count all output traffic (TODO: lock this down more)
#
# Commands
# --------
# * `nft list counters`: to show counter values
# * `nft list ruleset`: list rules
# * `nft monitor`: monitor traces
# * `nft monitor trace | grep 'output packet'`: monitor out traffic
# * `nft -f /etc/nftables-reset.conf`: disable filters
#
# Notes
# -----
# * See commented "log" line below to log dropped input headers
# * Used to need to enable non-wg http for certbot, but that isn't
#   necessary now because of `certbot-dns-linode`
#

# clear rules
flush ruleset

table inet filter {
  # declare named counters
  counter drop_ct_invalid {}
  counter accept_ct_rel {}
  counter drop_loop_v4 {}
  counter drop_loop_v6 {}
  counter accept_icmp_v4 {}
  counter accept_icmp_v6 {}
  counter accept_ssh {}
  counter accept_gitea_http {}
  counter accept_wg {}
  counter drop_input {}
  counter accept_output {}

  chain input {
    # input policy: drop
    type filter hook input priority 0; policy drop;

    # connection tracker
    ct state invalid counter name drop_ct_invalid drop \
      comment "drop ct invalid"
    ct state {established, related} counter name accept_ct_rel accept \
      comment "accept ct established, related"

    # accept all loopback traffic
    iif lo accept comment "accept iif lo"

    # drop loopback traffic on non-loopback interfaces
    iif != lo ip daddr 127.0.0.1/8 counter name drop_loop_v4 drop \
      comment "drop invalid loopback traffic"
    iif != lo ip6 daddr ::1/128 counter name drop_loop_v6 drop \
      comment "drop invalid loopback traffic"

    # accept icmp
    ip protocol icmp counter name accept_icmp_v4 accept \
      comment "accept icmp v4"
    ip6 nexthdr icmpv6 counter name accept_icmp_v6 accept \
      comment "accept icmp v6"

    # accept external ssh
    tcp dport 22 counter name accept_ssh accept comment "accept ssh"

    # accept external http
    tcp dport 3000 counter name accept_gitea_http accept comment "accept gitea http"

    # count/log remaining (disabled because of log spam)
    # counter name drop_input log prefix "DROP " comment "drop input"

    # count remaining (no logging)
    counter name drop_input comment "drop input"
  }

  # accept all forwarding (TODO: lock this down more)
  chain forward {
    # forward policy: accept
    type filter hook forward priority 0; policy accept;
  }

  # count/accept all output (TODO: lock this down more)
  chain output {
    # output policy: accept
    type filter hook output priority 0; policy accept;
    counter name accept_output comment "accept output"
  }
}

这里我们启用了 223000 这两个端口,其它端口全部禁用。

然后重启一下 Nftables:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ sudo systemctl restart nftables.service
$ sudo systemctl status nftables.service
● nftables.service - nftables
     Loaded: loaded (/lib/systemd/system/nftables.service; enabled; vendor preset: enabled)
     Active: active (exited) since Thu 2022-08-04 09:30:07 CST; 5 days ago
       Docs: man:nft(8)
             http://wiki.nftables.org
   Main PID: 69970 (code=exited, status=0/SUCCESS)
      Tasks: 0 (limit: 8775)
        CPU: 0
     CGroup: /system.slice/nftables.service

Aug 04 09:30:07 raspberrypi systemd[1]: Starting nftables...
Aug 04 09:30:07 raspberrypi systemd[1]: Finished nftables.

Fail2ban

Fail2ban 可以用来防止 DDoS 攻击,如果你希望通过内网访问你的 Web 服务的话,可以配置一下,否则如果你只是想要在公网中访问,那就没必要配置,因为我们接下来会用 Cloudflare Tunnel 来进行内网穿透,而 CF 家的服务本身就自带防 DDoS 攻击的功能。

同样以 Gitea 的 3000 端口为例,先安装并启用 Fail2ban:

1
2
$ sudo apt install fail2ban
$ sudo systemctl enable --now fail2ban.service

写一下配置。

/etc/fail2ban/filter.d/gitea.conf:

1
2
3
4
# gitea.conf
[Definition]
failregex =  .*(Failed authentication attempt|invalid credentials|Attempted access of unknown user).* from <HOST>
ignoreregex =

/etc/fail2ban/jail.d/gitea.conf:

1
2
3
4
5
6
7
8
[gitea]
enabled = true
filter = gitea
logpath = /var/lib/gitea/log/gitea.log
maxretry = 10
findtime = 3600
bantime = 900
action = nftables-allports

/etc/fail2ban/jail.d/gitea-docker.conf:

1
2
3
4
5
6
7
8
[gitea-docker]
enabled = true
filter = gitea
logpath = /var/lib/gitea/log/gitea.log
maxretry = 10
findtime = 3600
bantime = 900
action = nftables-allports[chain="FORWARD"]

重启一下服务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ sudo systemctl restart fail2ban.service
$ sudo systemctl status fail2ban.service
● fail2ban.service - Fail2Ban Service
     Loaded: loaded (/lib/systemd/system/fail2ban.service; enabled; vendor preset: enabled)
     Active: active (running) since Thu 2022-07-28 09:44:40 CST; 1 weeks 5 days ago
       Docs: man:fail2ban(1)
   Main PID: 524 (fail2ban-server)
      Tasks: 9 (limit: 8775)
        CPU: 23min 41.582s
     CGroup: /system.slice/fail2ban.service
             └─524 /usr/bin/python3 /usr/bin/fail2ban-server -xf start

Jul 28 09:44:40 raspberrypi systemd[1]: Starting Fail2Ban Service...
Jul 28 09:44:40 raspberrypi systemd[1]: Started Fail2Ban Service.
Jul 28 09:44:42 raspberrypi fail2ban-server[524]: Server ready

Gitea

为了方便更新和维护,这里直接上 Docker:

1
2
$ sudo apt install docker docker-compose
$ sudo systemctl enable --now docker.service

docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
version: "3"

networks:
  gitea:
    external: false

services:
  server:
    image: gitea/gitea:latest
    container_name: gitea
    environment:
      - USER_UID=1000
      - USER_GID=1000
    restart: always
    networks:
      - gitea
    volumes:
      - /srv/gitea:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "10000:22"

cd 到含有 docker-compose.yml 的目录下,执行 sudo docker-compose up -d.

这里 3000 是 Gitea 的网页端口,10000 是 SSH 端口,不过我们并不会用到 10000 这个端口,因为我们之后会用 Token 来访问私有仓库,所以就不在 Nftables 里面启用它了。

Docker 可以很方便地自动更新,我写了一个脚本来定时 Pull 最新的 Image:

pull.sh:

1
2
3
4
5
6
7
8
#!/usr/bin/env bash

cd /home/sainnhe/repo/gitea
docker-compose down
docker-compose pull
systemctl daemon-reload
systemctl restart docker
docker-compose up -d
1
2
$ chmod a+x pull.sh
$ sudo crontab -e
1
0   0  * * 0    /home/sainnhe/repo/gitea/pull.sh

内网穿透

我们现在要将 3000 这个端口暴露到公网当中,这里就要用到内网穿透了。

这里我选了 Cloudflare Tunnel 来实现,免费稳定速度也不错。

首先准备一枚域名,我的是 sainnhe.dev,并把这个域名托管到 Cloudflare 。

然后安装 cloudflared:

1
2
3
$ wget https://github.sainnhe.dev/cloudflare/cloudflared/releases/download/2022.7.1/cloudflared-linux-arm64.deb
$ sudo dpkg -i cloudflared-linux-arm64.deb
$ rm cloudflared-linux-arm64.deb

切换到 Root 账户,登录 Cloudflare:

1
2
$ sudo su
# cloudflared tunnel login

这时会弹出来一个URL,用浏览器打开,登录认证,然后选择你想用来做内网穿透的域名即可。

成功后会生成证书,放置于 /root/.cloudflared/cert.pem 中。

新建一个隧道:

1
# cloudflared tunnel create <tunnel-name>

<tunnel-name> 是隧道的名字,可以随意起。

成功后会生成证书,放置于 /root/.cloudflared/<tunnel-uuid>.json

查看一下是否创建成功:

1
# cloudflared tunnel list

这个命令也会列出隧道的名字和 UUID, 忘了的话可以在这里看。

接下来新建隧道对应的 DNS 记录:

1
# cloudflared tunnel route dns <tunnel-name> <subdomain>

其中 <subdomain> 是完整的域名(不包含 https),比如我的是 git.sainnhe.dev

现在写配置文件。

/root/.cloudflared/config.yml:

1
2
3
4
5
6
7
8
tunnel: <tunnel-uuid>
credentials-file: /root/.cloudflared/<tunnel-uuid>.json
protocol: http2
no-autoupdate: true
ingress:
  - hostname: git.sainnhe.dev
    service: http://localhost:3000
  - service: http_status:404

然后安装并启用系统服务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# cloudflared service install
# systemctl start cloudflared
# systemctl status cloudflared
     Loaded: loaded (/etc/systemd/system/cloudflared.service; enabled; vendor preset: enabled)
     Active: active (running) since Wed 2022-08-10 08:03:43 CST; 15min ago
   Main PID: 151623 (cloudflared)
      Tasks: 10 (limit: 8775)
        CPU: 3.245s
     CGroup: /system.slice/cloudflared.service
             └─151623 /usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run

Aug 10 08:03:40 raspberrypi cloudflared[151623]: 2022-08-10T00:03:40Z INF Settings: map[config:/etc/cloudflared/config.yml cred-file:/root/.cloudflared/b4a55bfd-8c33-4ac9-904f-c>
Aug 10 08:03:40 raspberrypi cloudflared[151623]: 2022-08-10T00:03:40Z INF cloudflared will not automatically update if installed by a package manager.
Aug 10 08:03:40 raspberrypi cloudflared[151623]: 2022-08-10T00:03:40Z INF Generated Connector ID: 6618239f-dfec-45fc-988c-02316a0e2da2
Aug 10 08:03:40 raspberrypi cloudflared[151623]: 2022-08-10T00:03:40Z INF Initial protocol http2
Aug 10 08:03:40 raspberrypi cloudflared[151623]: 2022-08-10T00:03:40Z INF Starting metrics server on 127.0.0.1:34093/metrics
Aug 10 08:03:43 raspberrypi cloudflared[151623]: 2022-08-10T00:03:43Z INF Connection 337ef3a3-0b98-40b3-9912-c42c7a7eb108 registered connIndex=0 ip=198.41.200.33 location=SJC
Aug 10 08:03:43 raspberrypi systemd[1]: Started cloudflared.
Aug 10 08:03:44 raspberrypi cloudflared[151623]: 2022-08-10T00:03:44Z INF Connection 8a27ed8e-08e6-4484-80ee-d32f6f2cd66e registered connIndex=1 ip=198.41.192.47 location=LAX
Aug 10 08:03:45 raspberrypi cloudflared[151623]: 2022-08-10T00:03:45Z INF Connection ac04131f-f4fd-44d8-a426-9b214d0e3851 registered connIndex=2 ip=198.41.200.63 location=SJC
Aug 10 08:03:47 raspberrypi cloudflared[151623]: 2022-08-10T00:03:47Z INF Connection 8fd4fe2c-06d0-4f78-a64c-6e87d8e5d01f registered connIndex=3 ip=198.41.192.107 location=LAX

你的配置文件会被复制到 /etc/cloudflared/config.yml, 所以之后的修改就应该在这个文件中进行,而不是原来的 /root/.cloudflared/config.yml

部署完成后就可以在 git.sainnhe.dev 中访问 Gitea 了。

由于我们禁用了 SSH 端口,所以无法通过 SSH 克隆。

我是通过 Token 来克隆的。在 Gitea 的设置里生成一个 Token, 然后就可以通过 https://<token>@git.sainnhe.dev/<user>/<repo>.git 来克隆了。

Drone

Gitea 默认不集成 CI/CD,我们再部署一个 Drone, 这是一个开源的 CI/CD 平台。

docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
version: '3'

services:
  drone_server:
    image: drone/drone:2
    container_name: drone-server
    restart: always
    volumes:
      - /srv/drone:/data
    env_file:
      - server.env
    ports:
      - 3001:80
  drone_runner:
    image: drone/drone-runner-docker:1
    container_name: drone-runner
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    env_file:
      - runner.env
    ports:
      - 3002:3000

server.env:

1
2
3
4
5
6
DRONE_GITEA_SERVER=https://git.sainnhe.dev
DRONE_GITEA_CLIENT_ID=<your-client-id>
DRONE_GITEA_CLIENT_SECRET=<your-client-secret>
DRONE_RPC_SECRET=<rpc-secret>
DRONE_SERVER_HOST=drone.sainnhe.dev
DRONE_SERVER_PROTO=https

各个环境变量的含义及获取方式参考 https://docs.drone.io/server/provider/gitea/

runner.env:

1
2
3
4
5
6
7
DRONE_RPC_HOST=drone.sainnhe.dev
DRONE_RPC_PROTO=https
DRONE_RPC_SECRET=<rpc-secret>
DRONE_RUNNER_CAPACITY=4
DRONE_RUNNER_NAME=main
DRONE_UI_USERNAME=<username>
DRONE_UI_PASSWORD=<password>

各个环境变量的含义及获取方式参考 https://docs.drone.io/runner/docker/installation/linux/

执行 sudo docker-compose up -d 开始运行服务。

如果你想在内网中访问它的话,可以再配置一下 Nftables 和 Fail2ban 。

接下来进行内网穿透,先添加两条新的 DNS 记录:

1
2
$ sudo cloudflared tunnel route dns <tunnel-name> drone.sainnhe.dev
$ sudo cloudflared tunnel route dns <tunnel-name> drone-runner.sainnhe.dev

然后编辑 /etc/cloudflared/config.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
tunnel: <tunnel-uuid>
credentials-file: /root/.cloudflared/<tunnel-uuid>.json
protocol: http2
no-autoupdate: true
ingress:
  - hostname: git.sainnhe.dev
    service: http://localhost:3000
  - hostname: drone.sainnhe.dev
    service: http://localhost:3001
  - hostname: drone-runner.sainnhe.dev
    service: http://localhost:3002
  - service: http_status:404

重启服务:

1
$ sudo systemctl restart cloudflared.service

过一会就可以访问了。

使用的时候需要注意几点:

  1. 你想跑 CI/CD 的仓库需要先在 Drone 里启用,如果你看不到你想跑的仓库,点一下主界面右上角的 “Sync” 。
  2. type 必须是 docker,因为我们是用 docker-compose 安装的。
  3. arch 必须是 arm64,因为树莓派 4B 的 CPU 是 arm64 结构的。

示例 .drone.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
kind: pipeline
type: docker
name: default
platform:
  os: linux
  arch: arm64

steps:
- name: greeting
  image: alpine
  commands:
  - echo hello
  - echo world

.drone.yml 放到项目的根目录下,提交并推送到远程仓库,Drone 就会自动触发流水线了。

补充:

部署过程中如果碰到类似以 “iptables: No chain/target/match by that name.” 结尾的报错,执行以下两条命令解决:

1
2
$ sudo systemctl daemon-reload
$ sudo systemctl restart docker