树莓派 SSH Git 服务器:安全裸仓库、Hooks 与 systemd 部署(2026)

维护说明(2026 年 9 月 1 日):本页现在先提供一套可复现的现行方案,文末再原样保存完整的 2019 年源导出。旧文中的默认分支假设、排版造成的错误命令、私有局域网示例和注明出处的 CSDN 摘录都保留在档案中,请勿从档案复制执行。维护层使用占位符和官方资料,不含推广链接。

这套设计适用于运行 Raspberry Pi OS 的个人服务器或互相信任的小团队。Git 客户端可以通过 SSH 访问裸仓库,但不会得到交互式 shell。本文给出两种部署路线:

  • 面向低风险静态目录的简短 post-receive hook;
  • 把 Git 访问和发布账号分开的 systemd 交接方案。

它们都不是完整代码托管平台:没有网页界面、Pull Request、按仓库授权或密钥扫描。若用户彼此并不完全信任,不要让大家共用一个不分仓库权限的 git 账号;应使用仍在维护的 Gitolite 或代码托管平台。

1. 安装前先确定信任边界

先把树莓派放在可信局域网或 VPN 后面使用。不要为了完成教程就把 SSH 端口直接映射到公网。确需公网访问时,应先明确防火墙规则、补丁责任、监控、备份和密钥吊销流程。

以下示例假设:

  • 管理账号名为 admin,拥有 sudo 权限;
  • 服务器名为 pi-git.local,或使用你控制的其他名称;
  • 仓库放在 /srv/git,而不是某个私人主目录;
  • Git 使用专用、不可交互登录的 git 账号;
  • 默认分支被明确指定为 main

替换名称和路径时要保持前后一致。应用密钥、上传文件、数据库和运行时可变数据必须放在部署工作树之外。

2. 安装 Git 并启用 SSH

全新 Raspberry Pi OS 默认禁用 SSH。Raspberry Pi Imager 可以在写入系统时设置仅公钥 SSH;已有系统则可以按官方文档使用 raspi-config 或系统服务。

在树莓派上用管理账号运行:

sudo apt update
sudo apt install --yes git openssh-server
sudo systemctl enable --now ssh
git --version
systemctl is-active ssh

按照操作系统的正常更新策略持续安装安全更新。在客户端首次接受 SSH 主机密钥之前,应通过可信渠道确认树莓派的地址和主机身份。

3. 创建仅用于 Git 的账号

创建不能使用密码登录的账号,找到 git-shell,再把它设为登录 shell:

sudo adduser --disabled-password --gecos '' git
command -v git-shell
grep -Fx /usr/bin/git-shell /etc/shells || printf '%sn' /usr/bin/git-shell | sudo tee -a /etc/shells
sudo chsh -s /usr/bin/git-shell git
getent passwd git

command -v git-shell 输出的不是 /usr/bin/git-shell,后续命令应全部换成实际路径。Git 官方把 git-shell 定义为受限登录 shell:它允许 pushfetch 和远程归档所需的服务端 Git 命令,但不提供普通交互式 shell。

不要为此直接编辑 /etc/passwdchsh 会校验并记录 shell,无需手工改写账号数据库。

4. 添加受限制的 SSH 密钥

每台客户端各生成一把专用密钥。当前 OpenSSH 支持 Ed25519;Raspberry Pi 的 SSH 文档也把它列为基础 RSA 示例之外安全性更好的选择。

ssh-keygen -t ed25519 -a 64 -C 'laptop-to-pi-git'

除非无人值守进程另有经过记录的密钥保护方案,否则应设置口令。私钥只留在客户端,服务器只接收 .pub 公钥文件。

在服务器准备目录:

sudo install -d -m 0700 -o git -g git /home/git/.ssh
sudo touch /home/git/.ssh/authorized_keys
sudo chown git:git /home/git/.ssh/authorized_keys
sudo chmod 0600 /home/git/.ssh/authorized_keys

从客户端给公钥加上 OpenSSH 的 restrict 选项,再通过管理账号追加:

{ printf 'restrict '; cat ~/.ssh/id_ed25519.pub; } 
  | ssh admin@pi-git.local 
      'sudo tee -a /home/git/.ssh/authorized_keys >/dev/null'

restrict 会对这把密钥禁用端口转发、agent 转发、X11 转发、PTY 和 ~/.ssh/rc,但仍允许 Git 所需的远程命令。每个人和每个自动化任务都应使用带有可识别注释的独立公钥行,这样可以单独吊销一项凭证。

测试限制:

ssh -T git@pi-git.local

出现“未启用交互式 Git shell”之类的信息属于预期结果:认证成功后,shell 拒绝交互登录。

可选:全局强化 SSH

先为管理账号安装密钥,并在第二个终端验证。之后才考虑创建 /etc/ssh/sshd_config.d/00-local-hardening.conf

PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no

重载前同时检查语法和最终生效值:

sudo sshd -t
sudo sshd -T | grep -E 'passwordauthentication|kbdinteractiveauthentication|permitrootlogin'
sudo systemctl reload ssh

在新建的公钥登录会话成功之前,不要关闭原来已经认证的管理会话。配置通过语法检查,并不代表访问策略一定符合你的意图。

5. 创建并测试裸仓库

裸仓库保存 refs、objects 和服务端 hooks,但没有检出的工作树。明确指定分支名,不依赖某一 Git 版本的默认值。

sudo install -d -m 0750 -o git -g git /srv/git
sudo -u git git init --bare --initial-branch=main /srv/git/sample.git
sudo -u git git --git-dir=/srv/git/sample.git config receive.denyNonFastForwards true
sudo -u git git --git-dir=/srv/git/sample.git config receive.denyDeletes true
sudo -u git git --git-dir=/srv/git/sample.git config --get-regexp '^receive.'

在已有本地仓库的客户端:

git remote add pi ssh://git@pi-git.local/srv/git/sample.git
git push -u pi HEAD:main
git ls-remote pi

也可以在另一个位置克隆:

git clone ssh://git@pi-git.local/srv/git/sample.git

这两项 receive 设置可以阻止普通分支被强制改写或删除,但它们不提供基于身份的授权。这个共享 git 账号接受的任何密钥,都能访问该账号本身有权读写的仓库。

6. 方案 A:面向小型静态目录的 Hook

只有在所有能推送 main 的人也都有权发布目标目录时,才使用此方案。目标目录只能包含可部署文件,不能混入上传内容、数据库或手工维护的配置。

创建目标目录和由服务器维护的 hook:

sudo install -d -m 0755 -o git -g git /srv/www/sample
sudoedit /srv/git/sample.git/hooks/post-receive
#!/bin/sh
set -eu

repo=/srv/git/sample.git
target=/srv/www/sample
deploy_ref=refs/heads/main
lock=/srv/git/sample.deploy.lock

while read -r oldrev newrev refname
do
    [ "$refname" = "$deploy_ref" ] || continue

    if ! git --git-dir="$repo" cat-file -e "$newrev^{commit}" 2>/dev/null
    then
        printf '%sn' 'The deploy branch was deleted or does not name a commit; skipping.' >&2
        continue
    fi

    (
        flock -x 9
        git --git-dir="$repo" --work-tree="$target" checkout --force main
    ) 9>"$lock"

    printf 'Deployed %s to %sn' "$newrev" "$target"
done

然后设置所有者和执行权限:

sudo chown git:git /srv/git/sample.git/hooks/post-receive
sudo chmod 0755 /srv/git/sample.git/hooks/post-receive
command -v flock

分支过滤可以阻止 tag 和其他分支触发部署;commit 校验可以处理删除分支的情况;路径加引号可避免分词;flock 让两次几乎同时发生的检出串行执行。

Git 在 refs 已经更新之后才调用 post-receive。它的输出会传回客户端,但非零退出码不能撤销已经接受的 push。因此,看到 Permission denied 表示“push 已存储、部署失败”,而不是“push 被拒绝”。必须拒绝 push 的策略应放在 pre-receiveupdate

Hooks 是服务端文件,普通 clone 或 push 不会安装它们。应像基础设施配置一样备份、审查这些文件。

7. 方案 B:把 commit 入队,再由 systemd 发布

对于应用或更重要的静态站点,应把 SSH/Git 访问与 release 所有权分开。这里的 hook 只把经过验证的 commit ID 写入队列;systemd.path 发现队列后,以 sample-deploy 身份启动沙箱化 oneshot 服务。服务把 commit 导出到新的不可变 release 目录,再原子切换 current 符号链接。

这个例子仍假定所有能推送 main 的人都有权请求部署。它不会执行仓库中的构建脚本。

创建身份和目录

sudo groupadd --system sample-web
sudo useradd --system --home-dir /nonexistent --no-create-home 
  --shell /usr/sbin/nologin --gid sample-web --groups git sample-deploy
sudo usermod -aG sample-web www-data
sudo chmod -R g+rX /srv/git/sample.git
sudo install -d -m 2770 -o git -g git /var/lib/sample-deploy/queue
sudo install -d -m 0750 -o sample-deploy -g sample-web /srv/www/sample
sudo install -d -m 0750 -o sample-deploy -g sample-web /srv/www/sample/releases

www-data 替换成实际的只读 Web 服务账号;修改附加组后要重启该服务。配置服务器读取 /srv/www/sample/current,不要给它 release 写权限。

用仅入队的 Hook 替换简单 Hook

#!/bin/sh
set -eu

repo=/srv/git/sample.git
queue=/var/lib/sample-deploy/queue
deploy_ref=refs/heads/main
umask 027

while read -r oldrev newrev refname
do
    [ "$refname" = "$deploy_ref" ] || continue
    git --git-dir="$repo" cat-file -e "$newrev^{commit}" 2>/dev/null || continue

    temporary="$queue/.${newrev}.$$"
    printf '%sn' "$newrev" >"$temporary"
    mv "$temporary" "$queue/$newrev"
    printf 'Queued deployment of %sn' "$newrev"
done

安装部署程序

把下面内容保存为 /usr/local/sbin/deploy-sample,所有者设为 root,权限设为 0755

#!/bin/sh
set -eu

repo=/srv/git/sample.git
queue=/var/lib/sample-deploy/queue
root=/srv/www/sample
releases=$root/releases
deploy_ref=refs/heads/main
temporary=
archive=

cleanup()
{
    [ -z "$archive" ] || rm -f -- "$archive"
    [ -z "$temporary" ] || rm -rf -- "$temporary"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

for job in "$queue"/*
do
    [ -f "$job" ] || exit 0
    commit=${job##*/}

    case "$commit" in
        ''|*[!0-9a-fA-F]*)
            printf 'Ignoring invalid deployment job: %sn' "$commit" >&2
            rm -f -- "$job"
            continue
            ;;
    esac

    if ! git --git-dir="$repo" cat-file -e "$commit^{commit}" 2>/dev/null
    then
        printf 'Commit is unavailable: %sn' "$commit" >&2
        rm -f -- "$job"
        continue
    fi

    current=$(git --git-dir="$repo" rev-parse "$deploy_ref^{commit}")
    if [ "$commit" != "$current" ]
    then
        printf 'Skipping superseded deployment: %sn' "$commit"
        rm -f -- "$job"
        continue
    fi

    release=$releases/$commit
    if [ ! -d "$release" ]
    then
        temporary=$releases/.${commit}.$$
        archive=$releases/.${commit}.$$.tar
        install -d -m 0750 "$temporary"
        git --git-dir="$repo" archive --format=tar --output="$archive" "$commit"
        tar -xf "$archive" -C "$temporary"
        if find "$temporary" -type l -print -quit | grep -q .
        then
            printf '%sn' 'Symlinks are not permitted in this static release.' >&2
            rm -f -- "$job"
            exit 1
        fi
        rm -f -- "$archive"
        archive=
        mv "$temporary" "$release"
        temporary=
    fi

    current=$(git --git-dir="$repo" rev-parse "$deploy_ref^{commit}")
    if [ "$commit" != "$current" ]
    then
        printf 'Branch advanced while preparing: %sn' "$commit"
        rm -f -- "$job"
        continue
    fi

    next=$root/.current.$$
    ln -s "$release" "$next"
    mv -Tf "$next" "$root/current"
    rm -f -- "$job"
    printf 'Published %sn' "$commit"
done

这个程序只导出受 Git 跟踪的内容,并不执行它。两次分支尖端检查会丢弃已过时的队列项,避免按哈希文件名排序后反而最后发布旧提交;若推送恰好发生在最终切换符号链接时,其队列项会保留并触发下一次服务运行。若必须构建,应在明确威胁模型下加入单独审查过、具有资源限制的构建步骤;即使推送者已认证,push 仍然是不可信输入。

添加 Path 与 Service 单元

/etc/systemd/system/sample-deploy.path

[Unit]
Description=Watch for queued sample deployments

[Path]
DirectoryNotEmpty=/var/lib/sample-deploy/queue
Unit=sample-deploy.service

[Install]
WantedBy=multi-user.target

/etc/systemd/system/sample-deploy.service

[Unit]
Description=Publish a queued sample Git commit

[Service]
Type=oneshot
User=sample-deploy
Group=sample-web
SupplementaryGroups=git
ExecStart=/usr/local/sbin/deploy-sample
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadOnlyPaths=/srv/git/sample.git
ReadWritePaths=/srv/www/sample /var/lib/sample-deploy
CapabilityBoundingSet=
RestrictAddressFamilies=AF_UNIX

验证并启用:

sudo systemd-analyze verify 
  /etc/systemd/system/sample-deploy.path 
  /etc/systemd/system/sample-deploy.service
sudo systemctl daemon-reload
sudo systemctl enable --now sample-deploy.path
sudo systemd-analyze security sample-deploy.service
sudo systemctl status sample-deploy.path

systemd-analyze verify 能发现单元文件错误;systemd-analyze security 会报告沙箱设置所暗示的暴露面,但两者都不能证明部署程序正确。完成一次真实 push 后检查:

sudo journalctl -u sample-deploy.service --since today
readlink -f /srv/www/sample/current

8. 验证与恢复清单

初次设置完成后以及每次有实质修改后,运行:

sudo sshd -t
sudo -u git git --git-dir=/srv/git/sample.git fsck --full
git ls-remote pi

还要验证以下事实:

  • 交互式 ssh git@host 会被拒绝;
  • 获准的 push 和 clone 正常;
  • tag 或非部署分支不会发布;
  • 删除部署分支不会清空站点;
  • hook 或部署错误能在日志中看到,不会伪装成 push 被拒;
  • 可以把 current 指回旧 release 完成回滚;
  • 裸仓库、hooks、SSH 公钥、单元文件和部署脚本均纳入备份;
  • 已在另一条路径或另一台机器上实际测试恢复。

相比 2019 年步骤,现行版本改了什么

2019 年档案细节2026 年维护方案
生成 RSA 密钥,却没有口令策略使用专用 Ed25519 密钥,并记录所有者、口令、限制和吊销方式
直接编辑 /etc/passwdchsh 和已安装的 git-shell 实际路径
仓库和部署路径混在多个主目录裸仓库放 /srv/git,部署放 /srv/www,明确所有者
所有命令默认 master明确选择 main,并过滤完整 ref refs/heads/main
占位 GIT_DIR 与前面创建的仓库不一致全文使用同一个绝对仓库路径
部署报权限错误,后面却仍显示 push 成功明确说明 post-receive 无法回滚已接受的 push
chown git:git 当作通用权限修复只在低风险静态部署中使用;更强隔离采用独立部署身份
复制的命令含排版破折号和弯引号只在档案保留;维护命令使用可执行的 ASCII 选项和引号
第三方主机和私有 IP 看起来像可复制示例仅视为历史字符串,不是现行推荐或背书

权威资料

软件包版本和默认值会变化。公开或自动化服务器之前,应查阅树莓派实际安装的手册(man git-initman githooksman sshd_configman systemd.exec),并按真实版本验证。

2019 年源导出全文(原样存档,请勿执行)


1, Set up git envioronment

sudo apt-get install git

sudo adduser git


2, Generate public and private key

ssh-keygen -t rsa -C “user@lazying.art”


Client:

/home/pi/.ssh/id_rsa


 Server: add public key of each client to the file below

/home/git/.ssh/authorized_keys


3, Initialize server end envioronment

mkdir Git
cd Git
sudo git init --bare sample.git
sudo chown -R git:git sample.git

vim /etc/passwd

git:x:1001:1001:,,,:/home/git:/bin/bash
git:x:1001:1001:,,,:/home/git:/usr/bin/git-shell


4, Upload code to your github server

git config --global user.email "you@example.com"
git config --global user.name "Your Name"
git add .
git commit -m "init commit"
git push -u origin master


5, Set-up Hooks

vim sapmle.git/hooks/post-receive


 Add code below to post-receive

#!/bin/bash
TARGET="/home/webuser/deploy-folder"
GIT_DIR="/home/webuser/www.git"
BRANCH="master"

while read oldrev newrev ref
do
# only checking out the master (or whatever branch you would like to deploy)
if [ "$ref" = "refs/heads/$BRANCH" ];
then
echo "Ref $ref received. Deploying ${BRANCH} branch to production..."
git --work-tree=$TARGET --git-dir=$GIT_DIR checkout -f $BRANCH
else
echo "Ref $ref received. Doing nothing: only the ${BRANCH} branch may be deployed on this server."
fi
done

chown git:git hooks/post-receive
chmod +x hooks/post-receive

Counting objects: 2, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 237 bytes | 23.00 KiB/s, done.
Total 2 (delta 0), reused 0 (delta 0)
remote: Ref refs/heads/master received. Deploying master branch to production…
remote: error: unable to create file main.py (Permission denied)
remote: error: unable to create file test.py (Permission denied)
remote: Already on 'master'
To 192.168.1.108:/home/pi/Git/printer.git
2e6a796..6085cd0 master -> master


Causing of the hooks script cannot access deployment files, you should change the owner of deployment directory to git

chown git:git /path/to/deployment/directory/

Counting objects: 2, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 239 bytes | 21.00 KiB/s, done.
Total 2 (delta 0), reused 0 (delta 0)
remote: Ref refs/heads/master received. Deploying master branch to production…
remote: Already on 'master'
To 192.168.1.108:/home/pi/Git/printer.git
6085cd0..b83c0d5 master -> master


________

1.在文件所在位置打开终端,输入如下命令,进行git全局设置:

git config –global user.name “用户名”
 git config –global user.email “用户邮箱”

1. 安装如下命令创建新的仓库,网址为自己新建项目的网址,cd转到自己要上传的项目文件夹:

git clone https://git.aiiage.com:9999/song.yl/ReID.git
 cd ReID
 touch README.md
 git add README.md
 git commit -m “add README”
 git push -u origin master

1. 对已经存在的文件夹进行操作, 可以不用cd命令转到文件夹,直接到文件夹下打开终端执行如下命令,其中git commit命令后引号里面的内容可以自己命名:

cd existing_folder
 git init
 git remote add origin https://git.aiiage.com:9999/song.yl/ReID.git
 git add .
 git commit -m “Initial commit”
 git push -u origin master

1. 对存在的git仓库进行操作。依然可以直接在所在文件夹直接打开终端执行命令:

cd existing_foloder
 git remote rename origin old-origin
 git remote add origin https://git.aiiage.com:9999/song.yl/ReID.git
 git push -u origin —all

git push -u origin –tags

## 作者:yllifesong
 来源:CSDN
 原文:https://blog.csdn.net/yllifesong/article/details/81041156
 版权声明:本文为博主原创文章,转载请附上博文链接!

Leave a Reply