diff --git a/.github/workflows/draft-release.yml b/.github/workflows/ci-release.yml similarity index 85% rename from .github/workflows/draft-release.yml rename to .github/workflows/ci-release.yml index fb221a3..82d631c 100644 --- a/.github/workflows/draft-release.yml +++ b/.github/workflows/ci-release.yml @@ -1,10 +1,11 @@ -name: Release Artifacts +name: CI & Release on: push: branches: - main - release + pull_request: permissions: contents: write @@ -68,12 +69,16 @@ jobs: )" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Decide release mode by branch and commit message + - name: Decide release mode by branch and event id: mode env: BRANCH_NAME: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} run: | - if [[ "$BRANCH_NAME" == "main" ]]; then + # pull_request events only build & test — they never publish a release. + if [[ "$EVENT_NAME" == "pull_request" ]]; then + echo "mode=none" >> "$GITHUB_OUTPUT" + elif [[ "$BRANCH_NAME" == "main" ]]; then echo "mode=pre-release" >> "$GITHUB_OUTPUT" elif [[ "$BRANCH_NAME" == "release" ]]; then echo "mode=release" >> "$GITHUB_OUTPUT" @@ -84,7 +89,9 @@ jobs: build-artifacts: runs-on: ubuntu-latest needs: meta - if: needs.meta.outputs.mode != 'none' + # Runs on every trigger (push to main/release AND pull_request). On PRs + # the `meta` job sets mode=none so publish-draft is skipped, but we still + # want full build + test coverage here to catch regressions pre-merge. strategy: fail-fast: false matrix: @@ -103,21 +110,14 @@ jobs: uses: actions/checkout@v4 - name: Setup Go - id: setup-go uses: actions/setup-go@v5 with: go-version-file: go.mod - - - name: Cache Go modules and build cache - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ steps.setup-go.outputs.go-version }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-${{ steps.setup-go.outputs.go-version }}-${{ matrix.goos }}-${{ matrix.goarch }}- - ${{ runner.os }}-go-${{ steps.setup-go.outputs.go-version }}- + # Built-in caching is enabled by default (setup-go@v4+) and keys + # on runner.os + go-version + hash(go.sum). Don't layer an extra + # actions/cache on top — double-caching the same paths causes + # tar "File exists" errors when restoring into a directory + # setup-go has already populated. - name: Install eBPF build dependencies run: | @@ -126,11 +126,17 @@ jobs: clang llvm libbpf-dev libelf-dev linux-headers-$(uname -r) \ make gcc pkg-config + - name: Validate shell scripts + run: bash -n kekkai.sh + - name: Build embedded eBPF object run: | make bpf test -s internal/loader/bpf/xdp_filter.o + - name: Run tests + run: go test ./... + - name: Build binaries env: GOOS: ${{ matrix.goos }} @@ -142,6 +148,7 @@ jobs: go build -ldflags "-s -w -X main.version=${VERSION}" -o "dist/kekkai-agent-${GOOS}-${GOARCH}" ./cmd/kekkai-agent - name: Upload artifacts + if: needs.meta.outputs.mode != 'none' uses: actions/upload-artifact@v4 with: name: bins-${{ matrix.goos }}-${{ matrix.goarch }} @@ -278,11 +285,19 @@ jobs: f.write("\n".join(lines)) PY + # `commit: github.sha` pins the release + tag to the exact commit + # that triggered this workflow. Without it, ncipollo/release-action + # falls back to the repo's default branch HEAD — which made every + # pre-release under an old setup end up pointing at the `release` + # branch tip instead of the `main` commit that actually built the + # binaries. target_commitish and created_at will now reflect the + # real source commit. - name: Publish / update release if: needs.meta.outputs.mode == 'release' uses: ncipollo/release-action@v1 with: tag: v${{ needs.meta.outputs.version }} + commit: ${{ github.sha }} name: v${{ needs.meta.outputs.version }} Release (auto) bodyFile: release-notes.md draft: false @@ -298,6 +313,7 @@ jobs: uses: ncipollo/release-action@v1 with: tag: v${{ needs.meta.outputs.version }} + commit: ${{ github.sha }} name: v${{ needs.meta.outputs.version }} Pre-release (auto) bodyFile: release-notes.md draft: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 6fa1dc4..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: CI - -on: - push: - pull_request: - -jobs: - test-and-build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - id: setup-go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Cache Go modules and build cache - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ steps.setup-go.outputs.go-version }}-ci-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-${{ steps.setup-go.outputs.go-version }}-ci- - ${{ runner.os }}-go-${{ steps.setup-go.outputs.go-version }}- - - - name: Validate shell scripts - run: bash -n kekkai.sh - - - name: Run tests - run: go test ./... - - - name: Build binaries - run: | - go build -o bin/kekkai ./cmd/kekkai - go build -o bin/kekkai-agent ./cmd/kekkai-agent diff --git a/COMMAND_ZH.md b/COMMAND_ZH.md index c1cbbd1..29bc74a 100644 --- a/COMMAND_ZH.md +++ b/COMMAND_ZH.md @@ -19,20 +19,29 @@ 通用 CLI,Docker Compose 風格 subcommand。執行 `kekkai help` 看最新列表。 +### 2.0 sudo 權限政策(先看這個) + +**所有 `kekkai` 指令一律用 `sudo kekkai `。** + +原因:Debian / Ubuntu / Pi OS 預設 `kernel.unprivileged_bpf_disabled=2`,非 root 呼叫 `bpf()` 會被 kernel 直接擋掉,`setcap` 無法繞過。為了跨主機一致、避免踩到平台差異,本專案乾脆把所有 CLI 使用統一到 sudo。 + +安裝器的行為: +- **會**寫 `/etc/sudoers.d/kekkai-cli-` NOPASSWD drop-in → `sudo kekkai ...` 不會問密碼 +- **不會**設 file capabilities (`setcap cap_bpf,...`) — 在這個 kernel sysctl 下沒用 +- **不會**在 `.bashrc` / `.zshrc` 加 `alias kekkai='sudo kekkai'` — 避免跨主機 muscle memory 不一致 + +直接把 `sudo kekkai` 背起來就好,體感上跟無 sudo 差不多(沒密碼提示)。若不小心打成 `kekkai`,CLI 會提示改用 sudo。 + +若要關掉 NOPASSWD:`sudo rm /etc/sudoers.d/kekkai-cli-$USER`。 + ### 2.1 kekkai status 啟動互動式 TUI,取代 `watch -n 1 cat /var/run/kekkai/stats.txt`。 啟動後會背景檢查 `update.channel` 對應來源是否有新版(release / pre-release),並在 header 顯示 `up-to-date` / `available`。 ```bash -kekkai status # 預設讀 /etc/kekkai/kekkai.yaml -kekkai status /path/to/kekkai.yaml # 指定 config 路徑 -``` - -一般情況不需要 root。若主機有額外 LSM/硬化策略擋住 bpffs 讀取,再改用 sudo: - -```bash -sudo kekkai status +sudo kekkai status # 預設讀 /etc/kekkai/kekkai.yaml +sudo kekkai status /path/to/kekkai.yaml # 指定 config 路徑 ``` **畫面分四頁** @@ -60,14 +69,14 @@ sudo kekkai status ### 2.2 kekkai check -驗證 config 檔後退出,**完全 read-only**,不動任何磁碟檔案。純 memory 驗證,非 root 也能跑。未來 schema 升版時這裡會印「would migrate on daemon start」但仍不寫回。 +驗證 config 檔後退出,**完全 read-only**,不動任何磁碟檔案。未來 schema 升版時這裡會印「would migrate on daemon start」但仍不寫回。 ```bash -kekkai check # 驗 /etc/kekkai/kekkai.yaml -kekkai check /tmp/new-kekkai.yaml # 指定檔案 +sudo kekkai check # 驗 /etc/kekkai/kekkai.yaml +sudo kekkai check /tmp/new-kekkai.yaml # 指定檔案 ``` -非 root 也能跑 — 即使你的 `/etc/kekkai/kekkai.yaml` 是 v1 需要遷移也不會爆 permission denied。實際的遷移寫回只會在 daemon 正式啟動(`systemctl start kekkai-agent` 或 `kekkai-agent -config ...`)時發生,那一定是 root。 +實際的遷移寫回只會在 daemon 正式啟動(`systemctl start kekkai-agent` 或 `kekkai-agent -config ...`)時發生。 Exit code:`0` 通過、`1` 驗證失敗(錯誤訊息到 stderr)。推薦每次 reload 前先跑。 @@ -76,8 +85,8 @@ Exit code:`0` 通過、`1` 驗證失敗(錯誤訊息到 stderr)。推薦 快速列出 `public/private` port,含顏色與 SSH 暴露提示,方便在 reload 前做肉眼檢查。 ```bash -kekkai ports # 讀 /etc/kekkai/kekkai.yaml -kekkai ports /tmp/new-kekkai.yaml # 指定檔案 +sudo kekkai ports # 讀 /etc/kekkai/kekkai.yaml +sudo kekkai ports /tmp/new-kekkai.yaml # 指定檔案 ``` 輸出重點: @@ -90,8 +99,8 @@ kekkai ports /tmp/new-kekkai.yaml # 指定檔案 印出正規化後的 config(post-migrate, post-defaults, post-normalize),**read-only**。輸入是舊版 v1 時顯示遷移後的 v2,但不會寫回磁碟。 ```bash -kekkai show > /tmp/current.yaml # 看 agent 實際在用什麼 -kekkai show /tmp/test.yaml # 指定檔案 +sudo kekkai show > /tmp/current.yaml # 看 agent 實際在用什麼 +sudo kekkai show /tmp/test.yaml # 指定檔案 ``` 用途: @@ -104,11 +113,12 @@ kekkai show /tmp/test.yaml # 指定檔案 手動寫一份備份。檔名 `kekkai.yaml.backup.<時戳>`。 ```bash -sudo kekkai backup # 預設路徑 -sudo kekkai backup /etc/kekkai/kekkai.yaml # 指定路徑 +sudo kekkai backup # 備份 /etc/kekkai/kekkai.yaml +sudo kekkai backup /etc/kekkai/kekkai.yaml # 顯式指定 +sudo kekkai backup /tmp/test.yaml # 任意路徑 ``` -需要 `sudo` 因為備份寫到 `/etc/kekkai/`。每個 kind (update/auto/backup) 各保留最新 10 份,舊的自動刪。 +每個 kind (update/auto/backup) 各保留最新 10 份,舊的自動刪。 ### 2.6 kekkai reset @@ -117,7 +127,7 @@ sudo kekkai backup /etc/kekkai/kekkai.yaml # 指定路徑 ```bash sudo kekkai reset # 用 default route 自動偵測 iface sudo kekkai reset --iface eth1 # 明確指定網卡 -sudo kekkai reset /tmp/test.yaml # 任意路徑(測試用,不需 sudo) +sudo kekkai reset /tmp/test.yaml # 任意路徑 sudo kekkai reset /tmp/test.yaml --iface eth0 ``` @@ -135,7 +145,7 @@ sudo nano /etc/kekkai/kekkai.yaml # 改成: # ingress_allowlist: # - 192.168.88.0/24 # 你的管理網段 -kekkai check +sudo kekkai check sudo systemctl restart kekkai-agent ``` @@ -207,8 +217,8 @@ Exit code:`0` 為 healthy 或只有 warning、`1` 為任何 error。設計上 ### 2.8 kekkai version / kekkai help ```bash -kekkai version # 印 kekkai 版本 + 偵測 kekkai-agent 是否存在 -kekkai help # 指令總表 +sudo kekkai version # 印 kekkai 版本 + 偵測 kekkai-agent 是否存在 +sudo kekkai help # 指令總表 ``` ### 2.9 kekkai bypass on|off [--save] @@ -255,7 +265,7 @@ kekkai-agent -backup -config # 寫一份 backup. kekkai-agent -reset -config -iface eth0 # 覆蓋成預設 template (原檔自動備份) ``` -四個 flag 互斥。`-check` / `-show` 完全不動磁碟(v2 之後的行為,之前的版本會寫回遷移);`-backup` / `-reset` 會寫檔,所以目標在 `/etc/kekkai/` 時要 sudo。 +四個 flag 互斥。`-check` / `-show` 完全不動磁碟(v2 之後的行為,之前的版本會寫回遷移);`-backup` / `-reset` 會寫檔。直接跑 `kekkai-agent` 時請記得 sudo。 --- @@ -452,60 +462,66 @@ sudo systemctl reload kekkai-agent --- -## 七、安裝 / 更新 / 建置 +## 七、安裝 / 更新 -所有生命週期動作統一走 `./kekkai.sh`(或 `make` 別名)。單一腳本會自動偵測當前狀態並執行對應 subcommand。 +kekkai 是**純 release 分發**:目標機不需要 Go、git、clang,也不需要 clone repo。所有生命週期動作走 `/usr/local/bin/kekkai.sh`(由一鍵安裝腳本落地),內部只做「下載 GitHub release 資產 → 安裝 → 重啟 service」。 ### 7.1 一鍵安裝(推薦) ```bash -cd /path/to/kekkai -bash ./kekkai.sh # 自動偵測狀態 +curl -fsSL https://raw.githubusercontent.com/ExpTechTW/kekkai/main/kekkai.sh \ + | sudo bash -s -- install +``` + +若要固定更新通道為 pre-release: + +```bash +curl -fsSL https://raw.githubusercontent.com/ExpTechTW/kekkai/main/kekkai.sh \ + | KEKKAI_UPDATE_CHANNEL=pre-release sudo bash -s -- install ``` -決策流程: -1. binary 都不存在 → `install`(裝依賴、編譯、安裝、寫 config、裝 systemd、啟動 service) -2. binary 有但 systemd unit 缺 → `repair` -3. 全部都在、git 有新 commit → `update` -4. 全部都在、沒新 commit → `doctor`(只報告不改) +安裝器會把 `kekkai.sh` 自己也落地到 `/usr/local/bin/kekkai.sh`,之後 `sudo kekkai update` 會找到它。 + +### 7.2 生命週期 subcommand -顯式 subcommand: +直接跑 `/usr/local/bin/kekkai.sh`(或用 `sudo kekkai update`): ```bash -bash ./kekkai.sh install # 強制走 install -bash ./kekkai.sh update # 強制走 update(依 update.channel 決定來源) -bash ./kekkai.sh repair # 補裝缺失的 binary / unit -bash ./kekkai.sh doctor # read-only 健康檢查 -bash ./kekkai.sh uninstall # 移除 binary + unit,config 保留 +sudo bash /usr/local/bin/kekkai.sh install # 強制重裝 +sudo bash /usr/local/bin/kekkai.sh update # 檢查 release 有無新版 +sudo bash /usr/local/bin/kekkai.sh repair # 補裝缺失的 binary / unit +sudo bash /usr/local/bin/kekkai.sh doctor # read-only 健康檢查 +sudo bash /usr/local/bin/kekkai.sh uninstall # 移除 binary + unit,config 保留 ``` -或用 Makefile 別名:`make install` / `make update` / `make repair` / `make doctor` / `make uninstall` +`sudo kekkai update` 是 `sudo bash /usr/local/bin/kekkai.sh update` 的等價捷徑。 -### 7.2 腳本旗標 +### 7.3 腳本旗標 ```bash -bash ./kekkai.sh --no-install # 跳過 apt 依賴安裝 -bash ./kekkai.sh --iface eth1 # reset/install 時指定網卡 -bash ./kekkai.sh --run # 裝完前景跑 agent(debug) -bash ./kekkai.sh --force # 略過 dirty tree / branch 不符 / 降級保護 -bash ./kekkai.sh --sudo-shortcut # 強制開啟 `kekkai` 免密碼 sudo + alias(預設已開) -bash ./kekkai.sh --no-sudo-shortcut # 關閉 sudo shortcut 設定 +bash kekkai.sh --no-install # 跳過 apt 依賴安裝 +bash kekkai.sh --iface eth1 # install 時指定網卡 +bash kekkai.sh --run # 裝完前景跑 agent(debug) ``` -`update.channel` 設定在 `/etc/kekkai/kekkai.yaml`: +### 7.4 Update channel + +設定在 `/etc/kekkai/kekkai.yaml`: ```yaml update: - channel: release # git:main | release | pre-release + channel: release # release(預設)或 pre-release ``` -### 7.3 安裝完成後 +或用 env 臨時覆蓋:`sudo KEKKAI_UPDATE_CHANNEL=pre-release kekkai update` + +### 7.5 安裝完成後 ```bash sudo nano /etc/kekkai/kekkai.yaml # 填 ingress_allowlist -kekkai check # 驗證 +sudo kekkai check # 驗證 sudo systemctl restart kekkai-agent # 套用 -kekkai doctor # 確認全綠 +sudo kekkai doctor # 確認全綠 sudo kekkai status # 看 TUI ``` @@ -514,31 +530,19 @@ sudo kekkai status # 看 TUI - `kekkai.sh` 安裝時若偵測到管理介面 IP 不在 `192.168.0.0/16`,會印警告。 - 實際上線前請務必改成你的管理網段(例如 `10.0.0.0/8`、`172.16.0.0/12`、Tailscale 網段)。 -### 7.4 更新流程內部細節 - -`bash ./kekkai.sh update` 會依 `update.channel` 走不同路徑(都含 rollback): - -- `git:main`: - 1. 還原 `internal/loader/bpf/xdp_filter.o`(避免上次 build 殘留 dirty) - 2. 檢查 working tree 乾淨,不乾淨列 `git status --short` 後終止 - 3. `git fetch origin main` + `git merge --ff-only` - 4. `make bpf && make build` - 5. 用新 binary 跑 `kekkai-agent -check` 驗當前 config(失敗中止) - 6. 安裝新 `kekkai-agent` + `kekkai`,`systemctl restart`,失敗自動 rollback +### 7.6 更新流程內部細節 -- `release`: - 1. 從 `https://github.com/ExpTechTW/kekkai/releases` 抓最新 stable release 資產 - 2. 驗 config - 3. 安裝 binary + restart,失敗自動 rollback +`sudo kekkai update` 做的事: -- `pre-release`: - 1. 從 `https://github.com/ExpTechTW/kekkai/releases` 抓最新 pre-release 資產 - 2. 驗 config - 3. 安裝 binary + restart,失敗自動 rollback +1. 從 `https://github.com/ExpTechTW/kekkai/releases` 抓目標 channel(release / pre-release)的最新資產 +2. 下載 `kekkai-agent-linux-` 和 `kekkai-linux-` 到 tmp 目錄 +3. 用新 agent binary 跑 `-check` 驗 `/etc/kekkai/kekkai.yaml`(失敗中止,不動 service) +4. 三路 diff:agent / cli / kekkai.sh 個別比對 sha256,每個獨立決定要不要更新 +5. agent 有變 → `systemctl restart kekkai-agent`(失敗自動 rollback 到 `kekkai-agent.prev`) +6. cli 或 kekkai.sh 有變 → 個別覆寫,不 restart service +7. 最後印藍色 `UPDATED`(有變)或綠色 `ALREADY UP-TO-DATE`(全部沒變)結果區塊 -可臨時覆蓋:`KEKKAI_UPDATE_CHANNEL=release kekkai update` - -### 7.5 手動建置 +### 7.7 開發者本機建置(maintainer 用) ```bash make bpf # 只編 eBPF .o @@ -555,20 +559,20 @@ make run # 本地 build 後以 sudo 前景跑 kekkai-agent ### 7.6 Makefile config 捷徑 ```bash -make config-check # = kekkai check +make config-check # = sudo kekkai check make config-backup # = sudo kekkai backup -make config-show # = kekkai show +make config-show # = sudo kekkai show ``` --- ## 八、統計檔案 -`kekkai status` 之外,`/var/run/kekkai/stats.txt` 仍會每秒更新,適合給 script / Prometheus node_exporter textfile collector 讀。 +`sudo kekkai status` 之外,`/var/run/kekkai/stats.txt` 仍會每秒更新,適合給 script / Prometheus node_exporter textfile collector 讀。 ```bash -cat /var/run/kekkai/stats.txt # 一次性快照 -watch -n 1 cat /var/run/kekkai/stats.txt # 舊的 watch 方式(仍可用) +sudo cat /var/run/kekkai/stats.txt # 一次性快照 +sudo watch -n 1 cat /var/run/kekkai/stats.txt # 舊的 watch 方式(仍可用) ``` 欄位:traffic rx/tx、protocols (since + rate)、counters、drops by reason、passes by reason、top 10 source IPs。 @@ -620,16 +624,29 @@ RPi 的 `macb` / `bcmgenet` driver 沒有 native XDP 支援。這是驅動層限 ### 9.4 `kekkai status` 回報 `open pinned stats map` -通常代表 agent 沒在跑、bpffs 沒掛,或主機策略限制了 bpffs 讀取權限。 +**第一件事:確認你是用 `sudo kekkai status`**。非 root 幾乎必定會因 `kernel.unprivileged_bpf_disabled` 失敗。 ```bash -systemctl is-active kekkai-agent # 確認 agent 跑著 -mount | grep bpf # 確認 bpffs 掛載 -ls /sys/fs/bpf/kekkai/ # 確認 pin 路徑有檔案 +sudo kekkai status +``` + +若 `sudo` 還是失敗,代表 agent 沒在跑或 bpffs 有問題: + +```bash +sudo systemctl is-active kekkai-agent # 確認 agent 跑著 +sudo mount | grep bpf # 確認 bpffs 掛載 +sudo ls /sys/fs/bpf/kekkai/ # 確認 pin 路徑有檔案 ``` bpffs 沒掛:`sudo mount -t bpf bpf /sys/fs/bpf`。 -若一般使用者仍 permission denied:先試 `sudo kekkai status` 驗證是否純權限問題。 + +如果你真的要讓非 root 使用者直接跑 CLI(不建議,跨主機不穩): + +```bash +sudo sysctl kernel.unprivileged_bpf_disabled=0 +``` + +這會放寬 kernel 的 Spectre 緩解,請自己評估風險。 ### 9.5 `kekkai.sh update` 中止且 rollback @@ -667,11 +684,9 @@ journalctl -u kekkai-agent -n 30 --no-pager 主要會看這些環境變數: - `NO_COLOR`:關閉彩色輸出 -- `KEKKAI_REPO`:`kekkai update` 在非 repo 目錄時指定 repo 根目錄 -- `KEKKAI_SCRIPT`:直接指定 `kekkai.sh` 路徑 -- `KEKKAI_UPDATE_CHANNEL`:臨時覆蓋 `update.channel`(`release` / `pre-release` / `git:main`) -- `KEKKAI_GIT_ACCEPT_NEW_HOSTKEY`:控制 update 時 git SSH 的 `accept-new` 行為(`1` 預設啟用) -- `GIT_SSH_COMMAND`:覆蓋 git SSH 行為(進階用途) +- `KEKKAI_SCRIPT`:直接指定 `kekkai.sh` 路徑(預設 `/usr/local/bin/kekkai.sh`) +- `KEKKAI_REPO`:指定 `kekkai.sh` 所在目錄(罕用,大部分人用預設就好) +- `KEKKAI_UPDATE_CHANNEL`:臨時覆蓋 `update.channel`(`release` / `pre-release`) --- @@ -679,7 +694,7 @@ journalctl -u kekkai-agent -n 30 --no-pager ```bash # 看當前過濾規則 -kekkai show | grep -A20 filter +sudo kekkai show | grep -A20 filter # 備份 + 編輯 + 驗證 + reload 的標準流程 sudo kekkai backup && sudo nano /etc/kekkai/kekkai.yaml && sudo kekkai reload @@ -753,4 +768,4 @@ cat /var/run/kekkai/stats.txt > /tmp/stats-$(date +%s).txt | `kekkai start/stop/restart/enable/disable` | M7 | systemctl 包裝 | | `kekkai stats` | M7 | 印 `stats.txt` 一次(script 友善) | -`kekkai update` 已整進 CLI,內部仍委派給 `kekkai.sh update`,所以更新/rollback/安全檢查邏輯維持單一來源。找不到 `kekkai.sh` 時,可在 repo root 執行,或設定 `KEKKAI_REPO=/path/to/waf-go`。 +`kekkai update` 已整進 CLI,內部委派給 `/usr/local/bin/kekkai.sh update`(安裝器會自動落地這份腳本)。更新 / rollback / 結果區塊等邏輯全部集中在 `kekkai.sh` 單一來源。 diff --git a/Makefile b/Makefile index 3ac6ae9..8257b0b 100644 --- a/Makefile +++ b/Makefile @@ -69,10 +69,10 @@ uninstall: CFG ?= /etc/kekkai/kekkai.yaml config-check: - @/usr/local/bin/kekkai check $(CFG) + @sudo /usr/local/bin/kekkai check $(CFG) config-backup: @sudo /usr/local/bin/kekkai backup $(CFG) config-show: - @/usr/local/bin/kekkai show $(CFG) + @sudo /usr/local/bin/kekkai show $(CFG) diff --git a/cmd/kekkai/main.go b/cmd/kekkai/main.go index 3faf6aa..85ea227 100644 --- a/cmd/kekkai/main.go +++ b/cmd/kekkai/main.go @@ -35,6 +35,96 @@ const agentBinary = "/usr/local/bin/kekkai-agent" const agentUnit = "kekkai-agent" const bypassUsage = "usage: kekkai bypass on|off [--save] [config]" +var ( + uiTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#a78bfa")) + uiKeyStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#94a3b8")) + uiErrStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#f43f5e")) + uiWarnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#f59e0b")) + uiOKStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#22c55e")) + uiInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#cbd5e1")) +) + +func stderrIsTerminal() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + fi, err := os.Stderr.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + +func uiErr(msg string) string { + if !stderrIsTerminal() { + return msg + } + return uiErrStyle.Render("✗ ") + msg +} + +func uiWarn(msg string) string { + if !stderrIsTerminal() { + return msg + } + return uiWarnStyle.Render("! ") + msg +} + +func uiOK(msg string) string { + if !stdoutIsTerminal() { + return msg + } + return uiOKStyle.Render("✓ ") + msg +} + +func uiInfo(msg string) string { + if !stdoutIsTerminal() { + return msg + } + return uiInfoStyle.Render("· " + msg) +} + +// requireRoot prints a clear error and exits 1 if euid != 0. kekkai CLI is +// designed for sudo-only use (kernel.unprivileged_bpf_disabled on Debian/ +// Ubuntu/Pi OS blocks non-root bpf() regardless of caps), so we fail fast +// with a copy-pasteable sudo hint rather than letting downstream calls +// emit cryptic permission-denied errors. +func requireRoot() { + if os.Geteuid() == 0 { + return + } + // Rebuild the full invocation so the user can copy the "retry with" + // line verbatim — including any trailing flags / config paths. + cmdline := "sudo kekkai" + for _, a := range os.Args[1:] { + cmdline += " " + shellQuote(a) + } + fmt.Fprintln(os.Stderr, uiErr("kekkai must run as root")) + fmt.Fprintln(os.Stderr, uiInfoStyle.Render("retry with: "+cmdline)) + os.Exit(1) +} + +// shellQuote wraps an argument in single quotes if it contains anything +// other than the POSIX "portable filename character set". Keeps the +// suggested command pasteable even when args have spaces or globs. +func shellQuote(s string) string { + if s == "" { + return "''" + } + safe := true + for _, r := range s { + if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && + !(r >= '0' && r <= '9') && r != '_' && r != '-' && + r != '.' && r != '/' && r != ':' && r != '=' && r != ',' { + safe = false + break + } + } + if safe { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + func main() { if version == "" { version = buildinfo.DefaultVersion @@ -47,6 +137,25 @@ func main() { cmd, args := os.Args[1], os.Args[2:] + // Everything except `help` / `version` requires root. The CLI is a + // sudo-only tool on Debian/Ubuntu/Pi OS (kernel.unprivileged_bpf_disabled + // blocks non-root bpf() even with file caps), so we fail fast with a + // copy-pasteable sudo hint instead of letting downstream calls emit + // cryptic EACCES. + // + // `version` is deliberately outside the gate: kekkai.sh's update flow + // calls `kekkai-candidate version` to diff old vs new version strings + // before deciding whether to restart the service. If version required + // root, a freshly-downloaded CLI in a tmp dir (which kekkai.sh may + // invoke while itself running under sudo's preserved env) could fail + // that probe and leave the update result block showing "unknown". + switch cmd { + case "help", "-h", "--help", "version", "-v", "--version": + // no gate + default: + requireRoot() + } + switch cmd { case "status": os.Exit(cmdStatus(args)) @@ -75,7 +184,8 @@ func main() { case "help", "-h", "--help": usage() default: - fmt.Fprintf(os.Stderr, "kekkai: unknown command %q\n\n", cmd) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("kekkai: unknown command %q", cmd))) + fmt.Fprintln(os.Stderr) usage() os.Exit(2) } @@ -120,8 +230,8 @@ func buildResetArgs(args []string) []string { // than duplicate the config-handling logic here. func runWafEdge(args ...string) int { if _, err := exec.LookPath(agentBinary); err != nil { - fmt.Fprintf(os.Stderr, "kekkai-agent binary not found at %s\n", agentBinary) - fmt.Fprintln(os.Stderr, "is kekkai installed? run: bash scripts/bootstrap.sh") + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("kekkai-agent binary not found at %s", agentBinary))) + fmt.Fprintln(os.Stderr, uiWarn("is kekkai installed? run: bash scripts/bootstrap.sh")) return 1 } c := exec.Command(agentBinary, args...) @@ -134,7 +244,7 @@ func cmdPorts(args []string) int { res, err := config.LoadReadOnly(cfgPath) if err != nil { - fmt.Fprintf(os.Stderr, "config: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("config: %v", err))) return 1 } cfg := res.Config @@ -243,7 +353,7 @@ func hasPort(list []uint16, p uint16) bool { func cmdBypass(args []string) int { p, err := parseBypassArgs(args) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, uiErr(err.Error())) return 2 } if p.save { @@ -254,11 +364,11 @@ func cmdBypass(args []string) int { func cmdBypassTemporary(wantBypass bool) int { if os.Geteuid() != 0 { - fmt.Fprintln(os.Stderr, "bypass toggle requires root (run: sudo kekkai bypass on|off)") + fmt.Fprintln(os.Stderr, uiErr("bypass toggle requires root (run: sudo kekkai bypass on|off)")) return 1 } if _, err := exec.LookPath("systemctl"); err != nil { - fmt.Fprintln(os.Stderr, "systemctl not found: cannot signal kekkai-agent") + fmt.Fprintln(os.Stderr, uiErr("systemctl not found: cannot signal kekkai-agent")) return 1 } @@ -273,8 +383,8 @@ func cmdBypassTemporary(wantBypass bool) int { return code } - fmt.Printf("temporary bypass %s (not saved)\n", action) - fmt.Println("WARNING: this temporary bypass state will be lost after restart/reboot; use --save to persist.") + fmt.Println(uiOK(fmt.Sprintf("temporary bypass %s (not saved)", action))) + fmt.Fprintln(os.Stderr, uiWarn("temporary bypass state is ephemeral; use --save to persist")) return 0 } @@ -313,43 +423,43 @@ func parseBypassArgs(args []string) (parsedBypassArgs, error) { func cmdBypassSave(wantBypass bool, cfgPath string) int { if os.Geteuid() != 0 { - fmt.Fprintln(os.Stderr, "bypass --save requires root (run: sudo kekkai bypass on|off --save)") + fmt.Fprintln(os.Stderr, uiErr("bypass --save requires root (run: sudo kekkai bypass on|off --save)")) return 1 } res, err := config.Load(cfgPath) if err != nil { - fmt.Fprintf(os.Stderr, "config: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("config: %v", err))) return 1 } cfg := res.Config if cfg.Runtime.EmergencyBypass == wantBypass { - fmt.Printf("config already has runtime.emergency_bypass=%v\n", wantBypass) + fmt.Println(uiInfo(fmt.Sprintf("config already has runtime.emergency_bypass=%v", wantBypass))) return cmdReload([]string{cfgPath}) } if backupPath, err := config.BackupFile(cfgPath, config.BackupKindManual); err == nil { - fmt.Printf("backup written: %s\n", backupPath) + fmt.Println(uiOK(fmt.Sprintf("backup written: %s", backupPath))) } else { - fmt.Fprintf(os.Stderr, "backup failed: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("backup failed: %v", err))) return 1 } cfg.Runtime.EmergencyBypass = wantBypass b, err := config.Marshal(cfg) if err != nil { - fmt.Fprintf(os.Stderr, "marshal: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("marshal: %v", err))) return 1 } if err := writeFileAtomic(cfgPath, b, 0o644); err != nil { - fmt.Fprintf(os.Stderr, "write config: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("write config: %v", err))) return 1 } if code := cmdReload([]string{cfgPath}); code != 0 { return code } - fmt.Printf("persisted runtime.emergency_bypass=%v in %s\n", wantBypass, cfgPath) + fmt.Println(uiOK(fmt.Sprintf("persisted runtime.emergency_bypass=%v in %s", wantBypass, cfgPath))) return 0 } @@ -360,7 +470,7 @@ func cmdConfig(args []string) int { nanoPath, err := exec.LookPath("nano") if err != nil { - fmt.Fprintln(os.Stderr, "nano not found; install nano first") + fmt.Fprintln(os.Stderr, uiErr("nano not found; install nano first")) return 1 } @@ -379,7 +489,7 @@ func cmdConfig(args []string) int { } exe, err := os.Executable() if err != nil { - fmt.Fprintf(os.Stderr, "resolve executable for reload: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("resolve executable for reload: %v", err))) return 1 } return runCommand(exec.Command("sudo", exe, "reload", cfgPath), "sudo reload after config edit") @@ -409,16 +519,16 @@ func cmdReload(args []string) int { // Always lint/validate before touching the running service. if code := runWafEdge("-check", "-config", cfgPath); code != 0 { - fmt.Fprintln(os.Stderr, "reload aborted: config check failed") + fmt.Fprintln(os.Stderr, uiErr("reload aborted: config check failed")) return code } if os.Geteuid() != 0 { - fmt.Fprintln(os.Stderr, "reload requires root (run: sudo kekkai reload)") + fmt.Fprintln(os.Stderr, uiErr("reload requires root (run: sudo kekkai reload)")) return 1 } if _, err := exec.LookPath("systemctl"); err != nil { - fmt.Fprintln(os.Stderr, "systemctl not found: cannot reload kekkai-agent") + fmt.Fprintln(os.Stderr, uiErr("systemctl not found: cannot reload kekkai-agent")) return 1 } @@ -427,7 +537,7 @@ func cmdReload(args []string) int { return code } - fmt.Printf("reload requested: %s (config checked: %s)\n", agentUnit, cfgPath) + fmt.Println(uiOK(fmt.Sprintf("reload requested: %s (config checked: %s)", agentUnit, cfgPath))) return 0 } @@ -436,31 +546,20 @@ func cmdReload(args []string) int { func cmdUpdate(args []string) int { script, searched := resolveUpdateScript() if script == "" { - fmt.Fprintln(os.Stderr, "kekkai update requires kekkai.sh") - fmt.Fprintln(os.Stderr, "run from repository root, or set KEKKAI_REPO=/path/to/waf-go") - fmt.Fprintln(os.Stderr, "searched:") + fmt.Fprintln(os.Stderr, uiErr("kekkai update requires kekkai.sh")) + fmt.Fprintln(os.Stderr, uiWarn("run from repository root, or set KEKKAI_REPO=/path/to/waf-go")) + fmt.Fprintln(os.Stderr, uiKeyStyle.Render("searched:")) for _, p := range searched { - fmt.Fprintf(os.Stderr, " - %s\n", p) + fmt.Fprintf(os.Stderr, " %s\n", uiInfoStyle.Render("- "+p)) } return 1 } - var c *exec.Cmd - if os.Geteuid() == 0 && os.Getenv("SUDO_USER") != "" { - // `kekkai` may be aliased to `sudo /usr/local/bin/kekkai`. - // Update needs git auth from the real user account, so drop back. - realUser := os.Getenv("SUDO_USER") - sudoArgs := []string{ - "-u", realUser, - "--preserve-env=KEKKAI_SCRIPT,KEKKAI_REPO,KEKKAI_GIT_ACCEPT_NEW_HOSTKEY,GIT_SSH_COMMAND,KEKKAI_UPDATE_CHANNEL", - "bash", script, "update", - } - sudoArgs = append(sudoArgs, args...) - c = exec.Command("sudo", sudoArgs...) - } else { - cmdArgs := append([]string{script, "update"}, args...) - c = exec.Command("bash", cmdArgs...) - } + // kekkai update pulls prebuilt release assets from GitHub — no git / + // SSH key required, so we just run kekkai.sh under the current (root) + // uid. requireRoot() above guarantees we're already root. + cmdArgs := append([]string{script, "update"}, args...) + c := exec.Command("bash", cmdArgs...) return runCommand(c, fmt.Sprintf("run %s update", script)) } @@ -473,11 +572,11 @@ func resolveUpdateScript() (string, []string) { if repo := strings.TrimSpace(os.Getenv("KEKKAI_REPO")); repo != "" { candidates = append(candidates, filepath.Join(repo, "kekkai.sh")) } - // Common default clone location. + // Common default clone location (legacy / dev). if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { candidates = append(candidates, filepath.Join(home, "kekkai", "kekkai.sh")) } - // When launched via sudo alias, prefer the original user's home. + // When running as root via sudo, also look in the invoking user's home. if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" { candidates = append(candidates, filepath.Join("/home", sudoUser, "kekkai", "kekkai.sh")) } @@ -521,14 +620,14 @@ func cmdStatus(args []string) int { res, err := config.Load(cfgPath) if err != nil { - fmt.Fprintf(os.Stderr, "config: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("config: %v", err))) return 1 } cfg := res.Config src, err := tui.NewSource(cfg.Interface.Name) if err != nil { - fmt.Fprintf(os.Stderr, "open eBPF maps: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("open eBPF maps: %v", err))) return 1 } defer src.Close() @@ -543,7 +642,7 @@ func cmdStatus(args []string) int { ) p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "tui: %v\n", err) + fmt.Fprintln(os.Stderr, uiErr(fmt.Sprintf("tui: %v", err))) return 1 } return 0 @@ -571,7 +670,12 @@ func stdoutIsTerminal() bool { } func cmdVersion() { - fmt.Printf("kekkai %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH) + if stdoutIsTerminal() { + fmt.Println(uiTitleStyle.Render("◈ KEKKAI VERSION")) + fmt.Printf("%s %s\n", uiKeyStyle.Render("kekkai"), uiInfoStyle.Render(fmt.Sprintf("%s (%s/%s)", version, runtime.GOOS, runtime.GOARCH))) + } else { + fmt.Printf("kekkai %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH) + } // Also print kekkai-agent's version if available. if _, err := exec.LookPath(agentBinary); err == nil { out, err := exec.Command(agentBinary, "-check", "/dev/null").CombinedOutput() @@ -579,36 +683,72 @@ func cmdVersion() { _ = err // kekkai-agent doesn't have -version yet; just note its presence. if st, err := os.Stat(agentBinary); err == nil { - fmt.Printf("kekkai-agent present at %s (size %d bytes, modified %s)\n", + msg := fmt.Sprintf("present at %s (size %d bytes, modified %s)", agentBinary, st.Size(), st.ModTime().Format("2006-01-02 15:04:05")) + if stdoutIsTerminal() { + fmt.Printf("%s %s\n", uiKeyStyle.Render("kekkai-agent"), uiInfoStyle.Render(msg)) + } else { + fmt.Printf("kekkai-agent %s\n", msg) + } } } } func usage() { + if stderrIsTerminal() { + fmt.Fprintln(os.Stderr, uiTitleStyle.Render("◈ KEKKAI CLI")) + fmt.Fprintln(os.Stderr, uiInfoStyle.Render("operator CLI for the kekkai edge firewall")) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, uiKeyStyle.Render("Usage:")) + fmt.Fprintln(os.Stderr, " sudo kekkai [args]") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, uiWarn("always run with sudo for cross-host consistency")) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, uiKeyStyle.Render("Commands:")) + fmt.Fprintln(os.Stderr, " status [config] launch the live TUI") + fmt.Fprintln(os.Stderr, " config [config] open config in nano, then auto-reload") + fmt.Fprintln(os.Stderr, " check [config] validate a config file (read-only)") + fmt.Fprintln(os.Stderr, " ports [config] show colorized public/private port summary") + fmt.Fprintln(os.Stderr, " show [config] print the normalised config after migration") + fmt.Fprintln(os.Stderr, " backup [config] write a timestamped manual backup") + fmt.Fprintln(os.Stderr, " reload [config] validate config, then systemctl reload") + fmt.Fprintln(os.Stderr, " bypass on|off [--save] toggle emergency bypass") + fmt.Fprintln(os.Stderr, " update [kekkai.sh flags] run installer update flow (delegates to kekkai.sh update)") + fmt.Fprintln(os.Stderr, " reset [config] [--iface] overwrite config with a fresh default template") + fmt.Fprintln(os.Stderr, " doctor run read-only health checks and print a report") + fmt.Fprintln(os.Stderr, " version show version information") + fmt.Fprintln(os.Stderr, " help show this message") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, uiInfoStyle.Render("See COMMAND_ZH.md for the full operator handbook.")) + return + } + fmt.Fprintln(os.Stderr, `kekkai — operator CLI for the kekkai edge firewall Usage: - kekkai [args] + sudo kekkai [args] + +Always run kekkai with sudo. On Debian/Ubuntu/Pi OS the kernel sysctl +kernel.unprivileged_bpf_disabled blocks non-root bpf() regardless of caps, +so the CLI needs root to open pinned eBPF maps. Commands: - status [config] launch the live TUI (default: /etc/kekkai/kekkai.yaml) - config [config] open config in nano, then auto-reload kekkai-agent - check [config] validate a config file (read-only; safe as non-root) + status [config] launch the live TUI + config [config] open config in nano, then auto-reload + check [config] validate a config file (read-only) ports [config] show colorized public/private port summary show [config] print the normalised config after migration - backup [config] write a timestamped manual backup of the config file - reload [config] validate config, then systemctl reload kekkai-agent - bypass on|off [--save] toggle emergency bypass (default temporary; --save persists) + backup [config] write a timestamped manual backup + reload [config] validate config, then systemctl reload + bypass on|off [--save] toggle emergency bypass update [kekkai.sh flags] run installer update flow (delegates to kekkai.sh update) reset [config] [--iface] overwrite config with a fresh default template - (existing file is backed up first; auto-detects iface) + (existing file is backed up first) doctor run read-only health checks and print a report version show version information help show this message -Run reset via sudo when the config lives under /etc/kekkai. -Run doctor to diagnose common installation/runtime problems. +Run "sudo kekkai doctor" to diagnose common installation/runtime problems. See COMMAND_ZH.md for the full operator handbook (in Chinese).`) } diff --git a/contrib/completions/_kekkai b/contrib/completions/_kekkai new file mode 100644 index 0000000..56b9ab3 --- /dev/null +++ b/contrib/completions/_kekkai @@ -0,0 +1,79 @@ +#compdef kekkai +# +# zsh completion for kekkai +# +# Install to /usr/share/zsh/vendor-completions/_kekkai (system-wide, Debian) +# or a directory in $fpath (per-user). After install, run `compinit` or +# start a fresh shell. kekkai.sh install / repair / update installs this +# file automatically. +# +# Works under `sudo kekkai ` because zsh's _sudo completer forwards +# to the completion of the next word. + +_kekkai() { + local curcontext="$curcontext" state line + typeset -A opt_args + + local -a subcommands + subcommands=( + 'status:launch the live TUI' + 'config:edit config then auto-reload' + 'check:validate a config file (read-only)' + 'ports:show public/private port summary' + 'show:print normalised config' + 'backup:write timestamped manual backup' + 'reload:validate + systemctl reload' + 'bypass:toggle emergency bypass' + 'update:run installer update flow' + 'reset:overwrite config with default template' + 'doctor:run health checks' + 'version:show version information' + 'help:show usage' + ) + + _arguments -C \ + '1: :->cmd' \ + '*::arg:->args' + + case $state in + cmd) + _describe -t commands 'kekkai command' subcommands + ;; + args) + case $words[1] in + bypass) + if (( CURRENT == 2 )); then + _values 'mode' on off + else + _arguments \ + '--save[persist to config and reload]' \ + '*:config:_files' + fi + ;; + reset) + local -a ifaces + if [[ -d /sys/class/net ]]; then + ifaces=(${(f)"$(ls /sys/class/net 2>/dev/null | grep -v '^lo$')"}) + fi + _arguments \ + '--iface[network interface]:iface:($ifaces)' \ + '*:config:_files' + ;; + update) + _arguments \ + '--force[bypass safety checks]' \ + '--no-install[skip apt deps]' \ + '--iface[force interface]:iface:' \ + '--run[launch agent in foreground after]' + ;; + status|config|check|ports|show|backup|reload) + _arguments '*:config:_files' + ;; + doctor|version|help) + ;; + esac + ;; + esac +} + +_kekkai "$@" diff --git a/contrib/completions/kekkai.bash b/contrib/completions/kekkai.bash new file mode 100644 index 0000000..cf75c57 --- /dev/null +++ b/contrib/completions/kekkai.bash @@ -0,0 +1,79 @@ +# bash completion for kekkai +# +# Install to /usr/share/bash-completion/completions/kekkai (system-wide) +# or ~/.local/share/bash-completion/completions/kekkai (per-user). +# +# kekkai.sh install / repair / update installs this file automatically. +# +# Also works transparently after `sudo` thanks to bash-completion's +# __load_completion helper: when the user types `sudo kekkai `, +# bash-completion detects kekkai as the command being sudo'd and loads +# this file. + +_kekkai() { + local cur prev words cword + _init_completion || return + + local subcommands="status config check ports show backup reload bypass update reset doctor version help" + + # Top-level subcommand + if [[ $cword -eq 1 ]]; then + COMPREPLY=( $(compgen -W "$subcommands" -- "$cur") ) + return + fi + + local sub="${words[1]}" + case "$sub" in + bypass) + # `kekkai bypass on|off [--save] [config]` + if [[ $cword -eq 2 ]]; then + COMPREPLY=( $(compgen -W "on off" -- "$cur") ) + return + fi + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "--save" -- "$cur") ) + return + fi + _filedir + return + ;; + reset) + # `kekkai reset [config] [--iface NAME]` + case "$prev" in + --iface|-iface|-i) + # Offer active network interfaces if we can see them. + local ifaces="" + if [[ -d /sys/class/net ]]; then + ifaces="$(ls /sys/class/net 2>/dev/null | grep -v '^lo$')" + fi + COMPREPLY=( $(compgen -W "$ifaces" -- "$cur") ) + return + ;; + esac + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "--iface" -- "$cur") ) + return + fi + _filedir + return + ;; + update) + # `kekkai update [kekkai.sh flags]` + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "--force --no-install --iface --run" -- "$cur") ) + return + fi + ;; + status|config|check|ports|show|backup|reload) + # These all take an optional config path as the sole positional. + _filedir + return + ;; + doctor|version|help) + # No args. + return + ;; + esac +} + +complete -F _kekkai kekkai diff --git a/internal/config/config.go b/internal/config/config.go index c47a779..32d5375 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,7 +48,6 @@ type InterfaceConfig struct { type UpdateConfig struct { // Channel controls `kekkai update` source. - // - git:main : fast-forward + local build from repository // - release : latest stable GitHub release asset // - pre-release : latest pre-release GitHub release asset Channel string `yaml:"channel"` @@ -347,9 +346,9 @@ func (c *Config) Validate() error { return fmt.Errorf("interface.xdp_mode: invalid %q (want generic|driver|offload)", c.Interface.XDPMode) } switch c.Update.Channel { - case "git:main", "release", "pre-release": + case "release", "pre-release": default: - return fmt.Errorf("update.channel: invalid %q (want git:main|release|pre-release)", c.Update.Channel) + return fmt.Errorf("update.channel: invalid %q (want release|pre-release)", c.Update.Channel) } tcpSeen, udpSeen := map[uint16]string{}, map[uint16]string{} diff --git a/internal/config/default.yaml b/internal/config/default.yaml index 0fab8b1..492d333 100644 --- a/internal/config/default.yaml +++ b/internal/config/default.yaml @@ -41,12 +41,10 @@ interface: # ---------- update channel ---------- update: - # EN: Select where `kekkai update` gets binaries/code from: - # - git:main -> use local repo fast-forward + build (developer mode) - # - release -> download latest stable release from GitHub Releases - # - pre-release -> download latest pre-release from GitHub Releases - # ZH: 指定 `kekkai update` 更新來源: - # - git:main -> 走本地 repo fast-forward + build(開發模式) + # EN: Select where `kekkai update` downloads its release assets from: + # - release -> latest stable release from GitHub Releases + # - pre-release -> latest pre-release from GitHub Releases + # ZH: 指定 `kekkai update` 下載來源: # - release -> 從 GitHub Releases 抓最新穩定版 # - pre-release -> 從 GitHub Releases 抓最新預發布版 channel: __UPDATE_CHANNEL__ diff --git a/internal/doctor/checks.go b/internal/doctor/checks.go index ffca07c..edbe469 100644 --- a/internal/doctor/checks.go +++ b/internal/doctor/checks.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/cilium/ebpf" "github.com/ExpTechTW/kekkai/internal/config" ) @@ -451,26 +452,93 @@ func sysfsTxPath(iface, metric string) string { // ---------- permissions -------------------------------------------------- +// checkPermissions verifies the bits that still vary when doctor is already +// running as root: bpffs mount, agent-pinned maps, and whether the pinned +// stats map is actually openable via bpf_obj_get. Non-root / setcap / sysctl +// checks were dropped when the CLI became sudo-only — doctor is gated by +// requireRoot() in cmd/kekkai, so euid is always 0 here. func checkPermissions(r *Runner) { sec := r.Section("permissions") - sec.Add(Result{ - Status: statusFor(os.Geteuid() == 0), - Title: "effective uid", - Detail: fmt.Sprintf("%d (%s)", os.Geteuid(), iff(os.Geteuid() == 0, "root", "non-root")), - }) - - // /sys/fs/bpf accessibility. if _, err := os.Stat("/sys/fs/bpf"); err == nil { - sec.Add(Result{Status: StatusOK, Title: "/sys/fs/bpf", Detail: "accessible"}) + sec.Add(Result{Status: StatusOK, Title: "/sys/fs/bpf", Detail: "mounted"}) } else { - sec.Add(Result{Status: StatusWarn, Title: "/sys/fs/bpf", Detail: err.Error()}) + sec.Add(Result{ + Status: StatusError, + Title: "/sys/fs/bpf", + Detail: err.Error(), + Suggestions: []string{"sudo mount -t bpf bpf /sys/fs/bpf"}, + }) } - // Try reading a pinned map path to verify bpffs read access. if _, err := os.Stat(bpffsPinRoot); err == nil { - sec.Add(Result{Status: StatusOK, Title: "pin root readable", Detail: bpffsPinRoot}) + sec.Add(Result{ + Status: StatusOK, + Title: "pin root", + Detail: bpffsPinRoot + " present", + }) + } else { + sec.Add(Result{ + Status: StatusError, + Title: "pin root", + Detail: err.Error(), + Suggestions: []string{"is the agent running? sudo systemctl start kekkai-agent"}, + }) + return + } + + // Informational: passwordless sudo drop-in. Doesn't affect the running + // doctor process (it already has root) — we surface it so the operator + // knows whether their *next* `sudo kekkai ...` will prompt for a password. + if entries, err := filepath.Glob("/etc/sudoers.d/kekkai-cli-*"); err == nil && len(entries) > 0 { + sec.Add(Result{ + Status: StatusOK, + Title: "sudo NOPASSWD", + Detail: filepath.Base(entries[0]), + }) + } else { + sec.Add(Result{ + Status: StatusWarn, + Title: "sudo NOPASSWD", + Detail: "no kekkai sudoers drop-in — sudo will prompt for password", + Suggestions: []string{ + "bash ./kekkai.sh repair", + }, + }) + } + + // Load-bearing check: actually open the pinned stats map via bpf_obj_get. + // If this fails with EACCES even as root, the kernel's BPF LSM or + // unprivileged_bpf_disabled is in an unusual state worth investigating. + statsPin := filepath.Join(bpffsPinRoot, "stats") + if _, err := os.Stat(statsPin); err != nil { + sec.Add(Result{ + Status: StatusError, + Title: "pinned stats map", + Detail: "missing at " + statsPin, + Suggestions: []string{"sudo systemctl restart kekkai-agent"}, + }) + return + } + m, err := ebpf.LoadPinnedMap(statsPin, nil) + if err != nil { + sec.Add(Result{ + Status: StatusError, + Title: "pinned stats map open", + Detail: err.Error(), + Suggestions: []string{ + "sudo systemctl status kekkai-agent", + "sudo journalctl -u kekkai-agent -n 50", + }, + }) + return } + _ = m.Close() + sec.Add(Result{ + Status: StatusOK, + Title: "pinned stats map", + Detail: "openable", + }) } func statusFor(ok bool) Status { @@ -480,13 +548,6 @@ func statusFor(ok bool) Status { return StatusWarn } -func iff(cond bool, a, b string) string { - if cond { - return a - } - return b -} - // ---------- runtime ------------------------------------------------------ func checkRuntime(r *Runner, cfg *config.Config) { diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 7a2d673..c261d30 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -12,7 +12,10 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" + + "github.com/ExpTechTW/kekkai/internal/buildinfo" ) // Status is the traffic-light value for a single check. @@ -96,6 +99,8 @@ func (r *Runner) Summary() Summary { func (r *Runner) Render(w io.Writer, colour bool) { p := newPalette(colour) fmt.Fprintln(w, p.title(" ◈ KEKKAI doctor 結界 · diagnostic report")) + fmt.Fprintln(w, p.dim(fmt.Sprintf(" kekkai %s · %s/%s", + buildinfo.DefaultVersion, runtime.GOOS, runtime.GOARCH))) fmt.Fprintln(w, p.dim(" ---------------------------------------------")) fmt.Fprintln(w) diff --git a/internal/tui/model.go b/internal/tui/model.go index 63d90d3..65120ba 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -257,8 +257,6 @@ func checkUpdateStatus(channel, current string) (state, latest, hint string) { channel = "release" } switch channel { - case "git:main": - return "n/a", "", "update check via status is release-only (channel=git:main)" case "release", "pre-release": default: return "error", "", "unsupported update channel: " + channel diff --git a/internal/tui/source.go b/internal/tui/source.go index 236484b..163b4de 100644 --- a/internal/tui/source.go +++ b/internal/tui/source.go @@ -6,9 +6,12 @@ package tui import ( + "errors" "fmt" "net/netip" + "os" "sort" + "syscall" "time" "github.com/cilium/ebpf" @@ -71,14 +74,12 @@ type Snapshot struct { func NewSource(iface string) (*Source, error) { st, err := ebpf.LoadPinnedMap(bpffsRoot+"/stats", nil) if err != nil { - return nil, fmt.Errorf( - "open pinned stats map: %w\n(is kekkai-agent running? maps are pinned under %s)", - err, bpffsRoot) + return nil, wrapOpenErr("stats", err) } perip, err := ebpf.LoadPinnedMap(bpffsRoot+"/perip_v4", nil) if err != nil { st.Close() - return nil, fmt.Errorf("open pinned perip_v4 map: %w", err) + return nil, wrapOpenErr("perip_v4", err) } cap := int(perip.MaxEntries()) @@ -97,6 +98,26 @@ func NewSource(iface string) (*Source, error) { }, nil } +// wrapOpenErr annotates pinned-map open failures. EACCES almost always means +// the user is non-root on a distro that sets kernel.unprivileged_bpf_disabled +// (Debian / Ubuntu / Pi OS default), which blocks bpf(BPF_OBJ_GET) regardless +// of file caps. Tell the operator to rerun with sudo. +func wrapOpenErr(which string, err error) error { + if errors.Is(err, syscall.EACCES) || errors.Is(err, os.ErrPermission) { + if os.Geteuid() != 0 { + return fmt.Errorf( + "open pinned %s map: %w\nkekkai CLI must run as root — retry with: sudo kekkai status", + which, err) + } + return fmt.Errorf( + "open pinned %s map: %w\n(kernel.unprivileged_bpf_disabled is set; even root may need sysctl=0 if this persists)", + which, err) + } + return fmt.Errorf( + "open pinned %s map: %w\n(is kekkai-agent running? maps are pinned under %s)", + which, err, bpffsRoot) +} + func (s *Source) Close() { if s.statsMap != nil { s.statsMap.Close() diff --git a/kekkai.sh b/kekkai.sh index 68329de..e028322 100755 --- a/kekkai.sh +++ b/kekkai.sh @@ -4,29 +4,38 @@ # Usage: # bash kekkai.sh # auto-detect state and do the right thing # bash kekkai.sh install # force first-time install -# bash kekkai.sh update # force update (source depends on update.channel) +# bash kekkai.sh update # force update (pulls prebuilt release assets) # bash kekkai.sh repair # force re-install of binaries + systemd unit # bash kekkai.sh doctor # read-only health check (delegates to `kekkai doctor`) # bash kekkai.sh uninstall # remove everything except config # # Flags (apply to any subcommand): -# --force bypass safety checks (dirty tree, branch mismatch, downgrade) # --no-install skip apt dependency install # --iface NAME force a specific interface in the default config # --run launch the agent in foreground at the end (debugging) -# --sudo-shortcut force enable passwordless `kekkai` sudo + shell alias -# --no-sudo-shortcut disable passwordless sudo shortcut setup +# +# Update model: kekkai is distributed as prebuilt GitHub release binaries. +# `update.channel` may be `release` (default) or `pre-release`. There is no +# source-build mode — operators never need Go, git, clang, or this repo on +# the target host. +# +# Runtime note: kekkai CLI always runs under sudo (e.g. `sudo kekkai status`) +# because on Debian/Ubuntu/Pi OS the kernel sysctl +# `kernel.unprivileged_bpf_disabled` blocks non-root bpf() regardless of caps. +# The installer writes a sudoers drop-in (/etc/sudoers.d/kekkai-cli-) +# so `sudo kekkai ...` won't prompt for a password. No shell alias is added — +# users should type literal `sudo kekkai` to build portable muscle memory. # # Auto-detect logic (no subcommand): # - no binaries yet → install # - binaries present but no systemd unit OR unit disabled → repair -# - everything installed + update source has new version → update -# - everything installed + no new commits → doctor +# - otherwise → doctor (use `update` subcommand explicitly +# to check for a new release) # set -euo pipefail # ROOT resolution: -# - repo mode: directory containing kekkai.sh (normal git clone usage) +# - normal: directory containing kekkai.sh on disk (/usr/local/bin or dev dir) # - raw mode (`bash <(curl ...)`): fallback to ~/kekkai (or $KEKKAI_REPO) # because $0 becomes /dev/fd/* and is not a writable project directory. resolve_root() { @@ -75,6 +84,9 @@ persist_self_script AGENT_BIN=/usr/local/bin/kekkai-agent CLI_BIN=/usr/local/bin/kekkai ROLLBACK_BIN=/usr/local/bin/kekkai-agent.prev +SCRIPT_INSTALL_PATH=/usr/local/bin/kekkai.sh +BASH_COMPLETION_DST=/usr/share/bash-completion/completions/kekkai +ZSH_COMPLETION_DST=/usr/share/zsh/vendor-completions/_kekkai CONFIG_DIR=/etc/kekkai CONFIG_FILE="$CONFIG_DIR/kekkai.yaml" STATS_DIR=/var/run/kekkai @@ -84,33 +96,29 @@ UNIT_SRC="$ROOT/deploy/systemd/kekkai-agent.service" UNIT_DST="/etc/systemd/system/$UNIT_NAME" SUDOERS_DIR=/etc/sudoers.d SUDOERS_FILE_PREFIX=kekkai-cli- -GO_MIN="1.22" -GO_DOWNLOAD_VERSION="1.23.4" +LOCAL_AGENT_BIN="$ROOT/bin/kekkai-agent" +LOCAL_CLI_BIN="$ROOT/bin/kekkai" BRANCH=main REPO_OWNER=ExpTechTW REPO_NAME=kekkai RELEASES_API_BASE="https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases" +RAW_BASE="https://raw.githubusercontent.com/$REPO_OWNER/$REPO_NAME" # --------------------------------------------------------------------------- # CLI parsing # --------------------------------------------------------------------------- CMD="" -FORCE=0 DO_INSTALL_DEPS=1 IFACE_OVERRIDE="" DO_RUN=0 -SETUP_SUDO_SHORTCUT=1 while [[ $# -gt 0 ]]; do case "$1" in install|update|repair|doctor|uninstall) CMD="$1"; shift ;; - --force) FORCE=1; shift ;; --no-install) DO_INSTALL_DEPS=0; shift ;; --iface) IFACE_OVERRIDE="$2"; shift 2 ;; --run) DO_RUN=1; shift ;; - --sudo-shortcut) SETUP_SUDO_SHORTCUT=1; shift ;; - --no-sudo-shortcut) SETUP_SUDO_SHORTCUT=0; shift ;; -h|--help) sed -n '2,23p' "$0"; exit 0 ;; *) die "unknown argument: $1" ;; @@ -128,9 +136,10 @@ if [[ -t 1 ]] && [[ "${NO_COLOR:-}" == "" ]]; then C_WARN=$'\033[1;33m' # yellow C_ERR=$'\033[1;31m' # red C_INFO=$'\033[1;36m' # cyan + C_BLUE=$'\033[1;34m' # blue — "something changed" accent for update results C_TITLE=$'\033[1;35m' # violet — kekkai barrier theme else - C_RESET=""; C_DIM=""; C_BOLD=""; C_OK=""; C_WARN=""; C_ERR=""; C_INFO=""; C_TITLE="" + C_RESET=""; C_DIM=""; C_BOLD=""; C_OK=""; C_WARN=""; C_ERR=""; C_INFO=""; C_BLUE=""; C_TITLE="" fi step() { printf '\n%s◈ %s%s\n' "$C_TITLE" "$*" "$C_RESET"; } @@ -184,25 +193,15 @@ OS="$(detect_os)" ARCH="$(detect_arch)" # --------------------------------------------------------------------------- -# Repo / source detection -# --------------------------------------------------------------------------- -# If we're inside the kekkai git repo the script can build from source. -# Otherwise it would need a prebuilt binary — not available yet, so error. -SOURCE_MODE="repo" -if [[ ! -d "$ROOT/.git" ]] || [[ ! -f "$ROOT/go.mod" ]]; then - SOURCE_MODE="release" -fi - # --------------------------------------------------------------------------- # State detection — which subcommand should auto mode run? # --------------------------------------------------------------------------- detect_state() { - # Returns one of: install / repair / update / healthy. + # Returns one of: install / repair / healthy. # - # "update" is triggered by either: - # a) the remote has commits we don't - # b) the local HEAD is newer than the installed agent binary's - # mtime — meaning the user already pulled but never rebuilt. + # Update detection from the installed state is no longer automatic — + # operators run `kekkai update` explicitly when they want to check GitHub + # releases. The auto-mode path just ensures the local install is sane. if [[ ! -x "$AGENT_BIN" ]] || [[ ! -x "$CLI_BIN" ]]; then echo install return @@ -222,38 +221,6 @@ detect_state() { return fi - if [[ "$SOURCE_MODE" == "repo" ]] && command -v git >/dev/null 2>&1; then - git fetch origin "$BRANCH" >/dev/null 2>&1 || true - - local head remote - head="$(git rev-parse HEAD 2>/dev/null || echo "")" - remote="$(git rev-parse "origin/$BRANCH" 2>/dev/null || echo "")" - - # (a) Remote has new commits → clearly need to update. - if [[ -n "$head" ]] && [[ -n "$remote" ]] && [[ "$head" != "$remote" ]]; then - echo update - return - fi - - # (b) Local HEAD is newer than the installed daemon binary. This - # catches "user ran git pull but never rebuilt" — the repo is - # caught up to origin but the binary on disk is stale. - if [[ -n "$head" ]]; then - local head_ts bin_ts - head_ts="$(git show -s --format=%ct "$head" 2>/dev/null || echo 0)" - if command -v stat >/dev/null 2>&1; then - # Prefer GNU stat, fall back to BSD stat. - bin_ts="$(stat -c %Y "$AGENT_BIN" 2>/dev/null || stat -f %m "$AGENT_BIN" 2>/dev/null || echo 0)" - else - bin_ts=0 - fi - if [[ "$head_ts" != "0" ]] && [[ "$bin_ts" != "0" ]] && (( head_ts > bin_ts )); then - echo update - return - fi - fi - fi - echo healthy } @@ -273,42 +240,6 @@ install_deps() { make gcc pkg-config ca-certificates curl } -install_go() { - local tarball="go${GO_DOWNLOAD_VERSION}.${OS}-${ARCH}.tar.gz" - log "installing Go ${GO_DOWNLOAD_VERSION} to /usr/local/go" - curl -fsSL "https://go.dev/dl/${tarball}" -o "/tmp/${tarball}" - $SUDO rm -rf /usr/local/go - $SUDO tar -C /usr/local -xzf "/tmp/${tarball}" - rm -f "/tmp/${tarball}" - export PATH="/usr/local/go/bin:$PATH" - if ! grep -q '/usr/local/go/bin' "$HOME/.profile" 2>/dev/null; then - echo 'export PATH=/usr/local/go/bin:$PATH' >> "$HOME/.profile" - fi -} - -ensure_go() { - [[ -x /usr/local/go/bin/go ]] && export PATH="/usr/local/go/bin:$PATH" - - if ! command -v go >/dev/null 2>&1; then - warn "go not found" - [[ $DO_INSTALL_DEPS -eq 1 ]] || die "install go manually (>= $GO_MIN)" - install_go - return - fi - local ver major minor - ver="$(go env GOVERSION 2>/dev/null | sed 's/go//')" - major="${ver%%.*}"; minor="$(echo "$ver" | awk -F. '{print $2}')" - local need_major need_minor - need_major="${GO_MIN%%.*}"; need_minor="${GO_MIN##*.}" - if (( major < need_major )) || { (( major == need_major )) && (( minor < need_minor )); }; then - warn "go $ver too old (need >= $GO_MIN)" - [[ $DO_INSTALL_DEPS -eq 1 ]] || die "upgrade go manually" - install_go - else - log "go $ver ok" - fi -} - check_kernel() { log "kernel: $(uname -r)" [[ "$OS" == "linux" ]] || die "kekkai requires Linux" @@ -377,51 +308,19 @@ resolve_update_channel() { fi [[ -n "$ch" ]] || ch="release" case "$ch" in - git:main|release|pre-release) ;; + release|pre-release) ;; *) - warn "unknown update.channel '$ch' — fallback to git:main" - ch="git:main" + warn "unknown update.channel '$ch' — fallback to release" + ch="release" ;; esac echo "$ch" } prepare_binaries() { - if [[ "$SOURCE_MODE" == "repo" ]]; then - ensure_go - build_from_source - return 0 - fi - local channel channel="$(resolve_update_channel)" - case "$channel" in - release|pre-release) - fetch_release_binaries_to_root_bin "$channel" - ;; - git:main) - die "update.channel=git:main requires repo mode. For raw install use update.channel=release or pre-release." - ;; - *) - die "unsupported update.channel: $channel" - ;; - esac -} - -# --------------------------------------------------------------------------- -# Build from source (repo mode) -# --------------------------------------------------------------------------- -build_from_source() { - [[ "$SOURCE_MODE" == "repo" ]] || \ - die "release binaries not available yet — clone the repo and run from there" - - log "compiling eBPF object" - make bpf - - log "compiling Go binaries (kekkai-agent + kekkai)" - make build - [[ -x "$ROOT/bin/kekkai-agent" ]] || die "build failed: bin/kekkai-agent missing" - [[ -x "$ROOT/bin/kekkai" ]] || die "build failed: bin/kekkai missing" + fetch_release_binaries_to_root_bin "$channel" } install_binaries_from() { @@ -441,10 +340,111 @@ install_binaries_from() { $SUDO install -D -m 0755 "$src_cli" "$CLI_BIN" log "installed: $CLI_BIN" + + install_completions +} + +# persist_script_for_updates puts a copy of kekkai.sh at /usr/local/bin/kekkai.sh +# so future `sudo kekkai update` calls can find it via resolveUpdateScript(). +# +# Three sources, in priority order: +# 1. $ROOT/kekkai.sh (repo clone or persist_self_script() already worked) +# 2. $0 itself, if it's a readable regular file (not /dev/fd/*) +# 3. curl from the main branch on GitHub (last resort for one-shot +# `curl | bash` installs where $0 is /dev/fd/* and $ROOT is empty) +persist_script_for_updates() { + [[ "$OS" == "linux" ]] || return 0 + + local src="" + if [[ -f "$ROOT/kekkai.sh" ]] && [[ -s "$ROOT/kekkai.sh" ]]; then + src="$ROOT/kekkai.sh" + elif [[ -r "$0" ]] && [[ -f "$0" ]]; then + src="$0" + fi + + # Skip self-copy: if $0 is already the installed script (common during + # `sudo kekkai update`, where kekkai delegates to /usr/local/bin/kekkai.sh), + # `install` would error with "same file". Treat that case as already persisted. + if [[ -n "$src" ]]; then + local src_real dst_real + src_real="$(readlink -f "$src" 2>/dev/null || echo "$src")" + dst_real="$(readlink -f "$SCRIPT_INSTALL_PATH" 2>/dev/null || echo "$SCRIPT_INSTALL_PATH")" + if [[ "$src_real" == "$dst_real" ]]; then + return 0 + fi + $SUDO install -D -m 0755 "$src" "$SCRIPT_INSTALL_PATH" + log "installed: $SCRIPT_INSTALL_PATH (for future 'sudo kekkai update')" + return 0 + fi + + # Fallback: fetch from GitHub. Acceptable here because we're already + # mid-install from curl|bash — the user has implicitly trusted main. + if command -v curl >/dev/null 2>&1; then + local tmp + tmp="$(mktemp)" + if curl -fsSL "$RAW_BASE/$BRANCH/kekkai.sh" -o "$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then + $SUDO install -D -m 0755 "$tmp" "$SCRIPT_INSTALL_PATH" + rm -f "$tmp" + log "installed: $SCRIPT_INSTALL_PATH (fetched from $BRANCH — for future 'sudo kekkai update')" + return 0 + fi + rm -f "$tmp" + fi + + warn "could not persist kekkai.sh to $SCRIPT_INSTALL_PATH — 'sudo kekkai update' will need KEKKAI_SCRIPT set" +} + +# fetch_or_copy_asset: stage a repo-tracked file at $1 (relative to $ROOT) +# into a temp path. Prefers the on-disk copy; falls back to curl from +# $RAW_BASE/$BRANCH/$1. Echoes the staged path on success, empty on failure. +fetch_or_copy_asset() { + local rel="$1" + local local_src="$ROOT/$rel" + if [[ -f "$local_src" ]] && [[ -s "$local_src" ]]; then + echo "$local_src" + return 0 + fi + command -v curl >/dev/null 2>&1 || return 1 + local tmp + tmp="$(mktemp)" + if curl -fsSL "$RAW_BASE/$BRANCH/$rel" -o "$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then + echo "$tmp" + return 0 + fi + rm -f "$tmp" + return 1 +} + +# install_completions drops the bash + zsh completion scripts into the +# distro's standard vendor paths. Silently skips targets whose parent +# dir doesn't exist (e.g. systems without zsh installed). Non-fatal. +install_completions() { + [[ "$OS" == "linux" ]] || return 0 + + local bash_src zsh_src + bash_src="$(fetch_or_copy_asset contrib/completions/kekkai.bash)" || bash_src="" + zsh_src="$(fetch_or_copy_asset contrib/completions/_kekkai)" || zsh_src="" + + if [[ -n "$bash_src" ]] && [[ -d "$(dirname "$BASH_COMPLETION_DST")" ]]; then + $SUDO install -D -m 0644 "$bash_src" "$BASH_COMPLETION_DST" + log "installed: $BASH_COMPLETION_DST" + fi + if [[ -n "$zsh_src" ]] && [[ -d "$(dirname "$ZSH_COMPLETION_DST")" ]]; then + $SUDO install -D -m 0644 "$zsh_src" "$ZSH_COMPLETION_DST" + log "installed: $ZSH_COMPLETION_DST" + fi + + # Clean up any temp files fetch_or_copy_asset may have created. + [[ -n "$bash_src" && "$bash_src" != "$ROOT"* ]] && rm -f "$bash_src" + [[ -n "$zsh_src" && "$zsh_src" != "$ROOT"* ]] && rm -f "$zsh_src" + + if [[ -z "$bash_src" ]] && [[ -z "$zsh_src" ]]; then + warn "shell completions not installed (no local or remote source)" + fi } install_binaries() { - install_binaries_from "$ROOT/bin/kekkai-agent" "$ROOT/bin/kekkai" + install_binaries_from "$LOCAL_AGENT_BIN" "$LOCAL_CLI_BIN" } read_cli_version() { @@ -456,61 +456,89 @@ read_cli_version() { echo "$v" } -print_version_transition() { - local old_v="$1" - local new_v="$2" - info "version: ${old_v} -> ${new_v}" + +# print_update_result renders the final coloured summary block that users +# should scan first after an update. Two states: +# +# state=updated → blue accent, headline "UPDATED" +# state=unchanged → green accent, headline "ALREADY UP-TO-DATE" +# +# Args: state old_ver new_ver tag channel changed +# `tag` / `channel` / `changed` may be empty. `changed` is a human-readable +# comma-separated list of components that were actually rewritten (e.g. +# "agent, cli, kekkai.sh") — only rendered for state=updated. +print_update_result() { + local state="$1" old_v="$2" new_v="$3" tag="$4" channel="$5" changed="$6" + local accent headline + case "$state" in + updated) + accent="$C_BLUE" + headline="UPDATED" + ;; + unchanged) + accent="$C_OK" + headline="ALREADY UP-TO-DATE" + ;; + *) + accent="$C_INFO" + headline="$state" + ;; + esac + + local bar="═══════════════════════════════════════════════" + echo + printf '%s%s%s\n' "$accent" "$bar" "$C_RESET" + printf '%s ◈ %s%s\n' "$accent" "$headline" "$C_RESET" + printf '%s%s%s\n' "$accent" "$bar" "$C_RESET" + if [[ "$state" == "unchanged" ]]; then + printf ' %sversion%s %s%s%s (no change)\n' \ + "$C_DIM" "$C_RESET" "$C_BOLD" "$new_v" "$C_RESET" + else + printf ' %sversion%s %s%s%s → %s%s%s\n' \ + "$C_DIM" "$C_RESET" \ + "$C_DIM" "$old_v" "$C_RESET" \ + "$C_BOLD" "$new_v" "$C_RESET" + fi + if [[ -n "$changed" ]]; then + printf ' %schanged%s %s\n' "$C_DIM" "$C_RESET" "$changed" + fi + if [[ -n "$tag" ]]; then + printf ' %stag%s %s\n' "$C_DIM" "$C_RESET" "$tag" + fi + if [[ -n "$channel" ]]; then + printf ' %schannel%s %s\n' "$C_DIM" "$C_RESET" "$channel" + fi + printf '%s%s%s\n' "$accent" "$bar" "$C_RESET" + echo } -setup_sudo_shortcut() { - [[ $SETUP_SUDO_SHORTCUT -eq 1 ]] || return 0 - [[ "$OS" == "linux" ]] || { warn "--sudo-shortcut is Linux-only"; return 0; } +setup_passwordless_sudo() { + # Install a sudoers drop-in so `sudo kekkai ...` never prompts for a + # password. Intentionally NO shell alias — we want the literal `sudo` + # keystrokes so users build the right muscle memory across hosts where + # the alias may not exist. + [[ "$OS" == "linux" ]] || return 0 local target_user target_user="${SUDO_USER:-$USER}" if [[ -z "$target_user" ]] || [[ "$target_user" == "root" ]]; then - warn "skip sudo shortcut setup for root user" - return 0 - fi - - local target_home - target_home="$(eval echo "~$target_user")" - if [[ -z "$target_home" ]] || [[ ! -d "$target_home" ]]; then - warn "cannot resolve home for user '$target_user'; skip sudo shortcut setup" + info "skip sudoers drop-in for root user" return 0 fi local sudoers_file="$SUDOERS_DIR/${SUDOERS_FILE_PREFIX}${target_user}" - local sudoers_line="$target_user ALL=(root) NOPASSWD: $CLI_BIN *" + local sudoers_line="$target_user ALL=(root) NOPASSWD: $CLI_BIN, $CLI_BIN *" log "configuring passwordless sudo for $CLI_BIN (user=$target_user)" $SUDO install -d -m 0755 "$SUDOERS_DIR" printf '%s\n' "$sudoers_line" | $SUDO tee "$sudoers_file" >/dev/null $SUDO chmod 0440 "$sudoers_file" if ! $SUDO visudo -cf "$sudoers_file" >/dev/null; then $SUDO rm -f "$sudoers_file" - die "invalid sudoers syntax generated; aborted --sudo-shortcut" - fi - - local shell_name rc_file alias_line - shell_name="$(basename "${SHELL:-}")" - case "$shell_name" in - zsh) rc_file="$target_home/.zshrc" ;; - bash) rc_file="$target_home/.bashrc" ;; - *) rc_file="$target_home/.profile" ;; - esac - alias_line="alias kekkai='sudo $CLI_BIN'" - if [[ ! -f "$rc_file" ]]; then - $SUDO touch "$rc_file" - $SUDO chown "$target_user":"$target_user" "$rc_file" 2>/dev/null || true - fi - if ! $SUDO grep -Fq "$alias_line" "$rc_file" 2>/dev/null; then - printf '\n%s\n' "$alias_line" | $SUDO tee -a "$rc_file" >/dev/null - $SUDO chown "$target_user":"$target_user" "$rc_file" 2>/dev/null || true - log "added alias to $rc_file" - else - info "alias already present in $rc_file" + warn "sudoers syntax check failed; removed $sudoers_file" + warn "you will be prompted for a password each time you run: sudo kekkai" + return 0 fi - info "open a new shell or run: source $rc_file" + info "sudo kekkai will no longer prompt for a password" } install_config() { @@ -526,6 +554,22 @@ install_config() { # We delegate the template to `kekkai-agent -reset` so shell and Go stay # in sync on one source of truth for the default config. $SUDO "$AGENT_BIN" -reset -config "$CONFIG_FILE" -iface "$iface" >/dev/null + + # Persist KEKKAI_UPDATE_CHANNEL into the fresh config so future + # `kekkai update` runs (without the env var) don't flip back to the + # default `release` channel. Only apply when the env var is explicitly + # set AND it's a supported value — otherwise leave whatever the + # template generated. + local desired_channel="${KEKKAI_UPDATE_CHANNEL:-}" + case "$desired_channel" in + release|pre-release) + if grep -qE '^[[:space:]]+channel:' "$CONFIG_FILE" 2>/dev/null; then + $SUDO sed -i -E "s|^([[:space:]]+channel:[[:space:]]*).*$|\1$desired_channel|" "$CONFIG_FILE" + log "update.channel set to $desired_channel (from KEKKAI_UPDATE_CHANNEL)" + fi + ;; + esac + if ! iface_has_default_allowlist_ip "$iface"; then warn "detected iface '$iface' is not in default ingress_allowlist 192.168.0.0/16" warn "service may reject startup until you set filter.ingress_allowlist to your management subnet" @@ -612,69 +656,6 @@ enable_and_start() { fi } -# --------------------------------------------------------------------------- -# Git update (repo mode only) -# --------------------------------------------------------------------------- -git_update() { - [[ "$SOURCE_MODE" == "repo" ]] || die "not in a git repo" - command -v git >/dev/null 2>&1 || die "git not found" - - # The embedded .o file is tracked but overwritten on every build, so a - # prior run leaves it dirty. Restore before sanity check. - if git ls-files --error-unmatch internal/loader/bpf/xdp_filter.o >/dev/null 2>&1; then - git checkout -- internal/loader/bpf/xdp_filter.o 2>/dev/null || true - fi - - if [[ $FORCE -eq 0 ]] && ! git diff --quiet; then - echo - git status --short - echo - die "working tree has uncommitted changes. commit, stash, or pass --force" - fi - - local current_branch - current_branch="$(git symbolic-ref --short HEAD 2>/dev/null || echo DETACHED)" - if [[ "$current_branch" != "$BRANCH" ]] && [[ $FORCE -eq 0 ]]; then - die "on branch '$current_branch', expected '$BRANCH'. switch or pass --force" - fi - - local before remote - before="$(git rev-parse HEAD)" - log "current HEAD: ${before:0:12}" - log "fetching origin/$BRANCH" - # Avoid interactive hangs on first SSH contact with github.com. - # Users can disable this behavior by setting: - # KEKKAI_GIT_ACCEPT_NEW_HOSTKEY=0 - local accept_new_hostkey="${KEKKAI_GIT_ACCEPT_NEW_HOSTKEY:-1}" - if [[ "$accept_new_hostkey" == "1" ]]; then - GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o StrictHostKeyChecking=accept-new}" \ - git fetch origin "$BRANCH" - else - git fetch origin "$BRANCH" - fi - remote="$(git rev-parse "origin/$BRANCH")" - - if [[ "$before" == "$remote" ]]; then - log "already up to date" - return 0 - fi - - log "incoming: ${remote:0:12}" - git --no-pager log --oneline "$before..$remote" | sed 's/^/ /' || true - - # Refuse time-travel (downgrade). - local before_ts remote_ts - before_ts="$(git show -s --format=%ct "$before")" - remote_ts="$(git show -s --format=%ct "$remote")" - if (( remote_ts < before_ts )) && [[ $FORCE -eq 0 ]]; then - die "remote commit is older than local — refusing to downgrade (pass --force)" - fi - - log "fast-forwarding" - git merge --ff-only "origin/$BRANCH" || die "fast-forward failed (diverged? use --force + git reset)" - return 0 -} - fetch_release_metadata() { local channel="$1" local endpoint @@ -698,20 +679,38 @@ select_release_assets() { local arch="$3" python3 -c ' import json +import re import sys channel, os_name, arch = sys.argv[1:4] data = json.load(sys.stdin) +# Tag format is fixed by draft-release.yml: vYYYY.MM.DD+build.N +# Anything that does not match sorts as (0,0,0,0) so it loses to any +# well-formed tag — we never want to accidentally pick a hand-pushed +# tag over the CI-generated ones. +TAG_RE = re.compile(r"^v(\d{4})\.(\d{2})\.(\d{2})\+build\.(\d+)$") + +def parse_version(tag): + m = TAG_RE.match(tag or "") + if not m: + return (0, 0, 0, 0) + return tuple(int(m.group(i)) for i in range(1, 5)) + def pick_release(obj): + # release channel: /releases/latest already returns the single + # newest non-prerelease release, nothing to sort. if channel == "release": return obj - for rel in obj: - if rel.get("draft"): - continue - if rel.get("prerelease"): - return rel - return None + + # pre-release channel: the list endpoint is sorted by created_at + # (newest first), which is NOT the same as version order — a + # re-published older build would hop to the top and shadow the + # real latest. Sort by parsed tag version and take the max. + candidates = [r for r in obj if not r.get("draft") and r.get("prerelease")] + if not candidates: + return None + return max(candidates, key=lambda r: parse_version(r.get("tag_name", ""))) release = pick_release(data) if not release: @@ -771,7 +770,8 @@ download_release_binary() { local archive="$tmpdir/$(basename "${url%%\?*}")" [[ -n "$archive" ]] || die "invalid asset url: $url" - curl -fsSL "$url" -o "$archive" + curl -fL --retry 4 --retry-delay 1 --retry-all-errors --connect-timeout 10 \ + "$url" -o "$archive" || return 1 local out="$tmpdir/$want_name" case "$archive" in @@ -800,116 +800,275 @@ download_release_binary() { esac chmod +x "$out" + # Quick sanity check: broken/partial downloads often crash immediately. + # We treat signal exits as corrupted binary and abort update early. + "$out" -h >/dev/null 2>&1 || { + local rc=$? + if (( rc >= 128 )); then + err "downloaded $want_name looks corrupted (exit=$rc)" + return 1 + fi + } echo "$out" } -release_update() { - local channel="$1" - local old_ver - old_ver="$(read_cli_version "$CLI_BIN")" - command -v curl >/dev/null 2>&1 || die "curl not found" +require_release_tools() { + command -v curl >/dev/null 2>&1 || die "curl not found" command -v python3 >/dev/null 2>&1 || die "python3 not found (required for release metadata parsing)" +} + +# fetch_release_artifacts: shared release fetch/parse/download. +# Inputs: channel, tmpdir +# Outputs (via globals): REL_TAG, REL_NEW_AGENT, REL_NEW_CLI +# +# Using globals keeps the caller simple (vs. parsing stdout). A trailing +# `unset` in each caller is unnecessary — install is a one-shot script. +fetch_release_artifacts() { + local channel="$1" + local tmpdir="$2" + + require_release_tools - local meta + local meta parsed meta="$(fetch_release_metadata "$channel")" || die "failed to fetch GitHub release metadata" + parsed="$(printf '%s' "$meta" | select_release_assets "$channel" "$OS" "$ARCH")" \ + || die "failed to resolve release assets for $OS/$ARCH" - local parsed - parsed="$(printf '%s' "$meta" | select_release_assets "$channel" "$OS" "$ARCH")" || die "failed to resolve release assets for $OS/$ARCH" - local tag agent_url cli_url - tag="$(printf '%s\n' "$parsed" | sed -n '1p')" + REL_TAG="$(printf '%s\n' "$parsed" | sed -n '1p')" + local agent_url cli_url agent_url="$(printf '%s\n' "$parsed" | sed -n '2p')" - cli_url="$(printf '%s\n' "$parsed" | sed -n '3p')" + cli_url="$(printf '%s\n' "$parsed" | sed -n '3p')" [[ -n "$agent_url" && -n "$cli_url" ]] || die "release metadata incomplete" - log "selected release: $tag ($channel)" + log "selected release: $REL_TAG ($channel)" info "agent asset: $(basename "${agent_url%%\?*}")" info "cli asset: $(basename "${cli_url%%\?*}")" - local tmpdir - tmpdir="$(mktemp -d)" + REL_NEW_AGENT="$(download_release_binary "$agent_url" "kekkai-agent" "$tmpdir")" \ + || die "failed to download/verify kekkai-agent binary" + REL_NEW_CLI="$(download_release_binary "$cli_url" "kekkai" "$tmpdir")" \ + || die "failed to download/verify kekkai binary" +} - local new_agent new_cli - new_agent="$(download_release_binary "$agent_url" "kekkai-agent" "$tmpdir")" - new_cli="$(download_release_binary "$cli_url" "kekkai" "$tmpdir")" - local new_ver - new_ver="$(read_cli_version "$new_cli")" +# files_match: 0 (true) if both files exist and have identical sha256. +files_match() { + local a="$1" b="$2" + [[ -f "$a" && -f "$b" ]] || return 1 + local a_sha b_sha + a_sha="$(sha256sum "$a" | awk '{print $1}')" + b_sha="$(sha256sum "$b" | awk '{print $1}')" + [[ "$a_sha" == "$b_sha" ]] +} - validate_config_against_new_binary "$new_agent" +# agent_unchanged / cli_unchanged: 0 (true) if the candidate binary matches +# the currently installed one byte-for-byte. Used to decide whether a +# release actually needs a service restart (or any write at all). +agent_unchanged() { files_match "$AGENT_BIN" "$1"; } +cli_unchanged() { files_match "$CLI_BIN" "$1"; } - local old_sha new_sha - old_sha=""; [[ -f "$AGENT_BIN" ]] && old_sha="$(sha256sum "$AGENT_BIN" | awk '{print $1}')" - new_sha="$(sha256sum "$new_agent" | awk '{print $1}')" - if [[ "$old_sha" == "$new_sha" ]]; then - log "up-to-date (binary unchanged — nothing to restart)" - print_version_transition "$old_ver" "$new_ver" - rm -rf "$tmpdir" - return 0 +# version_is_newer: 0 (true) if $1 (candidate) is strictly newer than $2 +# (installed), using natural version sort. Used to refuse downgrades — +# if somebody runs `kekkai update` while on pre-release build.9, the +# release channel's build.4 must NOT replace it. +# +# Special values: +# - "unknown" / "(none)" for the installed version → always accept +# (treat as "we don't know what's on disk, trust the candidate") +# - "unknown" / "(none)" for the candidate → always refuse +# (can't prove it's newer) +version_is_newer() { + local candidate="$1" installed="$2" + [[ "$candidate" == "unknown" || "$candidate" == "(none)" || -z "$candidate" ]] && return 1 + [[ "$installed" == "unknown" || "$installed" == "(none)" || -z "$installed" ]] && return 0 + [[ "$candidate" == "$installed" ]] && return 1 + local top + top="$(printf '%s\n%s\n' "$candidate" "$installed" | sort -V | tail -n1)" + [[ "$top" == "$candidate" ]] +} + +# sync_script_from_remote: fetch the latest kekkai.sh from $RAW_BASE and +# compare with the currently installed /usr/local/bin/kekkai.sh. +# +# Exit codes: +# 0 → remote content differs, installed copy was overwritten +# 10 → remote matches installed copy (no-op) +# 11 → fetch failed (network / curl missing) — caller should warn but not fail +# +# Kept separate from binary updates because the script can ship fixes that +# have no corresponding binary release (this very patch is one such case). +sync_script_from_remote() { + [[ "$OS" == "linux" ]] || return 10 + command -v curl >/dev/null 2>&1 || return 11 + + local tmp + tmp="$(mktemp)" + if ! curl -fsSL "$RAW_BASE/$BRANCH/kekkai.sh" -o "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then + rm -f "$tmp" + return 11 fi - install_binaries_from "$new_agent" "$new_cli" - rm -rf "$tmpdir" - setup_sudo_shortcut - enable_and_start - log "update complete (channel=$channel, tag=$tag)" - print_version_transition "$old_ver" "$new_ver" + if files_match "$SCRIPT_INSTALL_PATH" "$tmp"; then + rm -f "$tmp" + return 10 + fi + + $SUDO install -D -m 0755 "$tmp" "$SCRIPT_INSTALL_PATH" + rm -f "$tmp" + return 0 } -fetch_release_binaries_to_root_bin() { +release_update() { local channel="$1" - command -v curl >/dev/null 2>&1 || die "curl not found" - command -v python3 >/dev/null 2>&1 || die "python3 not found (required for release metadata parsing)" + local old_ver + old_ver="$(read_cli_version "$CLI_BIN")" - local meta - meta="$(fetch_release_metadata "$channel")" || die "failed to fetch GitHub release metadata" + local tmpdir + tmpdir="$(mktemp -d)" + fetch_release_artifacts "$channel" "$tmpdir" + local new_ver + new_ver="$(read_cli_version "$REL_NEW_CLI")" + + validate_config_against_new_binary "$REL_NEW_AGENT" + + # Downgrade guard: `kekkai update` must NEVER replace a newer binary with + # an older one. Common footgun: user installs pre-release (build.9), then + # forgets to set update.channel=pre-release in config, so the next update + # resolves to channel=release (build.4) and rolls them back. We refuse + # by pretending the binaries didn't change — kekkai.sh sync still runs + # (script fixes can legitimately ship between releases). + local -a changed_parts=() + local need_restart=0 + local downgrade_refused=0 + + if [[ "$new_ver" != "$old_ver" ]] && ! version_is_newer "$new_ver" "$old_ver"; then + downgrade_refused=1 + warn "refusing to downgrade: $channel channel offers $new_ver but installed is $old_ver" + warn "to switch channels, set update.channel in $CONFIG_FILE or export KEKKAI_UPDATE_CHANNEL" + fi - local parsed - parsed="$(printf '%s' "$meta" | select_release_assets "$channel" "$OS" "$ARCH")" || die "failed to resolve release assets for $OS/$ARCH" - local tag agent_url cli_url - tag="$(printf '%s\n' "$parsed" | sed -n '1p')" - agent_url="$(printf '%s\n' "$parsed" | sed -n '2p')" - cli_url="$(printf '%s\n' "$parsed" | sed -n '3p')" - [[ -n "$agent_url" && -n "$cli_url" ]] || die "release metadata incomplete" + if (( downgrade_refused == 0 )); then + # Three-way diff: agent binary, CLI binary, and kekkai.sh itself. + # Any single one changing counts as "updated". kekkai.sh can ship + # fixes independent of a binary release, so we can't gate on agent alone. + if ! agent_unchanged "$REL_NEW_AGENT"; then + changed_parts+=("agent") + need_restart=1 + fi + if ! cli_unchanged "$REL_NEW_CLI"; then + changed_parts+=("cli") + fi - log "selected release: $tag ($channel)" - info "agent asset: $(basename "${agent_url%%\?*}")" - info "cli asset: $(basename "${cli_url%%\?*}")" + if (( need_restart )); then + install_binaries_from "$REL_NEW_AGENT" "$REL_NEW_CLI" + elif (( ${#changed_parts[@]} > 0 )); then + # CLI changed but agent didn't — still need to install the new CLI, + # just no service restart. + $SUDO install -D -m 0755 "$REL_NEW_CLI" "$CLI_BIN" + log "installed: $CLI_BIN" + install_completions + fi + fi + rm -rf "$tmpdir" + + # Script sync: independent of binary changes. Done after binaries + # because sync_script_from_remote may overwrite the script this very + # process is running — bash has already parsed our source, so this is + # safe as long as we don't source the file again afterward. + # + # NOTE: `set -e` would kill us on the non-zero API returns (10/11) if we + # called this as a bare statement. The `|| script_rc=$?` idiom isolates + # the call from errexit so we can branch on the exit code ourselves. + local script_rc=0 + sync_script_from_remote || script_rc=$? + case $script_rc in + 0) changed_parts+=("kekkai.sh") ;; + 10) : ;; # already up-to-date + 11) warn "could not fetch remote kekkai.sh (network?) — skipped script sync" ;; + esac + if (( need_restart )); then + setup_passwordless_sudo + enable_and_start + fi + + # When a downgrade was refused, kekkai.sh may still have changed (script + # sync is orthogonal). Report based on what actually landed — but never + # show the `updated` version arrow pointing backwards, because `old_ver` + # is still the real installed version. + if (( downgrade_refused )); then + local changed_str="" + if (( ${#changed_parts[@]} > 0 )); then + changed_str="${changed_parts[*]}" + changed_str="${changed_str// /, } (downgrade refused: $channel offered $new_ver)" + fi + print_update_result unchanged "$old_ver" "$old_ver" "$REL_TAG" "$channel" "$changed_str" + elif (( ${#changed_parts[@]} == 0 )); then + print_update_result unchanged "$old_ver" "$new_ver" "$REL_TAG" "$channel" "" + else + local changed_str="${changed_parts[*]}" + changed_str="${changed_str// /, }" + print_update_result updated "$old_ver" "$new_ver" "$REL_TAG" "$channel" "$changed_str" + fi +} + +fetch_release_binaries_to_root_bin() { + local channel="$1" local tmpdir tmpdir="$(mktemp -d)" - local new_agent new_cli - new_agent="$(download_release_binary "$agent_url" "kekkai-agent" "$tmpdir")" - new_cli="$(download_release_binary "$cli_url" "kekkai" "$tmpdir")" + fetch_release_artifacts "$channel" "$tmpdir" mkdir -p "$ROOT/bin" - cp "$new_agent" "$ROOT/bin/kekkai-agent" - cp "$new_cli" "$ROOT/bin/kekkai" - chmod +x "$ROOT/bin/kekkai-agent" "$ROOT/bin/kekkai" + install -m 0755 "$REL_NEW_AGENT" "$LOCAL_AGENT_BIN" + install -m 0755 "$REL_NEW_CLI" "$LOCAL_CLI_BIN" rm -rf "$tmpdir" } validate_config_against_new_binary() { - local candidate_bin="${1:-$ROOT/bin/kekkai-agent}" + local candidate_bin="${1:-$LOCAL_AGENT_BIN}" [[ -f "$CONFIG_FILE" ]] || return 0 log "validating $CONFIG_FILE against new binary" - if ! "$candidate_bin" -check "$CONFIG_FILE" >/tmp/kekkai-check.log 2>&1; then + + # mktemp gives us a per-run log file owned by whoever is executing + # this function — avoids the "owned by root from a previous run" + # permission denied footgun on a shared /tmp. + local log_file + log_file="$(mktemp -t kekkai-check.XXXXXX)" + local rc=0 + if "$candidate_bin" -check "$CONFIG_FILE" >"$log_file" 2>&1; then + rc=0 + else + rc=$? + fi + if (( rc != 0 )); then echo - err "new binary rejects current config:" - sed 's/^/ /' /tmp/kekkai-check.log >&2 + err "new binary validation failed:" + sed 's/^/ /' "$log_file" >&2 echo + if (( rc >= 128 )); then + err "the new binary crashed during check (exit=$rc), likely corrupted download or bad artifact." + err "the installed config was NOT applied; old binary/service stay untouched." + echo + info "retry update first:" + info " sudo kekkai update" + info "if it repeats, pin previous tag or check release artifact health." + exit 1 + fi err "the installed config is incompatible with the new binary." err "the old binary and service are still running untouched." echo info "to fix, one of:" info " 1. edit the config: sudo nano $CONFIG_FILE" info " 2. reset to a clean template (backs up the broken file first):" - info " sudo $ROOT/bin/kekkai-agent -reset -config $CONFIG_FILE" + info " sudo kekkai reset" info " then edit to add filter.ingress_allowlist, and re-run:" - info " bash ./kekkai.sh update" + info " sudo kekkai update" info " 3. restore from an earlier backup:" info " ls /etc/kekkai/kekkai.yaml.*" info " sudo cp /etc/kekkai/kekkai.yaml.. $CONFIG_FILE" + rm -f "$log_file" exit 1 fi + rm -f "$log_file" log "config ok" } @@ -923,7 +1082,8 @@ do_install() { check_kernel prepare_binaries install_binaries - setup_sudo_shortcut + persist_script_for_updates + setup_passwordless_sudo install_config install_systemd_unit enable_and_start @@ -934,42 +1094,10 @@ do_install() { do_update() { banner step "update · $OS/$ARCH" - local old_ver - old_ver="$(read_cli_version "$CLI_BIN")" local channel channel="$(resolve_update_channel)" log "update channel: $channel" - - case "$channel" in - git:main) - ensure_go - git_update - build_from_source - validate_config_against_new_binary "$ROOT/bin/kekkai-agent" - local new_ver - new_ver="$(read_cli_version "$ROOT/bin/kekkai")" - - local old_sha new_sha - old_sha=""; [[ -f "$AGENT_BIN" ]] && old_sha="$(sha256sum "$AGENT_BIN" | awk '{print $1}')" - new_sha="$(sha256sum "$ROOT/bin/kekkai-agent" | awk '{print $1}')" - if [[ "$old_sha" == "$new_sha" ]]; then - log "up-to-date (binary unchanged — nothing to restart)" - print_version_transition "$old_ver" "$new_ver" - return 0 - fi - install_binaries - setup_sudo_shortcut - enable_and_start - log "update complete (channel=git:main)" - print_version_transition "$old_ver" "$new_ver" - ;; - release|pre-release) - release_update "$channel" - ;; - *) - die "unsupported update channel: $channel" - ;; - esac + release_update "$channel" } do_repair() { @@ -977,7 +1105,8 @@ do_repair() { step "repair · $OS/$ARCH" prepare_binaries install_binaries - setup_sudo_shortcut + persist_script_for_updates + setup_passwordless_sudo [[ -f "$CONFIG_FILE" ]] || install_config install_systemd_unit enable_and_start @@ -993,13 +1122,12 @@ do_doctor() { info "OS: $OS" info "arch: $ARCH" info "kernel: $(uname -r)" - info "source: $SOURCE_MODE" [[ -x "$AGENT_BIN" ]] && log "$AGENT_BIN present" || warn "$AGENT_BIN missing" [[ -x "$CLI_BIN" ]] && log "$CLI_BIN present" || warn "$CLI_BIN missing" [[ -f "$UNIT_DST" ]] && log "$UNIT_DST present" || warn "$UNIT_DST missing" [[ -f "$CONFIG_FILE" ]] && log "$CONFIG_FILE present" || warn "$CONFIG_FILE missing" echo - info "run: bash ./kekkai.sh install" + info "run the one-liner installer from the README to bootstrap kekkai" } do_uninstall() { @@ -1018,12 +1146,12 @@ do_uninstall() { post_install_hints() { echo - info "next steps:" + info "next steps (always run kekkai with sudo):" printf ' %s1.%s edit config: sudo nano %s\n' "$C_BOLD" "$C_RESET" "$CONFIG_FILE" - printf ' %s2.%s validate: kekkai check\n' "$C_BOLD" "$C_RESET" + printf ' %s2.%s validate: sudo kekkai check\n' "$C_BOLD" "$C_RESET" printf ' %s3.%s restart: sudo systemctl restart kekkai-agent\n' "$C_BOLD" "$C_RESET" printf ' %s4.%s watch live: sudo kekkai status\n' "$C_BOLD" "$C_RESET" - printf ' %s5.%s diagnose: kekkai doctor\n' "$C_BOLD" "$C_RESET" + printf ' %s5.%s diagnose: sudo kekkai doctor\n' "$C_BOLD" "$C_RESET" echo if [[ $DO_RUN -eq 1 ]]; then @@ -1043,7 +1171,6 @@ if [[ -z "$CMD" ]]; then case "$CMD" in install) info "detected state: not installed → install" ;; repair) info "detected state: partial install → repair" ;; - update) info "detected state: upstream has new commits → update" ;; healthy) info "detected state: healthy → running doctor"; CMD="doctor" ;; esac fi diff --git a/readme.md b/readme.md index 3259a5d..cf5ba72 100644 --- a/readme.md +++ b/readme.md @@ -39,27 +39,29 @@ curl -fsSL https://raw.githubusercontent.com/ExpTechTW/kekkai/main/scripts/delet | sudo bash -s -- --yes --purge-home ``` -或用 repo 模式(開發者): - -```bash -git clone git@github.com:ExpTechTW/kekkai.git -cd kekkai -bash ./kekkai.sh -``` +kekkai 已改成純 release 分發:沒有原始碼建置模式,目標機不需要 Go / clang / git。所有安裝/升級都走 GitHub Releases 的預編 binary,由 `kekkai.sh` 一鍵腳本處理。 安裝後建議流程: ```bash sudo nano /etc/kekkai/kekkai.yaml -kekkai check +sudo kekkai check sudo kekkai reload -kekkai status +sudo kekkai status ``` +權限速記: + +- **所有 `kekkai` 指令一律用 `sudo kekkai `** +- Debian / Ubuntu / Pi OS 預設 `kernel.unprivileged_bpf_disabled=2`,非 root 打 `bpf()` 會被 kernel 直接擋掉,無法用 `setcap` 繞過 +- 安裝器會寫一份 `/etc/sudoers.d/kekkai-cli-` NOPASSWD drop-in,所以 `sudo kekkai ...` **不會要密碼** +- 不再加 shell alias — 請直接敲 `sudo kekkai`,跨主機 muscle memory 才一致 +- 若不小心打成 `kekkai`(非 root),CLI 會提示改用 `sudo kekkai` + > 注意:預設 `filter.ingress_allowlist` 會先放 `192.168.0.0/16` 避免初次啟動被 SSH 防呆擋住;請務必改成你的實際管理網段。 > 所有指令細節(`status/check/ports/show/backup/reload/bypass/update/reset/doctor`)已移到 [`COMMAND_ZH.md`](COMMAND_ZH.md)。 -> `kekkai update` 來源可由 `update.channel` 設為 `git:main` / `release` / `pre-release`。 +> `kekkai update` 來源可由 `update.channel` 設為 `release`(預設)或 `pre-release`。 GitHub Releases 會提供各平台檔案(`kekkai-*` 與 `kekkai-agent-*`): @@ -70,8 +72,8 @@ GitHub Releases 會提供各平台檔案(`kekkai-*` 與 `kekkai-agent-*`): 版本字串規則: -- git 模式(本地/repo build):`YYYYMMDD+` -- release / draft CI build:`YYYYMMDD+b` +- git 模式(本地/repo build):`YYYY.MM.DD+` +- release / pre-release CI build:`YYYY.MM.DD+build.` ## 過濾模型(Ingress)