1 Commits
Author SHA1 Message Date
dependabot[bot] b1275dd665 build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.1
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.4.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...f87e5991a6d7451dcb8d9637bfbc97413f497069)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 17:04:37 +00:00
447 changed files with 5172 additions and 35505 deletions

No files matched your search

+2 -7
View File
@@ -9,23 +9,19 @@
# The push trigger is limited to main rather than every branch, for the reason release.yml is # The push trigger is limited to main rather than every branch, for the reason release.yml is
# not repeated here: a branch with an open PR would otherwise run the whole suite twice per # not repeated here: a branch with an open PR would otherwise run the whole suite twice per
# push, once for refs/heads/<branch> and once for refs/pull/N/merge. Those are different # push, once for refs/heads/<branch> and once for refs/pull/N/merge. Those are different
# concurrency groups, so neither cancels the other, and both would occupy runners for the # concurrency groups, so neither cancels the other, and this repository is private and billed
# same commit. A branch with no PR open yet is the one case that loses coverage, and opening the # for both. A branch with no PR open yet is the one case that loses coverage, and opening the
# PR is what asks for the answer. # PR is what asks for the answer.
name: ci name: ci
# #
# release.yml calls this workflow (workflow_call) before it builds anything, so a tag passes # release.yml calls this workflow (workflow_call) before it builds anything, so a tag passes
# exactly these gates and there is one list of them. # exactly these gates and there is one list of them.
#
# workflow_dispatch reruns the suite on a commit whose push produced no run, such as one
# pushed while Actions was unavailable.
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
workflow_call: workflow_call:
workflow_dispatch:
permissions: permissions:
contents: read contents: read
@@ -151,7 +147,6 @@ jobs:
- run: sh deploy/bootstrap_test.sh - run: sh deploy/bootstrap_test.sh
- run: sh deploy/uninstall_test.sh - run: sh deploy/uninstall_test.sh
- run: bash deploy/e2e_release_test.sh - run: bash deploy/e2e_release_test.sh
- run: bash deploy/e2e_upstream_test.sh
# The shipped alert rules (deploy/alerts): promtool parses them and runs their unit tests, # The shipped alert rules (deploy/alerts): promtool parses them and runs their unit tests,
# which pin when each alert fires and that it stays quiet before. internal/metrics' # which pin when each alert fires and that it stays quiet before. internal/metrics'
+16 -26
View File
@@ -21,11 +21,6 @@
# This runs on pushes that touch what gets installed, by hand, and weekly (a moving # This runs on pushes that touch what gets installed, by hand, and weekly (a moving
# upstream: apt mirrors, k3s's install script and release assets, Adoptium). # upstream: apt mirrors, k3s's install script and release assets, Adoptium).
# deploy/e2e_check.sh holds the assertions, deploy/e2e_seed.sh the upgrade's seed and its check. # deploy/e2e_check.sh holds the assertions, deploy/e2e_seed.sh the upgrade's seed and its check.
#
# An installer that stops on an upstream download refused or dropped (a GitHub 403, a 5xx, a
# timeout) ends its job green, with a warning on the run: each install step hands a failed
# run's log to deploy/e2e_upstream.sh, which sets E2E_UPSTREAM_SKIP for the job's later
# steps. Every other installer failure, a 404 included, fails the job.
name: e2e name: e2e
on: on:
@@ -48,8 +43,8 @@ on:
permissions: permissions:
contents: read contents: read
# An explicit bash runs with -o pipefail, so `bootstrap.sh | tee install.log` carries the # An explicit bash runs with -o pipefail, so `bootstrap.sh | tee install.log` fails the step
# installer's status to deploy/e2e_upstream.sh; the default shell reports tee's status. # when the installer fails; the default shell reports tee's status.
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -67,7 +62,7 @@ jobs:
with: with:
fetch-depth: 0 # the newest tag is the version's base, as on the dev channel fetch-depth: 0 # the newest tag is the version's base, as on the dev channel
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Free disk space - name: Free disk space
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
@@ -112,10 +107,9 @@ jobs:
run: sudo ufw --force enable run: sudo ufw --force enable
- name: Install - name: Install
run: sudo FELIS_ARTIFACT_DIR="$GITHUB_WORKSPACE/dist" FELIS_INSTALL_MODE=full bash deploy/bootstrap.sh 2>&1 | tee install.log || bash deploy/e2e_upstream.sh install.log $? run: sudo FELIS_ARTIFACT_DIR="$GITHUB_WORKSPACE/dist" FELIS_INSTALL_MODE=full bash deploy/bootstrap.sh 2>&1 | tee install.log
- name: Check the install - name: Check the install
if: env.E2E_UPSTREAM_SKIP != '1'
run: | run: |
sudo bash deploy/e2e_check.sh install sudo bash deploy/e2e_check.sh install
for tag in felis-k3s-pods felis-k3s-services felis-panel felis-proxy; do for tag in felis-k3s-pods felis-k3s-services felis-panel felis-proxy; do
@@ -130,13 +124,11 @@ jobs:
grep -q "felis-velocity.jar is the release's" install.log grep -q "felis-velocity.jar is the release's" install.log
- name: Rerun the same assets - name: Rerun the same assets
if: env.E2E_UPSTREAM_SKIP != '1' run: sudo FELIS_ARTIFACT_DIR="$GITHUB_WORKSPACE/dist" FELIS_INSTALL_MODE=full bash deploy/bootstrap.sh 2>&1 | tee rerun.log
run: sudo FELIS_ARTIFACT_DIR="$GITHUB_WORKSPACE/dist" FELIS_INSTALL_MODE=full bash deploy/bootstrap.sh 2>&1 | tee rerun.log || bash deploy/e2e_upstream.sh rerun.log $?
# containerd and the registry already hold every image under the digest the listing # containerd and the registry already hold every image under the digest the listing
# names, so nothing is imported or uploaded twice. # names, so nothing is imported or uploaded twice.
- name: Check the rerun - name: Check the rerun
if: env.E2E_UPSTREAM_SKIP != '1'
run: | run: |
sudo bash deploy/e2e_check.sh rerun sudo bash deploy/e2e_check.sh rerun
grep -q 'felis-velocity unchanged; left running' rerun.log grep -q 'felis-velocity unchanged; left running' rerun.log
@@ -168,9 +160,8 @@ jobs:
# The README's command on a fresh host: this commit's installer, as main serves it, on its # The README's command on a fresh host: this commit's installer, as main serves it, on its
# default channel with nothing pinned. It resolves the newest release and installs that # default channel with nothing pinned. It resolves the newest release and installs that
# release's binary, images and plugin from its assets, each checked against its # release's binary, images and plugin from its assets, each checked against its
# SHA256SUMS. The workflow's token is the only thing added: it lifts GitHub's limit of 60 API # SHA256SUMS; the private repo's token is the only thing added. FELIS_INSTALL_MODE picks the
# calls an hour for an address without one. FELIS_INSTALL_MODE picks the mode the setup # mode the setup console would ask for.
# console would ask for.
readme: readme:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 120 timeout-minutes: 120
@@ -190,12 +181,12 @@ jobs:
if: steps.release.outputs.tag != '' if: steps.release.outputs.tag != ''
env: env:
TOKEN: ${{ github.token }} TOKEN: ${{ github.token }}
run: sudo FELIS_GITHUB_TOKEN="$TOKEN" FELIS_INSTALL_MODE=full bash deploy/bootstrap.sh 2>&1 | tee readme.log || bash deploy/e2e_upstream.sh readme.log $? run: sudo FELIS_GITHUB_TOKEN="$TOKEN" FELIS_INSTALL_MODE=full bash deploy/bootstrap.sh 2>&1 | tee readme.log
# The binary is the release's, so the phase is `release`: its database backup and # The binary is the release's, so the phase is `release`: its database backup and
# timers are the release's to have or lack. # timers are the release's to have or lack.
- name: Check the install - name: Check the install
if: steps.release.outputs.tag != '' && env.E2E_UPSTREAM_SKIP != '1' if: steps.release.outputs.tag != ''
env: env:
TAG: ${{ steps.release.outputs.tag }} TAG: ${{ steps.release.outputs.tag }}
BINARY: ${{ steps.release.outputs.binary }} BINARY: ${{ steps.release.outputs.binary }}
@@ -255,10 +246,10 @@ jobs:
TOKEN: ${{ github.token }} TOKEN: ${{ github.token }}
run: | run: |
git show "${TAG}:deploy/bootstrap.sh" > release-bootstrap.sh git show "${TAG}:deploy/bootstrap.sh" > release-bootstrap.sh
sudo FELIS_GITHUB_TOKEN="$TOKEN" FELIS_RELEASE="$TAG" FELIS_INSTALL_MODE=full bash release-bootstrap.sh 2>&1 | tee release.log || bash deploy/e2e_upstream.sh release.log $? sudo FELIS_GITHUB_TOKEN="$TOKEN" FELIS_RELEASE="$TAG" FELIS_INSTALL_MODE=full bash release-bootstrap.sh 2>&1 | tee release.log
- name: Check the release install - name: Check the release install
if: steps.release.outputs.tag != '' && env.E2E_UPSTREAM_SKIP != '1' if: steps.release.outputs.tag != ''
env: env:
TAG: ${{ steps.release.outputs.tag }} TAG: ${{ steps.release.outputs.tag }}
BINARY: ${{ steps.release.outputs.binary }} BINARY: ${{ steps.release.outputs.binary }}
@@ -269,17 +260,17 @@ jobs:
# A fresh install's database holds only what its migrations wrote: the seed gives the # A fresh install's database holds only what its migrations wrote: the seed gives the
# upgrade's move and its pending migrations existing rows to carry. # upgrade's move and its pending migrations existing rows to carry.
- name: Seed the release's database - name: Seed the release's database
if: steps.release.outputs.tag != '' && env.E2E_UPSTREAM_SKIP != '1' if: steps.release.outputs.tag != ''
run: sudo bash deploy/e2e_seed.sh seed run: sudo bash deploy/e2e_seed.sh seed
- name: Upgrade to this commit - name: Upgrade to this commit
if: steps.release.outputs.tag != '' && env.E2E_UPSTREAM_SKIP != '1' if: steps.release.outputs.tag != ''
run: sudo FELIS_ARTIFACT_DIR="$GITHUB_WORKSPACE/dist" FELIS_INSTALL_MODE=full bash deploy/bootstrap.sh 2>&1 | tee upgrade.log || bash deploy/e2e_upstream.sh upgrade.log $? run: sudo FELIS_ARTIFACT_DIR="$GITHUB_WORKSPACE/dist" FELIS_INSTALL_MODE=full bash deploy/bootstrap.sh 2>&1 | tee upgrade.log
# Both checks run and report: the seeded rows go last, after the restore drill has # Both checks run and report: the seeded rows go last, after the restore drill has
# also put them back from a bundle. # also put them back from a bundle.
- name: Check the upgrade - name: Check the upgrade
if: steps.release.outputs.tag != '' && env.E2E_UPSTREAM_SKIP != '1' if: steps.release.outputs.tag != ''
run: | run: |
rc=0 rc=0
sudo bash deploy/e2e_check.sh upgrade || rc=1 sudo bash deploy/e2e_check.sh upgrade || rc=1
@@ -322,10 +313,9 @@ jobs:
sudo chown -R root:root /opt/felis/src sudo chown -R root:root /opt/felis/src
- name: Install - name: Install
run: sudo FELIS_SKIP_FETCH=1 FELIS_INSTALL_MODE=full bash /opt/felis/src/deploy/bootstrap.sh 2>&1 | tee source.log || bash deploy/e2e_upstream.sh source.log $? run: sudo FELIS_SKIP_FETCH=1 FELIS_INSTALL_MODE=full bash /opt/felis/src/deploy/bootstrap.sh 2>&1 | tee source.log
- name: Check the install - name: Check the install
if: env.E2E_UPSTREAM_SKIP != '1'
run: sudo bash deploy/e2e_check.sh install run: sudo bash deploy/e2e_check.sh install
- name: Diagnostics - name: Diagnostics
+6 -6
View File
@@ -58,7 +58,7 @@ jobs:
# runner (their build stages are pinned to $BUILDPLATFORM); the game images' runtime # runner (their build stages are pinned to $BUILDPLATFORM); the game images' runtime
# stages run apt-get for the target platform, which for arm64 takes QEMU. # stages run apt-get for the target platform, which for arm64 takes QEMU.
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# Two architectures of five images plus BuildKit's cache outgrow the runner's free disk # Two architectures of five images plus BuildKit's cache outgrow the runner's free disk
# with its preinstalled SDKs in place; none of them is used here. # with its preinstalled SDKs in place; none of them is used here.
@@ -72,23 +72,23 @@ jobs:
run: deploy/build-release-artifacts.sh "${GITHUB_REF_NAME}" dist run: deploy/build-release-artifacts.sh "${GITHUB_REF_NAME}" dist
# A CycloneDX SBOM per binary: the Go modules (and versions) linked into it, read # A CycloneDX SBOM per binary: the Go modules (and versions) linked into it, read
# from the build info the linker embeds. Written after SHA256SUMS, so beside it in the # from the build info the linker embeds.
# release but outside it: that lists what bootstrap installs. Straight into dist/,
# because the action does not create a missing output directory.
- uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with: with:
file: dist/felis-linux-amd64 file: dist/felis-linux-amd64
format: cyclonedx-json format: cyclonedx-json
output-file: dist/felis-linux-amd64.cdx.json output-file: sbom/felis-linux-amd64.cdx.json
upload-artifact: false upload-artifact: false
upload-release-assets: false upload-release-assets: false
- uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with: with:
file: dist/felis-linux-arm64 file: dist/felis-linux-arm64
format: cyclonedx-json format: cyclonedx-json
output-file: dist/felis-linux-arm64.cdx.json output-file: sbom/felis-linux-arm64.cdx.json
upload-artifact: false upload-artifact: false
upload-release-assets: false upload-release-assets: false
# Outside SHA256SUMS, which lists what bootstrap installs; beside it in the release.
- run: mv sbom/*.cdx.json dist/
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
-4
View File
@@ -51,7 +51,3 @@ AGENTS.md
docs/Felis-Spec-V4.1.md docs/Felis-Spec-V4.1.md
panel/DESIGN.md panel/DESIGN.md
panel/DESIGN-WEB-3SIDES.md panel/DESIGN-WEB-3SIDES.md
# Audit ledgers and readiness reviews stay on the maintainer's disk.
/AUDIT-*.md
/READINESS-*.md
+1013
View File
File diff suppressed because it is too large. Load diff
+4 -26
View File
@@ -23,28 +23,6 @@ The codebase is intentionally split by responsibility. Prefer changing the
smallest owning module instead of adding broad abstractions or rebuilding nearby smallest owning module instead of adding broad abstractions or rebuilding nearby
code. code.
## Interface Copy
Interface copy must use a formal documentation style: objective, concise, and
precise. State the current condition, its cause or impact, and the available
action. Describe confirmed facts only; avoid conversational phrasing,
personification, and unsupported duration estimates. Chinese instructions should
use explicit verbs such as “执行”, “选择”, “查看”, and “配置”, with operation names
matching the actual UI labels. English and Chinese copy must retain the same
meaning and degree of formality. Update existing translation entries rather than
adding duplicate messages or components.
## Panel Visual Style
Reuse `PageHeader`, the `Card` family, and the shared form and button components.
Card titles, borders, corner radii, spacing, and save footers follow their shared
definitions; avoid redefining these styles in individual pages. Use white card
backgrounds in the light theme, restrained semantic colors, and 16px functional
icons. Use `CardFooter` for save actions and `Button` for interactive controls.
Tables, consoles, compact statistics, and status messages may retain spacing and
colors suited to their content. Preserve the shared page margins and bottom
spacing.
## Local Development ## Local Development
You can do most day-to-day development on macOS or Linux without a full cluster. You can do most day-to-day development on macOS or Linux without a full cluster.
@@ -234,13 +212,13 @@ export FELIS_IMAGE=felis:dev
export FELIS_ROOT_DOMAIN=<node-ip>.nip.io export FELIS_ROOT_DOMAIN=<node-ip>.nip.io
``` ```
By default the installer builds the newest **published GitHub release**. Building the By default the installer builds the newest **published GitHub release**. While this
development tip needs an opt-in, and installing from a private fork additionally needs a repository is private that lookup — and the clone itself — needs a token, and building
token for the release lookup and the clone: the development tip needs an opt-in:
```bash ```bash
export FELIS_GITHUB_TOKEN=<token with read access to the repo>
export FELIS_VERSION_BOOTSTRAP=dev # build main instead of the newest release export FELIS_VERSION_BOOTSTRAP=dev # build main instead of the newest release
export FELIS_GITHUB_TOKEN=<token> # private forks only: read access to the fork
``` ```
`dev` is also the escape hatch before the first `vX.Y.Z` tag exists: with no published `dev` is also the escape hatch before the first `vX.Y.Z` tag exists: with no published
+50 -90
View File
@@ -1,25 +1,11 @@
<div align="center"> # Felis
<h1 align="center">
<img src="docs/assets/felis-logo.png" alt="Felis logo" width="270"><br>
Felis
</h1>
<p align="center">
基于 Kubernetes 的 Minecraft 服务器托管平台<br>
单条命令完成部署,自动管理服务器生命周期、备份与安全
<br><br>
<a href="README.md">简体中文</a> | <a href="README_EN.md">English</a>
<br>
<a href="https://felismc.com/">官网</a> | <a href="https://docs.felismc.com/">文档</a>
</p>
</div>
> [!CAUTION] 一款 Kubernetes 驱动的 Minecraft 服务器托管平台,一行命令部署,自动管理生命周期与安全。
> **当前仅提供开发快照,已撤下公开 Release。仅供开发者在隔离的测试环境中验证,不建议普通用户部署。** A Kubernetes-driven Minecraft server hosting platform — one command to deploy, automatic lifecycle, backup, and security.
> **此项目仍处于早期开发阶段,您不该在任何生产环境使用该项目。若产生任何问题,贵用户的使用行为与 FelisMC 团队无任何民事刑事法律关系。**<br>
> **THIS PROJECT IS STILL WIP, YOU SHOULD DO NOT USE THIS PROJECT IN ANY PRODUCTION USAGE. WE ARE NOT RESPOND FOR ANY LEGAL OR HUMANLY PROBLEM.**
<details> [简体中文](README.md) | [English](README_EN.md)
<summary>目录</summary>
目录
- [特性](#特性) - [特性](#特性)
- [使用方式](#使用方式) - [使用方式](#使用方式)
@@ -27,79 +13,53 @@
- [开源协议](#开源协议) - [开源协议](#开源协议)
- [致谢](#致谢) - [致谢](#致谢)
</details>
## 特性 ## 特性
* **按需启停**:玩家连接代理时自动启动目标服务器,启动期间玩家进入等待队列,服务器就绪后自动传送;服务器空闲后自动停止,释放内存。 - **即开即玩**:玩家尝试连接时自动唤醒服务器,空闲后自动休眠,像游戏主机一样省资源。
- **Web 控制面板**:浏览器中查看服务器状态、在线玩家与资源用量,管理备份与恢复。
* **Web 控制面板**:在浏览器中查看服务器状态、在线玩家与资源用量。 - **备份与恢复**:一键把整服数据(世界、配置、插件/模组,即整个 /data 卷)打包进集群内的归档库,支持从任意备份点回滚;默认安装就已启用(归档 PVC 与路径由安装器一并生成)。
* 控制台(RCON)、白名单、封禁、OP 与 LuckPerms 权限管理 - **控制面数据库备份**:账号、服务器归属、配额与存档索引所在的数据库每天自动备份,每次升级迁移前先快照,出错可用 `felis db restore` 整库原子回滚;面板「维护与备份」页显示备份是否新鲜(见 [故障排查 §16](docs/troubleshooting.md))。
* 文件管理:新建、删除、重命名、分片上传、下载,以及停服状态下解压 zip,可用于导入世界 - **运维自检**:`sudo felis status` 一屏列出节点、控制面、游戏代理、每台服务器、备份与未解决的告警;`sudo felis doctor` 把健康检查全跑一遍,按区域给出问题和下一步去哪看,不发邮件;`sudo felis support-bundle` 打出一个脱敏的诊断包,求助时直接附上(见 [故障排查 §0](docs/troubleshooting.md))。
* 计划任务:按星期与时区定时执行命令、重启、停止、启动或备份,执行前在游戏内向玩家发送提醒 - **智慧回收(可选开启)**:超过 15 天无人游玩的世界自动备份后删除,释放磁盘空间;安装时设置 `FELIS_WORLDS_HOST_PATH`(k3s 默认 `/var/lib/rancher/k3s/storage`)即启用每日回收,不设置则不删任何世界。过期备份无论是否开启都会每天清理。
- **多核心支持**:兼容 Paper、Fabric、Forge、NeoForge,经由 Velocity 代理统一入口。
* **备份与恢复**:默认启用,归档 PVC 及其路径由安装器生成。 - **模组自助提交**:玩家自行上传模组包,服主审批通过后自动构建;构建产物进入镜像白名单,可直接选用为服务器镜像完成部署。
* 手动备份:将服务器的完整数据卷(`/data`,含世界、配置、插件与模组)归档至集群内的归档存储,可回滚至任一备份点 - **Passkey 登录**:支持指纹、面容、硬件密钥等无密码认证方式。
* 每日恢复点:当天有玩家进入过的服务器在停止后自动生成恢复点,默认保留 7 个,保存期限 90 天;恢复点单独轮换,不影响手动备份 - **零信任安全**:面板流量由 Cloudflare Access 保护,集群内 API 不暴露到公网。
* 下载与导出:支持下载单个备份(附 sha256 校验)、删除单个备份及导出完整世界
* 异地副本(可选):备份在主机上加密后同步至 S3 兼容存储(AWS S3、Cloudflare R2、Backblaze B2、MinIO 等)
* 控制面数据库:存放账号、服务器归属、配额与存档索引的数据库每日自动备份,每次升级迁移前额外创建快照,故障时可通过 `felis db restore` 整库原子回滚;面板「维护与备份」页显示最近一次备份的时效(参见 [故障排查 §16](docs/troubleshooting.md))
* **运维诊断**
* `sudo felis status`:汇总显示节点、控制面、游戏代理、各服务器、备份及未解决的告警
* `sudo felis doctor`:执行全部健康检查,按模块列出问题及排查方向,执行过程中不发送邮件
* `sudo felis support-bundle`:生成已脱敏的诊断包,供提交问题时附带(参见 [故障排查 §0](docs/troubleshooting.md))
* 看门狗:每 2 分钟执行一次巡检,异常持续时向平台所有者发送邮件告警,支持外部心跳监测
* **世界回收(可选)**:超过 15 天无人游玩的世界在备份后删除,以释放磁盘空间。安装时设置 `FELIS_WORLDS_HOST_PATH`(k3s 默认为 `/var/lib/rancher/k3s/storage`)即启用每日回收;未设置时不删除任何世界。过期备份的每日清理与此设置无关,始终执行。
* **多核心支持**:兼容 Paper、Fabric、Forge 与 NeoForge,统一经由 Velocity 代理接入。
* **模组包投稿**:玩家可上传模组包,经服主审批后自动构建并通过 Trivy 安全扫描;构建产物加入镜像白名单后,可直接选作服务器镜像。
* **安全**
* Passkey 登录:支持指纹、面容识别及硬件密钥等无密码认证方式
* 零信任访问:面板流量经 Cloudflare Access 保护,集群内部 API 不对公网开放
* **多机部署(实验性,默认关闭)**:由一台主控节点统一下发指令,其余节点仅运行游戏服务器,已停止的服务器可迁移至其他节点。该功能目前仅位于 main 分支,尚未完成三机验收(参见 [多机部署](docs/distributed.md))。
## 使用方式 ## 使用方式
开发测试需在已准备好的 Linux 测试主机上显式选择 `dev` 通道(跟随 `main`,从源码构建): 在准备好的 Linux 主机上执行(已验证的发行版与架构见 [运维手册 §1](docs/operations.md#1-supported-hosts):CentOS Stream 9 aarch64 实机验证,Ubuntu 24.04 x86_64 每次推送由 CI 跑全新安装、重跑、升级和下面这条命令本身):
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh | sudo FELIS_VERSION_BOOTSTRAP=dev bash curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh | sudo bash
``` ```
脚本将安装 K3s,在 K3s 中部署 PostgreSQL 与控制平面,随后启动设置向导。设置完成后,通过浏览器访问所配置的域名即可进入控制面板。 脚本将自动安装 K3s,在 K3s 内部署 PostgreSQL 与控制平面,并启动设置向导。完成后浏览器访问已配置的域名进入控制面板即可使用。
* **设置向导**:先完成面板访问方式与存储配置,再由主机管理员创建首位 Owner,终端会给出一次性网页设置链接(30 分钟内有效)。用浏览器打开链接、登记邮箱并创建通行密钥,即可进入面板,无需启动 Minecraft。角色关联可稍后在“账户”页选择认证源、输入角色名或 UUID 并确认;普通玩家继续通过游戏绑定码验证身份。未完成网页登录或链接过期时,再次执行 `sudo felis setup` 会提供新链接;已设置登录凭证的账号不会被重置。登录服或大厅故障不会阻止面板初始化。“账户”页会解释世界树(Yggdrasil)认证、显示进服地址与登录服/大厅所需的 Java 版客户端版本;安装器会把实际构建版本写入 `[velocity].game_version`,自定义镜像需自行填写,未配置时不会猜测版本。标准世界树接口可直接查询角色;非标准 `hasJoined` 地址可通过 `[[auth_source]].api_url` 指定认证站 API 根地址,游戏绑定码仍可作为替代方式。安装器仅在交互式终端中自动启动向导;输出重定向至日志或经由 cloud-init 安装时,请在安装结束后执行 `sudo felis setup`。设置 `FELIS_NO_SETUP=1` 时,安装器在输出摘要后直接结束。 安装发布版时,二进制、全部镜像与 Velocity 插件都取自该版本在 CI 里预构建好的 release 附件,逐个核对 `SHA256SUMS` 后导入,主机上无需 Docker、Gradle 或 Go,也不从 Docker Hub 拉取;某个附件缺失或校验不符时,只有那一个镜像退回到本机构建,并给出提示(见 [故障排查 §15c](docs/troubleshooting.md))。附件也可以先拷到本机,再用 `FELIS_ARTIFACT_DIR=<绝对路径>` 安装,Felis 自己的二进制、镜像和插件就都取自这个目录;k3s 及其镜像、JRE、cloudflared、Velocity 和 Via 插件照旧从 GitHub 与 PaperMC 下载,RHEL、Fedora、openSUSE Leap 这类开着 SELinux 的主机还要从 rpm.rancher.io 装 k3s-selinux,系统软件包来自发行版的源。所以出网受限的主机要放行这几处的 HTTPS(或设 `https_proxy`),preflight 会在改动主机之前逐个探测,完全断网的主机目前装不了(地址清单见 [运维手册 §1](docs/operations.md#1-supported-hosts))。旧版本装在宿主上的 PostgreSQL 会在重跑时整库迁进 K3s,宿主上的那份停用保留,供回退(见 [运维手册 §4](docs/operations.md#4-upgrading-the-pieces-around-felis))。
* **认证源管理**:Owner 可在左侧“平台 → 认证源”添加、编辑、排序、停用第三方 Yggdrasil 认证站,并测试认证接口。保存后立即用于下一次登录和角色查询,无需重启;面板配置优先于安装配置。已保存的永久标识不能改名或删除,以保留玩家 UUID 与账号绑定;不再使用的源可停用。Mojang 始终优先验证。Nano 继续使用 TOML 配置。 动手之前,脚本先检查内存、磁盘、端口、网段冲突、已有的 Kubernetes 和外网连通,把所有问题一次列出并停下,主机上什么都没改(检查项见 [运维手册 §1](docs/operations.md#1-supported-hosts))。
* **支持的系统**:CentOS Stream 9(aarch64)已在实机上验证;Ubuntu 24.04(x86_64)在每次推送时由 CI 验证开发构建的全新安装与重复安装(参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。 > **本仓库当前为私有**,上面这条会返回 404。请改用带凭据的形式;安装器自身也需要同一个 token
> 去解析并下载 release,所以用 `sudo -E` 把它带进去:
>
> ```bash
> export FELIS_GITHUB_TOKEN=<对本仓库有读权限的 token>
> printf 'header = "Authorization: Bearer %s"\n' "$FELIS_GITHUB_TOKEN" \
> | curl -fsSL --config - -H "Accept: application/vnd.github.raw" \
> https://api.github.com/repos/FelisMC/Felis/contents/deploy/bootstrap.sh \
> | sudo -E bash
> ```
>
> token 经 stdin 交给 `curl --config -`,不放在命令行上:argv 在 `/proc` 下对本机任意用户可读,
> 而这正是安装器内部 `github_api` 采用同一写法的原因。
* **安装前检查**:安装器在修改主机之前检查内存、磁盘、端口、网段冲突、已有的 Kubernetes 及外网连通性。发现问题时一次性列出全部问题并退出,主机保持原状(检查项参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。 重跑这条命令也是把 felis-api 升到新版本的方式(`felis setup` 做不到,它用的是本机已有的二进制)。
重跑会沿用已安装的根域名,但**不会**沿用通道:若本机跟随 main,需一并 `export FELIS_VERSION_BOOTSTRAP=dev`。
* **升级**:重新执行安装命令即可将 felis-api 升级至新版本;`felis setup` 仅使用本机已安装的二进制,无法用于升级。重新执行时沿用已安装的根域名,发布通道需重新指定:跟随 main 分支的主机须同时设置 `export FELIS_VERSION_BOOTSTRAP=dev`。早期版本安装在宿主机上的 PostgreSQL 会在重新执行时整库迁入 K3s,宿主机上的原实例停用并保留,以便回退(参见 [运维手册 §4](docs/operations.md#4-upgrading-the-pieces-around-felis))。
<details>
<summary>安装来源与受限网络环境下的安装</summary>
<br>
以下说明发布机制;当前暂无公开 Release 附件,开发测试使用源码构建。
安装发布版时,二进制文件、全部镜像及 Velocity 插件均取自该版本由 CI 预构建的 release 附件,逐一校验 `SHA256SUMS` 后导入。主机无需安装 Docker、Gradle 或 Go,也无需访问 Docker Hub。若某个附件缺失或校验失败,仅该镜像回退为本机构建,并输出提示(参见 [故障排查 §15c](docs/troubleshooting.md))。
也可将附件预先复制到主机,再通过 `FELIS_ARTIFACT_DIR=<绝对路径>` 安装,此时 Felis 自身的二进制、镜像与插件均从该目录读取。k3s 及其镜像、JRE、cloudflared、Velocity 与 Via 插件仍从 GitHub 和 PaperMC 下载;RHEL、Fedora、openSUSE Leap 等启用 SELinux 的主机还需从 rpm.rancher.io 安装 k3s-selinux;系统软件包来自发行版软件源。
因此,出站网络受限的主机须放行上述地址的 HTTPS 访问,或设置 `https_proxy`。preflight 会在修改主机之前逐一探测这些地址。目前暂不支持完全离线安装(地址清单参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。
</details>
## 从源码构建 ## 从源码构建
本项目基于 Go 与 Node.js 开发: 本项目基于 Go 和 Node.js 开发:
```bash ```bash
# 后端(Go 1.26+) # 后端(Go 1.26+)
@@ -116,22 +76,22 @@ docker build -t felis:custom .
## 开源协议 ## 开源协议
本项目采用 [AGPL-3.0-only](LICENSE) 许可证。 本项目遵循 [AGPL-3.0-only](LICENSE) 开源协议。
### 协议注意事项 ### 协议注意事项
1. **衍生作品须采用 AGPL**:分发本项目副本或基于本项目的衍生软件时,须以 AGPL-3.0 开源,并保留原作者的版权声明与许可声明。 1. **衍生作品同样是 AGPL**:分发本项目的副本或基于本项目衍生的软件时,必须以 AGPL-3.0 开源,并保留原作者的版权声明和许可声明。
2. **网络服务同样须提供源码**(AGPL 第 13 条):通过网络向他人提供经修改的 Felis 服务时,即使未分发任何二进制文件,也须向这些用户提供修改后的完整源码。这是 AGPL 与 GPL 唯一的实质区别;Felis 作为通过网络访问的托管平台,几乎所有部署场景都适用此条款。 2. **通过网络提供服务同样要开源**(AGPL 第 13 条):如果你把修改过的 Felis 架起来给别人用,即使从不分发任何二进制,也必须向这些用户提供你那份修改后的完整源码。这是 AGPL 相对 GPL 的唯一实质区别,而 Felis 正是一个跑在网络上的托管平台,所以这一条基本总会触发。
3. **免责声明**:本项目按"原样"提供,作者不承担因使用本项目而产生的任何法律责任。 3. **免责声明**:本项目按"原样"提供,作者不承担任何因使用本项目而产生的法律责任。
## 致谢 ## 致谢
* [Kubernetes](https://kubernetes.io/):容器编排引擎 - [Kubernetes](https://kubernetes.io/):底层容器编排引擎
* [K3s](https://k3s.io/):轻量级 Kubernetes 发行版 - [K3s](https://k3s.io/):轻量级 Kubernetes 发行版
* [Cloudflare Zero Trust](https://www.cloudflare.com/zero-trust/):零信任安全基础设施 - [Cloudflare Zero Trust](https://www.cloudflare.com/zero-trust/):零信任安全基础设施
* [PostgreSQL](https://www.postgresql.org/):数据持久化 - [PostgreSQL](https://www.postgresql.org/):数据持久化
* [React](https://react.dev/):前端用户界面框架 - [React](https://react.dev/):前端用户界面框架
* [Vite](https://vitejs.dev/):前端构建工具 - [Vite](https://vitejs.dev/):前端构建工具
* [TailwindCSS](https://tailwindcss.com/):CSS 框架 - [TailwindCSS](https://tailwindcss.com/):CSS 框架
* [Bubble Tea](https://github.com/charmbracelet/bubbletea):TUI 框架 - [Bubble Tea](https://github.com/charmbracelet/bubbletea):TUI 框架
* [Minecraft](https://www.minecraft.net/):本项目服务的游戏 - [Minecraft](https://www.minecraft.net/):让这一切值得做
+54 -86
View File
@@ -1,24 +1,11 @@
<div align="center"> # Felis
<h1 align="center">
<img src="docs/assets/felis-logo.png" alt="Felis logo" width="270"><br>
Felis
</h1>
<p align="center">
A Kubernetes-driven Minecraft server hosting platform<br>
One command to deploy, with automatic lifecycle, backup, and security
<br><br>
<a href="README.md">简体中文</a> | <a href="README_EN.md">English</a>
<br>
<a href="https://felismc.com/">Website</a> | <a href="https://docs.felismc.com/en/">Documentation</a>
</p>
</div>
> [!CAUTION] A Kubernetes-driven Minecraft server hosting platform — one command to deploy, automatic lifecycle, backup, and security.
> **Only development snapshots are available; public releases have been withdrawn. These builds are for developers testing in isolated environments and are not recommended for general deployment.** 一款 Kubernetes 驱动的 Minecraft 服务器托管平台,一行命令部署,自动管理生命周期与安全。
> **THIS PROJECT IS STILL WIP, YOU SHOULD DO NOT USE THIS PROJECT IN ANY PRODUCTION USAGE. WE ARE NOT RESPOND FOR ANY LEGAL OR HUMANLY PROBLEM.**
<details> [简体中文](README.md) | [English](README_EN.md)
<summary>Table of Contents</summary>
Table of Contents
- [Features](#features) - [Features](#features)
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
@@ -26,73 +13,54 @@
- [License](#license) - [License](#license)
- [Acknowledgements](#acknowledgements) - [Acknowledgements](#acknowledgements)
</details>
## Features ## Features
* **On-demand Start and Stop**: A server starts when a player connects to the proxy. The player waits in a queue during start-up and is transferred once the server is ready. Idle servers stop automatically to free memory. - **Wake on Join**: Servers start automatically when a player connects, and stop when idle — like hibernate for your server.
- **Web Dashboard**: Monitor server status, online players, and resource usage from your browser, with backup and restore management.
* **Web Dashboard**: Monitor server status, online players, and resource usage from your browser. - **Backup & Restore**: One-click snapshots of a server's whole data volume (worlds, config, plugins/mods — the entire /data volume) into the cluster's archive store, with rollback from any backup point — enabled by default (the installer renders the archive PVC and its path).
* Console (RCON), whitelist, bans, OPs and LuckPerms permissions - **Control-plane database backups**: The database holding accounts, server ownership, quotas and the archive index is backed up daily and snapshotted before every upgrade migrates it; `felis db restore` rolls it back atomically, and the panel's Maintenance & Backups page shows whether the newest backup is fresh (see [troubleshooting §16](docs/troubleshooting.md)).
* File manager: create, delete, rename, chunked upload, download, and unzip while the server is stopped; also used to import worlds - **Self-check for operators**: `sudo felis status` shows the node, the control plane, the game proxy, every server, the backups and the open alerts on one screen; `sudo felis doctor` runs every health check once and lists each problem by area with where to look next, mailing nothing; `sudo felis support-bundle` writes one redacted diagnostics archive to attach when asking for help (see [troubleshooting §0](docs/troubleshooting.md)).
* Schedules: run commands, restart, stop, start or back up by weekday and time zone, with an in-game warning to players beforehand - **World Reaper** (opt in): Worlds idle for more than 15 days are automatically backed up and removed to free disk space. Enable it by setting `FELIS_WORLDS_HOST_PATH` at install time (on k3s: `/var/lib/rancher/k3s/storage`); without it, no world is ever deleted.
- **Multi-core Support**: Compatible with Paper, Fabric, Forge, and NeoForge, federated behind a Velocity proxy.
* **Backup & Restore**: Enabled by default; the installer renders the archive PVC and its path. - **Modpack Submission**: Players submit custom modpacks; admin approval triggers an automatic build, and the result is whitelisted as a server image you can select to deploy.
* Manual backups: archive a server's entire data volume (`/data`, including worlds, configuration, plugins and mods) to the cluster's archive store, with rollback to any backup point - **Passkey Login**: Passwordless authentication via fingerprint, face recognition, or hardware security keys.
* Daily restore points: a server played that day gets a restore point once it stops; by default 7 are kept for up to 90 days, rotated separately from manual backups - **Zero Trust Security**: Panel traffic protected by Cloudflare Access; the internal API is never exposed to the internet.
* Download and export: download a single backup (with sha256 verification), delete a single backup, or export a whole world
* Off-site copy (optional): backups are encrypted on the host and synced to S3-compatible storage (AWS S3, Cloudflare R2, Backblaze B2, MinIO and others)
* Control-plane database: the database holding accounts, server ownership, quotas and the archive index is backed up daily and snapshotted before every upgrade migration; `felis db restore` rolls it back atomically, and the panel's Maintenance & Backups page shows the age of the latest backup (see [troubleshooting §16](docs/troubleshooting.md))
* **Diagnostics**
* `sudo felis status`: a summary of the node, control plane, game proxy, each server, backups and open alerts
* `sudo felis doctor`: runs all health checks and lists problems by area with troubleshooting pointers; sends no email
* `sudo felis support-bundle`: generates a redacted diagnostics archive to attach to support requests (see [troubleshooting §0](docs/troubleshooting.md))
* Watchdog: runs a check every 2 minutes and emails the platform owners when a problem persists; supports an external heartbeat monitor
* **World Reaper** (optional): Worlds idle for more than 15 days are backed up and then removed to free disk space. Enable it by setting `FELIS_WORLDS_HOST_PATH` at install time (on k3s: `/var/lib/rancher/k3s/storage`); without it, no world is deleted. Expired backups are cleaned up daily regardless of this setting.
* **Multi-core Support**: Compatible with Paper, Fabric, Forge, and NeoForge, accessed through a single Velocity proxy.
* **Modpack Submission**: Players can upload modpacks. After admin approval, each modpack is built automatically and scanned with Trivy; the result is added to the image whitelist and can be selected as a server image.
* **Security**
* Passkey login: passwordless authentication via fingerprint, face recognition, or hardware security keys
* Zero-trust access: panel traffic is protected by Cloudflare Access, and the internal API is not exposed to the internet
* **Multi-node Deployment** (experimental, off by default): a single controller node issues all commands, the other nodes run game servers only, and a stopped server can be migrated to another node. Currently available only on the main branch; three-node acceptance testing is not yet complete (see [distributed mode](docs/distributed.md), in Chinese).
## Getting Started ## Getting Started
For development testing on a prepared Linux test host, explicitly select the `dev` channel, which follows `main` and builds from source: On a prepared Linux host, run (the verified distributions and architectures are listed in
[operations §1](docs/operations.md#1-supported-hosts): CentOS Stream 9 on aarch64 is verified
on a real host, and Ubuntu 24.04 on x86_64 gets a fresh install, rerun and upgrade in CI on
every push):
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh | sudo FELIS_VERSION_BOOTSTRAP=dev bash curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap.sh | sudo bash
``` ```
The script installs K3s, deploys PostgreSQL and the control plane inside it, and launches a setup wizard. When setup completes, open the configured domain in a browser to reach the control panel. The script installs K3s, deploys PostgreSQL and the control plane inside it, and launches a setup wizard. Once done, open your browser at the configured domain. A PostgreSQL an earlier release installed on the host is moved into K3s on the next rerun, and the host copy is stopped and kept for a rollback (see [Operations §4](docs/operations.md#4-upgrading-the-pieces-around-felis)).
* **Setup wizard**: Configure panel access and storage first. The host administrator then initializes the first Owner and receives a one-time browser setup link (valid for 30 minutes). Open it, record an email, and create a passkey to enter the panel; Minecraft is not required. Later, link a game role from Account by selecting an authentication source, entering a role name or UUID, and confirming it. Players retain the in-game bind-code flow. Rerun `sudo felis setup` if login setup is unfinished or the link expires; accounts with an established login factor are never reset. Login/lobby failures do not block panel initialization. Account explains Yggdrasil authentication and shows the join address and Java client version for the login/lobby servers. Bootstrap records the built protocol in `[velocity].game_version`; set it yourself for custom images, otherwise the panel reports it as unknown. Standard Yggdrasil endpoints support role lookup directly; sources with a nonstandard `hasJoined` path can set `[[auth_source]].api_url` to their API root, with game-code linking still available as a fallback. The installer launches the wizard automatically only on an interactive terminal; when output is redirected to a log or the install runs under cloud-init, run `sudo felis setup` after it finishes. Setting `FELIS_NO_SETUP=1` makes the installer end at its summary. Before it changes anything, the script checks RAM, disk, ports, network-range clashes, any Kubernetes already there and outbound access, lists every problem at once and stops with the host untouched (the checks are in [operations §1](docs/operations.md#1-supported-hosts)).
* **Supported hosts**: CentOS Stream 9 (aarch64) is verified on physical hardware; CI tests fresh installation and repeat installation of development builds on Ubuntu 24.04 (x86_64) on every push (see [operations §1](docs/operations.md#1-supported-hosts)). > **This repository is currently private**, so the command above returns 404. Use the
> credentialed form instead; the installer itself needs the same token to resolve and
> download the release, so pass it through with `sudo -E`:
>
> ```bash
> export FELIS_GITHUB_TOKEN=<a token with read access to this repository>
> printf 'header = "Authorization: Bearer %s"\n' "$FELIS_GITHUB_TOKEN" \
> | curl -fsSL --config - -H "Accept: application/vnd.github.raw" \
> https://api.github.com/repos/FelisMC/Felis/contents/deploy/bootstrap.sh \
> | sudo -E bash
> ```
>
> The token reaches `curl --config -` over stdin instead of the command line: argv is
> readable by any local user via `/proc`, and that is exactly why the installer's
> internal `github_api` uses the same form.
* **Preflight checks**: Before modifying the host, the installer checks memory, disk, ports, network range conflicts, existing Kubernetes installations and outbound connectivity. If any check fails, it lists all problems and exits, leaving the host unchanged (see [operations §1](docs/operations.md#1-supported-hosts) for the checks). Rerunning this command is also how you upgrade felis-api to a newer version (`felis setup`
cannot — it uses the binary already installed on the host). The rerun keeps the installed
* **Upgrading**: Rerun the install command to upgrade felis-api to a newer version; `felis setup` only uses the binary already installed on the host and cannot upgrade it. A rerun keeps the installed root domain, and the release channel must be specified again: hosts that follow the main branch must also set `export FELIS_VERSION_BOOTSTRAP=dev`. A PostgreSQL instance installed on the host by an earlier release is migrated into K3s during the rerun; the original instance on the host is stopped and retained for rollback (see [operations §4](docs/operations.md#4-upgrading-the-pieces-around-felis)). root domain but **not** the channel: if this host follows main, also
`export FELIS_VERSION_BOOTSTRAP=dev`.
<details>
<summary>Installation sources and restricted networks</summary>
<br>
The following describes the release mechanism. No public release assets are currently available; development testing uses source builds.
A release installation takes the binary, all images and the Velocity plugin from the release assets prebuilt in CI, verifying each against `SHA256SUMS` before import. The host requires no Docker, Gradle or Go, and no access to Docker Hub. If an asset is missing or fails verification, only that image falls back to a local build, and the installer prints a notice (see [troubleshooting §15c](docs/troubleshooting.md)).
The assets can also be copied to the host in advance and installed with `FELIS_ARTIFACT_DIR=<absolute path>`; the Felis binary, images and plugin are then read from that directory. k3s and its images, the JRE, cloudflared, Velocity and the Via plugins are still downloaded from GitHub and PaperMC; hosts with SELinux enabled, such as RHEL, Fedora and openSUSE Leap, additionally install k3s-selinux from rpm.rancher.io; system packages come from the distribution's repositories.
A host with restricted outbound access must therefore allow HTTPS to these addresses or set `https_proxy`. Preflight probes each address before changing the host. Fully offline installation is not yet supported (see [operations §1](docs/operations.md#1-supported-hosts) for the address list).
</details>
## Build from Source ## Build from Source
@@ -113,22 +81,22 @@ docker build -t felis:custom .
## License ## License
This project is licensed under [AGPL-3.0-only](LICENSE). The source code is released under [AGPL-3.0-only](LICENSE).
### License Notes ### License Notes
1. **Derivative works must use AGPL**: Any distribution of this project or of software derived from it must be released under AGPL-3.0 and must include the original copyright notice and license statement. 1. **Derivative works are AGPL too**: Any distribution of this project or of software derived from it must be released under AGPL-3.0 and must include the original copyright notice and license statement.
2. **Network services must also provide source** (AGPL section 13): anyone who offers a modified Felis to others over a network must provide those users with the complete source of the modified version, even without distributing any binary. This is the only substantive difference between AGPL and GPL; as Felis is a hosting platform accessed over a network, this clause applies to virtually every deployment. 2. **Running it as a network service also triggers the source obligation** (AGPL section 13): if you host a modified Felis for other people to use, you must offer those users the complete source of your modified version — even if you never distribute a binary. This is the one substantive difference between AGPL and GPL, and since Felis is a hosting platform reached over a network, it will essentially always apply.
3. **Disclaimer**: This project is provided "as is", without warranty of any kind. 3. **Disclaimer**: This project is provided "as is", without warranty of any kind.
## Acknowledgements ## Acknowledgements
* [Kubernetes](https://kubernetes.io/): Container orchestration engine - [Kubernetes](https://kubernetes.io/): Container orchestration engine
* [K3s](https://k3s.io/): Lightweight Kubernetes distribution - [K3s](https://k3s.io/): Lightweight Kubernetes distribution
* [Cloudflare Zero Trust](https://www.cloudflare.com/zero-trust/): Zero trust security infrastructure - [Cloudflare Zero Trust](https://www.cloudflare.com/zero-trust/): Zero trust security infrastructure
* [PostgreSQL](https://www.postgresql.org/): Data persistence - [PostgreSQL](https://www.postgresql.org/): Data persistence
* [React](https://react.dev/): User interface framework - [React](https://react.dev/): User interface framework
* [Vite](https://vitejs.dev/): Frontend build tool - [Vite](https://vitejs.dev/): Frontend build tool
* [TailwindCSS](https://tailwindcss.com/): CSS framework - [TailwindCSS](https://tailwindcss.com/): CSS framework
* [Bubble Tea](https://github.com/charmbracelet/bubbletea): TUI framework - [Bubble Tea](https://github.com/charmbracelet/bubbletea): TUI framework
* [Minecraft](https://www.minecraft.net/): The game this project serves - [Minecraft](https://www.minecraft.net/): What makes this all worthwhile
+1 -1
View File
@@ -33,7 +33,7 @@ var bootstrapAssets embed.FS
//go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src plugins/limbo/gradle/verification-metadata.xml //go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src plugins/limbo/gradle/verification-metadata.xml
//go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src plugins/paper/gradle/verification-metadata.xml //go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src plugins/paper/gradle/verification-metadata.xml
//go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src plugins/velocity/gradle/verification-metadata.xml //go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src plugins/velocity/gradle/verification-metadata.xml
//go:embed plugins/shared/src plugins/shared/build-progress.gradle //go:embed plugins/shared/src
var gameStackAssets embed.FS var gameStackAssets embed.FS
// GameStackTar streams the embedded game-stack sources as a tar, rooted so that // GameStackTar streams the embedded game-stack sources as a tar, rooted so that
-3
View File
@@ -147,9 +147,6 @@ func TestBootstrapPinsViaBlockConnectionsOff(t *testing.T) {
// inputs from the script and from each Dockerfile's own COPY lines instead of restating // inputs from the script and from each Dockerfile's own COPY lines instead of restating
// them here; a fourth image inherits the check for free. // them here; a fourth image inherits the check for free.
func TestGameStackTarCarriesEveryBuildInput(t *testing.T) { func TestGameStackTarCarriesEveryBuildInput(t *testing.T) {
// The plugin builds invoke this shared init script after COPYing the directory;
// a directory entry alone would pass the COPY check even if the script was omitted.
requireEmbedded(t, "plugins/shared/build-progress.gradle")
// Matches the path only when GAME_STACK_DIR is followed by one, which skips the // Matches the path only when GAME_STACK_DIR is followed by one, which skips the
// build-context arguments (`"$GAME_STACK_DIR"`, `"${GAME_STACK_DIR}:/src:z"`) and // build-context arguments (`"$GAME_STACK_DIR"`, `"${GAME_STACK_DIR}:/src:z"`) and
// the glob for gradle's output, none of which are inputs this tar has to carry. // the glob for gradle's output, none of which are inputs this tar has to carry.
+11 -117
View File
@@ -20,16 +20,13 @@ import (
"felis.lolicon.best/internal/backupjob" "felis.lolicon.best/internal/backupjob"
"felis.lolicon.best/internal/build" "felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/imagepin" "felis.lolicon.best/internal/imagepin"
"felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/metrics" "felis.lolicon.best/internal/metrics"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/nodecontrol"
"felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/panel"
"felis.lolicon.best/internal/passkey" "felis.lolicon.best/internal/passkey"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/reaper" "felis.lolicon.best/internal/reaper"
"felis.lolicon.best/internal/registryprune" "felis.lolicon.best/internal/registryprune"
@@ -62,9 +59,9 @@ func passkeyRelyingParty(cfg *config.Config) (string, []string) {
if rpID == "" { if rpID == "" {
return "", nil return "", nil
} }
origins := []string{"https://" + rpID, fmt.Sprintf("https://%s:%d", rpID, setupPanelNodePort())} origins := []string{"https://" + rpID}
if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != rpID { if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != rpID {
origins = append(origins, "https://"+admin, fmt.Sprintf("https://%s:%d", admin, setupPanelNodePort())) origins = append(origins, "https://"+admin)
} }
return rpID, origins return rpID, origins
} }
@@ -78,7 +75,7 @@ func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSourc
sources := make([]api.AuthSource, 0, len(configured)+1) sources := make([]api.AuthSource, 0, len(configured)+1)
sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true}) sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true})
for _, s := range configured { for _, s := range configured {
sources = append(sources, api.AuthSource{Tag: s.Tag, Prefix: s.Prefix, URL: s.URL, APIURL: s.APIURL}) sources = append(sources, api.AuthSource{Tag: s.Tag, Prefix: s.Prefix, URL: s.URL})
} }
return sources return sources
} }
@@ -276,23 +273,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// store at load, so by this point cfg.Archive.Store is guaranteed tarLocal.) // store at load, so by this point cfg.Archive.Store is guaranteed tarLocal.)
var restorer api.Restorer var restorer api.Restorer
felisImage, backupPVC := os.Getenv("FELIS_IMAGE"), os.Getenv("FELIS_BACKUP_PVC") felisImage, backupPVC := os.Getenv("FELIS_IMAGE"), os.Getenv("FELIS_BACKUP_PVC")
worldResolver := placement.Resolve(cl, cfg.K8s.Namespace)
distribution, err := distributionManager(cl, cfg, felisImage)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if distribution != nil {
worldResolver = distribution.Resolve
}
if felisImage != "" && backupPVC != "" { if felisImage != "" && backupPVC != "" {
rcfg := restoreConfig(cfg, felisImage, backupPVC) rcfg := restoreConfig(cfg, felisImage, backupPVC)
rcfg.ResolveWorld = worldResolver restorer = &restore.Restorer{Jobs: restore.NewK8sJobs(cl), Config: rcfg}
var jobs restore.Jobs = restore.NewK8sJobs(cl)
if distribution != nil {
jobs = distribution
}
restorer = &restore.Restorer{Jobs: jobs, Config: rcfg}
} else { } else {
fmt.Fprintln(stderr, "felis api: restore executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — restore endpoint returns 503") fmt.Fprintln(stderr, "felis api: restore executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — restore endpoint returns 503")
} }
@@ -305,13 +288,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// endpoint honestly returns 503. // endpoint honestly returns 503.
var backuper api.Backuper var backuper api.Backuper
if felisImage != "" && backupPVC != "" { if felisImage != "" && backupPVC != "" {
bcfg := backupConfig(cfg, felisImage, backupPVC) backuper = &backupjob.Backuper{Jobs: backupjob.NewK8sJobs(cl), Config: backupConfig(cfg, felisImage, backupPVC)}
bcfg.ResolveWorld = worldResolver
var jobs backupjob.Jobs = backupjob.NewK8sJobs(cl)
if distribution != nil {
jobs = distribution
}
backuper = &backupjob.Backuper{Jobs: jobs, Config: bcfg}
} else { } else {
fmt.Fprintln(stderr, "felis api: backup executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — backup endpoint returns 503") fmt.Fprintln(stderr, "felis api: backup executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — backup endpoint returns 503")
} }
@@ -322,9 +299,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// is wired under the restore gate; otherwise the export routes return 503. // is wired under the restore gate; otherwise the export routes return 503.
var exporter api.Exporter var exporter api.Exporter
if felisImage != "" && backupPVC != "" { if felisImage != "" && backupPVC != "" {
ecfg := exportConfig(cfg, felisImage, backupPVC) exporter = worldexport.New(clientset, exportConfig(cfg, felisImage, backupPVC))
ecfg.ResolveWorld = worldResolver
exporter = worldexport.New(clientset, ecfg)
} else { } else {
fmt.Fprintln(stderr, "felis api: world export disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — export endpoints return 503") fmt.Fprintln(stderr, "felis api: world export disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — export endpoints return 503")
} }
@@ -343,16 +318,10 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// orphaned (the index is in memory), so the stage starts empty. // orphaned (the index is in memory), so the stage starts empty.
var files api.FileEditor var files api.FileEditor
var fileStage *fileedit.Stage var fileStage *fileedit.Stage
var fileBrowser *fileedit.Browser
if felisImage != "" { if felisImage != "" {
fcfg := fileEditConfig(cfg, felisImage)
fcfg.ResolveWorld = worldResolver
fileBrowser = &fileedit.Browser{BaseURL: internalAPIBaseURL()}
runner := fileedit.NewK8sRunner(clientset)
runner.Browser = fileBrowser
files = &fileedit.Editor{ files = &fileedit.Editor{
Runner: runner, Runner: fileedit.NewK8sRunner(clientset),
Config: fcfg, Config: fileEditConfig(cfg, felisImage),
} }
fileStage = &fileedit.Stage{Dir: fileStagingDir()} fileStage = &fileedit.Stage{Dir: fileStagingDir()}
if err := fileStage.Sweep(); err != nil { if err := fileStage.Sweep(); err != nil {
@@ -386,21 +355,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "felis api: MinecraftServer cache: %v\n", err) fmt.Fprintf(stderr, "felis api: MinecraftServer cache: %v\n", err)
return 1 return 1
} }
cluster := api.NewK8sCluster(cl, cfg.K8s.Namespace).WithServerCache(serverCache, serversSynced).WithDistributed(distribution != nil, os.Getenv("FELIS_CONTROLLER_NODE")) cluster := api.NewK8sCluster(cl, cfg.K8s.Namespace).WithServerCache(serverCache, serversSynced)
if distribution != nil {
distribution.Record = func(ctx context.Context, b distributed.Backup) error {
keep, retention, ok := backupPolicy(b.Reason, rcfg)
if !ok {
return fmt.Errorf("unknown backup reason %s", b.Reason)
}
st := reaper.NewPGStore(drv.DB())
if err := st.InsertBackup(ctx, reaper.BackupRecord{ID: "bk-" + b.ID, ServerName: b.Server, FormerOwner: b.Owner, BackupRef: b.Receipt.Ref, SizeBytes: b.Receipt.Size, SHA256: b.Receipt.SHA256, Reason: b.Reason, ExpiresAt: time.Now().Add(retention)}); err != nil {
return err
}
pruneBackups(ctx, st, distribution.Archive, b.Server, b.Owner, b.Reason, keep, b.Protect, stdout, stderr)
return nil
}
}
jobStatus := api.NewK8sJobStatus(cl, cfg.K8s.Namespace) jobStatus := api.NewK8sJobStatus(cl, cfg.K8s.Namespace)
a := &api.API{ a := &api.API{
Repo: repo, Repo: repo,
@@ -421,7 +376,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
RestoreChains: jobStatus, RestoreChains: jobStatus,
Files: files, Files: files,
FileStage: fileStage, FileStage: fileStage,
FileBrowser: fileBrowser,
// The file Job fetches an upload from here; it runs in the minecraft // The file Job fetches an upload from here; it runs in the minecraft
// namespace, where the internal face is reachable like it is for the login // namespace, where the internal face is reachable like it is for the login
// gate. // gate.
@@ -476,8 +430,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// [[auth_source]] is configured, so an empty list has to mean a Mojang-only relay, the // [[auth_source]] is configured, so an empty list has to mean a Mojang-only relay, the
// same as under `felis nano`. A nil list would 204 every login, premium ones included. // same as under `felis nano`. A nil list would 204 every login, premium ones included.
a.AuthSources = authSourcesFromConfig(cfg.AuthSources) a.AuthSources = authSourcesFromConfig(cfg.AuthSources)
a.AuthSourceSettings = &api.AuthSourceSettings{Repo: repo, Defaults: a.AuthSources} fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources))
fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d default third-party source(s); panel settings take precedence\n", len(cfg.AuthSources))
// Passkey (WebAuthn) verifier (spec §14, Phase 6). One relying party spans BOTH // Passkey (WebAuthn) verifier (spec §14, Phase 6). One relying party spans BOTH
// web faces: the RP id is the panel hostname (console.<root>), and because that is // web faces: the RP id is the panel hostname (console.<root>), and because that is
@@ -502,7 +455,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain,
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname), defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname),
defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname), defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname),
cfg.Velocity.GamePort, cfg.Velocity.GameVersion, resolvedVersion(), distribution != nil) cfg.Velocity.GamePort, resolvedVersion())
internalSrv := newAPIServer(*internalAddr, a.InternalHandler()) internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler) externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
@@ -538,22 +491,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
go pruner.Loop(ctx, registryPruneInterval) go pruner.Loop(ctx, registryPruneInterval)
} }
go reapRejectedContexts(ctx, submissions, stderr) go reapRejectedContexts(ctx, submissions, stderr)
if fileStage != nil {
go expireFileSessions(ctx, fileStage, fileSessionSweep, stderr)
}
if exporter != nil {
go expireExports(ctx, a, exportSweep)
}
go retention.Loop(ctx, drv.DB(), retention.Policy{Audit: auditRetention}, retentionInterval, slog.Default()) go retention.Loop(ctx, drv.DB(), retention.Policy{Audit: auditRetention}, retentionInterval, slog.Default())
if distribution != nil {
a.Distribution = distribution
go reconcileDistribution(ctx, distribution, stderr)
}
if socket := os.Getenv("FELIS_NODE_CONTROL_SOCKET"); socket != "" {
a.NodeControl = nodecontrol.NewClient(socket)
cluster.NodeMaintenanceGuard = api.NodeMaintenanceGuard(a.NodeControl)
}
servers := []*http.Server{internalSrv, externalSrv} servers := []*http.Server{internalSrv, externalSrv}
if httpsSrv != nil { if httpsSrv != nil {
servers = append(servers, httpsSrv) servers = append(servers, httpsSrv)
@@ -858,51 +797,6 @@ func scheduleBackups(ctx context.Context, s *api.BackupScheduler, stderr io.Writ
} }
} }
// fileSessionSweep is how often expireFileSessions looks for idle upload
// sessions: small beside fileedit.SessionIdle, so an abandoned one gives its
// room back within minutes of going stale.
const fileSessionSweep = 10 * time.Minute
// expireFileSessions drops the file manager's upload sessions left untouched
// for fileedit.SessionIdle. Each reserved room on the staging disk for its whole
// file when it began, so one abandoned would otherwise hold that room until
// felis-api restarts.
func expireFileSessions(ctx context.Context, s *fileedit.Stage, every time.Duration, stderr io.Writer) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n := s.Expire(); n > 0 {
fmt.Fprintf(stderr, "felis api: dropped %d upload session(s) left idle for %s\n", n, fileedit.SessionIdle)
}
}
}
}
// exportSweep is how often expireExports runs: an export whose Job never
// connected is stopped within a minute of going stale.
const exportSweep = time.Minute
// expireExports runs the export sweep (api.API.ExpireExports) on a ticker. The
// export routes sweep as they are called, and an owner who closed the tab calls
// none; a Job whose Pod never got going would then keep the server from
// starting until the Job's deadline.
func expireExports(ctx context.Context, a interface{ ExpireExports() }, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
a.ExpireExports()
}
}
}
// reapRejectedContexts deletes, once an hour, the uploaded contexts of // reapRejectedContexts deletes, once an hour, the uploaded contexts of
// submissions rejected more than submit.RejectedContextRetention ago, and the // submissions rejected more than submit.RejectedContextRetention ago, and the
// chunked uploads left untouched for submit.StalePartRetention. Without it a // chunked uploads left untouched for submit.StalePartRetention. Without it a
+4 -95
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -9,7 +8,6 @@ import (
"net" "net"
"net/http" "net/http"
"slices" "slices"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -17,14 +15,12 @@ import (
"felis.lolicon.best/internal/api" "felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/build" "felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/fileedit"
) )
// The passkey relying party follows the panel host the SPA is served on: an install // The passkey relying party follows the panel host the SPA is served on: an install
// that names only its root domain still gets passkeys, on console.<root>, with the // that names only its root domain still gets passkeys, on console.<root>, with the
// operator host as the second origin; only an install with no panel host goes without. // operator host as the second origin; only an install with no panel host goes without.
func TestPasskeyRelyingParty(t *testing.T) { func TestPasskeyRelyingParty(t *testing.T) {
t.Setenv("FELIS_PANEL_NODEPORT", "")
for _, tc := range []struct { for _, tc := range []struct {
name string name string
root, panel, admin string root, panel, admin string
@@ -44,28 +40,14 @@ func TestPasskeyRelyingParty(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname = tc.root, tc.panel, tc.admin cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname = tc.root, tc.panel, tc.admin
var wantOrigins []string
for _, origin := range tc.wantOrigins {
wantOrigins = append(wantOrigins, origin, fmt.Sprintf("%s:%d", origin, defaultPanelNodePort))
}
rp, origins := passkeyRelyingParty(cfg) rp, origins := passkeyRelyingParty(cfg)
if rp != tc.wantRP || !slices.Equal(origins, wantOrigins) { if rp != tc.wantRP || !slices.Equal(origins, tc.wantOrigins) {
t.Fatalf("relying party = %q %q, want %q %q", rp, origins, tc.wantRP, wantOrigins) t.Fatalf("relying party = %q %q, want %q %q", rp, origins, tc.wantRP, tc.wantOrigins)
} }
}) })
} }
} }
func TestPasskeyRelyingPartyIncludesConfiguredNodePort(t *testing.T) {
t.Setenv("FELIS_PANEL_NODEPORT", "30445")
cfg := &config.Config{}
cfg.Server.RootDomain = "example.com"
_, origins := passkeyRelyingParty(cfg)
if !slices.Contains(origins, "https://op.console.example.com:30445") {
t.Fatalf("configured NodePort origin missing: %v", origins)
}
}
// TestAuthSourcesFromConfig pins the one place the hasJoined identity anchor is decided: // TestAuthSourcesFromConfig pins the one place the hasJoined identity anchor is decided:
// Mojang is prepended in code, first, and is the only source whose UUIDs are trusted as-is. // Mojang is prepended in code, first, and is the only source whose UUIDs are trusted as-is.
// The empty case matters on its own — both `felis api` and `felis nano` call this with a // The empty case matters on its own — both `felis api` and `felis nano` call this with a
@@ -79,7 +61,7 @@ func TestAuthSourcesFromConfig(t *testing.T) {
{"no configured sources", nil}, {"no configured sources", nil},
{"configured sources", []config.AuthSourceConfig{ {"configured sources", []config.AuthSourceConfig{
{Tag: "littleskin", Prefix: "LS", URL: "https://littleskin.example/hasJoined"}, {Tag: "littleskin", Prefix: "LS", URL: "https://littleskin.example/hasJoined"},
{Tag: "guild", Prefix: "GD", URL: "https://guild.example/hasJoined", APIURL: "https://guild.example/api"}, {Tag: "guild", Prefix: "GD", URL: "https://guild.example/hasJoined"},
}}, }},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -95,7 +77,7 @@ func TestAuthSourcesFromConfig(t *testing.T) {
if s.Identity { if s.Identity {
t.Errorf("configured source %q is marked Identity; only Mojang may be", c.Tag) t.Errorf("configured source %q is marked Identity; only Mojang may be", c.Tag)
} }
if s.Tag != c.Tag || s.Prefix != c.Prefix || s.URL != c.URL || s.APIURL != c.APIURL { if s.Tag != c.Tag || s.Prefix != c.Prefix || s.URL != c.URL {
t.Errorf("source %d = %+v, want %+v in config order", i+1, s, c) t.Errorf("source %d = %+v, want %+v in config order", i+1, s, c)
} }
} }
@@ -253,76 +235,3 @@ func TestInUseImageRefsCoversEverySource(t *testing.T) {
t.Fatal("a failing whitelist read produced a reference list") t.Fatal("a failing whitelist read produced a reference list")
} }
} }
// TestExpireFileSessions runs the loop against a stage whose clock the test
// holds: the session idle past fileedit.SessionIdle goes, the one touched since
// stays, and the drop is said once.
func TestExpireFileSessions(t *testing.T) {
var mu sync.Mutex
now := time.Date(2026, 9, 28, 10, 0, 0, 0, time.UTC)
advance := func(d time.Duration) { mu.Lock(); now = now.Add(d); mu.Unlock() }
st := &fileedit.Stage{Dir: t.TempDir(), MinFree: 1e-9, Now: func() time.Time {
mu.Lock()
defer mu.Unlock()
return now
}}
idle, err := st.Begin("u1", "survival", "a.jar", 3)
if err != nil {
t.Fatal(err)
}
advance(fileedit.SessionIdle - time.Minute)
fresh, err := st.Begin("u1", "survival", "b.jar", 3)
if err != nil {
t.Fatal(err)
}
advance(2 * time.Minute)
ctx, cancel := context.WithCancel(context.Background())
var out bytes.Buffer
done := make(chan struct{})
go func() { expireFileSessions(ctx, st, time.Millisecond, &out); close(done) }()
for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(time.Millisecond) {
if _, err := st.Status("u1", "survival", idle.ID); errors.Is(err, fileedit.ErrNotStaged) {
break
}
if time.Now().After(deadline) {
cancel()
t.Fatal("the idle session was never dropped")
}
}
// A few more ticks with nothing idle, which must stay quiet.
time.Sleep(20 * time.Millisecond)
cancel()
<-done
if _, err := st.Status("u1", "survival", fresh.ID); err != nil {
t.Fatalf("the session touched since went too: %v", err)
}
if got := out.String(); got != "felis api: dropped 1 upload session(s) left idle for 6h0m0s\n" {
t.Fatalf("said %q", got)
}
}
type sweepCount struct{ n atomic.Int32 }
func (s *sweepCount) ExpireExports() { s.n.Add(1) }
// TestExpireExports: the loop sweeps on each tick, and returns once felis-api
// shuts down.
func TestExpireExports(t *testing.T) {
var s sweepCount
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { expireExports(ctx, &s, time.Millisecond); close(done) }()
for deadline := time.Now().Add(5 * time.Second); s.n.Load() < 3; time.Sleep(time.Millisecond) {
if time.Now().After(deadline) {
cancel()
t.Fatalf("swept %d times in 5s at a 1ms tick", s.n.Load())
}
}
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the loop outlived its context")
}
}
-58
View File
@@ -1,58 +0,0 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"time"
"felis.lolicon.best/internal/archivetransfer"
)
func cmdArchiveServe(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("archive-serve", flag.ContinueOnError)
fs.SetOutput(stderr)
root := fs.String("root", "/backups", "archive PVC mount (must match archive.local_path)")
addr := fs.String("listen", ":8090", "private archive listener")
limit := fs.Int64("max-bytes", archivetransfer.DefaultLimit, "maximum compressed archive bytes")
if err := fs.Parse(args); err != nil {
return 2
}
key := os.Getenv(archivetransfer.KeyEnv)
if len(key) < 32 || *limit <= 0 {
fmt.Fprintln(stderr, "archive-serve: signing key and positive size limit required")
return 2
}
if err := os.MkdirAll(*root, 0750); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
limitHeapToCgroup()
transport := &archivetransfer.Server{Root: *root, Key: key, Limit: *limit}
done := make(chan struct{})
defer close(done)
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
if err := transport.Sweep(time.Now()); err != nil {
fmt.Fprintln(stderr, "archive journal cleanup:", err)
}
select {
case <-done:
return
case <-ticker.C:
}
}
}()
srv := &http.Server{Addr: *addr, Handler: transport, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Hour, WriteTimeout: 2 * time.Hour, MaxHeaderBytes: 16 << 10}
fmt.Fprintln(stdout, "archive transport listening", *addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}
+1 -3
View File
@@ -168,9 +168,7 @@ func backupPolicy(reason string, rcfg reaper.Config) (keep int, retention time.D
// previous owner's. protect is never removed: it is the backup a chained restore // previous owner's. protect is never removed: it is the backup a chained restore
// is about to extract. The new backup is already recorded; a removal that fails // is about to extract. The new backup is already recorded; a removal that fails
// is reported and retried after the next backup. // is reported and retried after the next backup.
func pruneBackups(ctx context.Context, st *reaper.PGStore, archiver interface { func pruneBackups(ctx context.Context, st *reaper.PGStore, archiver backup.WorldArchiver, server, owner, reason string, keep int, protect string, stdout, stderr io.Writer) {
Delete(context.Context, backup.ArchiveRef) error
}, server, owner, reason string, keep int, protect string, stdout, stderr io.Writer) {
excess, err := st.ExcessBackups(ctx, server, owner, reason, keep, protect) excess, err := st.ExcessBackups(ctx, server, owner, reason, keep, protect)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis backup: list older backups of %s: %v\n", server, err) fmt.Fprintf(stderr, "felis backup: list older backups of %s: %v\n", server, err)
+2 -9
View File
@@ -9,17 +9,10 @@ import (
func cmdBootstrapAssets(args []string, stdout, stderr io.Writer) int { func cmdBootstrapAssets(args []string, stdout, stderr io.Writer) int {
if len(args) != 1 { if len(args) != 1 {
fmt.Fprintln(stderr, "felis bootstrap-assets: usage: felis bootstrap-assets crd|game-stack|config-keys") fmt.Fprintln(stderr, "felis bootstrap-assets: usage: felis bootstrap-assets crd|game-stack")
return 2 return 2
} }
switch args[0] { switch args[0] {
case "config-keys":
// Optional keys the installer may emit; older binaries reject this verb.
_, err := fmt.Fprintln(stdout, "velocity.game_version")
if err != nil {
fmt.Fprintf(stderr, "felis bootstrap-assets: write: %v\n", err)
return 1
}
case "crd": case "crd":
crd, err := felis.MinecraftServerCRD() crd, err := felis.MinecraftServerCRD()
if err != nil { if err != nil {
@@ -39,7 +32,7 @@ func cmdBootstrapAssets(args []string, stdout, stderr io.Writer) int {
return 1 return 1
} }
default: default:
fmt.Fprintln(stderr, "felis bootstrap-assets: usage: felis bootstrap-assets crd|game-stack|config-keys") fmt.Fprintln(stderr, "felis bootstrap-assets: usage: felis bootstrap-assets crd|game-stack")
return 2 return 2
} }
return 0 return 0
+66 -27
View File
@@ -11,7 +11,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"net/url"
"os" "os"
"strings" "strings"
"time" "time"
@@ -94,9 +93,11 @@ type ownerStore interface {
// role=owner identity (migration 0011 adds that role); the two are the only // role=owner identity (migration 0011 adds that role); the two are the only
// staff roles. // staff roles.
InsertOperator(ctx context.Context, id, username, email string) error InsertOperator(ctx context.Context, id, username, email string) error
// CompleteOwnerSetup creates or resumes the first panel login atomically. // CompleteOwnerSetup atomically consumes the in-game link code, creates or
CompleteOwnerSetup(ctx context.Context, newUserID string, now time.Time, // promotes the bound Owner, enables local auth, and stores the one-time setup
tokenHash string, tokenExpiresAt time.Time) (userID, username string, err error) // token. A failure rolls all four writes back so setup is always retryable.
CompleteOwnerSetup(ctx context.Context, newUserID, code string, now time.Time,
tokenHash string, tokenExpiresAt time.Time) (userID, mcUUID, authSource string, err error)
SetSetting(ctx context.Context, key string, value []byte) error SetSetting(ctx context.Context, key string, value []byte) error
// Audit records the break-glass accountability row. // Audit records the break-glass accountability row.
Audit(ctx context.Context, e api.AuditEntry) error Audit(ctx context.Context, e api.AuditEntry) error
@@ -367,7 +368,7 @@ type breakGlassOp struct {
// breakGlassOutcome is what performBreakGlass reports back to the TUI. // breakGlassOutcome is what performBreakGlass reports back to the TUI.
type breakGlassOutcome struct { type breakGlassOutcome struct {
setupTokenURL string // non-empty when setup minted a one-time first-login URL setupTokenURL string // non-empty when setup minted a one-time first-login URL
ownerUsername string // panel Owner created or resumed by setup ownerIdentity string // verified Minecraft UUID for the setup Owner-bind path
auditErr error // non-nil if the accountability row could not be written auditErr error // non-nil if the accountability row could not be written
} }
@@ -407,12 +408,25 @@ func newSetupToken() (raw, hash string, err error) {
return raw, hex.EncodeToString(sum[:]), nil return raw, hex.EncodeToString(sum[:]), nil
} }
// performSetupOwner establishes panel access under the caller's host-root // performSetupMCBind is the `felis setup` Owner-establishment path: the operator
// authority. Minecraft identity can be linked later from the authenticated panel. // binds their Minecraft account via a one-time link code the login gate handed
func performSetupOwner(ctx context.Context, s ownerStore, panelURL, osUser string) (breakGlassOutcome, error) { // them in-game, the bound user is promoted to role='owner' (passwordless Owner),
base, err := url.Parse(strings.TrimRight(panelURL, "/")) // local auth is enabled, and a one-time setup URL is minted for the first web
if err != nil || base.Scheme != "https" || base.Host == "" { // login where the Owner verifies email / enrolls a passkey. adminHostname is the
return breakGlassOutcome{}, errors.New("a configured HTTPS operator console is required") // operator-console host the URL points at (op.console.<root>): the Owner is staff,
// so first-run onboarding belongs on the operator face, not the player panel. The
// passkey verifier's RP id is the panel host, but its permitted origins now include
// op.console (cmd/felis/api.go), so enrollment on op.console is a valid ceremony —
// one binding that works on both faces. osUser is recorded as the accountable actor.
//
// Local auth is as load-bearing here as it is in break-glass, and for a sharper
// reason: an MC-bound Owner has no password AND no email, so the setup token is
// their ONLY door. CompleteOwnerSetup therefore commits the identity bind, auth
// toggle, and token together; any failed write leaves the link code retryable.
func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, osUser string) (breakGlassOutcome, error) {
code = strings.TrimSpace(strings.ToUpper(code))
if code == "" {
return breakGlassOutcome{}, errors.New("link code is required")
} }
newID := newOwnerID() newID := newOwnerID()
if newID == "" { if newID == "" {
@@ -423,21 +437,48 @@ func performSetupOwner(ctx context.Context, s ownerStore, panelURL, osUser strin
return breakGlassOutcome{}, err return breakGlassOutcome{}, err
} }
now := time.Now() now := time.Now()
userID, username, err := s.CompleteOwnerSetup(ctx, newID, now, hash, now.Add(setupTokenTTL)) _, mcUUID, authSource, err := s.CompleteOwnerSetup(
out := breakGlassOutcome{ownerUsername: username} ctx, newID, code, now, hash, now.Add(setupTokenTTL))
if err != nil { if err != nil {
return out, err return breakGlassOutcome{}, fmt.Errorf("complete owner setup: %w", err)
} }
base.Path = "/setup" // The load-bearing writes committed together above. Accountability remains
base.RawQuery = url.Values{"token": {raw}}.Encode() // best-effort: an unhappy audit sink never costs the operator their install.
out.setupTokenURL = base.String() out := breakGlassOutcome{
blob, _ := json.Marshal(map[string]string{"os_user": osUser, "user_id": userID, "username": username}) ownerIdentity: mcUUID,
out.auditErr = s.Audit(ctx, api.AuditEntry{ auditErr: auditSetupMCBind(ctx, s, osUser, mcUUID, authSource),
Actor: osUser, Source: "setup", Action: "setup.owner_login", Payload: blob, }
}) host := strings.TrimSpace(adminHostname)
if host == "" {
host = "op.console.localhost"
}
out.setupTokenURL = "https://" + host + "/setup?token=" + raw
return out, nil return out, nil
} }
// auditSetupMCBind records who claimed the Owner seat at setup. It carries the
// Minecraft identity rather than a username because that IS the evidence: the
// login gate only issues a link code to a player it authenticated, so mc_uuid +
// auth_source say which account was verified and by whom. Actor is the OS user who
// ran `felis setup` — honest attribution, not proof (root can edit the row).
func auditSetupMCBind(ctx context.Context, s ownerStore, osUser, mcUUID, authSource string) error {
blob, err := json.Marshal(map[string]any{
"mode": "setup",
"os_user": osUser,
"mc_uuid": mcUUID,
"auth_source": authSource,
})
if err != nil {
return err
}
return s.Audit(ctx, api.AuditEntry{
Actor: osUser,
Source: "setup",
Action: "setup.owner_bind",
Payload: blob,
})
}
// auditBreakGlass writes the break-glass accountability row. The actor is the // auditBreakGlass writes the break-glass accountability row. The actor is the
// resolved human identity (a verified admin in recovery, the OS user otherwise); // resolved human identity (a verified admin in recovery, the OS user otherwise);
// the payload carries the full who/what/how so an after-the-fact reader can tell a // the payload carries the full who/what/how so an after-the-fact reader can tell a
@@ -590,9 +631,10 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, db config.DatabaseConfi
return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery) return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
} }
// runSetupTUI configures deployment before issuing the first panel login link. // runSetupTUI never reaches recovery: setup with a staff account present lands on
// the status screen, so it has no relay to hand over.
func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) { func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
return runConsoleRoot(newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})) return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
} }
// newConsoleRoot is the console's root model as the host runs it: the summary // newConsoleRoot is the console's root model as the host runs it: the summary
@@ -607,10 +649,7 @@ func newConsoleRoot(ctx context.Context, s ownerStore, db config.DatabaseConfig,
} }
func runConsoleTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) { func runConsoleTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) {
return runConsoleRoot(newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode, recovery)) rm := newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode, recovery)
}
func runConsoleRoot(rm *rootModel) (breakGlassResult, error) {
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
if err != nil { if err != nil {
return breakGlassResult{}, err return breakGlassResult{}, err
+163 -77
View File
@@ -24,14 +24,16 @@ type fakeOwnerStore struct {
settings map[string][]byte settings map[string][]byte
audits []api.AuditEntry audits []api.AuditEntry
tokens []setupTokenCall tokens []setupTokenCall
setupIDs []string redeems []redeemCall
users map[string]*api.StaffUser // keyed by username users map[string]*api.StaffUser // keyed by username
admins bool // AdminExists answer admins bool // AdminExists answer
ownerSeat string // OwnerUsername answer: the occupied seat, "" when none ownerSeat string // OwnerUsername answer: the occupied seat, "" when none
setupUserID string // CompleteOwnerSetup's success result. redeemUserID defaults to the fresh id
setupUsername string // the caller passes (the unlinked-UUID case) when left empty.
onboarded bool redeemUserID string
redeemMCUUID string
redeemAuthSource string
upsertErr error upsertErr error
insertErr error insertErr error
@@ -57,6 +59,12 @@ type setupTokenCall struct {
expiresAt time.Time expiresAt time.Time
} }
// redeemCall records the inputs CompleteOwnerSetup was called with.
type redeemCall struct {
newUserID string
code string
}
func (f *fakeOwnerStore) AdminExists(_ context.Context) (bool, error) { func (f *fakeOwnerStore) AdminExists(_ context.Context) (bool, error) {
if f.adminErr != nil { if f.adminErr != nil {
return false, f.adminErr return false, f.adminErr
@@ -110,31 +118,30 @@ func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email s
return nil return nil
} }
// CompleteOwnerSetup models the transaction without any game link code. // CompleteOwnerSetup models the real all-or-nothing transaction: injected failures
func (f *fakeOwnerStore) CompleteOwnerSetup(_ context.Context, newUserID string, _ time.Time, // record none of the redeem, auth-toggle, or setup-token writes.
tokenHash string, expiresAt time.Time) (string, string, error) { func (f *fakeOwnerStore) CompleteOwnerSetup(_ context.Context, newUserID, code string, _ time.Time,
for _, err := range []error{f.redeemErr, f.setErr, f.createTokenErr} { tokenHash string, expiresAt time.Time) (string, string, string, error) {
if err != nil { if f.redeemErr != nil {
return "", "", err return "", "", "", f.redeemErr
}
} }
userID, username := f.setupUserID, f.setupUsername if f.setErr != nil {
return "", "", "", f.setErr
}
if f.createTokenErr != nil {
return "", "", "", f.createTokenErr
}
f.redeems = append(f.redeems, redeemCall{newUserID, code})
userID := f.redeemUserID
if userID == "" { if userID == "" {
userID = newUserID userID = newUserID // unlinked UUID → the fresh id becomes the Owner
} }
if username == "" {
username = "owner"
}
if f.onboarded {
return userID, username, api.ErrConflict
}
f.setupIDs = append(f.setupIDs, newUserID)
if f.settings == nil { if f.settings == nil {
f.settings = map[string][]byte{} f.settings = map[string][]byte{}
} }
f.settings[api.LocalAuthEnabledKey] = []byte("true") f.settings[api.LocalAuthEnabledKey] = []byte("true")
f.tokens = append(f.tokens, setupTokenCall{tokenHash, userID, expiresAt}) f.tokens = append(f.tokens, setupTokenCall{tokenHash, userID, expiresAt})
return userID, username, nil return userID, f.redeemMCUUID, f.redeemAuthSource, nil
} }
func (f *fakeOwnerStore) SetSetting(_ context.Context, key string, value []byte) error { func (f *fakeOwnerStore) SetSetting(_ context.Context, key string, value []byte) error {
@@ -703,70 +710,149 @@ func TestPerformAddOperator(t *testing.T) {
}) })
} }
func TestPerformSetupOwner(t *testing.T) { func TestPerformSetupMCBind(t *testing.T) {
ctx := context.Background() ctx := context.Background()
f := &fakeOwnerStore{setupUserID: "usr-owner-1"}
out, err := performSetupOwner(ctx, f, "https://op.console.example.com:30443", "deploybot")
if err != nil {
t.Fatal(err)
}
if out.ownerUsername != "owner" {
t.Fatalf("username = %q", out.ownerUsername)
}
const prefix = "https://op.console.example.com:30443/setup?token="
if !strings.HasPrefix(out.setupTokenURL, prefix) {
t.Fatalf("URL = %q", out.setupTokenURL)
}
if len(f.tokens) != 1 || f.tokens[0].userID != "usr-owner-1" {
t.Fatalf("tokens = %+v", f.tokens)
}
raw := strings.TrimPrefix(out.setupTokenURL, prefix)
sum := sha256.Sum256([]byte(raw))
if f.tokens[0].tokenHash != hex.EncodeToString(sum[:]) {
t.Fatal("stored token does not match URL")
}
if !f.tokens[0].expiresAt.After(time.Now()) {
t.Fatal("token already expired")
}
if string(f.settings[api.LocalAuthEnabledKey]) != "true" {
t.Fatal("local auth stayed disabled")
}
e, payload := auditOf(t, f)
if e.Actor != "deploybot" || e.Action != "setup.owner_login" || payload["user_id"] != "usr-owner-1" {
t.Fatalf("audit = %+v, %v", e, payload)
}
t.Run("failed writes leave no partially initialized login", func(t *testing.T) { t.Run("binds the owner and mints a setup URL whose token hash is what is stored", func(t *testing.T) {
for _, store := range []*fakeOwnerStore{ f := &fakeOwnerStore{redeemUserID: "usr-owner-1", redeemMCUUID: "mc-uuid-1", redeemAuthSource: "mojang"}
{redeemErr: errors.New("database unavailable")}, out, err := performSetupMCBind(ctx, f, " abc-123 ", "console.example.com", "deploybot")
{setErr: errors.New("settings write failed")}, if err != nil {
{createTokenErr: errors.New("token write failed")}, t.Fatalf("performSetupMCBind: %v", err)
} { }
if _, err := performSetupOwner(ctx, store, "https://op.console.example.com", "root"); err == nil { // The Owner this mints has no password and no email, so the setup token is the
t.Fatal("want failure") // only door — and handleSetupRedeem is gated on local_auth_enabled. A bind that
} // leaves the toggle off hands back a URL that answers 403.
if len(store.tokens) != 0 || len(store.setupIDs) != 0 || len(store.settings) != 0 { if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
t.Fatal("partial setup") t.Error("local auth was not enabled — the setup URL would 403 local_auth_disabled")
} }
// The bind is attributed by Minecraft identity, because that is what the login
// gate verified; a username would be the one thing nobody checked.
e, payload := auditOf(t, f)
if e.Actor != "deploybot" || e.Source != "setup" || e.Action != "setup.owner_bind" {
t.Errorf("audit = %+v, want actor=deploybot source=setup action=setup.owner_bind", e)
}
if payload["mc_uuid"] != "mc-uuid-1" || payload["auth_source"] != "mojang" {
t.Errorf("audit payload = %v, want the redeemed mc_uuid + auth_source", payload)
}
if out.ownerIdentity != "mc-uuid-1" {
t.Errorf("owner identity = %q, want the verified Minecraft UUID", out.ownerIdentity)
}
const prefix = "https://console.example.com/setup?token="
if !strings.HasPrefix(out.setupTokenURL, prefix) {
t.Fatalf("setup URL = %q, want prefix %q", out.setupTokenURL, prefix)
}
// The link code is trimmed and upper-cased before redemption.
if len(f.redeems) != 1 {
t.Fatalf("want 1 redeem, got %d", len(f.redeems))
}
if f.redeems[0].code != "ABC-123" {
t.Errorf("redeemed code = %q, want ABC-123 (trimmed + upper-cased)", f.redeems[0].code)
}
if !strings.HasPrefix(f.redeems[0].newUserID, "usr-") {
t.Errorf("redeem newUserID = %q, want usr- prefix", f.redeems[0].newUserID)
}
// Exactly one token minted, for the redeemed user, and only its hash stored —
// the stored hash must be sha-256 of the raw token carried in the URL.
if len(f.tokens) != 1 {
t.Fatalf("want 1 setup token, got %d", len(f.tokens))
}
tok := f.tokens[0]
if tok.userID != "usr-owner-1" {
t.Errorf("token userID = %q, want usr-owner-1 (the redeemed owner)", tok.userID)
}
raw := strings.TrimPrefix(out.setupTokenURL, prefix)
sum := sha256.Sum256([]byte(raw))
if tok.tokenHash != hex.EncodeToString(sum[:]) {
t.Error("stored token hash is not sha-256 of the raw token in the URL")
}
if tok.tokenHash == raw || tok.tokenHash == "" {
t.Error("the raw token (or nothing) was stored instead of its hash")
}
// The token is short-lived and in the future.
if !tok.expiresAt.After(time.Now()) {
t.Errorf("token expiresAt = %v, want a future time", tok.expiresAt)
} }
}) })
t.Run("settled account is not reset", func(t *testing.T) {
store := &fakeOwnerStore{setupUserID: "existing", setupUsername: "alice", onboarded: true} t.Run("an empty link code mints nothing", func(t *testing.T) {
out, err := performSetupOwner(ctx, store, "https://op.console.example.com", "root") f := &fakeOwnerStore{}
if !errors.Is(err, api.ErrConflict) || out.ownerUsername != "alice" || len(store.tokens) != 0 { if _, err := performSetupMCBind(ctx, f, " ", "console.example.com", "root"); err == nil {
t.Fatalf("out = %+v err = %v", out, err) t.Fatal("want error for an empty link code")
}
if len(f.redeems) != 0 || len(f.tokens) != 0 {
t.Errorf("want no redeem/token on an empty code, got redeems=%d tokens=%d", len(f.redeems), len(f.tokens))
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
t.Error("local auth was enabled without an owner — the gate must not open on a failed bind")
} }
}) })
t.Run("audit failure still returns the login link", func(t *testing.T) {
out, err := performSetupOwner(ctx, &fakeOwnerStore{auditErr: errors.New("audit down")}, "https://op.console.example.com", "root") t.Run("a link-code redemption failure mints no token", func(t *testing.T) {
if err != nil || out.auditErr == nil || out.setupTokenURL == "" { f := &fakeOwnerStore{redeemErr: errors.New("code expired")}
t.Fatalf("out = %+v err = %v", out, err) if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil {
t.Fatal("want error when the link code cannot be redeemed")
}
if len(f.tokens) != 0 {
t.Errorf("want no token minted on a redeem failure, got %d", len(f.tokens))
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
t.Error("local auth was enabled without an owner — the gate must not open on a failed redeem")
} }
}) })
t.Run("missing HTTPS origin cannot create an account", func(t *testing.T) {
store := &fakeOwnerStore{} t.Run("a local-auth failure fails the bind rather than minting an unredeemable URL", func(t *testing.T) {
if _, err := performSetupOwner(ctx, store, "", "root"); err == nil || len(store.tokens) != 0 { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", setErr: errors.New("db down")}
t.Fatal("invalid origin accepted") if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil {
t.Fatal("want error when local auth cannot be enabled")
}
if len(f.redeems) != 0 || len(f.tokens) != 0 {
t.Errorf("atomic setup was partially recorded: redeems=%d tokens=%d", len(f.redeems), len(f.tokens))
}
})
t.Run("a token-store failure rolls the bind back", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", createTokenErr: errors.New("db down")}
if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil {
t.Fatal("want error when the setup token cannot be stored")
}
if len(f.redeems) != 0 {
t.Errorf("link code was consumed despite token failure, got %d redeems", len(f.redeems))
}
if len(f.tokens) != 0 {
t.Errorf("want no recorded token when the store fails, got %d", len(f.tokens))
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
t.Error("local auth stayed enabled despite transaction rollback")
}
})
t.Run("an audit failure does not cost the operator their install", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1", auditErr: errors.New("audit sink down")}
out, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root")
if err != nil {
t.Fatalf("an audit failure must not fail the bind: %v", err)
}
if out.auditErr == nil {
t.Error("the audit failure was swallowed instead of surfaced on the outcome")
}
if out.setupTokenURL == "" {
t.Error("no setup URL minted despite a recoverable audit failure")
}
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
t.Error("local auth was not enabled despite a recoverable audit failure")
}
})
t.Run("defaults to the op.console host when adminHostname is empty", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1"}
out, err := performSetupMCBind(ctx, f, "abc-123", " ", "root")
if err != nil {
t.Fatalf("performSetupMCBind: %v", err)
}
// The Owner is staff, so onboarding lands on the operator console, not the
// player panel — the empty-host fallback must reflect that.
if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") {
t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL)
} }
}) })
} }
+1 -4
View File
@@ -551,10 +551,7 @@ func TestPreMigrateBackupExportsServers(t *testing.T) {
func TestServerExportStopsWaitingWithTheContext(t *testing.T) { func TestServerExportStopsWaitingWithTheContext(t *testing.T) {
dir := newPodRig(t) dir := newPodRig(t)
writeTestFile(t, filepath.Join(dir, "servers_fail"), "99", 0o600) writeTestFile(t, filepath.Join(dir, "servers_fail"), "99", 0o600)
// Long enough for the first try to run to its refusal: starting the fake ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
// k3s on a busy machine can take a few hundred ms. Still far below the
// 10s retry wait, so waiting it out would fail the check below.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
start := time.Now() start := time.Now()
_, err := exportMinecraftServers(ctx) _, err := exportMinecraftServers(ctx)
-44
View File
@@ -1,44 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"os"
"time"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/placement"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func distributionManager(cl client.Client, cfg *config.Config, image string) (*distributed.Manager, error) {
if os.Getenv("FELIS_DISTRIBUTED") != "true" {
return nil, nil
}
controller, url, key := os.Getenv("FELIS_CONTROLLER_NODE"), os.Getenv("FELIS_ARCHIVE_URL"), os.Getenv(archivetransfer.KeyEnv)
if controller == "" || url == "" || len(key) < 32 || image == "" || cfg.Archive.Store != "tarLocal" {
return nil, fmt.Errorf("distributed mode requires controller identity, archive service/key, Felis image and tarLocal")
}
return &distributed.Manager{Client: cl, Namespace: cfg.K8s.Namespace, Image: image, Controller: controller, Archive: archivetransfer.Client{URL: url, Root: cfg.Archive.LocalPath, Key: key}, Resolve: placement.Resolve(cl, cfg.K8s.Namespace, controller)}, nil
}
func reconcileDistribution(ctx context.Context, m *distributed.Manager, stderr io.Writer) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
if err := m.SettleBackups(ctx); err != nil {
fmt.Fprintln(stderr, "distributed backup:", err)
}
if err := m.ReconcileMigrations(ctx); err != nil {
fmt.Fprintln(stderr, "migration:", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
-13
View File
@@ -37,7 +37,6 @@ func cmdEgressGate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("egress-gate", flag.ContinueOnError) fs := flag.NewFlagSet("egress-gate", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
probe := fs.String("probe", "", "host:port the pod's NetworkPolicy denies (default: the Kubernetes API Service from KUBERNETES_SERVICE_HOST/PORT)") probe := fs.String("probe", "", "host:port the pod's NetworkPolicy denies (default: the Kubernetes API Service from KUBERNETES_SERVICE_HOST/PORT)")
positive := fs.String("positive-probe", "", "allowed host:port that must remain reachable during denial checks")
wait := fs.Duration("wait", 2*time.Minute, "how long the probe may keep answering before the gate gives up") wait := fs.Duration("wait", 2*time.Minute, "how long the probe may keep answering before the gate gives up")
failOpen := fs.Bool("fail-open", false, "when --wait runs out, warn and let the pod go on instead of refusing it") failOpen := fs.Bool("fail-open", false, "when --wait runs out, warn and let the pod go on instead of refusing it")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
@@ -54,18 +53,6 @@ func cmdEgressGate(args []string, stdout, stderr io.Writer) int {
start := time.Now() start := time.Now()
for { for {
if *positive != "" {
allowed, err := net.DialTimeout("tcp", *positive, egressDialTimeout)
if err != nil {
if time.Since(start) >= *wait {
fmt.Fprintln(stderr, "felis egress-gate: positive probe unavailable; refusing to start", err)
return 1
}
time.Sleep(egressPollInterval)
continue
}
allowed.Close()
}
conn, err := net.DialTimeout("tcp", *probe, egressDialTimeout) conn, err := net.DialTimeout("tcp", *probe, egressDialTimeout)
if err != nil { if err != nil {
fmt.Fprintf(stdout, "felis egress-gate: %s is unreachable after %s (%v); the egress lock is in effect\n", fmt.Fprintf(stdout, "felis egress-gate: %s is unreachable after %s (%v); the egress lock is in effect\n",
-14
View File
@@ -15,20 +15,6 @@ func shrinkEgressGate(t *testing.T) {
t.Cleanup(func() { egressDialTimeout, egressPollInterval = dial, poll }) t.Cleanup(func() { egressDialTimeout, egressPollInterval = dial, poll })
} }
func TestEgressGateRequiresPositiveReachability(t *testing.T) {
shrinkEgressGate(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
closed := ln.Addr().String()
ln.Close()
var out, errb bytes.Buffer
if code := cmdEgressGate([]string{"--positive-probe", closed, "--probe", closed, "--wait", "20ms"}, &out, &errb); code != 1 {
t.Fatal("unavailable probes allowed startup", code)
}
}
// The gate holds while the probe answers and lets the pod go on once the policy // The gate holds while the probe answers and lets the pod go on once the policy
// lands, which the test plays by closing the listener. // lands, which the test plays by closing the listener.
func TestEgressGateWaitsForTheLock(t *testing.T) { func TestEgressGateWaitsForTheLock(t *testing.T) {
+34 -188
View File
@@ -2,35 +2,26 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"flag" "flag"
"fmt" "fmt"
"hash"
"io" "io"
"io/fs"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings"
"syscall" "syscall"
"time" "time"
"felis.lolicon.best/internal/backup" "felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/worldexport" "felis.lolicon.best/internal/worldexport"
) )
// cmdExport is the in-Pod entrypoint the export Job runs. internal/worldexport // cmdExport is the in-Pod entrypoint the export Job runs. internal/worldexport
// renders a Pod whose command is `/usr/local/bin/felis export`. It archives the // renders a Pod whose command is `/usr/local/bin/felis export`. It archives the
// mounted world, re-streams one archive from the mounted backup store, or sends // mounted world (or opens one archive on the mounted backup store), PUTs the
// one file or folder of the world, PUTs it to felis-api's internal face, and // tar.gz to felis-api's internal face, and exits once felis-api says the
// exits once felis-api says the owner's browser got all of it. It is NOT a // owner's browser got all of it. It is NOT a user-facing command and is never
// user-facing command and is never invoked by hand. // invoked by hand.
// //
// Like cmdRestore it holds no database credentials and never calls config.Load: // Like cmdRestore it holds no database credentials and never calls config.Load:
// felis-api made every decision (who may download what, that the server is // felis-api made every decision (who may download what, that the server is
@@ -38,30 +29,19 @@ import (
// plus the one-time upload token in the environment, which opens this one // plus the one-time upload token in the environment, which opens this one
// export and nothing else. // export and nothing else.
// //
// Whatever leaves goes through the same guards as the file editor
// (fileedit.Guard): the proxy forwarding secret, which every server on the
// install shares, never leaves, and server.properties leaves with its RCON
// password redacted. A backup is stored with both, since a restore must bring
// the world back whole, so it is filtered on the way out rather than handed
// over as stored.
//
// Exit status: 0 once felis-api answers 204 (the download completed), 1 when // Exit status: 0 once felis-api answers 204 (the download completed), 1 when
// the export could not be read or handed over, a backup failed its digest // the archive could not be read or handed over, or felis-api refused it (the
// check, or felis-api refused it (the browser never came or left early), 2 on // browser never came, left early, or the backup failed its digest check), 2 on
// bad flags. The last stderr line reaches the export's status and the jobs list. // bad flags. The last stderr line reaches the export's status and the jobs list.
func cmdExport(args []string, stdout, stderr io.Writer) int { func cmdExport(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("export", flag.ContinueOnError) fs := flag.NewFlagSet("export", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
mode := fs.String("mode", "", "what to export: world, backup or files") mode := fs.String("mode", "", "what to export: world or backup")
server := fs.String("server", "", "server name being exported (for logging)") server := fs.String("server", "", "server name being exported (for logging)")
target := fs.String("target-url", "", "felis-api URL to PUT the export to") target := fs.String("target-url", "", "felis-api URL to PUT the archive to")
ref := fs.String("ref", "", "backup only: absolute path to the archive on the backup mount") ref := fs.String("ref", "", "backup only: absolute path to the archive on the backup mount")
backupRoot := fs.String("backup-root", "/backups", "backup only: mount path of the backup PVC (the ref must resolve under it)") backupRoot := fs.String("backup-root", "/backups", "backup only: mount path of the backup PVC (the ref must resolve under it)")
sum := fs.String("sha256", "", "backup only: the sha256 recorded when the archive was written; a mismatch fails the export before its end is sent") worldsRoot := fs.String("worlds-root", "/world", "world only: mount path of the world PVC to archive")
worldsRoot := fs.String("worlds-root", "/world", "world and files: mount path of the world PVC")
path := fs.String("path", "", "files only: the file or folder to send, relative to the world root")
rawArchive := fs.Bool("archive-raw", false, "internal archive transfer: preserve the complete world")
dir := fs.Bool("dir", false, "files only: the path is a folder, sent as a zip")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
@@ -70,7 +50,6 @@ func cmdExport(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "felis export: --target-url and %s are required\n", worldexport.TokenEnv) fmt.Fprintf(stderr, "felis export: --target-url and %s are required\n", worldexport.TokenEnv)
return 2 return 2
} }
limitHeapToCgroup()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
@@ -81,21 +60,11 @@ func cmdExport(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "felis export: --ref is required for a backup") fmt.Fprintln(stderr, "felis export: --ref is required for a backup")
return 2 return 2
} }
err = exportBackup(ctx, *target, token, *ref, *backupRoot, *sum, stdout) err = exportBackup(ctx, *target, token, *ref, *backupRoot)
case worldexport.ModeWorld: case worldexport.ModeWorld:
if *rawArchive { err = exportWorld(ctx, *target, token, *worldsRoot, stdout)
err = streamExport(ctx, *target, token, archiveType, -1, func(w io.Writer) error { _, _, err := backup.WriteTarGz(ctx, w, *worldsRoot, nil); return err })
} else {
err = exportWorld(ctx, *target, token, *worldsRoot, stdout)
}
case worldexport.ModeFiles:
if *path == "" {
fmt.Fprintln(stderr, "felis export: --path is required for files")
return 2
}
err = exportFiles(ctx, *target, token, *worldsRoot, *path, *dir, stdout)
default: default:
fmt.Fprintf(stderr, "felis export: --mode must be %s, %s or %s\n", worldexport.ModeWorld, worldexport.ModeBackup, worldexport.ModeFiles) fmt.Fprintf(stderr, "felis export: --mode must be %s or %s\n", worldexport.ModeWorld, worldexport.ModeBackup)
return 2 return 2
} }
if err != nil { if err != nil {
@@ -106,20 +75,9 @@ func cmdExport(args []string, stdout, stderr io.Writer) int {
return 0 return 0
} }
// archiveType is the content type of a world or backup export. // exportBackup hands over one stored archive as it is, with its length, so the
const archiveType = "application/gzip" // browser shows real progress and felis-api can check its recorded digest.
func exportBackup(ctx context.Context, target, token, ref, root string) error {
// errBackupDigest fails a backup export whose stored archive no longer hashes
// to what was recorded when it was written.
var errBackupDigest = errors.New("the backup archive does not match the sha256 recorded when it was written")
// exportBackup re-streams one stored archive through the export guards
// (backup.FilterTarGz with archiveFilter). Its length changes on the way, so it
// goes chunked. With want set, the stored bytes are hashed as they are read,
// and FilterTarGz reads them to their end before it closes its own archive: a
// mismatch aborts the upload while what felis-api has passed on still lacks
// its end, so the browser never keeps a complete-looking corrupt file.
func exportBackup(ctx context.Context, target, token, ref, root, want string, stdout io.Writer) error {
// Defense in depth, as in cmdRestore: the ref comes from felis-api, but this // Defense in depth, as in cmdRestore: the ref comes from felis-api, but this
// process opens it, so it confirms the ref stays on the backup mount. // process opens it, so it confirms the ref stays on the backup mount.
if !refWithinRoot(ref, root) { if !refWithinRoot(ref, root) {
@@ -130,159 +88,47 @@ func exportBackup(ctx context.Context, target, token, ref, root, want string, st
return err return err
} }
defer f.Close() defer f.Close()
var src io.Reader = f st, err := f.Stat()
if want != "" { if err != nil {
src = &digestReader{r: f, sum: sha256.New(), want: want}
}
var withheld []string
err = streamExport(ctx, target, token, archiveType, -1, func(w io.Writer) error {
var err error
withheld, err = backup.FilterTarGz(ctx, w, src, archiveFilter)
return err return err
})
if errors.Is(err, errBackupDigest) {
return errBackupDigest // the jobs list shows it as it is, not wrapped as a read error
} }
reportWithheld(stdout, len(withheld)) return putExport(ctx, target, token, f, st.Size())
return err
} }
// exportWorld archives the world straight into the request body: nothing is // exportWorld archives the world straight into the request body: nothing is
// staged, so a world bigger than the Pod's memory or any scratch disk exports // staged, so a world bigger than the Pod's memory or any scratch disk exports
// the same. // the same. A read error mid-way aborts the chunked body, and felis-api cuts
// the browser's download off rather than end it.
func exportWorld(ctx context.Context, target, token, root string, stdout io.Writer) error { func exportWorld(ctx context.Context, target, token, root string, stdout io.Writer) error {
r, err := os.OpenRoot(root) pr, pw := io.Pipe()
if err != nil { skippedc := make(chan []string, 1)
return err go func() {
} skipped, err := backup.WriteTarGz(ctx, pw, root)
guard := fileedit.NewGuard(r) pw.CloseWithError(err)
r.Close() skippedc <- skipped
var skipped, withheld []string }()
err = streamExport(ctx, target, token, archiveType, -1, func(w io.Writer) error { err := putExport(ctx, target, token, pr, -1)
var err error pr.CloseWithError(io.ErrClosedPipe) // stops the archiver if the PUT ended first
skipped, withheld, err = backup.WriteTarGz(ctx, w, root, worldFilter(guard)) if skipped := <-skippedc; len(skipped) > 0 {
return err
})
if len(skipped) > 0 {
fmt.Fprintf(stdout, "felis export: left out %d entries a tar cannot hold (symbolic links, devices, sockets)\n", len(skipped)) fmt.Fprintf(stdout, "felis export: left out %d entries a tar cannot hold (symbolic links, devices, sockets)\n", len(skipped))
} }
reportWithheld(stdout, len(withheld))
return err return err
} }
// exportFiles sends one file or folder of the world (fileedit.OpenDownload): a // putExport PUTs the archive to felis-api. There is no retry: the token opens
// file with its exact length, a folder as a zip made as it streams. dir is
// what the owner saw at path when they asked.
func exportFiles(ctx context.Context, target, token, root, path string, dir bool, stdout io.Writer) error {
d, err := fileedit.OpenDownload(root, path, dir)
if err != nil {
return err
}
defer d.Close()
err = streamExport(ctx, target, token, d.ContentType, d.Size, func(w io.Writer) error { return d.WriteTo(ctx, w) })
if d.Skipped > 0 {
fmt.Fprintf(stdout, "felis export: left out %d entries a zip does not carry (symbolic links, devices, sockets)\n", d.Skipped)
}
reportWithheld(stdout, d.Withheld)
return err
}
func reportWithheld(stdout io.Writer, n int) {
if n > 0 {
fmt.Fprintf(stdout, "felis export: left out %d files that hold platform secrets\n", n)
}
}
// worldFilter guards a live world by file identity, so a link to a guarded
// file under another name is caught as well.
func worldFilter(g fileedit.Guard) backup.Filter {
return func(_ string, info fs.FileInfo) (bool, func([]byte) []byte) {
return guardAction(g.Rule(info))
}
}
// archiveFilter guards a stored archive, which has only names.
func archiveFilter(name string, _ fs.FileInfo) (bool, func([]byte) []byte) {
return guardAction(fileedit.ArchiveRule(name))
}
func guardAction(withhold, redact bool) (bool, func([]byte) []byte) {
if redact {
return withhold, fileedit.RedactProps
}
return withhold, nil
}
// digestReader passes r through, hashing it, and turns r's EOF into
// errBackupDigest when the bytes do not hash to want.
type digestReader struct {
r io.Reader
sum hash.Hash
want string
}
func (d *digestReader) Read(p []byte) (int, error) {
n, err := d.r.Read(p)
d.sum.Write(p[:n])
if err == io.EOF && !strings.EqualFold(hex.EncodeToString(d.sum.Sum(nil)), d.want) {
return n, errBackupDigest
}
return n, err
}
// streamExport runs write straight into the body of the PUT, hashing it as it
// goes. Once write has finished, the SHA-256 of all it wrote rides the
// request's trailer (worldexport.DigestTrailer), and felis-api holds back the
// last bytes from the browser until what it received hashes the same. An error
// from write aborts the chunked body before the trailer, and felis-api then
// cuts the browser's download off rather than end it; that error is the one
// reported, since the PUT's own error only wraps it. When the PUT ends first,
// write is stopped.
func streamExport(ctx context.Context, target, token, contentType string, size int64, write func(io.Writer) error) error {
pr, pw := io.Pipe()
trailer := http.Header{worldexport.DigestTrailer: nil}
werr := make(chan error, 1)
go func() {
sum := sha256.New()
err := write(io.MultiWriter(pw, sum))
if err == nil {
// Set before the body ends: the transport reads the trailer once it
// has read the body to its end.
trailer.Set(worldexport.DigestTrailer, "sha-256=:"+base64.StdEncoding.EncodeToString(sum.Sum(nil))+":")
}
pw.CloseWithError(err)
werr <- err
}()
err := putExport(ctx, target, token, contentType, pr, size, trailer)
pr.CloseWithError(io.ErrClosedPipe)
if w := <-werr; w != nil && !errors.Is(w, io.ErrClosedPipe) {
return w
}
return err
}
// putExport PUTs the export to felis-api. There is no retry: the token opens
// the export once, so a second attempt could only be refused. Redirects are // the export once, so a second attempt could only be refused. Redirects are
// refused because the request carries the token and the internal face never // refused because the request carries the token and the internal face never
// redirects. felis-api answers only after the whole download, which the Job's // redirects. felis-api answers only after the whole download, which the Job's
// activeDeadlineSeconds bounds, so the header timeout is a backstop for a // activeDeadlineSeconds bounds, so the header timeout is a backstop for a
// wedged endpoint and not the real limit. // wedged endpoint and not the real limit.
// func putExport(ctx context.Context, target, token string, body io.Reader, size int64) error {
// The body always goes chunked, which is what lets it end with a trailer; a
// size the Job knows (-1 when it does not) goes as worldexport.LengthHeader in
// place of Content-Length.
func putExport(ctx context.Context, target, token, contentType string, body io.Reader, size int64, trailer http.Header) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, target, body) req, err := http.NewRequestWithContext(ctx, http.MethodPut, target, body)
if err != nil { if err != nil {
return err return err
} }
req.ContentLength = -1 req.ContentLength = size
req.Trailer = trailer
if size >= 0 {
req.Header.Set(worldexport.LengthHeader, strconv.FormatInt(size, 10))
}
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Type", "application/gzip")
client := &http.Client{ client := &http.Client{
Transport: &http.Transport{ResponseHeaderTimeout: 10 * time.Minute}, Transport: &http.Transport{ResponseHeaderTimeout: 10 * time.Minute},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+33 -322
View File
@@ -2,22 +2,13 @@ package main
import ( import (
"archive/tar" "archive/tar"
"archive/zip"
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -55,25 +46,6 @@ func receiveExport(t *testing.T, reply func(w http.ResponseWriter)) *exportRecei
func noContent(w http.ResponseWriter) { w.WriteHeader(http.StatusNoContent) } func noContent(w http.ResponseWriter) { w.WriteHeader(http.StatusNoContent) }
// sentWhole fails unless the upload rcv got ended with the Content-Digest
// trailer of its own bytes, and declared length as its size (-1: none).
func sentWhole(t *testing.T, rcv *exportReceiver, length int64) {
t.Helper()
sum := sha256.Sum256(rcv.body)
want := "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
wantLength := ""
if length >= 0 {
wantLength = strconv.FormatInt(length, 10)
}
r := rcv.req
if rcv.readErr != nil || r.Trailer.Get(worldexport.DigestTrailer) != want || r.Header.Get(worldexport.LengthHeader) != wantLength ||
r.ContentLength != -1 || strings.Join(r.TransferEncoding, ",") != "chunked" {
t.Fatalf("upload read %v, trailer %v, %s %q, length %d, encoding %v; want trailer %q and %s %q, chunked",
rcv.readErr, r.Trailer, worldexport.LengthHeader, r.Header.Get(worldexport.LengthHeader), r.ContentLength, r.TransferEncoding,
want, worldexport.LengthHeader, wantLength)
}
}
func tarEntries(t *testing.T, archive []byte) map[string]string { func tarEntries(t *testing.T, archive []byte) map[string]string {
t.Helper() t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive)) gz, err := gzip.NewReader(bytes.NewReader(archive))
@@ -95,52 +67,16 @@ func tarEntries(t *testing.T, archive []byte) map[string]string {
} }
} }
// writeTree writes name → body under root, making the folders on the way.
func writeTree(t *testing.T, root string, files map[string]string) {
t.Helper()
for name, body := range files {
p := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
}
// The two secrets a world holds, and what server.properties reads as once
// redacted.
const (
secretProps = "motd=hi\nrcon.password=hunter2\n"
redactedProps = "motd=hi\nrcon.password=<redacted by felis>\n"
forwardingKey = "secret: aVeryRealForwardingKey\n"
)
// secretWorld is a world holding both secrets, with a hard link to the
// forwarding secret under a name nothing would guard by.
func secretWorld(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTree(t, root, map[string]string{
"server.properties": secretProps,
"config/paper-global.yml": forwardingKey,
"world/region/r.0.0.mca": "chunks",
})
if err := os.MkdirAll(filepath.Join(root, "plugins"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Link(filepath.Join(root, "config/paper-global.yml"), filepath.Join(root, "plugins/copy.yml")); err != nil {
t.Fatal(err)
}
return root
}
func TestCmdExportWorld(t *testing.T) { func TestCmdExportWorld(t *testing.T) {
root := secretWorld(t) root := t.TempDir()
if err := os.Symlink("server.properties", filepath.Join(root, "props-link")); err != nil { if err := os.MkdirAll(filepath.Join(root, "world", "region"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for name, body := range map[string]string{"server.properties": "motd=hi\n", "world/region/r.0.0.mca": "chunks"} {
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
rcv := receiveExport(t, noContent) rcv := receiveExport(t, noContent)
t.Setenv(worldexport.TokenEnv, "tok") t.Setenv(worldexport.TokenEnv, "tok")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -154,204 +90,61 @@ func TestCmdExportWorld(t *testing.T) {
r.Header.Get("Content-Type") != "application/gzip" || r.ContentLength != -1 || strings.Join(r.TransferEncoding, ",") != "chunked" { r.Header.Get("Content-Type") != "application/gzip" || r.ContentLength != -1 || strings.Join(r.TransferEncoding, ",") != "chunked" {
t.Fatalf("request = %s %s, headers %v, length %d, encoding %v", r.Method, r.URL.Path, r.Header, r.ContentLength, r.TransferEncoding) t.Fatalf("request = %s %s, headers %v, length %d, encoding %v", r.Method, r.URL.Path, r.Header, r.ContentLength, r.TransferEncoding)
} }
want := map[string]string{ got := tarEntries(t, rcv.body)
"server.properties": redactedProps, "config/": "", "plugins/": "", want := map[string]string{"server.properties": "motd=hi\n", "world/": "", "world/region/": "", "world/region/r.0.0.mca": "chunks"}
"world/": "", "world/region/": "", "world/region/r.0.0.mca": "chunks", if len(got) != len(want) {
t.Fatalf("archive holds %v, want %v", got, want)
} }
if got := tarEntries(t, rcv.body); !reflect.DeepEqual(got, want) { for name, body := range want {
t.Fatalf("archive holds %v\nwant %v", got, want) if b, ok := got[name]; !ok || b != body {
t.Errorf("%s = %q (present %v), want %q", name, b, ok, body)
}
} }
sentWhole(t, rcv, -1) if !strings.Contains(stdout.String(), "server=survival mode=world downloaded") {
want2 := "felis export: left out 1 entries a tar cannot hold (symbolic links, devices, sockets)\n" + t.Errorf("stdout = %q", stdout.String())
"felis export: left out 2 files that hold platform secrets\n" +
"felis export: server=survival mode=world downloaded\n"
if stdout.String() != want2 {
t.Errorf("stdout = %q, want %q", stdout.String(), want2)
} }
} }
// A world root that cannot be opened fails before anything reaches felis-api. // A world that cannot be read must never reach felis-api as a complete body:
func TestCmdExportWorldUnreadable(t *testing.T) { // the chunked upload is cut off, so felis-api aborts the browser's download.
func TestCmdExportWorldReadErrorAbortsTheUpload(t *testing.T) {
rcv := receiveExport(t, noContent) rcv := receiveExport(t, noContent)
t.Setenv(worldexport.TokenEnv, "tok") t.Setenv(worldexport.TokenEnv, "tok")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
code := cmdExport([]string{"--mode", "world", "--target-url", rcv.srv.URL, "--worlds-root", filepath.Join(t.TempDir(), "missing")}, &stdout, &stderr) code := cmdExport([]string{"--mode", "world", "--target-url", rcv.srv.URL, "--worlds-root", filepath.Join(t.TempDir(), "missing")}, &stdout, &stderr)
if code != 1 || rcv.hits.Load() != 0 { if code != 1 {
t.Fatalf("exit %d with %d requests, want 1 and none", code, rcv.hits.Load()) t.Fatalf("exit %d, want 1", code)
}
}
// An export that fails part-way must never reach felis-api as a complete body:
// the chunked upload is cut off, so felis-api aborts the browser's download,
// and the failure itself is what the Job reports.
func TestStreamExportWriteErrorAbortsTheUpload(t *testing.T) {
broken := errors.New("disk read failed")
rcv := receiveExport(t, noContent)
err := streamExport(context.Background(), rcv.srv.URL, "tok", "application/gzip", -1, func(w io.Writer) error {
if _, err := w.Write(bytes.Repeat([]byte("x"), 100_000)); err != nil {
return err
}
return broken
})
if err != broken {
t.Fatalf("err = %v, want the write's own error, unwrapped", err)
} }
select { select {
case <-rcv.served: case <-rcv.served:
if rcv.readErr == nil { if rcv.readErr == nil {
t.Fatalf("felis-api read a complete %d-byte body from a failed export", len(rcv.body)) t.Fatalf("felis-api read a complete %d-byte body from an unreadable world", len(rcv.body))
} }
case <-time.After(5 * time.Second): case <-time.After(2 * time.Second): // the request never reached the handler
t.Fatal("the request never reached felis-api")
} }
} }
// When felis-api refuses first, its reason is reported, not the closed pipe
// that then stops the writer.
func TestStreamExportRefusalStopsTheWriter(t *testing.T) {
refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer refusing.Close()
stopped := make(chan error, 1)
err := streamExport(context.Background(), refusing.URL, "tok", "application/gzip", -1, func(w io.Writer) error {
for {
if _, err := w.Write(make([]byte, 32<<10)); err != nil {
stopped <- err
return err
}
}
})
if err == nil || err.Error() != "felis-api answered 404 Not Found" {
t.Fatalf("err = %v, want felis-api's answer", err)
}
if werr := <-stopped; !errors.Is(werr, io.ErrClosedPipe) {
t.Fatalf("the writer stopped on %v, want the closed pipe", werr)
}
// A PUT that never starts leaves no transport to close the body: the writer
// is still stopped, and the export fails rather than hangs.
done := make(chan error, 1)
go func() {
done <- streamExport(context.Background(), "http://[::1", "tok", "application/gzip", -1, func(w io.Writer) error {
_, err := w.Write([]byte("x"))
return err
})
}()
select {
case err := <-done:
if err == nil || !strings.Contains(err.Error(), "missing ']'") {
t.Fatalf("err = %v, want the bad URL", err)
}
case <-time.After(5 * time.Second):
t.Fatal("an export whose PUT never started hung")
}
}
// storedBackup writes, at path, a gzip+tar like one the backup store holds:
// the world whole, both secrets included, and a region file that does not
// compress. It returns the archive's sha256.
func storedBackup(t *testing.T, path string) string {
t.Helper()
region := make([]byte, 64<<10)
if _, err := rand.Read(region); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
for _, e := range []struct{ name, body string }{
{"server.properties", secretProps},
{"config/paper-global.yml", forwardingKey},
{"world/level.dat", "level"},
{"world/region/r.0.0.mca", string(region)},
} {
if err := tw.WriteHeader(&tar.Header{Name: e.name, Typeflag: tar.TypeReg, Mode: 0o600, Size: int64(len(e.body))}); err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(tw, e.body); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(buf.Bytes())
return hex.EncodeToString(sum[:])
}
func TestCmdExportBackup(t *testing.T) { func TestCmdExportBackup(t *testing.T) {
root := t.TempDir() root := t.TempDir()
archive := bytes.Repeat([]byte("felis"), 10_000)
ref := filepath.Join(root, "survival-1.tar.gz") ref := filepath.Join(root, "survival-1.tar.gz")
sum := storedBackup(t, ref) if err := os.WriteFile(ref, archive, 0o600); err != nil {
stored, err := os.ReadFile(ref)
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
region := tarEntries(t, stored)["world/region/r.0.0.mca"]
args := func(url, ref string) []string { args := func(url, ref string) []string {
return []string{"--mode", "backup", "--server", "survival", "--target-url", url, "--ref", ref, "--backup-root", root} return []string{"--mode", "backup", "--server", "survival", "--target-url", url, "--ref", ref, "--backup-root", root}
} }
for name, extra := range map[string][]string{ t.Run("hands the archive over with its length", func(t *testing.T) {
"no digest recorded": nil,
"recorded digest matches": {"--sha256", sum},
} {
t.Run(name+": re-streamed through the guards", func(t *testing.T) {
rcv := receiveExport(t, noContent)
t.Setenv(worldexport.TokenEnv, "tok")
var stdout, stderr bytes.Buffer
if code := cmdExport(append(args(rcv.srv.URL, ref), extra...), &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String())
}
r := rcv.req
if r.ContentLength != -1 || r.Header.Get("Content-Type") != "application/gzip" || r.Header.Get("Authorization") != "Bearer tok" {
t.Fatalf("length %d, headers %v", r.ContentLength, r.Header)
}
want := map[string]string{"server.properties": redactedProps, "world/level.dat": "level", "world/region/r.0.0.mca": region}
if got := tarEntries(t, rcv.body); !reflect.DeepEqual(got, want) {
t.Fatalf("archive holds %d entries, want exactly the redacted properties, level.dat and the region file", len(got))
}
sentWhole(t, rcv, -1)
if want := "felis export: left out 1 files that hold platform secrets\nfelis export: server=survival mode=backup downloaded\n"; stdout.String() != want {
t.Errorf("stdout = %q, want %q", stdout.String(), want)
}
})
}
// The stored bytes are checked as they stream, and the archive the Job sends
// is only closed once they are all read: a mismatch cuts the upload off
// short of its end, so felis-api never passes on a complete-looking copy.
t.Run("a digest mismatch cuts the upload off before its end", func(t *testing.T) {
rcv := receiveExport(t, noContent) rcv := receiveExport(t, noContent)
t.Setenv(worldexport.TokenEnv, "tok") t.Setenv(worldexport.TokenEnv, "tok")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
if code := cmdExport(append(args(rcv.srv.URL, ref), "--sha256", strings.Repeat("ab", 32)), &stdout, &stderr); code != 1 { if code := cmdExport(args(rcv.srv.URL, ref), &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, want 1", code) t.Fatalf("exit %d, stderr %q", code, stderr.String())
} }
if want := "felis export: the backup archive does not match the sha256 recorded when it was written\n"; stderr.String() != want { if rcv.req.ContentLength != int64(len(archive)) || !bytes.Equal(rcv.body, archive) || rcv.req.Header.Get("Authorization") != "Bearer tok" {
t.Fatalf("stderr = %q, want %q", stderr.String(), want) t.Fatalf("got %d bytes (length %d, auth %q), want the %d archive bytes",
} len(rcv.body), rcv.req.ContentLength, rcv.req.Header.Get("Authorization"), len(archive))
select {
case <-rcv.served:
case <-time.After(5 * time.Second):
t.Fatal("the upload never reached felis-api")
}
if rcv.readErr == nil {
t.Fatalf("felis-api read a complete %d-byte body", len(rcv.body))
}
zr, err := gzip.NewReader(bytes.NewReader(rcv.body))
if err == nil {
_, err = io.ReadAll(zr)
}
if err == nil {
t.Fatal("what felis-api got is a complete archive")
} }
}) })
@@ -411,87 +204,6 @@ func TestCmdExportBackup(t *testing.T) {
} }
} }
func TestCmdExportFiles(t *testing.T) {
root := secretWorld(t)
writeTree(t, root, map[string]string{"plugins/Essentials/config.yml": "x: 1"})
if err := os.Symlink("config.yml", filepath.Join(root, "plugins/Essentials/link.yml")); err != nil {
t.Fatal(err)
}
export := func(t *testing.T, path string, dir bool) (*exportReceiver, int, string, string) {
t.Helper()
rcv := receiveExport(t, noContent)
t.Setenv(worldexport.TokenEnv, "tok")
args := []string{"--mode", "files", "--server", "survival", "--target-url", rcv.srv.URL, "--worlds-root", root, "--path", path}
if dir {
args = append(args, "--dir")
}
var stdout, stderr bytes.Buffer
code := cmdExport(args, &stdout, &stderr)
return rcv, code, stdout.String(), stderr.String()
}
for path, want := range map[string]string{
"world/region/r.0.0.mca": "chunks",
"server.properties": redactedProps,
} {
t.Run("a file goes with its exact length: "+path, func(t *testing.T) {
rcv, code, stdout, stderr := export(t, path, false)
if code != 0 || stdout != "felis export: server=survival mode=files downloaded\n" {
t.Fatalf("exit %d, stdout %q, stderr %q", code, stdout, stderr)
}
if string(rcv.body) != want || rcv.req.Header.Get("Content-Type") != "application/octet-stream" {
t.Fatalf("body %q, type %q; want %q", rcv.body, rcv.req.Header.Get("Content-Type"), want)
}
sentWhole(t, rcv, int64(len(want)))
})
}
t.Run("a folder goes as a zip, guarded", func(t *testing.T) {
rcv, code, stdout, stderr := export(t, "plugins", true)
if code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr)
}
if rcv.req.ContentLength != -1 || rcv.req.Header.Get("Content-Type") != "application/zip" {
t.Fatalf("length %d, type %q", rcv.req.ContentLength, rcv.req.Header.Get("Content-Type"))
}
zr, err := zip.NewReader(bytes.NewReader(rcv.body), int64(len(rcv.body)))
if err != nil {
t.Fatal(err)
}
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
if want := []string{"plugins/", "plugins/Essentials/", "plugins/Essentials/config.yml"}; !slices.Equal(names, want) {
t.Fatalf("zip holds %v, want %v", names, want)
}
want := "felis export: left out 1 entries a zip does not carry (symbolic links, devices, sockets)\n" +
"felis export: left out 1 files that hold platform secrets\n" +
"felis export: server=survival mode=files downloaded\n"
if stdout != want {
t.Errorf("stdout = %q, want %q", stdout, want)
}
})
for _, c := range []struct {
path string
dir bool
want string
}{
{"config/paper-global.yml", false, "forwarding secret"},
{"plugins/copy.yml", false, "forwarding secret"},
{"plugins", false, "is a folder now"},
{"server.properties", true, "is not a folder now"},
} {
t.Run("refused before any request: "+c.path, func(t *testing.T) {
rcv, code, _, stderr := export(t, c.path, c.dir)
if code != 1 || rcv.hits.Load() != 0 || !strings.Contains(stderr, c.want) {
t.Fatalf("exit %d, %d requests, stderr %q; want 1, none, and %q", code, rcv.hits.Load(), stderr, c.want)
}
})
}
}
func TestCmdExportUsage(t *testing.T) { func TestCmdExportUsage(t *testing.T) {
for name, tc := range map[string]struct { for name, tc := range map[string]struct {
token string token string
@@ -501,7 +213,6 @@ func TestCmdExportUsage(t *testing.T) {
"no target": {"tok", []string{"--mode", "world"}}, "no target": {"tok", []string{"--mode", "world"}},
"unknown mode": {"tok", []string{"--mode", "both", "--target-url", "http://api/x"}}, "unknown mode": {"tok", []string{"--mode", "both", "--target-url", "http://api/x"}},
"backup without ref": {"tok", []string{"--mode", "backup", "--target-url", "http://api/x"}}, "backup without ref": {"tok", []string{"--mode", "backup", "--target-url", "http://api/x"}},
"files without path": {"tok", []string{"--mode", "files", "--target-url", "http://api/x"}},
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Setenv(worldexport.TokenEnv, tc.token) t.Setenv(worldexport.TokenEnv, tc.token)
@@ -526,7 +237,7 @@ func TestCmdExportWiring(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
cfg.K8s.Namespace, cfg.Archive.LocalPath = "games", "/srv/felis-backups" cfg.K8s.Namespace, cfg.Archive.LocalPath = "games", "/srv/felis-backups"
want := worldexport.Config{Namespace: "games", Image: "felis:1", BackupPVC: "felis-backups", BackupRoot: "/srv/felis-backups"} want := worldexport.Config{Namespace: "games", Image: "felis:1", BackupPVC: "felis-backups", BackupRoot: "/srv/felis-backups"}
if got := exportConfig(cfg, "felis:1", "felis-backups"); !reflect.DeepEqual(got, want) { if got := exportConfig(cfg, "felis:1", "felis-backups"); got != want {
t.Fatalf("exportConfig = %+v, want %+v", got, want) t.Fatalf("exportConfig = %+v, want %+v", got, want)
} }
} }
+6 -62
View File
@@ -39,8 +39,7 @@ import (
func cmdFiles(args []string, stdout, stderr io.Writer) int { func cmdFiles(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("files", flag.ContinueOnError) fs := flag.NewFlagSet("files", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename, upload or unzip") op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename or upload")
browseURL := fs.String("browse-url", "", "internal command channel for a read-only file browser")
path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)") path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)")
worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it") worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it")
expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this") expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this")
@@ -48,27 +47,16 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
to := fs.String("to", "", "rename only: the destination path") to := fs.String("to", "", "rename only: the destination path")
sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from") sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from")
size := fs.Int64("size", -1, "upload only: the byte count the fetched file must have") size := fs.Int64("size", -1, "upload only: the byte count the fetched file must have")
sum := fs.String("sha256", "", "write and upload: the SHA-256 (hex) the content or the fetched file must have") sum := fs.String("sha256", "", "upload only: the SHA-256 (hex) the fetched file must have")
overwrite := fs.Bool("overwrite", false, "upload and unzip: replace files already there") overwrite := fs.Bool("overwrite", false, "upload only: replace a file already at the path")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
if *browseURL != "" {
limitHeapToCgroup()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := fileedit.Browse(ctx, *worldsRoot, *browseURL, os.Getenv(fileedit.BrowserTokenEnv)); err != nil {
fmt.Fprintf(stderr, "felis files: %v\n", err)
return 1
}
return 0
}
if *op == "" { if *op == "" {
fmt.Fprintln(stderr, "felis files: --op is required") fmt.Fprintln(stderr, "felis files: --op is required")
return 2 return 2
} }
limitHeapToCgroup()
req := fileedit.Request{ req := fileedit.Request{
Op: *op, Path: *path, To: *to, Expect: *expect, CreateOnly: *createOnly, Overwrite: *overwrite, Op: *op, Path: *path, To: *to, Expect: *expect, CreateOnly: *createOnly, Overwrite: *overwrite,
} }
@@ -81,18 +69,12 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
// channel that must be a valid string. // channel that must be a valid string.
switch *op { switch *op {
case fileedit.OpWrite: case fileedit.OpWrite:
// The content's SHA-256 comes with it, so bytes that changed on the way
// to this Job are refused rather than written (Request.ContentSHA256).
if *sum == "" {
fmt.Fprintln(stderr, "felis files: a write needs --sha256")
return 2
}
content, err := fileedit.ContentFromEnv(os.LookupEnv) content, err := fileedit.ContentFromEnv(os.LookupEnv)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis files: %v\n", err) fmt.Fprintf(stderr, "felis files: %v\n", err)
return 2 return 2
} }
req.Content, req.ContentSHA256 = content, *sum req.Content = content
case fileedit.OpUpload: case fileedit.OpUpload:
token := os.Getenv(fileedit.UploadTokenEnv) token := os.Getenv(fileedit.UploadTokenEnv)
if *sourceURL == "" || token == "" { if *sourceURL == "" || token == "" {
@@ -104,19 +86,8 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
req.Upload = &fileedit.Upload{ req.Upload = &fileedit.Upload{
Size: *size, SHA256: *sum, Size: *size, SHA256: *sum,
Open: func() (io.ReadCloser, error) { return fetchUpload(ctx, *sourceURL, token) }, Open: func() (io.ReadCloser, error) { return fetchUpload(ctx, *sourceURL, token) },
Landed: func() {
if err := reportLanded(ctx, *sourceURL, token); err != nil {
// The file is in place; felis-api drops its copy when it
// has sat idle long enough, and the panel cancels it too.
fmt.Fprintf(stderr, "felis files: tell felis-api the upload landed: %v\n", err)
}
},
} }
} }
// An upload or an unzip (the only ops that report progress) can run long
// enough that felis-api does not wait on its Job, and the panel shows how far
// it has got from the latest of these lines (fileedit.K8sRunner.Ops).
req.Progress = fileedit.ThrottledProgress(stdout, time.Second, time.Now)
res, err := fileedit.Execute(*worldsRoot, req) res, err := fileedit.Execute(*worldsRoot, req)
if err != nil { if err != nil {
@@ -136,9 +107,8 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
// fetchUpload opens the staged upload on felis-api's internal face. There is no // fetchUpload opens the staged upload on felis-api's internal face. There is no
// retry: the token opens the upload once (fileedit.Stage), so a second attempt // retry: the token opens the upload once (fileedit.Stage), so a second attempt
// could only be refused, and the caller retries the failed Job whole (a file // could only be refused, and felis-api answers the failed Job with a 500 the
// sent in parts stays staged until its Job reports it landed, so that retry // caller can retry whole. Redirects are refused because the request carries the
// does not send it again). Redirects are refused because the request carries the
// token and the internal face never redirects; the header timeout catches a // token and the internal face never redirects; the header timeout catches a
// wedged endpoint, and the Job's activeDeadlineSeconds bounds the body. // wedged endpoint, and the Job's activeDeadlineSeconds bounds the body.
func fetchUpload(ctx context.Context, url, token string) (io.ReadCloser, error) { func fetchUpload(ctx context.Context, url, token string) (io.ReadCloser, error) {
@@ -161,29 +131,3 @@ func fetchUpload(ctx context.Context, url, token string) (io.ReadCloser, error)
} }
return resp.Body, nil return resp.Body, nil
} }
// reportLanded tells felis-api the upload's file is in place (DELETE on the URL
// it was fetched from, with the same token), so it deletes the copy it staged.
// One try: the file has landed whatever the answer, and a copy nobody deletes
// is dropped once it has sat idle for fileedit.SessionIdle.
func reportLanded(ctx context.Context, url, token string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("DELETE returned %s", resp.Status)
}
return nil
}
+13 -168
View File
@@ -1,14 +1,11 @@
package main package main
import ( import (
"archive/zip"
"bytes" "bytes"
"cmp"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -20,12 +17,10 @@ import (
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
) )
// filesResult is the Result a `felis files` run printed on its marked line, // filesResult is the Result a `felis files` run printed on its marked line.
// the last it prints.
func filesResult(t *testing.T, stdout string) fileedit.Result { func filesResult(t *testing.T, stdout string) fileedit.Result {
t.Helper() t.Helper()
lines := strings.Split(strings.TrimSpace(stdout), "\n") line, ok := strings.CutPrefix(strings.TrimSpace(stdout), fileedit.ResultPrefix)
line, ok := strings.CutPrefix(lines[len(lines)-1], fileedit.ResultPrefix)
if !ok { if !ok {
t.Fatalf("stdout has no result line: %q", stdout) t.Fatalf("stdout has no result line: %q", stdout)
} }
@@ -36,43 +31,19 @@ func filesResult(t *testing.T, stdout string) fileedit.Result {
return res return res
} }
// stagedSource is felis-api's internal face for one staged upload. It serves // stagedUpload serves body to a request carrying Bearer token, and 404 to any
// body to a GET carrying Bearer token and 404 to any other, and answers the // other, the way felis-api's internal face does.
// DELETE that reports the file landed with landedCode (204 when unset), func stagedUpload(t *testing.T, token string, body []byte) *httptest.Server {
// redirecting to landedTo when that is a redirect. reports counts those
// DELETEs, each with the token and at the path the bytes came from.
type stagedSource struct {
*httptest.Server
reports, strays atomic.Int32
landedCode int
landedTo string
}
func stagedUpload(t *testing.T, token string, body []byte) *stagedSource {
t.Helper() t.Helper()
s := &stagedSource{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer "+token {
if r.Header.Get("Authorization") != "Bearer "+token || r.URL.Path != "/u" {
s.strays.Add(1)
http.Error(w, "no such upload", http.StatusNotFound) http.Error(w, "no such upload", http.StatusNotFound)
return return
} }
switch r.Method { w.Write(body)
case http.MethodGet:
w.Write(body)
case http.MethodDelete:
s.reports.Add(1)
if s.landedTo != "" {
w.Header().Set("Location", s.landedTo)
}
w.WriteHeader(cmp.Or(s.landedCode, http.StatusNoContent))
default:
s.strays.Add(1)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})) }))
t.Cleanup(s.Close) t.Cleanup(srv.Close)
return s return srv
} }
func uploadArgs(root, sourceURL string, body []byte) []string { func uploadArgs(root, sourceURL string, body []byte) []string {
@@ -104,9 +75,6 @@ func TestCmdFilesUpload(t *testing.T) {
if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 0 { if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String()) t.Fatalf("exit %d, stderr %q", code, stderr.String())
} }
if !strings.HasPrefix(stdout.String(), fileedit.ProgressPrefix+`{"done":4,"total":4}`+"\n") {
t.Fatalf("stdout %q does not start with the progress to the last byte", stdout.String())
}
if res := filesResult(t, stdout.String()); res.Code != "" { if res := filesResult(t, stdout.String()); res.Code != "" {
t.Fatalf("result = %+v", res) t.Fatalf("result = %+v", res)
} }
@@ -114,58 +82,8 @@ func TestCmdFilesUpload(t *testing.T) {
if err != nil || !bytes.Equal(got, body) { if err != nil || !bytes.Equal(got, body) {
t.Fatalf("landed %q, %v", got, err) t.Fatalf("landed %q, %v", got, err)
} }
if n, strays := srv.reports.Load(), srv.strays.Load(); n != 1 || strays != 0 || stderr.Len() != 0 {
t.Fatalf("reported landed %d times, %d stray requests, stderr %q; want once", n, strays, stderr.String())
}
}) })
t.Run("a file already there is a result, and nothing is reported landed", func(t *testing.T) {
root := uploadRoot(t)
if err := os.WriteFile(filepath.Join(root, "plugins", "a.jar"), []byte("old!"), 0o644); err != nil {
t.Fatal(err)
}
srv := stagedUpload(t, "tok", body)
t.Setenv(fileedit.UploadTokenEnv, "tok")
var stdout, stderr bytes.Buffer
if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String())
}
if res := filesResult(t, stdout.String()); res.Code != fileedit.CodeExists || srv.reports.Load() != 0 {
t.Fatalf("result = %+v, reported landed %d times", res, srv.reports.Load())
}
})
// The file is in place whatever felis-api answers, so the Job still succeeds
// and says why the staged copy may linger. A redirect is not followed, since
// the request carries the token.
for name, tc := range map[string]struct {
code int
stderr string
}{
"refused": {http.StatusNotFound, "felis files: tell felis-api the upload landed: DELETE returned 404 Not Found\n"},
"redirected": {http.StatusFound, "felis files: tell felis-api the upload landed: DELETE returned 302 Found\n"},
} {
t.Run("a landed report "+name+" still lands the file", func(t *testing.T) {
var elsewhere atomic.Int32
away := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { elsewhere.Add(1) }))
defer away.Close()
root := uploadRoot(t)
srv := stagedUpload(t, "tok", body)
srv.landedCode, srv.landedTo = tc.code, away.URL+"/u"
t.Setenv(fileedit.UploadTokenEnv, "tok")
var stdout, stderr bytes.Buffer
if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String())
}
if res := filesResult(t, stdout.String()); res.Code != "" || stderr.String() != tc.stderr || elsewhere.Load() != 0 {
t.Fatalf("result = %+v, stderr %q, redirect followed %d times", res, stderr.String(), elsewhere.Load())
}
if got, err := os.ReadFile(filepath.Join(root, "plugins", "a.jar")); err != nil || !bytes.Equal(got, body) {
t.Fatalf("landed %q, %v", got, err)
}
})
}
// A refused fetch is the Job failing, never a Result: the API answers it with a // A refused fetch is the Job failing, never a Result: the API answers it with a
// 500 the caller retries whole. // 500 the caller retries whole.
t.Run("a refused fetch exits 1 and lands nothing", func(t *testing.T) { t.Run("a refused fetch exits 1 and lands nothing", func(t *testing.T) {
@@ -179,9 +97,6 @@ func TestCmdFilesUpload(t *testing.T) {
if !strings.Contains(stderr.String(), "404") { if !strings.Contains(stderr.String(), "404") {
t.Fatalf("stderr %q does not name the status", stderr.String()) t.Fatalf("stderr %q does not name the status", stderr.String())
} }
if srv.reports.Load() != 0 {
t.Fatal("a refused fetch was reported landed")
}
if _, err := os.Lstat(filepath.Join(root, "plugins", "a.jar")); !os.IsNotExist(err) { if _, err := os.Lstat(filepath.Join(root, "plugins", "a.jar")); !os.IsNotExist(err) {
t.Fatalf("a refused fetch left a file: %v", err) t.Fatalf("a refused fetch left a file: %v", err)
} }
@@ -238,11 +153,10 @@ func TestCmdFilesUpload(t *testing.T) {
func TestCmdFilesWrite(t *testing.T) { func TestCmdFilesWrite(t *testing.T) {
root := t.TempDir() root := t.TempDir()
content := []byte("[]\r\n") args := []string{"--op", "write", "--path", "ops.json", "--worlds-root", root}
sum := sha256.Sum256(content)
args := []string{"--op", "write", "--path", "ops.json", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])}
t.Run("reassembles the content parts", func(t *testing.T) { t.Run("reassembles the content parts", func(t *testing.T) {
content := []byte("[]\r\n")
t.Setenv(fileedit.ContentPartsEnv, "1") t.Setenv(fileedit.ContentPartsEnv, "1")
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content)) t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content))
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -262,40 +176,11 @@ func TestCmdFilesWrite(t *testing.T) {
t.Setenv(fileedit.ContentPartsEnv, "2") t.Setenv(fileedit.ContentPartsEnv, "2")
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("x"))) t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("x")))
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])}, &stdout, &stderr); code != 2 {
t.Fatalf("exit %d, want 2", code)
}
if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("an incomplete spec wrote a file: %v", err)
}
})
// Without the content's SHA-256 the Job could not tell bytes changed on the
// way from the bytes felis-api sent.
t.Run("a write without its SHA-256 exits 2 and writes nothing", func(t *testing.T) {
t.Setenv(fileedit.ContentPartsEnv, "1")
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content))
var stdout, stderr bytes.Buffer
if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root}, &stdout, &stderr); code != 2 { if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root}, &stdout, &stderr); code != 2 {
t.Fatalf("exit %d, want 2", code) t.Fatalf("exit %d, want 2", code)
} }
if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) { if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("a write without its SHA-256 wrote a file: %v", err) t.Fatalf("an incomplete spec wrote a file: %v", err)
}
})
t.Run("content that changed on the way is a result and writes nothing", func(t *testing.T) {
t.Setenv(fileedit.ContentPartsEnv, "1")
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("[]\n")))
var stdout, stderr bytes.Buffer
if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])}, &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String())
}
if res := filesResult(t, stdout.String()); res.Code != fileedit.CodeDigestMismatch {
t.Fatalf("result = %+v, want %s", res, fileedit.CodeDigestMismatch)
}
if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("changed content wrote a file: %v", err)
} }
}) })
} }
@@ -316,43 +201,3 @@ func TestCmdFilesCallerFaultIsAResult(t *testing.T) {
t.Fatalf("no --op: exit %d, want 2", code) t.Fatalf("no --op: exit %d, want 2", code)
} }
} }
// TestCmdFilesUnzip checks an unzip extracts next to the archive and reports its
// progress before its result, the same way an upload does.
func TestCmdFilesUnzip(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "maps"), 0o755); err != nil {
t.Fatal(err)
}
var zb bytes.Buffer
zw := zip.NewWriter(&zb)
for name, body := range map[string]string{"world/level.dat": "level", "world/region/r.0.0.mca": "region!"} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
io.WriteString(w, body)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "maps", "a.zip"), zb.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
if code := cmdFiles([]string{"--op", "unzip", "--path", "maps/a.zip", "--worlds-root", root}, &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String())
}
if res := filesResult(t, stdout.String()); res.Code != "" || res.Files != 2 || res.Bytes != 12 {
t.Fatalf("result = %+v", res)
}
if !strings.HasPrefix(stdout.String(), fileedit.ProgressPrefix) ||
!strings.Contains(stdout.String(), fileedit.ProgressPrefix+`{"done":12,"total":12}`+"\n") {
t.Fatalf("stdout %q does not report the progress to the last byte", stdout.String())
}
got, err := os.ReadFile(filepath.Join(root, "maps", "world", "region", "r.0.0.mca"))
if err != nil || string(got) != "region!" {
t.Fatalf("extracted %q, %v", got, err)
}
}
+2 -17
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"path/filepath"
"strings" "strings"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
@@ -44,13 +43,6 @@ func (m *multiFlag) Set(v string) error {
func cmdManifests(args []string, stdout, stderr io.Writer) int { func cmdManifests(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("manifests", flag.ContinueOnError) fs := flag.NewFlagSet("manifests", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
distributed := fs.Bool("distributed", false, "enable approved workers and archive transport")
controller := fs.String("controller-node", "", "protected controller identity for A")
probe := fs.String("egress-probe", "", "reachable controller host:port denied to game Pods")
var registryNodes multiFlag
fs.Var(&registryNodes, "registry-node-cidr", "exact node pull source for the registry (repeatable)")
socket := fs.String("node-control-socket", "", "host node-control Unix socket (optional, API only)")
nodeControlNode := fs.String("node-control-node", "", "controller hostname hosting the socket")
controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "namespace the control plane (api/operator/reaper) runs in") controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "namespace the control plane (api/operator/reaper) runs in")
minecraftNS := fs.String("minecraft-namespace", platform.DefaultMinecraftNamespace, "namespace MinecraftServer workloads run in") minecraftNS := fs.String("minecraft-namespace", platform.DefaultMinecraftNamespace, "namespace MinecraftServer workloads run in")
buildNS := fs.String("build-namespace", platform.DefaultBuildNamespace, "namespace image-build Jobs run in") buildNS := fs.String("build-namespace", platform.DefaultBuildNamespace, "namespace image-build Jobs run in")
@@ -83,7 +75,6 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
case "": case "":
case "postgres": case "postgres":
return renderManifests(stdout, stderr, platform.PostgresObjects(platform.Params{ return renderManifests(stdout, stderr, platform.PostgresObjects(platform.Params{
ControllerNode: *controller,
ControlNamespace: *controlNS, ControlNamespace: *controlNS,
MinecraftNamespace: *minecraftNS, MinecraftNamespace: *minecraftNS,
PostgresImage: *postgresImage, PostgresImage: *postgresImage,
@@ -124,7 +115,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
// The node pin exists only for the reaper's hostPath: naming a node without the // The node pin exists only for the reaper's hostPath: naming a node without the
// worlds root would be silently dropped (no CronJob renders), so fail loud like // worlds root would be silently dropped (no CronJob renders), so fail loud like
// the storage-trio check below. // the storage-trio check below.
if *reaperNode != "" && *worldsHostPath == "" && !*distributed { if *reaperNode != "" && *worldsHostPath == "" {
fmt.Fprintln(stderr, "felis manifests: --reaper-node requires --worlds-host-path "+ fmt.Fprintln(stderr, "felis manifests: --reaper-node requires --worlds-host-path "+
"(it pins the reaper CronJob, which renders only with the retention storage trio)") "(it pins the reaper CronJob, which renders only with the retention storage trio)")
return 2 return 2
@@ -165,7 +156,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
"writes under /var/lib/rancher/k3s/storage). Any other provisioner needs its volumes exposed as "+ "writes under /var/lib/rancher/k3s/storage). Any other provisioner needs its volumes exposed as "+
"<path>/<pvc>, or each candidate's archive fails and the world is preserved;\n"+ "<path>/<pvc>, or each candidate's archive fails and the world is preserved;\n"+
" - %s.\n", *worldsHostPath, *worldsHostPath, *worldsHostPath, pin) " - %s.\n", *worldsHostPath, *worldsHostPath, *worldsHostPath, pin)
} else if !*distributed { } else {
switch { switch {
case *archiveLocalPath != "" && *backupPVC == "": case *archiveLocalPath != "" && *backupPVC == "":
fmt.Fprintln(stderr, "felis manifests: --archive-local-path names where the backup PVC is mounted, "+ fmt.Fprintln(stderr, "felis manifests: --archive-local-path names where the backup PVC is mounted, "+
@@ -184,13 +175,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
} }
} }
if *socket != "" && (!filepath.IsAbs(*socket) || *nodeControlNode == "") {
fmt.Fprintln(stderr, "node-control requires an absolute socket and its controller hostname")
return 2
}
params := platform.Params{ params := platform.Params{
NodeControlSocket: *socket, NodeControlNode: *nodeControlNode,
Distributed: *distributed, ControllerNode: *controller, EgressProbe: *probe, RegistryNodeCIDRs: registryNodes,
ControlNamespace: *controlNS, ControlNamespace: *controlNS,
MinecraftNamespace: *minecraftNS, MinecraftNamespace: *minecraftNS,
BuildNamespace: *buildNS, BuildNamespace: *buildNS,
-48
View File
@@ -1,48 +0,0 @@
package main
import (
"os"
"runtime/debug"
"strconv"
"strings"
)
// cgroupMemoryFiles are where a container reads the memory it is allowed:
// cgroup v2 first, then v1.
var cgroupMemoryFiles = []string{"/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"}
// limitHeapToCgroup sets the Go heap's soft limit from the container's memory
// limit, so the collector works harder as a Job nears it and the kernel does not
// kill the Job first. An extraction or a folder zipped for download keeps a few
// hundred bytes per entry for as long as it runs; without the limit the heap
// grows to twice that before a collection, and a 256 MiB Job was killed at
// 400,000 entries whose live heap was 115 MB. GOMEMLIMIT set by hand wins.
func limitHeapToCgroup() {
if os.Getenv("GOMEMLIMIT") != "" {
return
}
for _, f := range cgroupMemoryFiles {
b, err := os.ReadFile(f)
if err != nil {
continue
}
if n, ok := softMemoryLimit(string(b)); ok {
debug.SetMemoryLimit(n)
}
return
}
}
// softMemoryLimit answers three fifths of the limit a cgroup memory file holds,
// or false for "max" (no limit) and anything unreadable. The rest is left for
// what the kernel charges the container beyond the Go heap: the page cache of
// the files it reads and writes, and the inodes it creates. Under a 256 MiB
// limit, 400,000 extracted entries peaked at 184 MB resident with the heap held
// to 150 MiB.
func softMemoryLimit(content string) (int64, bool) {
n, err := strconv.ParseInt(strings.TrimSpace(content), 10, 64)
if err != nil || n <= 0 {
return 0, false
}
return n / 5 * 3, true
}
-58
View File
@@ -1,58 +0,0 @@
package main
import (
"math"
"os"
"path/filepath"
"runtime/debug"
"testing"
)
func TestSoftMemoryLimit(t *testing.T) {
for _, c := range []struct {
in string
want int64
ok bool
}{
{"268435456\n", 161061273, true}, // 256 MiB, as memory.max holds it
{"max\n", 0, false},
{"0\n", 0, false},
{"-1", 0, false},
{"", 0, false},
} {
got, ok := softMemoryLimit(c.in)
if got != c.want || ok != c.ok {
t.Errorf("softMemoryLimit(%q) = %d %v, want %d %v", c.in, got, ok, c.want, c.ok)
}
}
}
// TestLimitHeapToCgroup checks the limit comes from the first cgroup file there
// is, and that GOMEMLIMIT set by hand leaves the heap alone.
func TestLimitHeapToCgroup(t *testing.T) {
prevFiles, prevLimit := cgroupMemoryFiles, debug.SetMemoryLimit(-1)
t.Cleanup(func() { cgroupMemoryFiles = prevFiles; debug.SetMemoryLimit(prevLimit) })
dir := t.TempDir()
v1, v1b := filepath.Join(dir, "v1"), filepath.Join(dir, "v1b")
if err := os.WriteFile(v1, []byte("268435456\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(v1b, []byte("536870912\n"), 0o600); err != nil {
t.Fatal(err)
}
cgroupMemoryFiles = []string{filepath.Join(dir, "missing"), v1, v1b}
t.Setenv("GOMEMLIMIT", "")
debug.SetMemoryLimit(math.MaxInt64)
limitHeapToCgroup()
if got := debug.SetMemoryLimit(-1); got != 161061273 {
t.Fatalf("limit = %d, want three fifths of 256 MiB", got)
}
t.Setenv("GOMEMLIMIT", "1GiB")
debug.SetMemoryLimit(math.MaxInt64)
limitHeapToCgroup()
if got := debug.SetMemoryLimit(-1); got != math.MaxInt64 {
t.Fatalf("limit = %d with GOMEMLIMIT set, want it left alone", got)
}
}
-466
View File
@@ -1,466 +0,0 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os/exec"
"strconv"
"strings"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/operator"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/platform"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func approveNode(ctx context.Context, cl client.Client, cs kubernetes.Interface, ns, controlNS, name, image, remote string, stdout io.Writer) error {
var n corev1.Node
if err := cl.Get(ctx, types.NamespacedName{Name: name}, &n); err != nil {
return err
}
_, controlPlane := n.Labels["node-role.kubernetes.io/control-plane"]
if !placement.Online(&n) || controlPlane || n.Labels[placement.LabelRole] == placement.RoleController {
return fmt.Errorf("candidate must be an online agent")
}
var nodes corev1.NodeList
if err := cl.List(ctx, &nodes); err != nil {
return err
}
var controller *corev1.Node
var peers []string
var denied []string
for i := range nodes.Items {
node := &nodes.Items[i]
if node.Labels[placement.LabelRole] == placement.RoleController {
if controller != nil {
return fmt.Errorf("multiple controllers found")
}
controller = node
}
for _, addr := range node.Status.Addresses {
if addr.Type == corev1.NodeInternalIP || addr.Type == corev1.NodeExternalIP {
cidr, err := exactCIDR(addr.Address)
if err != nil {
return err
}
peers = append(peers, cidr)
for _, p := range []string{"6443", "10250", "5000", "30443"} {
denied = append(denied, net.JoinHostPort(addr.Address, p))
}
}
}
}
if controller == nil || controller.Name == n.Name || controller.Status.NodeInfo.Architecture != n.Status.NodeInfo.Architecture {
return fmt.Errorf("candidate architecture must match the sole controller")
}
if n.Status.NodeInfo.KubeletVersion != controller.Status.NodeInfo.KubeletVersion {
return fmt.Errorf("candidate k3s version must match A")
}
var apiSvc, registrySvc, archiveSvc, kubernetesSvc corev1.Service
for _, svc := range []struct {
obj *corev1.Service
ns, name string
}{{&kubernetesSvc, "default", "kubernetes"}, {&apiSvc, controlNS, platform.SAAPI}, {&registrySvc, controlNS, "registry"}, {&archiveSvc, ns, platform.ArchiveName}} {
if err := cl.Get(ctx, types.NamespacedName{Namespace: svc.ns, Name: svc.name}, svc.obj); err != nil {
return err
}
}
if len(apiSvc.Spec.Ports) == 0 || apiSvc.Spec.Ports[0].NodePort == 0 {
return fmt.Errorf("controller API NodePort is absent")
}
// A's exact interface addresses let the probe observe DNAT/SNAT before the production policy is adjusted.
var aCIDRs []string
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, a := range addrs {
ip, _, err := net.ParseCIDR(a.String())
if err == nil && !ip.IsLoopback() {
cidr, _ := exactCIDR(ip.String())
aCIDRs = append(aCIDRs, cidr)
}
}
}
if len(aCIDRs) == 0 {
return fmt.Errorf("cannot determine A's exact interface addresses")
}
onController := false
for _, addr := range controller.Status.Addresses {
cidr, err := exactCIDR(addr.Address)
if err != nil {
continue
}
for _, local := range aCIDRs {
if cidr == local {
onController = true
}
}
}
if !onController || !placement.Online(controller) {
return fmt.Errorf("run admission on the online controller A")
}
// Quarantine first. Approval is the final write, after all checks have succeeded.
before := n.DeepCopy()
if n.Labels == nil {
n.Labels = map[string]string{}
}
delete(n.Labels, placement.LabelApproved)
found := false
for _, t := range n.Spec.Taints {
if t.Key == "felis.lolicon.best/unapproved" {
found = true
}
}
if !found {
n.Spec.Taints = append(n.Spec.Taints, corev1.Taint{Key: "felis.lolicon.best/unapproved", Value: "true", Effect: corev1.TaintEffectNoSchedule})
}
if err := cl.Patch(ctx, &n, client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{})); err != nil {
return err
}
if err := denyNodeAddresses(ctx, cl, ns, nodes.Items); err != nil {
return err
}
// The host's Service dial may be masqueraded to its bridge gateway. Permit exact host addresses only.
pullSources := append([]string{}, peers...)
if ip, network, err := net.ParseCIDR(n.Spec.PodCIDR); err == nil && ip.To4() != nil {
v := append(net.IP(nil), network.IP.To4()...)
pullSources = append(pullSources, v.String()+"/32")
v[3]++
pullSources = append(pullSources, v.String()+"/32")
}
var registryNP networkingv1.NetworkPolicy
if err := cl.Get(ctx, types.NamespacedName{Namespace: controlNS, Name: "felis-registry-ingress"}, &registryNP); err != nil {
return err
}
if len(registryNP.Spec.Ingress) == 0 {
return fmt.Errorf("registry ingress policy is not configured")
}
prev := registryNP.DeepCopy()
for _, cidr := range pullSources {
registryNP.Spec.Ingress[0].From = append(registryNP.Spec.Ingress[0].From, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: cidr}})
}
if err := cl.Patch(ctx, &registryNP, client.MergeFrom(prev)); err != nil {
return err
}
ctrlIP := ""
for _, a := range controller.Status.Addresses {
if a.Type == corev1.NodeInternalIP {
ctrlIP = a.Address
break
}
}
if ctrlIP == "" {
return fmt.Errorf("controller has no address")
}
script := "set -euo pipefail\numask 077\n" +
"systemctl is-active --quiet k3s-agent\n! systemctl is-active --quiet k3s\ntest ! -f /etc/rancher/k3s/k3s.yaml\n" +
"ip -d link show flannel-wg | grep -q wireguard\n" +
"/usr/local/bin/felis node firewall --peers " + shellQuote(strings.Join(peers, ",")) + " --controller-ip " + shellQuote(ctrlIP) + " --api-service-ip " + shellQuote(kubernetesSvc.Spec.ClusterIP) + " --node-port " + strconv.Itoa(int(apiSvc.Spec.Ports[0].NodePort)) + " --namespace " + shellQuote(ns) + " --control-namespace " + shellQuote(controlNS) + "\n" +
"kubeconfig=/var/lib/rancher/k3s/agent/kubelet.kubeconfig\n" +
"/usr/local/bin/k3s kubectl --kubeconfig \"$kubeconfig\" get node " + shellQuote(name) + " -o name >/dev/null\n" +
"proof=$(mktemp); trap 'rm -f \"$proof\"' EXIT\n" +
"if /usr/local/bin/k3s kubectl --kubeconfig \"$kubeconfig\" label node " + shellQuote(name) + " felis.node-restriction.kubernetes.io/probe=controller --overwrite 2>\"$proof\"; then echo 'NodeRestriction failed' >&2;exit 1;fi\ngrep -qi forbidden \"$proof\"\n" +
"image=" + shellQuote(image) + "\nif [ -n \"$(/usr/local/bin/k3s crictl images -q \"$image\")\" ]; then /usr/local/bin/k3s crictl rmi \"$image\" >/dev/null; fi\ntest -z \"$(/usr/local/bin/k3s crictl images -q \"$image\")\"\n/usr/local/bin/k3s crictl pull \"$image\" >/dev/null\n"
cmd := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=10", "--", remote, "if [ \"$(id -u)\" = 0 ]; then bash -s; else sudo -n bash -s; fi")
cmd.Stdin = strings.NewReader(script)
cmd.Stdout = stdout
cmd.Stderr = stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("worker host, NodeRestriction or uncached registry pull check failed: %w", err)
}
id := "felis-probe-" + archivetransfer.ID()[:12]
var objects []client.Object
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for i := len(objects) - 1; i >= 0; i-- {
cl.Delete(cleanup, objects[i])
}
}()
create := func(o client.Object) error {
if err := cl.Create(ctx, o); err != nil {
return err
}
objects = append(objects, o)
return nil
}
var endpoints []string
var echoPods []*corev1.Pod
for i := range nodes.Items {
node := &nodes.Items[i]
if !placement.Online(node) || (node.Name != name && node.Name != controller.Name && node.Labels[placement.LabelApproved] != "true") {
continue
}
echoName := fmt.Sprintf("%s-%d", id, i)
p := probePod(echoName, ns, node.Name, image, []string{"--listen", ":25565"}, true)
p.Labels["felis.lolicon.best/probe-echo"] = id
if err := create(p); err != nil {
return err
}
echoPods = append(echoPods, p)
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: echoName, Namespace: ns}, Spec: corev1.ServiceSpec{Selector: map[string]string{"felis.lolicon.best/probe-name": echoName}, Ports: []corev1.ServicePort{{Port: 25565, TargetPort: intstr.FromInt32(25565)}}}}
if err := create(svc); err != nil {
return err
}
endpoints = append(endpoints, net.JoinHostPort(svc.Spec.ClusterIP, "25565"))
}
tcp := corev1.ProtocolTCP
port := intstr.FromInt32(25565)
policy := &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: id, Namespace: ns}, Spec: networkingv1.NetworkPolicySpec{PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"felis.lolicon.best/probe-echo": id}}, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, Ingress: []networkingv1.NetworkPolicyIngressRule{{From: []networkingv1.NetworkPolicyPeer{{PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"felis.lolicon.best/probe-source": id}}}}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}}}}}}
for _, cidr := range aCIDRs {
policy.Spec.Ingress[0].From = append(policy.Spec.Ingress[0].From, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: cidr}})
}
if err := create(policy); err != nil {
return err
}
for _, p := range echoPods {
if err := waitProbePod(ctx, cl, p); err != nil {
return err
}
}
open := append([]string{}, endpoints...)
open = append(open, net.JoinHostPort(apiSvc.Spec.ClusterIP, "443"), net.JoinHostPort(registrySvc.Spec.ClusterIP, "5000"), net.JoinHostPort(archiveSvc.Spec.ClusterIP, "8090"))
// Positive probes need temporary, narrowly scoped ingress grants where the
// production fence admits only the controller or archive-transfer jobs.
for _, svc := range []*corev1.Service{&apiSvc, &registrySvc, &archiveSvc} {
if len(svc.Spec.Selector) == 0 || len(svc.Spec.Ports) == 0 {
return fmt.Errorf("probe Service %s has no backend", svc.Name)
}
targetPort := svc.Spec.Ports[0].TargetPort
if targetPort.IntVal == 0 && targetPort.StrVal == "" {
targetPort = intstr.FromInt32(svc.Spec.Ports[0].Port)
}
allow := &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: id + "-" + svc.Name, Namespace: svc.Namespace}, Spec: networkingv1.NetworkPolicySpec{
PodSelector: metav1.LabelSelector{MatchLabels: svc.Spec.Selector}, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress},
Ingress: []networkingv1.NetworkPolicyIngressRule{{From: []networkingv1.NetworkPolicyPeer{{
NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": ns}},
PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"felis.lolicon.best/probe-source": id}},
}}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &targetPort}}}},
}}
if err := create(allow); err != nil {
return err
}
}
positive := probeJob(id+"-positive", ns, name, image, probeArgs("--open", open), false)
positive.Spec.Template.Labels["felis.lolicon.best/probe-source"] = id
if err := create(positive); err != nil {
return err
}
if err := waitProbeJob(ctx, cl, positive); err != nil {
return err
}
denied = append(denied, open...)
denied = append(denied, "169.254.169.254:80", "169.254.170.2:80")
negative := probeJob(id+"-negative", ns, name, image, probeArgs("--closed", denied), true)
negative.Spec.Template.Spec.InitContainers = []corev1.Container{{Name: "egress-gate", Image: image, Command: []string{"/usr/local/bin/felis", "egress-gate", "--probe", net.JoinHostPort(apiSvc.Spec.ClusterIP, "443"), "--wait", "2m"}, SecurityContext: negative.Spec.Template.Spec.Containers[0].SecurityContext}}
if err := create(negative); err != nil {
return err
}
if err := waitProbeJob(ctx, cl, negative); err != nil {
return err
}
// Dial from Velocity's host namespace and record the address actually observed inside each backend.
for _, endpoint := range endpoints {
c, err := net.DialTimeout("tcp", endpoint, 5*time.Second)
if err != nil {
return fmt.Errorf("velocity host cannot dial backend Service %s: %w", endpoint, err)
}
c.Close()
}
var observed []string
time.Sleep(500 * time.Millisecond)
for _, p := range echoPods {
foundA := false
stream, err := cs.CoreV1().Pods(ns).GetLogs(p.Name, &corev1.PodLogOptions{}).Stream(ctx)
if err != nil {
return err
}
scan := bufio.NewScanner(stream)
for scan.Scan() {
line := scan.Text()
if strings.HasPrefix(line, "felis-probe-source ") {
host, _, err := net.SplitHostPort(strings.TrimPrefix(line, "felis-probe-source "))
if err == nil {
cidr, _ := exactCIDR(host)
for _, a := range aCIDRs {
if cidr == a {
observed = append(observed, cidr)
foundA = true
}
}
}
}
}
if !foundA {
stream.Close()
return fmt.Errorf("backend %s did not observe A as an exact source", p.Name)
}
err = scan.Err()
stream.Close()
if err != nil {
return err
}
}
if len(observed) < len(echoPods) {
return fmt.Errorf("backend observed a source outside A's exact interfaces")
}
var game networkingv1.NetworkPolicy
if err := cl.Get(ctx, types.NamespacedName{Namespace: ns, Name: "felis-allow-game-from-velocity"}, &game); err != nil {
return err
}
if len(game.Spec.Ingress) == 0 {
return fmt.Errorf("velocity ingress policy is not configured")
}
prevGame := game.DeepCopy()
for _, cidr := range observed {
exists := false
for _, peer := range game.Spec.Ingress[0].From {
if peer.IPBlock != nil && peer.IPBlock.CIDR == cidr {
exists = true
break
}
}
if exists {
continue
}
game.Spec.Ingress[0].From = append(game.Spec.Ingress[0].From, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: cidr}})
}
if err := cl.Patch(ctx, &game, client.MergeFrom(prevGame)); err != nil {
return err
}
// Read fresh resourceVersion so concurrent node health writes cannot be overwritten.
if err := cl.Get(ctx, types.NamespacedName{Name: name}, &n); err != nil {
return err
}
if !placement.Online(&n) {
return fmt.Errorf("worker became offline during validation")
}
before = n.DeepCopy()
n.Labels[placement.LabelIdentity] = name
n.Labels[placement.LabelRole] = placement.RoleWorker
n.Labels[placement.LabelApproved] = "true"
taints := n.Spec.Taints[:0]
for _, t := range n.Spec.Taints {
if t.Key != "felis.lolicon.best/unapproved" {
taints = append(taints, t)
}
}
n.Spec.Taints = taints
if err := cl.Patch(ctx, &n, client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{})); err != nil {
return err
}
fmt.Fprintln(stdout, "approved worker", name, "Velocity sources", strings.Join(observed, ","))
return nil
}
func probeArgs(flag string, addresses []string) []string {
var args []string
for _, a := range addresses {
args = append(args, flag, a)
}
return args
}
func probePod(name, ns, node, image string, args []string, game bool) *corev1.Pod {
no, yes := false, true
uid := int64(1000)
labels := map[string]string{"felis.lolicon.best/probe-name": name}
if game {
labels[v1alpha1.LabelManagedBy] = operator.ManagedByValue
labels[v1alpha1.LabelComponent] = operator.ComponentValue
labels[v1alpha1.LabelServer] = name
}
return &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: labels}, Spec: corev1.PodSpec{NodeName: node, RestartPolicy: corev1.RestartPolicyNever, AutomountServiceAccountToken: &no, SecurityContext: &corev1.PodSecurityContext{RunAsNonRoot: &yes, RunAsUser: &uid, SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}}, Containers: []corev1.Container{{Name: "probe", Image: image, ImagePullPolicy: corev1.PullAlways, Command: []string{"/usr/local/bin/felis", "node-probe"}, Args: args, Resources: corev1.ResourceRequirements{Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("256Mi"), corev1.ResourceCPU: resource.MustParse("200m")}}, SecurityContext: &corev1.SecurityContext{AllowPrivilegeEscalation: &no, ReadOnlyRootFilesystem: &yes, Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}}}}}}
}
func probeJob(name, ns, node, image string, args []string, game bool) *batchv1.Job {
p := probePod(name, ns, node, image, args, game)
zero := int32(0)
deadline := int64(600)
return &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: batchv1.JobSpec{BackoffLimit: &zero, ActiveDeadlineSeconds: &deadline, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: p.Labels}, Spec: p.Spec}}}
}
func waitProbePod(ctx context.Context, cl client.Client, p *corev1.Pod) error {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
if err := cl.Get(ctx, client.ObjectKeyFromObject(p), p); err != nil {
return err
}
for _, c := range p.Status.Conditions {
if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue {
return nil
}
}
if p.Status.Phase == corev1.PodFailed {
return fmt.Errorf("probe Pod %s failed", p.Name)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
}
func waitProbeJob(ctx context.Context, cl client.Client, j *batchv1.Job) error {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
if err := cl.Get(ctx, client.ObjectKeyFromObject(j), j); err != nil {
return err
}
for _, c := range j.Status.Conditions {
if c.Status != corev1.ConditionTrue {
continue
}
if c.Type == batchv1.JobComplete {
return nil
}
if c.Type == batchv1.JobFailed {
return fmt.Errorf("admission probe Job %s failed; inspect its Pod log", j.Name)
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
}
func denyNodeAddresses(ctx context.Context, cl client.Client, ns string, nodes []corev1.Node) error {
var np networkingv1.NetworkPolicy
if err := cl.Get(ctx, types.NamespacedName{Namespace: ns, Name: "felis-server-egress"}, &np); err != nil {
return err
}
before := np.DeepCopy()
for _, n := range nodes {
for _, a := range n.Status.Addresses {
if a.Type != corev1.NodeInternalIP && a.Type != corev1.NodeExternalIP {
continue
}
cidr, err := exactCIDR(a.Address)
if err != nil {
return err
}
for i := range np.Spec.Egress {
for k := range np.Spec.Egress[i].To {
block := np.Spec.Egress[i].To[k].IPBlock
if block != nil && ((strings.Contains(cidr, ":") && block.CIDR == "::/0") || (!strings.Contains(cidr, ":") && block.CIDR == "0.0.0.0/0")) {
block.Except = append(block.Except, cidr)
}
}
}
}
}
return cl.Patch(ctx, &np, client.MergeFrom(before))
}
-581
View File
@@ -1,581 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"slices"
"strings"
"syscall"
"time"
felis "felis.lolicon.best"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/nodecontrol"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/platform"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
func cmdNodeControl(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("node-control", flag.ContinueOnError)
fs.SetOutput(stderr)
socket := fs.String("socket", nodecontrol.Socket, "local API-only Unix socket")
dir := fs.String("state", "/var/lib/felis/node-control", "root-owned persistent task state")
kubeconfig := fs.String("kubeconfig", "/etc/rancher/k3s/k3s.yaml", "controller kubeconfig")
config := fs.String("config", "/etc/felis/felis.host.toml", "host configuration")
ns := fs.String("namespace", platform.DefaultMinecraftNamespace, "world namespace")
controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "control namespace")
if err := fs.Parse(args); err != nil {
return 2
}
if os.Geteuid() != 0 {
fmt.Fprintln(stderr, "node-control requires root")
return 1
}
cfg, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
cs, err := kubernetes.NewForConfig(cfg)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
exe, err := os.Executable()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
execute := nodeExecutor{cs: cs, binary: exe, kubeconfig: *kubeconfig, config: *config, namespace: *ns, controlNamespace: *controlNS, stateDir: *dir}
socketDir := filepath.Dir(*socket)
if err = os.MkdirAll(socketDir, 0750); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if err = os.Chown(socketDir, 0, 65532); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if err = os.Chmod(socketDir, 0750); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if existing, err := os.Lstat(*socket); err == nil {
if existing.Mode()&os.ModeSocket == 0 {
fmt.Fprintln(stderr, "refusing to replace non-socket path")
return 1
}
conn, err := net.DialTimeout("unix", *socket, time.Second)
if err == nil {
conn.Close()
fmt.Fprintln(stderr, "node-control is already running")
return 1
}
if err = os.Remove(*socket); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
listener, err := net.Listen("unix", *socket)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
defer listener.Close()
if err = os.Chown(*socket, 0, 65532); err == nil {
err = os.Chmod(*socket, 0660)
}
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
// /run is recreated at boot; label both the directory and the new socket inode.
if os.Getenv("FELIS_NODE_CONTROL_SELINUX") == "1" {
if err := exec.Command("chcon", "-R", "-t", "felis_node_control_socket_t", socketDir).Run(); err != nil {
fmt.Fprintln(stderr, "node-control socket labeling failed:", err)
return 1
}
}
manager, err := nodecontrol.Open(ctx, *dir, execute.run)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
server := &http.Server{Handler: manager.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second}
go func() { <-ctx.Done(); server.Close() }()
fmt.Fprintln(stdout, "node-control listening on", *socket)
err = server.Serve(listener)
cancel()
manager.Wait()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}
type nodeExecutor struct {
cs kubernetes.Interface
binary, kubeconfig, config, namespace, controlNamespace, stateDir string
}
func (e nodeExecutor) run(ctx context.Context, r nodecontrol.Request, stage func(string) error, out io.Writer) (resultErr error) {
// Host-wide changes and cache-removing admission probes are serialized by the manager.
if r.Action == "approve" {
node, err := e.cs.CoreV1().Nodes().Get(ctx, r.Name, metav1.GetOptions{})
if err != nil {
return err
}
if node.Labels[placement.LabelRole] != placement.RoleWorker {
return errors.New("only worker nodes can be approved")
}
patch := []byte(`{"spec":{"unschedulable":true}}`)
if !node.Spec.Unschedulable {
patch = []byte(`{"spec":{"unschedulable":true},"metadata":{"annotations":{"felis.lolicon.best/node-control-cordon":"true"}}}`)
}
if _, err = e.cs.CoreV1().Nodes().Patch(ctx, r.Name, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil {
return err
}
}
if err := e.requireStopped(ctx, r); err != nil {
return err
}
nodes, err := e.cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if err != nil {
return err
}
controller, peers, err := nodeController(nodes.Items, r.ExternalIP, r.Peers)
if err != nil {
return err
}
deployment, err := e.cs.AppsV1().Deployments(e.controlNamespace).Get(ctx, platform.SAAPI, metav1.GetOptions{})
if err != nil {
return err
}
if len(deployment.Spec.Template.Spec.Containers) == 0 {
return errors.New("the Felis API image is unavailable")
}
image := deployment.Spec.Template.Spec.Containers[0].Image
if r.Action == "enable" {
if err := stage("pause_operator"); err != nil {
return err
}
scale, err := e.cs.AppsV1().Deployments(e.controlNamespace).GetScale(ctx, platform.SAOperator, metav1.GetOptions{})
if err != nil {
return err
}
replicas := scale.Spec.Replicas
if replicas < 1 {
replicas = 1
}
scale.Spec.Replicas = 0
if _, err = e.cs.AppsV1().Deployments(e.controlNamespace).UpdateScale(ctx, platform.SAOperator, scale, metav1.UpdateOptions{}); err != nil {
return err
}
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
latest, err := e.cs.AppsV1().Deployments(e.controlNamespace).GetScale(cleanup, platform.SAOperator, metav1.GetOptions{})
if err == nil {
latest.Spec.Replicas = replicas
_, err = e.cs.AppsV1().Deployments(e.controlNamespace).UpdateScale(cleanup, platform.SAOperator, latest, metav1.UpdateOptions{})
}
if err != nil {
fmt.Fprintln(out, "Operator restoration failed:", err)
resultErr = fmt.Errorf("operator restoration failed: %w", err)
}
}()
// Drain the old reconciler before the second stopped-state check.
for {
pods, err := e.cs.CoreV1().Pods(e.controlNamespace).List(ctx, metav1.ListOptions{LabelSelector: platform.LabelComponent + "=" + platform.ComponentOperator})
if err != nil {
return err
}
if len(pods.Items) == 0 {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
if err = e.requireStopped(ctx, r); err != nil {
return err
}
return e.enable(ctx, r, controller, peers, stage, out)
}
if controller.Labels[placement.LabelRole] != placement.RoleController {
return errors.New("distributed mode is not configured on the controller")
}
if r.Name == controller.Name {
return errors.New("worker name must differ from the controller")
}
// SSH trust and keys are configured on A; no password, key or bootstrap token crosses the API.
if err := stage("ssh_check"); err != nil {
return err
}
arch := map[string]string{"amd64": "x86_64", "arm64": "aarch64"}[runtime.GOARCH]
if arch == "" {
return errors.New("unsupported host architecture")
}
check := "set -eu\n[ \"$(uname -s)\" = Linux ] || { echo 'worker requires Linux' >&2; exit 1; }\n[ \"$(uname -m)\" = " + shellQuote(arch) + " ] || { echo 'worker architecture differs from controller' >&2; exit 1; }\n"
if r.Action == "join" {
check += "[ ! -e /etc/rancher/k3s/k3s.yaml ] && [ ! -e /var/lib/rancher/k3s/agent ] || { echo 'k3s is already installed; enrollment will not overwrite an existing node' >&2; exit 1; }\n"
check += "ip -o address show | awk '{print $4}' | cut -d/ -f1 | grep -Fx -- " + shellQuote(r.ExternalIP) + " >/dev/null || { echo 'fixed node IP is not assigned to the worker' >&2; exit 1; }\n"
}
if err = e.ssh(ctx, r.SSHTarget, "if [ \"$(id -u)\" = 0 ]; then bash -s; else sudo -n bash -s; fi", strings.NewReader(check), out); err != nil {
return fmt.Errorf("worker preflight or SSH connection failed: %w", err)
}
if r.Action == "join" {
for _, n := range nodes.Items {
if n.Name == r.Name {
return errors.New("node already exists; use approval instead of reinstalling it")
}
}
if err = e.join(ctx, r, controller, peers, stage, out); err != nil {
return err
}
}
if err = stage("approval"); err != nil {
return err
}
cmd := exec.CommandContext(ctx, e.binary, "node", "approve", "--name", r.Name, "--ssh-target", r.SSHTarget, "--image", image, "--kubeconfig", e.kubeconfig, "--namespace", e.namespace, "--control-namespace", e.controlNamespace)
cmd.Stdout = out
cmd.Stderr = out
if err = cmd.Run(); err != nil {
return fmt.Errorf("node approval failed; node remains quarantined: %w", err)
}
// Admission succeeded; release only the task's scheduling quarantine.
admitted, err := e.cs.CoreV1().Nodes().Get(ctx, r.Name, metav1.GetOptions{})
if err != nil {
return err
}
if admitted.Annotations["felis.lolicon.best/node-control-cordon"] == "true" {
if _, err = e.cs.CoreV1().Nodes().Patch(ctx, r.Name, types.MergePatchType, []byte(`{"spec":{"unschedulable":false},"metadata":{"annotations":{"felis.lolicon.best/node-control-cordon":null}}}`), metav1.PatchOptions{}); err != nil {
return fmt.Errorf("node approved but scheduling remains disabled: %w", err)
}
}
return stage("complete")
}
func (e nodeExecutor) requireStopped(ctx context.Context, r nodecontrol.Request) error {
// Even pre-existing worker names may contain worlds. Never run installer/admission on an occupied worker.
ss, err := e.cs.AppsV1().StatefulSets(e.namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return err
}
for _, s := range ss.Items {
if r.Action != "enable" && s.Spec.Template.Spec.NodeSelector[placement.LabelIdentity] != r.Name {
continue
}
if s.Spec.Replicas != nil && *s.Spec.Replicas > 0 {
return fmt.Errorf("StatefulSet %s still requests %d replicas; stop the server before changing nodes", s.Name, *s.Spec.Replicas)
}
}
pods, err := e.cs.CoreV1().Pods(e.namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return err
}
for _, p := range pods.Items {
if r.Action != "enable" && p.Spec.NodeName != r.Name && p.Spec.NodeSelector[placement.LabelIdentity] != r.Name {
continue
}
if p.Status.Phase != corev1.PodSucceeded && p.Status.Phase != corev1.PodFailed {
return fmt.Errorf("pod %s has not exited (phase %s); wait for game and maintenance workloads to stop", p.Name, p.Status.Phase)
}
}
// Prevent a pending wake intent racing admission or an installation.
raw, err := e.cs.CoreV1().RESTClient().Get().AbsPath("/apis/" + v1alpha1.GroupVersion.Group + "/" + v1alpha1.GroupVersion.Version + "/namespaces/" + e.namespace + "/minecraftservers").DoRaw(ctx)
if err != nil {
return err
}
var servers v1alpha1.MinecraftServerList
if err = json.Unmarshal(raw, &servers); err != nil {
return err
}
for _, s := range servers.Items {
if r.Action != "enable" && s.Spec.NodeName != r.Name && s.Status.NodeName != r.Name {
continue
}
if s.Spec.DesiredState != "" && string(s.Spec.DesiredState) != "Stopped" {
return fmt.Errorf("server %s must have stopped intent", s.Name)
}
}
return nil
}
func nodeController(nodes []corev1.Node, ip string, extra []string) (*corev1.Node, []string, error) {
var controller *corev1.Node
peers := append([]string{}, extra...)
for i := range nodes {
n := &nodes[i]
_, controlPlane := n.Labels["node-role.kubernetes.io/control-plane"]
if controlPlane || n.Labels[placement.LabelRole] == placement.RoleController {
if controller != nil {
return nil, nil, errors.New("exactly one controller is required")
}
controller = n
}
for _, a := range n.Status.Addresses {
if a.Type == corev1.NodeExternalIP || a.Type == corev1.NodeInternalIP {
cidr, err := exactCIDR(a.Address)
if err != nil {
return nil, nil, err
}
peers = append(peers, cidr)
}
}
}
if controller == nil || !placement.Online(controller) {
return nil, nil, errors.New("the sole controller must be online")
}
if ip != "" {
cidr, err := exactCIDR(ip)
if err != nil {
return nil, nil, err
}
peers = append(peers, cidr)
}
slices.Sort(peers)
peers = slices.Compact(peers)
return controller, peers, nil
}
func controllerIP(n *corev1.Node) string {
for _, kind := range []corev1.NodeAddressType{corev1.NodeExternalIP, corev1.NodeInternalIP} {
for _, a := range n.Status.Addresses {
if a.Type == kind {
return a.Address
}
}
}
return ""
}
func (e nodeExecutor) ssh(ctx context.Context, target, command string, in io.Reader, out io.Writer) error {
cmd := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=10", "--", target, command)
cmd.Stdin = in
cmd.Stdout = out
cmd.Stderr = out
return cmd.Run()
}
func (e nodeExecutor) join(ctx context.Context, r nodecontrol.Request, controller *corev1.Node, peers []string, stage func(string) error, out io.Writer) error {
registry, err := e.cs.CoreV1().Services(e.controlNamespace).Get(ctx, "registry", metav1.GetOptions{})
if err != nil {
return err
}
if err = stage("firewall"); err != nil {
return err
}
// All peers must be updated before the new agent is admitted. Existing worker SSH targets must be configured by stable node name.
list, err := e.cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if err != nil {
return err
}
for _, n := range list.Items {
if n.Name == controller.Name || n.Name == r.Name {
continue
}
if n.Labels[placement.LabelRole] != placement.RoleWorker {
return fmt.Errorf("node %s has an unknown role", n.Name)
}
script := shellQuote(e.binary) + " node firewall --peers " + shellQuote(strings.Join(peers, ",")) + " --controller-ip " + shellQuote(controllerIP(controller))
script += " --namespace " + shellQuote(e.namespace) + " --control-namespace " + shellQuote(e.controlNamespace)
if err = e.ssh(ctx, n.Name, "if [ \"$(id -u)\" = 0 ]; then "+script+"; else sudo -n "+script+"; fi", nil, out); err != nil {
return fmt.Errorf("update peer firewall on %s: %w", n.Name, err)
}
}
cmd := exec.CommandContext(ctx, e.binary, "node", "firewall", "--controller", "--controller-ip", controllerIP(controller), "--peers", strings.Join(peers, ","), "--namespace", e.namespace, "--control-namespace", e.controlNamespace)
cmd.Stdout = out
cmd.Stderr = out
if err = cmd.Run(); err != nil {
return err
}
if err = stage("bootstrap_token"); err != nil {
return err
}
temp, err := os.MkdirTemp("", "felis-node-join-")
if err != nil {
return err
}
defer os.RemoveAll(temp)
tokenPath := filepath.Join(temp, "bootstrap")
tokenCmd := exec.CommandContext(ctx, e.binary, "node", "token", "--name", r.Name, "--ttl", "1h", "--out", tokenPath)
tokenCmd.Stdout = out
tokenCmd.Stderr = out
if err = tokenCmd.Run(); err != nil {
return err
}
// Revoke even on failure. The worker exchanges bootstrap credentials for its node identity.
token, err := os.ReadFile(tokenPath)
if err != nil {
return err
}
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
exec.CommandContext(cleanup, "k3s", "token", "delete", bootstrapTokenID(string(token))).Run()
}()
remoteDir := "/var/tmp/felis-node-" + filepath.Base(temp)
if err = stage("transfer"); err != nil {
return err
}
script := "set -eu; umask 077; mkdir " + shellQuote(remoteDir) + "; cat > " + shellQuote(remoteDir+"/bootstrap")
rootCommand := "if [ \"$(id -u)\" = 0 ]; then bash -s; else sudo -n bash -s; fi"
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
e.ssh(cleanup, r.SSHTarget, rootCommand, strings.NewReader("rm -rf -- "+shellQuote(remoteDir)), io.Discard)
}()
// stdin carries the token; it is never in command arguments, task records or logs.
if err = e.ssh(ctx, r.SSHTarget, "if [ \"$(id -u)\" = 0 ]; then "+script+"; else sudo -n sh -c "+shellQuote(script)+"; fi", strings.NewReader(string(token)), out); err != nil {
return err
}
binary, err := os.Open(e.binary)
if err != nil {
return err
}
defer binary.Close()
script = "set -eu; cat > " + shellQuote(remoteDir+"/felis") + "; chmod 0700 " + shellQuote(remoteDir+"/felis")
if err = e.ssh(ctx, r.SSHTarget, "if [ \"$(id -u)\" = 0 ]; then "+script+"; else sudo -n sh -c "+shellQuote(script)+"; fi", binary, out); err != nil {
return err
}
if err = stage("install_worker"); err != nil {
return err
}
script = "set -eu\nexport FELIS_K3S_VERSION=" + shellQuote(controller.Status.NodeInfo.KubeletVersion) + "\n" + shellQuote(remoteDir+"/felis") + " node join --name " + shellQuote(r.Name) + " --server " + shellQuote("https://"+net.JoinHostPort(controllerIP(controller), "6443")) + " --external-ip " + shellQuote(r.ExternalIP) + " --token-file " + shellQuote(remoteDir+"/bootstrap") + " --registry-ip " + shellQuote(registry.Spec.ClusterIP) + " --peers " + shellQuote(strings.Join(peers, ",")) + "\n"
if err = e.ssh(ctx, r.SSHTarget, rootCommand, strings.NewReader(script), out); err != nil {
return fmt.Errorf("worker installation failed: %w", err)
}
if err = stage("wait_ready"); err != nil {
return err
}
deadline := time.NewTimer(5 * time.Minute)
defer deadline.Stop()
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
n, err := e.cs.CoreV1().Nodes().Get(ctx, r.Name, metav1.GetOptions{})
if err == nil && placement.Online(n) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return errors.New("worker did not become Ready within five minutes")
case <-ticker.C:
}
}
}
func (e nodeExecutor) enable(ctx context.Context, r nodecontrol.Request, controller *corev1.Node, peers []string, stage func(string) error, out io.Writer) error {
if controllerIP(controller) != r.ExternalIP {
return errors.New("controller IP must match the registered fixed node address")
}
if len(peers) == 0 {
return errors.New("peer addresses are required")
}
if err := stage("database_backup"); err != nil {
return err
}
backup := exec.CommandContext(ctx, e.binary, "db", "backup", "-config", e.config, "-dir", "/var/lib/felis/db-backups", "-label", "pre-migrate")
backup.Stdout = out
backup.Stderr = out
if err := backup.Run(); err != nil {
return err
}
if err := stage("cluster_backup"); err != nil {
return err
}
if err := e.backupCluster(ctx, out); err != nil {
return err
}
if err := stage("configure_controller"); err != nil {
return err
}
patch := fmt.Sprintf(`{"metadata":{"labels":{"%s":"%s","%s":"controller"}}}`, placement.LabelIdentity, controller.Name, placement.LabelRole)
if _, err := e.cs.CoreV1().Nodes().Patch(ctx, controller.Name, types.MergePatchType, []byte(patch), metav1.PatchOptions{}); err != nil {
return err
}
for _, name := range []string{platform.SAAPI, platform.SAOperator, platform.PostgresName, "registry"} {
body := fmt.Sprintf(`{"spec":{"template":{"spec":{"nodeSelector":{"%s":"%s"}}}}}`, placement.LabelIdentity, controller.Name)
if _, err := e.cs.AppsV1().Deployments(e.controlNamespace).Patch(ctx, name, types.MergePatchType, []byte(body), metav1.PatchOptions{}); err != nil {
return err
}
}
if err := stage("install_controller"); err != nil {
return err
}
cmd := exec.CommandContext(ctx, "bash", "-s")
cmd.Stdin = strings.NewReader(felis.BootstrapScript())
cmd.Stdout = out
cmd.Stderr = out
cmd.Env = append(os.Environ(), "FELIS_DISTRIBUTED=1", "FELIS_NODE_EXTERNAL_IP="+r.ExternalIP, "FELIS_PEER_CIDRS="+strings.Join(peers, ","), "FELIS_BOOTSTRAP_FROM_TUI=1", "FELIS_BOOTSTRAP_BINARY="+e.binary, "FELIS_NO_SETUP=1", "FELIS_NODE_CONTROL_TASK=1", "FELIS_INSTALL_MODE=full")
if err := cmd.Run(); err != nil {
return fmt.Errorf("controller installation failed; retain the database backup: %w", err)
}
return stage("complete")
}
func bootstrapTokenID(token string) string {
token = strings.TrimSpace(token)
if _, short, ok := strings.Cut(token, "::"); ok {
token = short
}
id, _, _ := strings.Cut(token, ".")
return id
}
func (e nodeExecutor) backupCluster(ctx context.Context, out io.Writer) (err error) {
dir := filepath.Join(filepath.Dir(e.stateDir), "cluster-backups")
if err = os.MkdirAll(dir, 0700); err != nil {
return err
}
path := filepath.Join(dir, "pre-distributed-"+time.Now().UTC().Format("20060102T150405.000000000Z")+".tar")
stop := exec.CommandContext(ctx, "systemctl", "stop", "k3s")
stop.Stdout = out
stop.Stderr = out
if err = stop.Run(); err != nil {
return err
}
defer func() {
restart, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(restart, "systemctl", "start", "k3s")
cmd.Stdout = out
cmd.Stderr = out
if restartErr := cmd.Run(); restartErr != nil {
err = fmt.Errorf("k3s restart failed after snapshot: %w", restartErr)
}
}()
cmd := exec.CommandContext(ctx, "tar", "-cf", path, "-C", "/var/lib/rancher/k3s/server", "db", "token", "tls")
cmd.Stdout = out
cmd.Stderr = out
if err = cmd.Run(); err != nil {
return fmt.Errorf("cluster snapshot failed: %w", err)
}
if err = os.Chmod(path, 0600); err != nil {
return err
}
fmt.Fprintln(out, "Cluster snapshot:", path)
return nil
}
-40
View File
@@ -1,40 +0,0 @@
package main
import (
"context"
"strings"
"testing"
"felis.lolicon.best/internal/nodecontrol"
"felis.lolicon.best/internal/platform"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestNodeControlTopologyAndBootstrapID(t *testing.T) {
node := corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "controller", Labels: map[string]string{"node-role.kubernetes.io/control-plane": "true"}}, Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, Addresses: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "192.0.2.1"}}}}
controller, peers, err := nodeController([]corev1.Node{node}, "192.0.2.2", []string{"192.0.2.1/32"})
if err != nil || controller.Name != "controller" || strings.Join(peers, ",") != "192.0.2.1/32,192.0.2.2/32" {
t.Fatal(controller, peers, err)
}
duplicate := node
duplicate.Name = "second"
if _, _, err = nodeController([]corev1.Node{node, duplicate}, "", nil); err == nil {
t.Fatal("multiple controllers accepted")
}
for _, token := range []string{"abcdef.0123456789abcdef", "K10hash::abcdef.0123456789abcdef\n"} {
if bootstrapTokenID(token) != "abcdef" {
t.Fatal("token secret passed to revocation")
}
}
}
func TestNodeControlRejectsActiveWorkloadsBeforeHostCommands(t *testing.T) {
replicas := int32(1)
set := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "survival", Namespace: "minecraft"}, Spec: appsv1.StatefulSetSpec{Replicas: &replicas}}
executor := nodeExecutor{cs: fake.NewSimpleClientset(set), namespace: platform.DefaultMinecraftNamespace}
if err := executor.requireStopped(context.Background(), nodecontrol.Request{Action: "enable"}); err == nil {
t.Fatal("host changes permitted with active worlds")
}
}
-187
View File
@@ -1,187 +0,0 @@
package main
import (
"flag"
"fmt"
"io"
"net"
"os"
"os/exec"
"strconv"
"strings"
)
func nodeFirewall(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("node firewall", flag.ContinueOnError)
fs.SetOutput(stderr)
peers := fs.String("peers", "", "comma-separated exact node IP CIDRs")
controller := fs.String("controller-ip", "", "controller address (optionally :6443)")
main := fs.Bool("controller", false, "A hosts the public API NodePort")
pod := fs.String("pod-cidr", "10.42.0.0/16", "cluster Pod CIDR")
dryRun := fs.Bool("dry-run", false, "print firewall scripts without installing")
controlNS := fs.String("control-namespace", "felis", "controller namespace")
minecraftNS := fs.String("namespace", "minecraft", "game namespace")
apiIP := fs.String("api-service-ip", "10.43.0.1", "Kubernetes API Service IP")
port := fs.Int("node-port", 30443, "API NodePort to block on workers before DNAT")
if err := fs.Parse(args); err != nil {
return 2
}
if host, _, err := net.SplitHostPort(*controller); err == nil {
*controller = host
}
if net.ParseIP(*apiIP) == nil || net.ParseIP(*controller) == nil || *port < 30000 || *port > 32767 {
fmt.Fprintln(stderr, "node firewall: controller IP and NodePort required")
return 2
}
if _, _, err := net.ParseCIDR(*pod); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
var v4, v6 []string
for _, p := range strings.Split(*peers, ",") {
ip, n, err := net.ParseCIDR(p)
if err != nil {
fmt.Fprintln(stderr, "node firewall: invalid peer CIDR")
return 2
}
ones, bits := n.Mask.Size()
if ones != bits {
fmt.Fprintln(stderr, "node firewall: only exact peer addresses accepted")
return 2
}
if ip.To4() != nil {
v4 = append(v4, n.String())
} else {
v6 = append(v6, n.String())
}
}
script := "#!/bin/bash\nset -euo pipefail\n"
for _, f := range []struct {
bin string
peers []string
metadata string
}{{"iptables", v4, "169.254.0.0/16"}, {"ip6tables", v6, "fe80::/10"}} {
if f.bin == "ip6tables" && len(f.peers) == 0 {
continue
}
b := f.bin + " -w 10"
script += b + " -N FELIS-HOST 2>/dev/null || true\n" + b + " -F FELIS-HOST\n"
script += b + " -N FELIS-FORWARD 2>/dev/null || true\n" + b + " -F FELIS-FORWARD\n"
script += b + " -t raw -N FELIS-NODEPORT 2>/dev/null || true\n" + b + " -t raw -F FELIS-NODEPORT\n"
for _, c := range []struct{ table, parent, chain string }{{"filter", "INPUT", "FELIS-HOST"}, {"filter", "FORWARD", "FELIS-FORWARD"}, {"raw", "PREROUTING", "FELIS-NODEPORT"}} {
script += "while " + b + " -t " + c.table + " -D " + c.parent + " -j " + c.chain + " 2>/dev/null; do :; done\n" + b + " -t " + c.table + " -I " + c.parent + " 1 -j " + c.chain + "\n"
}
script += b + " -A FELIS-HOST -i lo -j RETURN\n"
if *main {
script += b + " -N FELIS-CONTROL 2>/dev/null || true\n" + b + " -A FELIS-HOST -j FELIS-CONTROL\n"
script += b + " -t raw -N FELIS-CONTROL 2>/dev/null || true\n" + b + " -t raw -A FELIS-NODEPORT -j FELIS-CONTROL\n"
}
script += b + " -A FELIS-HOST -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN\n" + b + " -A FELIS-FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN\n"
family := 4
if f.bin == "ip6tables" {
family = 6
}
podIP, _, _ := net.ParseCIDR(*pod)
podFamily := 6
if podIP.To4() != nil {
podFamily = 4
}
if family == podFamily {
script += b + " -A FELIS-HOST -s " + *pod + " -j DROP\n"
// raw precedes DNAT and kube-router's filter ACCEPT rules. Block new
// host connections while preserving established Velocity/RCON replies.
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -m addrtype --dst-type LOCAL -p tcp --syn -j DROP\n"
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -m addrtype --dst-type LOCAL -p udp -j DROP\n"
if (net.ParseIP(*apiIP).To4() != nil) == (family == 4) {
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -d " + *apiIP + " -p tcp --dport 443 --syn -j DROP\n"
}
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -m addrtype --dst-type LOCAL -p tcp --dport " + strconv.Itoa(*port) + " -j DROP\n"
script += b + " -A FELIS-FORWARD -s " + *pod + " -d " + f.metadata + " -j DROP\n"
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -d " + f.metadata + " -j DROP\n"
for _, p := range f.peers {
script += b + " -A FELIS-FORWARD -s " + *pod + " -d " + p + " -j DROP\n"
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -d " + p + " -p tcp --syn -j DROP\n"
script += b + " -t raw -A FELIS-NODEPORT -s " + *pod + " -d " + p + " -p udp -j DROP\n"
}
}
if !*main {
script += b + " -t raw -A FELIS-NODEPORT -m addrtype --dst-type LOCAL -p tcp --dport " + strconv.Itoa(*port) + " -j DROP\n"
}
for _, p := range f.peers {
script += b + " -A FELIS-HOST -s " + p + " -p udp --dport 51820:51821 -j RETURN\n"
}
script += b + " -A FELIS-HOST -p udp --dport 51820:51821 -j DROP\n"
ctrlIP := net.ParseIP(*controller)
ctrlFamily := 6
if ctrlIP.To4() != nil {
ctrlFamily = 4
}
if *main {
for _, p := range f.peers {
script += b + " -A FELIS-HOST -s " + p + " -p tcp --dport 6443 -j RETURN\n"
}
}
if ctrlFamily == family {
script += b + " -A FELIS-HOST -s " + ctrlIP.String() + " -p tcp --dport 10250 -j RETURN\n"
}
script += b + " -A FELIS-HOST -p tcp -m multiport --dports 6443,6444,10250,10255,2379,2380,5000,5001,15432 -j DROP\n"
}
if *dryRun {
fmt.Fprint(stdout, script)
if *main {
fmt.Fprint(stdout, controlFirewallScript(*controlNS, *minecraftNS))
}
return 0
}
if err := os.MkdirAll("/etc/felis", 0700); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if err := os.WriteFile("/etc/felis/node-firewall.sh", []byte(script), 0700); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
unit := "[Unit]\nDescription=Felis host and NodePort isolation\nAfter=network-online.target firewalld.service ufw.service\nBefore=k3s.service k3s-agent.service\n[Service]\nType=oneshot\nExecStart=/etc/felis/node-firewall.sh\nRemainAfterExit=yes\n[Install]\nWantedBy=multi-user.target\n"
if err := os.WriteFile("/etc/systemd/system/felis-node-firewall.service", []byte(unit), 0644); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if *main {
refresh := controlFirewallScript(*controlNS, *minecraftNS)
if err := os.WriteFile("/etc/felis/control-firewall.sh", []byte(refresh), 0700); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
svc := "[Unit]\nDescription=Refresh exact controller Pod access to the apiserver\nAfter=k3s.service\n[Service]\nType=oneshot\nExecStart=/etc/felis/control-firewall.sh\n"
timer := "[Unit]\nDescription=Track trusted controller Pods after rescheduling\n[Timer]\nOnBootSec=5s\nOnUnitActiveSec=5s\n[Install]\nWantedBy=timers.target\n"
if err := os.WriteFile("/etc/systemd/system/felis-control-firewall.service", []byte(svc), 0644); err != nil {
return 1
}
if err := os.WriteFile("/etc/systemd/system/felis-control-firewall.timer", []byte(timer), 0644); err != nil {
return 1
}
}
for _, cmd := range []*exec.Cmd{exec.Command("systemctl", "daemon-reload"), exec.Command("systemctl", "enable", "felis-node-firewall.service"), exec.Command("bash", "/etc/felis/node-firewall.sh")} {
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
if *main {
cmd := exec.Command("systemctl", "enable", "--now", "felis-control-firewall.timer")
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
return 0
}
func controlFirewallScript(controlNS, minecraftNS string) string {
return "#!/bin/bash\nset -euo pipefail\niptables -w 10 -F FELIS-CONTROL\niptables -w 10 -t raw -F FELIS-CONTROL\n/usr/local/bin/k3s kubectl --kubeconfig /etc/rancher/k3s/k3s.yaml get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{\" \"}{.spec.serviceAccountName}{\" \"}{.status.podIP}{\"\\n\"}{end}' | while read -r ns sa ip; do\ncase \"$ns/$sa\" in " + shellQuote(controlNS+"/felis-api") + "|" + shellQuote(controlNS+"/felis-operator") + "|" + shellQuote(minecraftNS+"/felis-reaper") + "|kube-system/*) ;; *) continue;; esac\n[[ $ip =~ ^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$ ]] || continue\niptables -w 10 -A FELIS-CONTROL -s \"$ip/32\" -p tcp --dport 6443 -j ACCEPT\niptables -w 10 -t raw -A FELIS-CONTROL -s \"$ip/32\" -p tcp -m multiport --dports 443,6443 -j ACCEPT\niptables -w 10 -t raw -A FELIS-CONTROL -s \"$ip/32\" -p udp --dport 53 -j ACCEPT\niptables -w 10 -A FELIS-CONTROL -s \"$ip/32\" -p udp --dport 53 -j ACCEPT\ndone\n"
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"bytes"
"os/exec"
"strings"
"testing"
)
func TestDistributedFirewallPathsAndSyntax(t *testing.T) {
for _, controller := range []bool{false, true} {
args := []string{"--dry-run", "--peers", "192.0.2.1/32,192.0.2.2/32", "--controller-ip", "192.0.2.1"}
if controller {
args = append(args, "--controller")
}
var out, err bytes.Buffer
if code := nodeFirewall(args, &out, &err); code != 0 {
t.Fatal(code, err.String())
}
script := out.String()
for _, must := range []string{"-I INPUT 1 -j FELIS-HOST", "-I FORWARD 1 -j FELIS-FORWARD", "-t raw -I PREROUTING 1 -j FELIS-NODEPORT", "-A FELIS-HOST -s 10.42.0.0/16 -j DROP", "--dst-type LOCAL -p tcp --dport 30443 -j DROP", "-d 169.254.0.0/16 -j DROP", "--dst-type LOCAL -p tcp --syn -j DROP", "-d 10.43.0.1 -p tcp --dport 443 --syn -j DROP"} {
if !strings.Contains(script, must) {
t.Fatal("missing protection", must)
}
}
if !controller && strings.Contains(script, " -p tcp --dport 6443 -j RETURN") {
t.Fatal("worker exposed apiserver")
}
check := exec.Command("bash", "-n")
check.Stdin = strings.NewReader(script)
if result, e := check.CombinedOutput(); e != nil {
t.Fatalf("invalid firewall script: %s %v", result, e)
}
}
var out, err bytes.Buffer
if code := nodeFirewall([]string{"--dry-run", "--peers", "10.0.0.0/8", "--controller-ip", "10.0.0.1"}, &out, &err); code != 2 {
t.Fatal("broad node range accepted")
}
}
-56
View File
@@ -1,56 +0,0 @@
package main
import (
"flag"
"fmt"
"io"
"net"
"time"
)
// A trusted probe runs with the same server labels and security context before node admission.
func cmdNodeProbe(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("node-probe", flag.ContinueOnError)
fs.SetOutput(stderr)
listen := fs.String("listen", "", "listen and print observed source addresses (admission only)")
var open, closed multiFlag
fs.Var(&open, "open", "required reachable host:port")
fs.Var(&closed, "closed", "required blocked host:port")
if err := fs.Parse(args); err != nil {
return 2
}
if *listen != "" {
l, err := net.Listen("tcp", *listen)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
defer l.Close()
for {
c, err := l.Accept()
if err != nil {
return 1
}
fmt.Fprintln(stdout, "felis-probe-source", c.RemoteAddr().String())
c.Write([]byte("felis-probe\n"))
c.Close()
}
}
time.Sleep(3 * time.Second)
for _, check := range []struct {
addresses []string
wantOpen bool
}{{open, true}, {closed, false}} {
for _, addr := range check.addresses {
c, err := net.DialTimeout("tcp", addr, 2*time.Second)
if c != nil {
c.Close()
}
if (err == nil) != check.wantOpen {
fmt.Fprintf(stderr, "node-probe: %s open=%t, expected %t\n", addr, err == nil, check.wantOpen)
return 1
}
}
}
return 0
}
-157
View File
@@ -1,157 +0,0 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"time"
felis "felis.lolicon.best"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/platform"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func cmdNode(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "felis node: list | token | join | approve | firewall")
return 2
}
if os.Geteuid() != 0 {
fmt.Fprintln(stderr, "felis node requires root/sudo")
return 1
}
if args[0] == "firewall" {
return nodeFirewall(args[1:], stdout, stderr)
}
fs := flag.NewFlagSet("node "+args[0], flag.ContinueOnError)
fs.SetOutput(stderr)
name := fs.String("name", "", "stable worker node name")
kubeconfig := fs.String("kubeconfig", "/etc/rancher/k3s/k3s.yaml", "A's local administrator kubeconfig")
ns := fs.String("namespace", platform.DefaultMinecraftNamespace, "world namespace")
controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "controller namespace")
image := fs.String("image", "", "Felis image to re-pull and use for admission probes")
remote := fs.String("ssh-target", "", "SSH target of the trusted worker (normal SSH host verification applies)")
out := fs.String("out", "", "token output file; never printed to stdout")
ttl := fs.Duration("ttl", 10*time.Minute, "bootstrap token lifetime (maximum 1h)")
server := fs.String("server", "", "A's https://address:6443 endpoint for join")
tokenFile := fs.String("token-file", "", "CA-pinned bootstrap token file for join")
registry := fs.String("registry-ip", "", "registry ClusterIP for join")
peers := fs.String("peers", "", "comma-separated exact peer CIDRs for host firewall")
external := fs.String("external-ip", "", "this worker's fixed external IP")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
switch args[0] {
case "token":
if *name == "" || *out == "" || *ttl <= 0 || *ttl > time.Hour {
fmt.Fprintln(stderr, "node token: name, output file and lifetime <=1h required")
return 2
}
cmd := exec.CommandContext(ctx, "k3s", "token", "create", "--ttl", ttl.String(), "--description", "Felis worker "+*name)
cmd.Stderr = stderr
raw, err := cmd.Output()
if err != nil {
fmt.Fprintln(stderr, "node token: creation failed:", err)
return 1
}
f, err := os.OpenFile(*out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
_, err = f.Write(raw)
if err == nil {
err = f.Sync()
}
f.Close()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
fmt.Fprintln(stdout, "limited bootstrap token written to", *out)
return 0
case "join":
exe, err := os.Executable()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
cmd := exec.CommandContext(ctx, "bash", "-s")
cmd.Stdin = strings.NewReader(felis.BootstrapScript())
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Env = append(os.Environ(), "FELIS_INSTALL_MODE=worker", "FELIS_BOOTSTRAP_FROM_TUI=1", "FELIS_BOOTSTRAP_BINARY="+exe, "FELIS_NODE_NAME="+*name, "FELIS_SERVER_URL="+*server, "FELIS_BOOTSTRAP_TOKEN_FILE="+*tokenFile, "FELIS_REGISTRY_CLUSTER_IP="+*registry, "FELIS_PEER_CIDRS="+*peers, "FELIS_NODE_EXTERNAL_IP="+*external)
if err = cmd.Run(); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
case "list", "approve":
default:
fmt.Fprintln(stderr, "unknown node operation")
return 2
}
cfg, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
scheme := runtime.NewScheme()
clientgoscheme.AddToScheme(scheme)
v1alpha1.AddToScheme(scheme)
cl, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
m := &distributed.Manager{Client: cl, Namespace: *ns}
if args[0] == "list" {
nodes, err := m.Nodes(ctx)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
json.NewEncoder(stdout).Encode(nodes)
return 0
}
if *name == "" || *image == "" || *remote == "" || strings.HasPrefix(*remote, "-") {
fmt.Fprintln(stderr, "node approve requires name, image and SSH target")
return 2
}
cs, err := kubernetes.NewForConfig(cfg)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if err = approveNode(ctx, cl, cs, *ns, *controlNS, *name, *image, *remote, stdout); err != nil {
fmt.Fprintln(stderr, "node remains quarantined:", err)
return 1
}
return 0
}
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" }
func exactCIDR(ip string) (string, error) {
parsed := net.ParseIP(ip)
if parsed == nil {
return "", fmt.Errorf("invalid node address %q", ip)
}
if parsed.To4() != nil {
return parsed.String() + "/32", nil
}
return parsed.String() + "/128", nil
}
+1 -7
View File
@@ -114,10 +114,7 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
// The operator's own image, for the forwarding-config initContainer it // The operator's own image, for the forwarding-config initContainer it
// injects into user servers. The Deployment passes it as FELIS_IMAGE (see // injects into user servers. The Deployment passes it as FELIS_IMAGE (see
// platform.OperatorDeployment); absent, that injection is simply skipped. // platform.OperatorDeployment); absent, that injection is simply skipped.
FelisImage: os.Getenv("FELIS_IMAGE"), FelisImage: os.Getenv("FELIS_IMAGE"),
Nodes: nil,
EgressProbe: os.Getenv("FELIS_EGRESS_PROBE"),
ControllerNode: os.Getenv("FELIS_CONTROLLER_NODE"),
// Uncached: the maintenance-lock check lists Jobs only when a server is // Uncached: the maintenance-lock check lists Jobs only when a server is
// about to start, which does not justify a namespace-wide Job informer. // about to start, which does not justify a namespace-wide Job informer.
Jobs: mgr.GetAPIReader(), Jobs: mgr.GetAPIReader(),
@@ -130,9 +127,6 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
Recorder: mgr.GetEventRecorderFor("felis-operator"), Recorder: mgr.GetEventRecorderFor("felis-operator"),
Watch: watch, Watch: watch,
} }
if os.Getenv("FELIS_DISTRIBUTED") == "true" {
r.Nodes = mgr.GetAPIReader()
}
if err := r.SetupWithManager(mgr); err != nil { if err := r.SetupWithManager(mgr); err != nil {
fmt.Fprintf(stderr, "felis operator: setup controller: %v\n", err) fmt.Fprintf(stderr, "felis operator: setup controller: %v\n", err)
return 1 return 1
+1 -15
View File
@@ -16,7 +16,6 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/backup" "felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/reaper" "felis.lolicon.best/internal/reaper"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -79,19 +78,6 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
return 1 return 1
} }
if os.Getenv("FELIS_DISTRIBUTED") == "true" && !*retentionOnly {
m, err := distributionManager(cl, cfg, os.Getenv("FELIS_IMAGE"))
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
local, ok := archiver.(*backup.TarLocal)
if !ok {
fmt.Fprintln(stderr, "remote reaper requires tarLocal")
return 1
}
archiver = &distributed.RemoteArchiver{TarLocal: local, Manager: m}
}
drv, err := openPodStore(ctx, cfg.Database.URL, "reaper", stderr) drv, err := openPodStore(ctx, cfg.Database.URL, "reaper", stderr)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis reaper: open database: %v\n", err) fmt.Fprintf(stderr, "felis reaper: open database: %v\n", err)
@@ -108,7 +94,7 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "felis reaper: retention only — no worlds root is configured, so idle worlds are neither archived nor released") fmt.Fprintln(stderr, "felis reaper: retention only — no worlds root is configured, so idle worlds are neither archived nor released")
return reportReaperRun(r.RunRetention(ctx), stdout, stderr) return reportReaperRun(r.RunRetention(ctx), stdout, stderr)
} }
r.Cluster = reaper.NewK8sCluster(cl, cfg.K8s.Namespace).WithDistributed(os.Getenv("FELIS_DISTRIBUTED") == "true") r.Cluster = reaper.NewK8sCluster(cl, cfg.K8s.Namespace)
// Pre-reap warnings go out by email when [smtp] is configured (the same // Pre-reap warnings go out by email when [smtp] is configured (the same
// relay and password_ref convention felis-api uses); without it the channel // relay and password_ref convention felis-api uses); without it the channel
+2 -30
View File
@@ -1,18 +1,14 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os"
"os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"felis.lolicon.best/internal/archivetransfer"
"felis.lolicon.best/internal/backup" "felis.lolicon.best/internal/backup"
ctrl "sigs.k8s.io/controller-runtime"
) )
// cmdRestore is the in-Pod entrypoint the restore Job runs. internal/restore // cmdRestore is the in-Pod entrypoint the restore Job runs. internal/restore
@@ -30,9 +26,6 @@ import (
func cmdRestore(args []string, stdout, stderr io.Writer) int { func cmdRestore(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("restore", flag.ContinueOnError) fs := flag.NewFlagSet("restore", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
source := fs.String("source-url", "", "one-use archive download URL")
sum := fs.String("sha256", "", "required digest for remote archive")
limit := fs.Int64("max-bytes", archivetransfer.DefaultLimit, "maximum download size")
server := fs.String("server", "", "server name being restored (for logging)") server := fs.String("server", "", "server name being restored (for logging)")
ref := fs.String("ref", "", "absolute path to the archive on the backup mount") ref := fs.String("ref", "", "absolute path to the archive on the backup mount")
store := fs.String("archive-store", "tarLocal", "archive backend (only tarLocal is implemented)") store := fs.String("archive-store", "tarLocal", "archive backend (only tarLocal is implemented)")
@@ -42,22 +35,6 @@ func cmdRestore(args []string, stdout, stderr io.Writer) int {
return 2 return 2
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if *source != "" {
dir, err := os.MkdirTemp("/tmp", "felis-restore-")
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
defer os.RemoveAll(dir)
*backupRoot = dir
*ref = filepath.Join(dir, "world.tar.gz")
if err := archivetransfer.Fetch(ctx, *source, os.Getenv(archivetransfer.TokenEnv), *ref, *sum, *limit); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
if *ref == "" { if *ref == "" {
fmt.Fprintln(stderr, "felis restore: --ref is required") fmt.Fprintln(stderr, "felis restore: --ref is required")
return 2 return 2
@@ -85,16 +62,11 @@ func cmdRestore(args []string, stdout, stderr io.Writer) int {
}, },
} }
ctx := ctrl.SetupSignalHandler()
if err := archiver.Restore(ctx, backup.ArchiveRef(*ref), *server); err != nil { if err := archiver.Restore(ctx, backup.ArchiveRef(*ref), *server); err != nil {
fmt.Fprintf(stderr, "felis restore: %v\n", err) fmt.Fprintf(stderr, "felis restore: %v\n", err)
return 1 return 1
} }
if *source != "" {
if err := backup.VerifyRestored(ctx, *ref, *worldsRoot); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
fmt.Fprintf(stdout, "felis restore: server=%s restored from %s into %s\n", *server, *ref, *worldsRoot) fmt.Fprintf(stdout, "felis restore: server=%s restored from %s into %s\n", *server, *ref, *worldsRoot)
return 0 return 0
} }
+2 -12
View File
@@ -21,18 +21,13 @@ Commands:
restore Extract a world archive into a world volume (internal Job entrypoint) restore Extract a world archive into a world volume (internal Job entrypoint)
backup Archive a world into the backup store and record it (internal Job entrypoint) backup Archive a world into the backup store and record it (internal Job entrypoint)
backup-now Archive every user server's world now, one at a time (or the named ones; -stop stops running ones first; prints the plan, -yes applies; requires root/sudo) backup-now Archive every user server's world now, one at a time (or the named ones; -stop stops running ones first; prints the plan, -yes applies; requires root/sudo)
files List, read, write, mkdir, delete, rename, upload or unzip one path in a stopped server's world (internal Job entrypoint) files List, read, write, mkdir, delete, rename or upload one path in a stopped server's world (internal Job entrypoint)
export Archive a stopped server's world, or read one of its backups, and hand it to felis-api for download (internal Job entrypoint) export Archive a stopped server's world, or read one of its backups, and hand it to felis-api for download (internal Job entrypoint)
egress-gate Hold a build or game server pod until its egress NetworkPolicy is enforced (internal init container entrypoint) egress-gate Hold a build or game server pod until its egress NetworkPolicy is enforced (internal init container entrypoint)
fetch-context Fetch and extract a submission's build context (internal Job entrypoint) fetch-context Fetch and extract a submission's build context (internal Job entrypoint)
scan-gate Apply the scan policy to a build's Trivy report and hand felis-api the report and SBOM (internal Job entrypoint) scan-gate Apply the scan policy to a build's Trivy report and hand felis-api the report and SBOM (internal Job entrypoint)
push-image Push a scanned image tarball to the registry (internal Job entrypoint) push-image Push a scanned image tarball to the registry (internal Job entrypoint)
mirror-build-tools Copy kaniko, trivy and Trivy's DBs into the registry (run by felis-build-tools.timer) mirror-build-tools Copy kaniko, trivy and Trivy's DBs into the registry (run by felis-build-tools.timer)
server-migrate Move a stopped world between approved nodes (start|status|retry; requires root)
node Join and approve trusted daemon nodes (list|token|join|approve|firewall; requires root)
node-control Run the host node-task service on an API-only Unix socket (requires root)
node-probe Verify reachability and observed sources (internal admission probe)
archive-serve Serve scoped one-use archive transfers on the controller (internal entrypoint)
registry-gate Authorize registry writes in front of registry:2 (internal sidecar entrypoint) registry-gate Authorize registry writes in front of registry:2 (internal sidecar entrypoint)
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json) apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
@@ -45,7 +40,7 @@ Commands:
support-bundle Collect status, doctor, logs and cluster state into one redacted tar.gz to share when asking for help (requires root/sudo) support-bundle Collect status, doctor, logs and cluster state into one redacted tar.gz to share when asking for help (requires root/sudo)
watchdog Check the platform once and mail the owners what has gone wrong (run by felis-watchdog.timer) watchdog Check the platform once and mail the owners what has gone wrong (run by felis-watchdog.timer)
version Print the build stamp of this binary version Print the build stamp of this binary
update Check platform versions; --apply installs a reviewed target update Report which platform components have updates available
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo) breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
Run "felis <command> -h" for command-specific flags. Run "felis <command> -h" for command-specific flags.
@@ -79,11 +74,6 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
"push-image": cmdPushImage, "push-image": cmdPushImage,
"mirror-build-tools": cmdMirrorBuildTools, "mirror-build-tools": cmdMirrorBuildTools,
"registry-gate": cmdRegistryGate, "registry-gate": cmdRegistryGate,
"archive-serve": cmdArchiveServe,
"node": cmdNode,
"node-control": cmdNodeControl,
"server-migrate": cmdServerMigrate,
"node-probe": cmdNodeProbe,
"manifests": cmdManifests, "manifests": cmdManifests,
"apply": cmdApply, "apply": cmdApply,
"setup": cmdSetup, "setup": cmdSetup,
-19
View File
@@ -5,27 +5,8 @@ import (
"os" "os"
"strings" "strings"
"testing" "testing"
"github.com/BurntSushi/toml"
"felis.lolicon.best/internal/config"
) )
func TestBootstrapConfigKeys(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"bootstrap-assets", "config-keys"}, &out, &errBuf); code != 0 {
t.Fatalf("exit code = %d: %s", code, errBuf.String())
}
var cfg config.Config
meta, err := toml.Decode(strings.TrimSpace(out.String())+` = "26.3"`, &cfg)
if err != nil || len(meta.Undecoded()) != 0 {
t.Fatalf("advertised config key is unsupported: %v, undecoded: %v", err, meta.Undecoded())
}
if cfg.Velocity.GameVersion != "26.3" {
t.Fatalf("advertised key did not set the game version: %q", cfg.Velocity.GameVersion)
}
}
func TestRunNoArgsPrintsUsage(t *testing.T) { func TestRunNoArgsPrintsUsage(t *testing.T) {
var out, errBuf bytes.Buffer var out, errBuf bytes.Buffer
if code := run(nil, &out, &errBuf); code != 2 { if code := run(nil, &out, &errBuf); code != 2 {
-114
View File
@@ -1,114 +0,0 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/distributed"
"felis.lolicon.best/internal/placement"
"felis.lolicon.best/internal/platform"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func cmdServerMigrate(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "server-migrate: start | status | retry (separate from database migrate)")
return 2
}
fs := flag.NewFlagSet("server-migrate "+args[0], flag.ContinueOnError)
fs.SetOutput(stderr)
name := fs.String("name", "", "stopped user server")
target := fs.String("target-node", "", "approved target worker")
id := fs.String("id", "", "operation id (required for retry)")
kube := fs.String("kubeconfig", "/etc/rancher/k3s/k3s.yaml", "A's local kubeconfig")
ns := fs.String("namespace", platform.DefaultMinecraftNamespace, "world namespace")
cfgPath := fs.String("config", "/var/lib/felis/felis.host.toml", "host config used to record the current owner on the safety backup")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
if os.Geteuid() != 0 {
fmt.Fprintln(stderr, "server-migrate requires root/sudo")
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg, err := clientcmd.BuildConfigFromFlags("", *kube)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
scheme := runtime.NewScheme()
clientgoscheme.AddToScheme(scheme)
v1alpha1.AddToScheme(scheme)
cl, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
m := &distributed.Manager{Client: cl, Namespace: *ns, Resolve: placement.Resolve(cl, *ns)}
var nodes corev1.NodeList
if err = cl.List(ctx, &nodes); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
for _, n := range nodes.Items {
if n.Labels[placement.LabelRole] == placement.RoleController {
m.Controller = n.Name
}
}
if m.Controller == "" {
fmt.Fprintln(stderr, "distributed controller identity is not configured")
return 1
}
m.Resolve = placement.Resolve(cl, *ns, m.Controller)
var op distributed.Operation
switch args[0] {
case "start":
host, cfgErr := config.Load(*cfgPath)
if cfgErr != nil {
fmt.Fprintln(stderr, cfgErr)
return 1
}
drv, dbErr := openPodStore(ctx, host.Database.URL, "migration", stderr)
if dbErr != nil {
fmt.Fprintln(stderr, dbErr)
return 1
}
defer drv.Close()
var owner sql.NullString
if err := drv.DB().QueryRowContext(ctx, "SELECT owner_id FROM servers WHERE name=$1 AND deleted_at IS NULL AND retire_requested_at IS NULL", *name).Scan(&owner); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
op, err = m.BeginMigration(ctx, *name, *target, owner.String)
case "status":
op, err = m.Migration(ctx, *name, *id)
case "retry":
if *id == "" {
fmt.Fprintln(stderr, "retry requires --id")
return 2
}
op, err = m.RetryMigration(ctx, *name, *id)
default:
fmt.Fprintln(stderr, "unknown migration operation")
return 2
}
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
json.NewEncoder(stdout).Encode(op)
return 0
}
+51 -24
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"felis.lolicon.best/internal/api" "felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/store" "felis.lolicon.best/internal/store"
@@ -99,6 +100,18 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
} }
defer setup.drv.Close() defer setup.drv.Close()
// The wizard's first screen asks the operator to join the server and run /link:
// the Owner IS the Minecraft account, so the login gate must be UP before we ask
// for a link code. This used to run after the wizard, which is why setup asked
// for a code from a server that had never been started. On a re-run the Owner
// already exists, so provisioning stays best-effort and never blocks the
// operator from reaching the status screen.
if err := provisionSystemServers(ctx, setup.cfg, stdout, !setup.adminExists); err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err)
fmt.Fprintln(stderr, "The Owner is bound by joining the login gate in-game, so setup cannot continue without it.")
return 1
}
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), setup.adminExists) res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), setup.adminExists)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err) fmt.Fprintf(stderr, "felis setup: %v\n", err)
@@ -108,50 +121,34 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
if panelURL == "" { if panelURL == "" {
panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname) panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname)
} }
// Game services are provisioned independently. A failed login/lobby must not
// stop the host administrator from opening the panel to diagnose it.
if err := provisionSystemServers(ctx, setup.cfg, stdout); err != nil {
fmt.Fprintf(stdout, "felis setup: Minecraft services need attention: %v\n", err)
}
reportSetupResult(stdout, res, bootstrapped, setup.adminExists, panelURL)
return 0
}
// reportSetupResult preserves the browser handoff after the TUI closes.
func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adminExisted bool, panelURL string) {
if !adminExisted && !res.provisioned {
defer fmt.Fprintln(stdout, "\nOwner login is unfinished. Run sudo felis setup again to get a panel setup link; Minecraft is not required.")
}
if !res.provisioned && !res.connectConfigured { if !res.provisioned && !res.connectConfigured {
if bootstrapped { if bootstrapped {
fmt.Fprintln(stdout, "felis setup: host bootstrap completed; panel/connection setup unfinished.") fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/connection setup skipped.")
if panelURL != "" { if panelURL != "" {
fmt.Fprintf(stdout, "Panel: %s\n", panelURL) fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.") fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
} }
return return 0
} }
// A re-run lands on the status screen, which changes nothing by design — // A re-run lands on the status screen, which changes nothing by design —
// reporting that as "cancelled" reads as a failure the operator did not cause. // reporting that as "cancelled" reads as a failure the operator did not cause.
msg := "felis setup: cancelled — no changes made." msg := "felis setup: cancelled — no changes made."
switch { if res.alreadySetUp {
case res.alreadySetUp:
msg = "felis setup: already set up — nothing to change." msg = "felis setup: already set up — nothing to change."
} }
fmt.Fprintln(stdout, msg) fmt.Fprintln(stdout, msg)
if panelURL != "" { if panelURL != "" {
fmt.Fprintf(stdout, "Panel: %s\n", panelURL) fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
} }
return return 0
} }
if res.provisioned { if res.provisioned {
fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned (passwordless).\n", res.username) fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned (passwordless).\n", res.username)
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser) fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
if res.setupTokenURL != "" { if res.setupTokenURL != "" {
fmt.Fprintf(stdout, "Open this URL to record your email and create a passkey (Minecraft is optional):\n\n %s\n\n", res.setupTokenURL) fmt.Fprintf(stdout, "Open this URL to complete passwordless login setup (verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
} }
if res.auditWarning != "" { if res.auditWarning != "" {
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning) fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
@@ -179,15 +176,28 @@ func reportSetupResult(stdout io.Writer, res breakGlassResult, bootstrapped, adm
fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.") fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.")
} }
} }
return 0
} }
// provisionSystemServers ensures the login limbo and lobby system services exist, // provisionSystemServers ensures the login limbo and lobby system services exist,
// then prints the login-first Velocity wiring. deploy/bootstrap.sh writes this // then prints the login-first Velocity wiring. deploy/bootstrap.sh writes this
// configuration for its host proxy; operators only need to mirror it when they // configuration for its host proxy; operators only need to mirror it when they
// deliberately run Velocity elsewhere. // deliberately run Velocity elsewhere.
//
func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writer) error { // required is set on a first run, where the next screen asks the operator to join
// the server and run /link. There a gate that never comes up is not a degraded
// install, it is an impossible one — so every soft landing below becomes a hard
// error and we block until the gate reports Ready. On a re-run the Owner already
// exists and nothing downstream needs the gate, so unconfigured images or an
// unreachable cluster degrade to printed guidance exactly as before.
func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writer, required bool) error {
// fail is the one place the two modes diverge: fatal on a first run, guidance
// on a re-run.
fail := func(format string, args ...any) error { fail := func(format string, args ...any) error {
if required {
return fmt.Errorf(format, args...)
}
fmt.Fprintf(out, "\nfelis setup: "+format+"\n", args...) fmt.Fprintf(out, "\nfelis setup: "+format+"\n", args...)
return nil return nil
} }
@@ -195,6 +205,10 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
return fail("login/lobby system servers NOT provisioned — set [velocity] login_image " + return fail("login/lobby system servers NOT provisioned — set [velocity] login_image " +
"and lobby_image in felis.toml (build them from deploy/limbo and deploy/lobby), then re-run `sudo felis setup`") "and lobby_image in felis.toml (build them from deploy/limbo and deploy/lobby), then re-run `sudo felis setup`")
} }
if required && cfg.Velocity.LoginImage == "" {
return errors.New("the Owner binds by joining the login gate, but [velocity] login_image is not set in felis.toml " +
"(build it from deploy/limbo), then re-run `sudo felis setup`")
}
cl, err := buildSystemServerClient() cl, err := buildSystemServerClient()
if err != nil { if err != nil {
return fail("could not reach the cluster to provision the login/lobby system servers: %v\n"+ return fail("could not reach the cluster to provision the login/lobby system servers: %v\n"+
@@ -214,7 +228,7 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
// mount them are created: the login gate's token (login authenticates to // mount them are created: the login gate's token (login authenticates to
// felis-api with it), the Velocity forwarding secret (every backend verifies the proxy's // felis-api with it), the Velocity forwarding secret (every backend verifies the proxy's
// signed handshake with it — without it the login gate would derive an OFFLINE // signed handshake with it — without it the login gate would derive an OFFLINE
// UUID and players would bind the wrong Minecraft identity), and felis-config // UUID and the Owner would bind the wrong Minecraft identity), and felis-config
// (the on-demand BACKUP Job runs in the minecraft namespace and mounts it to // (the on-demand BACKUP Job runs in the minecraft namespace and mounts it to
// self-record its world_backups row; without the replica the Job's volume // self-record its world_backups row; without the replica the Job's volume
// mount fails and every backup request strands in the cluster). // mount fails and every backup request strands in the cluster).
@@ -240,6 +254,19 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ
fmt.Fprintf(out, " - %s: skipped (%s)\n", o.name, o.skipped) fmt.Fprintf(out, " - %s: skipped (%s)\n", o.name, o.skipped)
} }
} }
if required {
if err := requiredProvisioningError(outcomes); err != nil {
return fmt.Errorf("required Minecraft provisioning failed: %w", err)
}
fmt.Fprintln(out, "\nfelis setup: waiting for the login gate to accept players…")
err := awaitLoginGateReady(ctx, cl, cfg.K8s.Namespace, loginGateReadyTimeout, loginGatePollInterval, func(p v1alpha1.Phase) {
fmt.Fprintf(out, " login: %s\n", phaseOrPending(p))
})
if err != nil {
return err
}
fmt.Fprintln(out, " login: Ready")
}
printVelocityWiringGuidance(out, cfg.Server.RootDomain) printVelocityWiringGuidance(out, cfg.Server.RootDomain)
return nil return nil
} }
+3 -18
View File
@@ -33,6 +33,9 @@ func setupPanelNodePort() int {
} }
func localPanelURL(rootDomain, adminHostname string) string { func localPanelURL(rootDomain, adminHostname string) string {
if ip := rootDomainEmbeddedIP(rootDomain); ip != "" {
return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort())
}
host := defaultAdminHostname(rootDomain, adminHostname) host := defaultAdminHostname(rootDomain, adminHostname)
if host == "" { if host == "" {
return "" return ""
@@ -54,24 +57,6 @@ func rootDomainEmbeddedIP(rootDomain string) string {
return "" return ""
} }
// setupGameAddress is where players join Minecraft: the
// IP a nip.io or sslip.io root domain spells out (nothing to resolve), otherwise the
// root domain, with the port when it is not Minecraft's default. The proxy lands
// every fresh connection on the login server whatever name it was dialled by.
func setupGameAddress(rootDomain string, gamePort int) string {
host := rootDomainEmbeddedIP(rootDomain)
if host == "" {
host = strings.TrimSpace(strings.TrimSuffix(rootDomain, "."))
}
if host == "" {
return ""
}
if gamePort == 0 || gamePort == 25565 {
return host
}
return net.JoinHostPort(host, strconv.Itoa(gamePort))
}
func localPanelOrigin() string { func localPanelOrigin() string {
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort()) return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
} }
+92
View File
@@ -4,11 +4,13 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
@@ -558,6 +560,75 @@ func convergeDerivedEnv(existing, desired *v1alpha1.MinecraftServer) []string {
return changes return changes
} }
// The login gate is a hard prerequisite of the Owner bind, so setup waits for it
// rather than racing it. The ceiling covers a cold image pull on a fresh node;
// the poll is fast enough that a warm start feels immediate.
const (
loginGateReadyTimeout = 5 * time.Minute
loginGatePollInterval = 3 * time.Second
)
// awaitLoginGateReady blocks until the login system server reports status.ready.
//
// The Owner claims their seat by JOINING the game and running /link, so the gate
// being up is not a nicety — it is the precondition for the very next thing setup
// asks of the operator. progress is called on each phase change so the caller can
// show movement during a cold image pull; it may be nil.
func awaitLoginGateReady(ctx context.Context, cl client.Client, namespace string, timeout, poll time.Duration, progress func(v1alpha1.Phase)) error {
key := client.ObjectKey{Namespace: namespace, Name: naming.SystemLoginServer}
deadline := time.Now().Add(timeout)
last := v1alpha1.Phase("")
for {
var ms v1alpha1.MinecraftServer
switch err := cl.Get(ctx, key, &ms); {
case err == nil:
if ms.Status.Ready {
return nil
}
if ms.Status.Phase != last {
last = ms.Status.Phase
if progress != nil {
progress(last)
}
}
// The operator only marks Failed once its OWN startup deadline has already
// elapsed, so Failed is a settled verdict rather than a transient — sitting
// out the rest of our timeout on top of it would only hide the reason.
if ms.Status.Phase == v1alpha1.PhaseFailed {
return fmt.Errorf("the login gate failed to start: %s", readyConditionMessage(&ms))
}
case !apierrors.IsNotFound(err):
return err
}
if !time.Now().Before(deadline) {
return fmt.Errorf("timed out after %s waiting for the login gate to become ready (last phase: %s)", timeout, phaseOrPending(last))
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// readyConditionMessage is the operator's own account of why the gate is not
// ready — far more useful to an operator than "phase: Failed".
func readyConditionMessage(ms *v1alpha1.MinecraftServer) string {
if c := meta.FindStatusCondition(ms.Status.Conditions, v1alpha1.ConditionReady); c != nil && c.Message != "" {
return c.Message
}
return "no Ready condition was reported"
}
// phaseOrPending names the empty phase, which means the operator has not
// reconciled the server yet (commonly: the operator itself is not running).
func phaseOrPending(p v1alpha1.Phase) string {
if p == "" {
return "not yet reconciled — is the felis operator running?"
}
return string(p)
}
// provisionSecretReplicas copies the Secrets workload pods mount from the control // provisionSecretReplicas copies the Secrets workload pods mount from the control
// namespace into the namespaces those pods run in. The proxy's felis-service-token // namespace into the namespaces those pods run in. The proxy's felis-service-token
// is not among them: it lives in the control namespace and on the host, and a copy // is not among them: it lives in the control namespace and on the host, and a copy
@@ -723,3 +794,24 @@ func ensureSecretReplica(ctx context.Context, cl client.Client, controlNamespace
} }
return systemServerOutcome{name: name, created: true, available: true} return systemServerOutcome{name: name, created: true, available: true}
} }
func requiredProvisioningError(outcomes []systemServerOutcome) error {
required := map[string]struct{}{
"limbo-token (minecraft ns)": {},
"forwarding-secret (minecraft ns)": {},
naming.SystemLoginServer: {},
}
for _, o := range outcomes {
if o.err != nil {
return fmt.Errorf("%s: %w", o.name, o.err)
}
if _, ok := required[o.name]; ok && !o.available {
reason := o.skipped
if reason == "" {
reason = "object was not created"
}
return fmt.Errorf("%s unavailable: %s", o.name, reason)
}
}
return nil
}
+93
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"strings" "strings"
"testing" "testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
@@ -331,6 +332,98 @@ func TestEnsureSecretReplicaRefresh(t *testing.T) {
}) })
} }
func TestRequiredProvisioningError(t *testing.T) {
ready := []systemServerOutcome{
{name: "limbo-token (minecraft ns)", available: true},
{name: "forwarding-secret (minecraft ns)", available: true},
{name: naming.SystemLoginServer, available: true},
{name: naming.SystemLobbyServer, skipped: "image not configured"},
}
if err := requiredProvisioningError(ready); err != nil {
t.Fatalf("ready outcomes: %v", err)
}
missing := append([]systemServerOutcome(nil), ready...)
missing[1] = systemServerOutcome{name: "forwarding-secret (minecraft ns)", skipped: "source missing"}
if err := requiredProvisioningError(missing); err == nil || !strings.Contains(err.Error(), "forwarding-secret") {
t.Fatalf("missing forwarding secret = %v, want named error", err)
}
// The login gate cannot reach felis-api without its token, so setup must not
// report success while that replica is missing.
noToken := append([]systemServerOutcome(nil), ready...)
noToken[0] = systemServerOutcome{name: "limbo-token (minecraft ns)", skipped: "source missing"}
if err := requiredProvisioningError(noToken); err == nil || !strings.Contains(err.Error(), "limbo-token") {
t.Fatalf("missing limbo token = %v, want named error", err)
}
failed := append([]systemServerOutcome(nil), ready...)
failed[3] = systemServerOutcome{name: naming.SystemLobbyServer, err: context.DeadlineExceeded}
if err := requiredProvisioningError(failed); err == nil || !strings.Contains(err.Error(), naming.SystemLobbyServer) {
t.Fatalf("lobby create failure = %v, want immediate named error", err)
}
}
// The Owner binds by joining the game, so setup blocks on the login gate rather
// than racing it. What matters is that each ending is distinguishable: Ready
// proceeds, Failed reports the operator's own reason instead of waiting out the
// clock, and a gate that never appears (no operator reconciling it) times out
// saying so rather than dropping the operator on a bind screen that cannot work.
func TestAwaitLoginGateReady(t *testing.T) {
scheme := newSystemServerScheme(t)
ctx := context.Background()
gate := func(mut func(*v1alpha1.MinecraftServer)) *v1alpha1.MinecraftServer {
ms := &v1alpha1.MinecraftServer{
ObjectMeta: metav1.ObjectMeta{Name: naming.SystemLoginServer, Namespace: "minecraft"},
}
mut(ms)
return ms
}
t.Run("returns once the gate is ready", func(t *testing.T) {
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gate(func(ms *v1alpha1.MinecraftServer) {
ms.Status.Phase = v1alpha1.PhaseRunning
ms.Status.Ready = true
})).Build()
if err := awaitLoginGateReady(ctx, cl, "minecraft", time.Second, 10*time.Millisecond, nil); err != nil {
t.Fatalf("await: %v", err)
}
})
t.Run("fails fast on Failed, carrying the operator's reason", func(t *testing.T) {
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gate(func(ms *v1alpha1.MinecraftServer) {
ms.Status.Phase = v1alpha1.PhaseFailed
ms.Status.Conditions = []metav1.Condition{{
Type: v1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
Reason: "StartupTimeout",
Message: "pod never became ready: ImagePullBackOff",
LastTransitionTime: metav1.Now(),
}}
})).Build()
start := time.Now()
err := awaitLoginGateReady(ctx, cl, "minecraft", time.Minute, 10*time.Millisecond, nil)
if err == nil {
t.Fatal("await: nil error, want failure")
}
if !strings.Contains(err.Error(), "ImagePullBackOff") {
t.Errorf("error = %q, want the operator's Ready-condition message", err)
}
if time.Since(start) > 5*time.Second {
t.Error("await sat out the full timeout on a settled Failed verdict")
}
})
t.Run("times out when nothing ever reconciles the gate", func(t *testing.T) {
cl := fake.NewClientBuilder().WithScheme(scheme).Build()
err := awaitLoginGateReady(ctx, cl, "minecraft", 30*time.Millisecond, 10*time.Millisecond, nil)
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("await = %v, want a timeout", err)
}
})
}
func newSystemServerScheme(t *testing.T) *runtime.Scheme { func newSystemServerScheme(t *testing.T) *runtime.Scheme {
t.Helper() t.Helper()
scheme := runtime.NewScheme() scheme := runtime.NewScheme()
-1
View File
@@ -104,7 +104,6 @@ func TestRootSummaryReadsAlertRoute(t *testing.T) {
return route return route
} }
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk"}) m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk"})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
sum, ok := m.screen.(*summaryModel) sum, ok := m.screen.(*summaryModel)
if !ok || sum.alerts == nil || sum.alerts.relay != "" { if !ok || sum.alerts == nil || sum.alerts.relay != "" {
t.Fatalf("summary after storage: %T %+v", m.screen, sum) t.Fatalf("summary after storage: %T %+v", m.screen, sum)
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"context"
"strings"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
// mcBindModel is the `felis setup` Owner-establishment screen: the operator
// joins the server, runs /link to get a one-time code, and types it here. The
// bound Minecraft account is promoted to the passwordless Owner, and a one-time
// setup URL is minted for the first web login. It replaces the old ownerModel
// bootstrap form in setup mode — no username/email/password is typed here, the
// MC identity is the root of trust.
type mcBindModel struct {
ctx context.Context
store ownerStore
adminHost string
osUser string
step mcBindStep
form *huh.Form
sp spinner.Model
working string
linkCode string
ownerIdentity string
setupTokenURL string
auditWarning string
width, height int
}
type mcBindStep int
const (
mcBindForm mcBindStep = iota
mcBindWorking
mcBindDone
)
type mcBindMsg struct {
outcome breakGlassOutcome
err error
}
func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser string) *mcBindModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
m := &mcBindModel{
ctx: ctx,
store: store,
adminHost: adminHost,
osUser: osUser,
sp: sp,
step: mcBindForm,
}
m.form = m.buildForm()
return m
}
func (m *mcBindModel) buildForm() *huh.Form {
return m.sized(newFelisForm(huh.NewGroup(
huh.NewNote().
Title("Bind your Minecraft account").
Description("Join the server and run /link to get a one-time code,\nthen type it here. Your bound account becomes the\npasswordless Owner."),
huh.NewInput().
Title("Link code").
Placeholder("ABCD12").
Value(&m.linkCode).
Validate(requiredField("link code")),
)))
}
func (m *mcBindModel) sized(f *huh.Form) *huh.Form {
if m.width > 0 {
return f.WithWidth(m.width).WithHeight(m.height)
}
return f
}
func (m *mcBindModel) setSize(w, h int) {
m.width, m.height = w, h
if m.form != nil {
m.form = m.form.WithWidth(w).WithHeight(h)
}
}
func (m *mcBindModel) Init() tea.Cmd { return m.form.Init() }
func (m *mcBindModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case mcBindMsg:
if msg.err != nil {
return m, m.failCmd(msg.err)
}
m.step = mcBindDone
m.ownerIdentity = msg.outcome.ownerIdentity
m.setupTokenURL = msg.outcome.setupTokenURL
if msg.outcome.auditErr != nil {
m.auditWarning = msg.outcome.auditErr.Error()
}
return m, nil
case spinner.TickMsg:
if m.step == mcBindWorking {
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
}
return m, nil
case tea.KeyMsg:
switch m.step {
case mcBindDone:
switch msg.String() {
case "ctrl+c", "esc", "enter":
return m, m.resultCmd()
}
return m, nil
case mcBindWorking:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
return m, nil
default:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
}
}
}
if m.step == mcBindForm && m.form != nil {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
switch m.form.State {
case huh.StateCompleted:
return m.onFormComplete()
case huh.StateAborted:
return m, tea.Quit
}
return m, cmd
}
return m, nil
}
func (m *mcBindModel) onFormComplete() (tea.Model, tea.Cmd) {
m.step = mcBindWorking
m.working = "Binding Minecraft account…"
code := strings.TrimSpace(strings.ToUpper(m.linkCode))
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser)
return mcBindMsg{outcome: out, err: err}
})
}
func (m *mcBindModel) failCmd(err error) tea.Cmd {
return func() tea.Msg { return ownerResultMsg{err: err} }
}
func (m *mcBindModel) resultCmd() tea.Cmd {
return func() tea.Msg {
return ownerResultMsg{
username: m.ownerIdentity,
setupTokenURL: m.setupTokenURL,
mode: "setup",
accountable: m.osUser,
auditWarning: m.auditWarning,
}
}
}
func (m *mcBindModel) View() string {
switch m.step {
case mcBindWorking:
msg := m.working
if msg == "" {
msg = "Working…"
}
return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n"
case mcBindDone:
return m.doneView()
default:
if m.form == nil {
return ""
}
return m.form.View()
}
}
func (m *mcBindModel) doneView() string {
var b strings.Builder
b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n")
var box strings.Builder
if m.ownerIdentity != "" {
box.WriteString(tuiLabel.Render("minecraft ") + m.ownerIdentity + "\n")
}
if m.setupTokenURL != "" {
if box.Len() > 0 {
box.WriteString("\n")
}
box.WriteString(tuiLabel.Render("setup URL ") + "\n")
for _, line := range wrapDisplayURL(m.setupTokenURL, 70) {
box.WriteString(tuiPassword.Render(line) + "\n")
}
box.WriteString("\n")
box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup.\nIt is shown only once."))
}
if m.auditWarning != "" {
box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning))
}
b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n")
b.WriteString(tuiAction("enter", "continue"))
return b.String()
}
+16 -7
View File
@@ -211,14 +211,23 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) {
} }
} }
func TestSetupOwnerCarriesAuditWarning(t *testing.T) { func TestMCBindCarriesAuditWarning(t *testing.T) {
m := newSetupOwnerModel(context.Background(), &fakeOwnerStore{}, "https://op.console.example.com", "root") m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root")
_, cmd := m.Update(setupOwnerMsg{outcome: breakGlassOutcome{ next, _ := m.Update(mcBindMsg{outcome: breakGlassOutcome{
ownerUsername: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken", auditErr: errors.New("audit insert failed"), ownerIdentity: "mc-uuid-1",
setupTokenURL: "https://op.console.example.com/setup?token=t0ken",
auditErr: errors.New("audit insert failed"),
}}) }})
res := cmd().(ownerResultMsg) bound := next.(*mcBindModel)
if res.username != "owner" || res.auditWarning != "audit insert failed" { if !strings.Contains(bound.doneView(), "audit insert failed") {
t.Fatalf("result = %+v", res) t.Fatalf("done view did not surface audit warning:\n%s", bound.doneView())
}
res := bound.resultCmd()().(ownerResultMsg)
if res.username != "mc-uuid-1" {
t.Fatalf("result username = %q, want verified Minecraft UUID", res.username)
}
if res.auditWarning != "audit insert failed" {
t.Fatalf("result audit warning = %q", res.auditWarning)
} }
} }
+31 -61
View File
@@ -94,9 +94,9 @@ type wizardStage int
const ( const (
stagePreflight wizardStage = iota stagePreflight wizardStage = iota
stageOwner
stageConnect stageConnect
stageStorage stageStorage
stageOwner
stageSummary stageSummary
// stageMenu is the break-glass operation menu. It is appended last so the // stageMenu is the break-glass operation menu. It is appended last so the
// setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed // setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed
@@ -109,7 +109,7 @@ const (
// and the post-install wizard owns cells 1–4. Defining it once keeps the two // and the post-install wizard owns cells 1–4. Defining it once keeps the two
// programs' breadcrumbs identical so the rail reads as a single continuous bar // programs' breadcrumbs identical so the rail reads as a single continuous bar
// rather than restarting when the wizard takes over. // rather than restarting when the wizard takes over.
var setupRailSteps = []string{"Bootstrap", "Preflight", "Connection", "Storage", "Panel login", "Done"} var setupRailSteps = []string{"Bootstrap", "Preflight", "Owner", "Connection", "Storage", "Done"}
type rootModel struct { type rootModel struct {
ctx context.Context ctx context.Context
@@ -211,14 +211,14 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case preflightDoneMsg: case preflightDoneMsg:
if m.adminExists { if m.adminExists {
return m.startOwnerSetup() // Re-run: setup already happened. Land on the status screen.
return m.showStatus()
} }
m.stage = stageConnect m.stage = stageOwner
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) if m.mode == consoleModeSetup {
return m.adopt(newMCBindModel(m.ctx, m.store, defaultAdminHostname(m.rootDomain, m.adminHost), m.osUser))
case setupReadyMsg: }
m.result.username = msg.username return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
return m.showStatus()
case menuChoiceMsg: case menuChoiceMsg:
// The break-glass menu picked an account operation; build its screen. Both reuse // The break-glass menu picked an account operation; build its screen. Both reuse
@@ -289,19 +289,14 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit return m, tea.Quit
} }
m.adminExists = true m.adminExists = true
if m.result.alreadySetUp { m.stage = stageConnect
return m.showStatus() return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
}
return m.showSummary()
case connectResultMsg: case connectResultMsg:
m.applyConnectResult(msg) m.applyConnectResult(msg)
if m.reconfiguringConnect { if m.reconfiguringConnect {
// Changing only the connection — storage is already set, so skip it. // Changing only the connection — storage is already set, so skip it.
m.reconfiguringConnect = false m.reconfiguringConnect = false
if m.result.setupTokenURL != "" {
return m.startOwnerSetup()
}
return m.showSummary() return m.showSummary()
} }
m.stage = stageStorage m.stage = stageStorage
@@ -310,9 +305,6 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case storageResultMsg: case storageResultMsg:
m.result.storageMethod = msg.method m.result.storageMethod = msg.method
m.result.storageDetail = msg.detail m.result.storageDetail = msg.detail
if !m.adminExists {
return m.startOwnerSetup()
}
return m.showSummary() return m.showSummary()
case storageBackMsg: case storageBackMsg:
@@ -423,7 +415,7 @@ func (m *rootModel) displayStage() int {
// reviewBody renders a read-only recap of an already-completed step. Steps in // reviewBody renders a read-only recap of an already-completed step. Steps in
// this wizard commit as you finish them (the Owner account and its one-time // this wizard commit as you finish them (the Owner account and its one-time
// login link are created on submit), so review is deliberately look-only — there // password are created on submit), so review is deliberately look-only — there
// is no re-editing a step you've passed. // is no re-editing a step you've passed.
func (m *rootModel) reviewBody(stage int) string { func (m *rootModel) reviewBody(stage int) string {
var b strings.Builder var b strings.Builder
@@ -432,8 +424,11 @@ func (m *rootModel) reviewBody(stage int) string {
b.WriteString(tuiOK.Render("✓ Preflight") + "\n") b.WriteString(tuiOK.Render("✓ Preflight") + "\n")
b.WriteString(tuiHint.Render("Control plane verified before configuration.")) b.WriteString(tuiHint.Render("Control plane verified before configuration."))
case stageOwner: case stageOwner:
b.WriteString(tuiOK.Render("✓ Panel login") + "\n") b.WriteString(tuiOK.Render("✓ Owner account") + "\n")
b.WriteString(tuiHint.Render("Open the one-time setup link in the summary to create your passkey.")) if m.result.username != "" {
b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n")
}
b.WriteString(tuiHint.Render("Created and recorded. The one-time setup URL was shown on the Owner step."))
case stageConnect: case stageConnect:
b.WriteString(tuiOK.Render("✓ Connection") + "\n") b.WriteString(tuiOK.Render("✓ Connection") + "\n")
b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n") b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n")
@@ -496,9 +491,10 @@ func (m *rootModel) View() string {
// adminExistsAtStart reports whether this run began with an Owner already // adminExistsAtStart reports whether this run began with an Owner already
// present (a re-run). The rail only makes sense for the first-run linear wizard. // present (a re-run). The rail only makes sense for the first-run linear wizard.
func (m *rootModel) adminExistsAtStart() bool { func (m *rootModel) adminExistsAtStart() bool {
// First-run creation flips adminExists, but must keep its rail. Resuming an // adminExists flips true once we provision the Owner mid-run; the rail should
// unfinished browser login is still a re-run even though it renews a token. // keep showing through the connect/summary stages of that same first run. So
return m.result.alreadySetUp || (m.adminExists && !m.result.provisioned) // only suppress the rail when the Owner pre-existed AND we never provisioned.
return m.adminExists && !m.result.provisioned
} }
func (m *rootModel) rail() string { func (m *rootModel) rail() string {
@@ -545,7 +541,7 @@ func (m *rootModel) applyConnectResult(msg connectResultMsg) {
m.result.connectConfigured = true m.result.connectConfigured = true
m.result.reverseProxyGuide = msg.guide m.result.reverseProxyGuide = msg.guide
} }
m.result.panelURL = panelURLFor(msg.method, m.rootDomain, m.result.adminHostname) m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain, m.adminHost)
} }
func (m *rootModel) showSummary() (tea.Model, tea.Cmd) { func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
@@ -558,7 +554,6 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
panelURL: m.result.panelURL, panelURL: m.result.panelURL,
ownerUsername: m.result.username, ownerUsername: m.result.username,
setupTokenURL: m.result.setupTokenURL, setupTokenURL: m.result.setupTokenURL,
auditWarning: m.result.auditWarning,
accessLabel: connectMethodLabel(m.result.connectMethod), accessLabel: connectMethodLabel(m.result.connectMethod),
storageLabel: m.result.storageDetail, storageLabel: m.result.storageDetail,
routedHosts: routed, routedHosts: routed,
@@ -568,28 +563,6 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
}) })
} }
func (m *rootModel) startOwnerSetup() (tea.Model, tea.Cmd) {
m.stage = stageOwner
method := m.result.connectMethod
adminHost := defaultAdminHostname(m.rootDomain, m.adminHost)
if m.result.adminHostname != "" {
adminHost = m.result.adminHostname
}
if m.adminExists && !m.result.provisioned {
m.result.alreadySetUp = true
if m.accessAud != "" {
method = connectCloudflare
}
}
base := "https://" + adminHost
if method == connectLocal {
base = localPanelURL(m.rootDomain, adminHost)
}
model := newSetupOwnerModel(m.ctx, m.store, base, m.osUser)
model.probe = func() error { return checkPanelAccess(m.rootDomain, adminHost).err }
return m.adopt(model)
}
// showStatus is the re-run landing: prove the backend is up, then point the // showStatus is the re-run landing: prove the backend is up, then point the
// operator at the panel without forcing any reconfiguration. // operator at the panel without forcing any reconfiguration.
func (m *rootModel) showStatus() (tea.Model, tea.Cmd) { func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
@@ -601,16 +574,13 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
method = connectCloudflare method = connectCloudflare
accessLabel = connectMethodLabel(connectCloudflare) accessLabel = connectMethodLabel(connectCloudflare)
} }
m.result.panelURL = panelURLFor(method, m.rootDomain, m.result.adminHostname) m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain, m.adminHost)
return m.adopt(&summaryModel{ return m.adopt(&summaryModel{
panelURL: m.result.panelURL, panelURL: m.result.panelURL,
ownerUsername: m.result.username, accessLabel: accessLabel,
setupTokenURL: m.result.setupTokenURL, alreadySetUp: true,
auditWarning: m.result.auditWarning, localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "",
accessLabel: accessLabel, alerts: m.readAlertRoute(),
alreadySetUp: true,
localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "",
alerts: m.readAlertRoute(),
}) })
} }
@@ -624,9 +594,9 @@ func (m *rootModel) readAlertRoute() *alertRoute {
return &r return &r
} }
func panelURLFor(method connectMethod, rootDomain, adminHostname string) string { func panelURLFor(method connectMethod, panelHostname, rootDomain, adminHostname string) string {
if method != connectLocal { if method != connectLocal && panelHostname != "" {
return "https://" + defaultAdminHostname(rootDomain, adminHostname) return "https://" + panelHostname
} }
return localPanelURL(rootDomain, adminHostname) return localPanelURL(rootDomain, adminHostname)
} }
+131 -45
View File
@@ -46,38 +46,94 @@ func newTestRoot(adminExists bool, mode consoleMode, accessAud string) *rootMode
func TestRootSetupHappyPath(t *testing.T) { func TestRootSetupHappyPath(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "") m := newTestRoot(false, consoleModeSetup, "")
// First-run setup begins at preflight.
if m.stage != stagePreflight {
t.Fatalf("initial stage = %v, want stagePreflight", m.stage)
}
if _, ok := m.screen.(*preflightModel); !ok {
t.Fatalf("initial screen = %T, want *preflightModel", m.screen)
}
// Preflight done → MC-bind (setup mode establishes the Owner by binding a
// Minecraft account, not by typing a username/password). The stage label is
// still stageOwner; only the screen differs by mode.
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
if m.stage != stageOwner {
t.Fatalf("after preflight, stage = %v, want stageOwner", m.stage)
}
if _, ok := m.screen.(*mcBindModel); !ok {
t.Fatalf("after preflight, screen = %T, want *mcBindModel", m.screen)
}
// Owner provisioned → Connection chooser.
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
if m.stage != stageConnect { if m.stage != stageConnect {
t.Fatalf("stage = %v, want Connection", m.stage) t.Fatalf("after owner, stage = %v, want stageConnect", m.stage)
} }
m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", adminHostname: "new-admin.felis.example.com", guide: "caddy…"}) if _, ok := m.screen.(*connectChooserModel); !ok {
t.Fatalf("after owner, screen = %T, want *connectChooserModel", m.screen)
}
if !m.result.provisioned || m.result.username != "owner" || m.result.setupTokenURL != "https://op.console.example.com/setup?token=t0ken" {
t.Fatalf("owner result not recorded: %+v", m.result)
}
// Reverse-proxy chosen → Storage chooser, with the connection recorded.
guide := "caddy config…"
m = drive(t, m, connectResultMsg{
method: connectReverseProxy,
panelHostname: "panel.felis.example.com",
adminHostname: "admin.felis.example.com",
guide: guide,
})
if m.stage != stageStorage { if m.stage != stageStorage {
t.Fatalf("stage = %v, want Storage", m.stage) t.Fatalf("after connect, stage = %v, want stageStorage", m.stage)
} }
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"}) if _, ok := m.screen.(*storageChooserModel); !ok {
owner, ok := m.screen.(*setupOwnerModel) t.Fatalf("after connect, screen = %T, want *storageChooserModel", m.screen)
if !ok || owner.panelURL != "https://new-admin.felis.example.com" {
t.Fatalf("owner setup = %#v", m.screen)
} }
if m.result.provisioned { if !m.result.connectConfigured {
t.Fatal("Owner must not be minted before configuration") t.Fatalf("connectConfigured not set")
}
if m.result.connectMethod != connectReverseProxy {
t.Fatalf("connectMethod = %v, want connectReverseProxy", m.result.connectMethod)
}
if m.result.reverseProxyGuide != guide {
t.Fatalf("reverseProxyGuide = %q, want %q", m.result.reverseProxyGuide, guide)
}
// Storage chosen → Summary, with both the connection and storage recorded.
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket · minio:9000"})
if m.stage != stageSummary {
t.Fatalf("after storage, stage = %v, want stageSummary", m.stage)
} }
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://new-admin.felis.example.com/setup?token=t0ken"})
sum, ok := m.screen.(*summaryModel) sum, ok := m.screen.(*summaryModel)
if !ok || sum.setupTokenURL != m.result.setupTokenURL || sum.panelURL != "https://new-admin.felis.example.com" || sum.storageLabel != "s3://bucket" { if !ok {
t.Fatalf("summary = %#v", m.screen) t.Fatalf("after storage, screen = %T, want *summaryModel", m.screen)
} }
if !strings.Contains(sum.View(), "Finish Owner login") || !strings.Contains(sum.View(), "Minecraft can be linked later") { if m.result.storageMethod != storageS3 || m.result.storageDetail == "" {
t.Fatal(sum.View()) t.Fatalf("storage result not recorded: %+v", m.result)
}
if sum.storageLabel != m.result.storageDetail {
t.Fatalf("summary storageLabel = %q, want %q", sum.storageLabel, m.result.storageDetail)
}
if want := "https://panel.felis.example.com"; sum.panelURL != want {
t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want)
}
if want := "https://op.console.example.com/setup?token=t0ken"; sum.setupTokenURL != want {
t.Fatalf("summary setupTokenURL = %q, want %q", sum.setupTokenURL, want)
}
if sum.alreadySetUp {
t.Fatalf("first-run summary should not be marked alreadySetUp")
} }
} }
func TestRootSetupLocalSummary(t *testing.T) { func TestRootSetupLocalSummary(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "") m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, ownerResultMsg{username: "owner"})
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"}) m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"}) m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
m = drive(t, m, ownerResultMsg{username: "owner"})
sum, ok := m.screen.(*summaryModel) sum, ok := m.screen.(*summaryModel)
if !ok { if !ok {
@@ -99,9 +155,9 @@ func TestRootSetupLocalSummary(t *testing.T) {
func TestRootReconfigureConnectSkipsStorage(t *testing.T) { func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "") m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"}) m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"}) m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
if _, ok := m.screen.(*summaryModel); !ok { if _, ok := m.screen.(*summaryModel); !ok {
t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen) t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
} }
@@ -118,10 +174,6 @@ func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
// Completing it returns straight to the summary — NOT the storage chooser — // Completing it returns straight to the summary — NOT the storage chooser —
// with the original storage recap intact. // with the original storage recap intact.
m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", guide: "caddy…"}) m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", guide: "caddy…"})
if _, ok := m.screen.(*setupOwnerModel); !ok {
t.Fatalf("pending link should refresh, screen = %T", m.screen)
}
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://admin.felis.example.com/setup?token=fresh"})
if m.stage != stageSummary { if m.stage != stageSummary {
t.Fatalf("after reconfigure connect, stage = %v, want stageSummary", m.stage) t.Fatalf("after reconfigure connect, stage = %v, want stageSummary", m.stage)
} }
@@ -143,9 +195,9 @@ func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
func TestRootReconfigureStorageReEntersChooser(t *testing.T) { func TestRootReconfigureStorageReEntersChooser(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "") m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, ownerResultMsg{username: "owner"})
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"}) m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"}) m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
m = drive(t, m, ownerResultMsg{username: "owner"})
if _, ok := m.screen.(*summaryModel); !ok { if _, ok := m.screen.(*summaryModel); !ok {
t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen) t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
} }
@@ -180,7 +232,6 @@ func TestRootReconfigureStorageReEntersChooser(t *testing.T) {
func TestRootReconfigureSMTP(t *testing.T) { func TestRootReconfigureSMTP(t *testing.T) {
m := newTestRoot(true, consoleModeSetup, "") m := newTestRoot(true, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, setupReadyMsg{username: "owner"})
if _, ok := m.screen.(*summaryModel); !ok { if _, ok := m.screen.(*summaryModel); !ok {
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen) t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
} }
@@ -206,7 +257,6 @@ func TestRootReconfigureSMTP(t *testing.T) {
func TestRootReconfigureStorageKeepsStatusFraming(t *testing.T) { func TestRootReconfigureStorageKeepsStatusFraming(t *testing.T) {
m := newTestRoot(true, consoleModeSetup, "") m := newTestRoot(true, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, setupReadyMsg{username: "owner"})
if _, ok := m.screen.(*summaryModel); !ok { if _, ok := m.screen.(*summaryModel); !ok {
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen) t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
} }
@@ -230,27 +280,23 @@ func TestRootReconfigureStorageKeepsStatusFraming(t *testing.T) {
} }
func TestRootRerunLandsOnStatus(t *testing.T) { func TestRootRerunLandsOnStatus(t *testing.T) {
// adminExists at start of a setup run = re-run: preflight should skip straight
// to the "manage in panel" status screen, never touching owner/connect.
m := newTestRoot(true, consoleModeSetup, "") m := newTestRoot(true, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) if _, ok := m.screen.(*preflightModel); !ok {
if _, ok := m.screen.(*setupOwnerModel); !ok { t.Fatalf("re-run initial screen = %T, want *preflightModel", m.screen)
t.Fatalf("screen = %T", m.screen)
} }
m = drive(t, m, setupReadyMsg{username: "owner"})
if m.stage != stageSummary || !m.result.alreadySetUp || m.result.provisioned {
t.Fatalf("result = %+v", m.result)
}
}
func TestRootRerunResumesUnfinishedLogin(t *testing.T) {
m := newTestRoot(true, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://admin.felis.example.com:30443/setup?token=new"}) sum, ok := m.screen.(*summaryModel)
sum := m.screen.(*summaryModel) if !ok {
if sum.setupTokenURL == "" || !sum.alreadySetUp { t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
t.Fatalf("summary = %+v", sum)
} }
if strings.Contains(m.View(), "Bootstrap") { if !sum.alreadySetUp {
t.Fatal("renewing a login link restarted the deployment rail") t.Fatalf("re-run summary should be marked alreadySetUp")
}
if m.result.provisioned {
t.Fatalf("re-run must not provision an owner")
} }
} }
@@ -296,18 +342,58 @@ func key(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }
func TestRootRailReviewNavigation(t *testing.T) { func TestRootRailReviewNavigation(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "") m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
// On the Owner screen (text inputs) ← must NOT hijack the arrow: it stays
// with the field, so we remain on the live screen.
m = drive(t, m, key(tea.KeyLeft)) m = drive(t, m, key(tea.KeyLeft))
if m.reviewing != int(stagePreflight) || !strings.Contains(m.View(), "Control plane verified") { if m.reviewing != -1 {
t.Fatal("connection review should return to preflight") t.Fatalf("← on the owner (text-input) screen entered review (%d); arrows belong to the field", m.reviewing)
}
// Advance to the Connection chooser (a select — it yields ←/→).
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
if m.reviewing != -1 {
t.Fatalf("fresh chooser should start live, reviewing = %d", m.reviewing)
}
// ← walks back to Owner (read-only recap), then Preflight, then clamps.
m = drive(t, m, key(tea.KeyLeft))
if m.reviewing != int(stageOwner) {
t.Fatalf("first ← = stage %d, want stageOwner %d", m.reviewing, stageOwner)
}
if v := m.View(); !strings.Contains(v, "Owner account") || !strings.Contains(v, "username") {
t.Fatalf("owner review body missing recap, got:\n%s", v)
}
m = drive(t, m, key(tea.KeyLeft))
if m.reviewing != int(stagePreflight) {
t.Fatalf("second ← = stage %d, want stagePreflight %d", m.reviewing, stagePreflight)
}
m = drive(t, m, key(tea.KeyLeft))
if m.reviewing != int(stagePreflight) {
t.Fatalf("← past the first step should clamp, got %d", m.reviewing)
}
// → walks forward; stepping past the last completed step returns to live.
m = drive(t, m, key(tea.KeyRight))
if m.reviewing != int(stageOwner) {
t.Fatalf("→ = stage %d, want stageOwner %d", m.reviewing, stageOwner)
} }
m = drive(t, m, key(tea.KeyRight)) m = drive(t, m, key(tea.KeyRight))
if m.reviewing != -1 { if m.reviewing != -1 {
t.Fatal("right should return to live connection chooser") t.Fatalf("→ past the last completed step should return live, reviewing = %d", m.reviewing)
} }
if v := m.View(); !strings.Contains(v, "reach the panel") {
t.Fatalf("returning live should show the chooser, got:\n%s", v)
}
// esc is an immediate escape hatch back to the live screen.
m = drive(t, m, key(tea.KeyLeft)) m = drive(t, m, key(tea.KeyLeft))
if m.reviewing < 0 {
t.Fatalf("← should re-enter review")
}
m = drive(t, m, key(tea.KeyEsc)) m = drive(t, m, key(tea.KeyEsc))
if m.reviewing != -1 { if m.reviewing != -1 {
t.Fatal("escape should return to live screen") t.Fatalf("esc should return to the live screen, reviewing = %d", m.reviewing)
} }
} }
@@ -325,8 +411,8 @@ func TestSetupRailSpansBootstrap(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "") m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30}) m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30})
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
if _, ok := m.screen.(*connectChooserModel); !ok { if _, ok := m.screen.(*mcBindModel); !ok {
t.Fatalf("expected connection screen after preflight, got %T", m.screen) t.Fatalf("expected MC-bind screen after preflight, got %T", m.screen)
} }
if v := m.View(); !strings.Contains(v, "✓ Bootstrap") { if v := m.View(); !strings.Contains(v, "✓ Bootstrap") {
t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v) t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v)
-94
View File
@@ -1,94 +0,0 @@
package main
import (
"context"
"errors"
"felis.lolicon.best/internal/api"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
type setupOwnerMsg struct {
outcome breakGlassOutcome
err error
}
type setupReadyMsg struct{ username string }
// setupOwnerModel waits for the panel, then issues a host-authorized login link.
// It never needs a running Minecraft client or login server.
type setupOwnerModel struct {
ctx context.Context
store ownerStore
panelURL, osUser string
probe func() error
sp spinner.Model
err error
}
func newSetupOwnerModel(ctx context.Context, store ownerStore, panelURL, osUser string) *setupOwnerModel {
sp := spinner.New()
sp.Spinner, sp.Style = spinner.Dot, tuiLabel
return &setupOwnerModel{ctx: ctx, store: store, panelURL: panelURL, osUser: osUser, sp: sp}
}
func (m *setupOwnerModel) Init() tea.Cmd {
return tea.Batch(m.sp.Tick, func() tea.Msg {
if m.probe != nil {
if err := m.probe(); err != nil {
return setupOwnerMsg{err: err}
}
}
out, err := performSetupOwner(m.ctx, m.store, m.panelURL, m.osUser)
return setupOwnerMsg{outcome: out, err: err}
})
}
func (m *setupOwnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case setupOwnerMsg:
if errors.Is(msg.err, api.ErrConflict) {
return m, func() tea.Msg { return setupReadyMsg{username: msg.outcome.ownerUsername} }
}
if msg.err != nil {
m.err = msg.err
return m, nil
}
return m, func() tea.Msg {
res := ownerResultMsg{username: msg.outcome.ownerUsername,
setupTokenURL: msg.outcome.setupTokenURL, mode: "setup", accountable: m.osUser}
if msg.outcome.auditErr != nil {
res.auditWarning = msg.outcome.auditErr.Error()
}
return res
}
case spinner.TickMsg:
if m.err == nil {
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "r", "R", "enter":
if m.err != nil {
m.err = nil
return m, m.Init()
}
}
}
return m, nil
}
func (m *setupOwnerModel) View() string {
if m.err != nil {
return tuiWarn.Render("Panel login setup could not finish: "+m.err.Error()) + "\n\n" +
tuiHint.Render("Minecraft is not required. Fix the reported service, then retry.") + "\n\n" +
tuiAction("r/enter", "retry", "esc", "exit")
}
return " " + m.sp.View() + " " + tuiHint.Render("Preparing your first panel login…") + "\n"
}
-64
View File
@@ -1,64 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestSetupOwnerWaitsForPanelBeforeMinting(t *testing.T) {
store := &fakeOwnerStore{}
m := newSetupOwnerModel(context.Background(), store, "https://op.console.example.com:30443", "root")
m.probe = func() error { return errors.New("panel not ready") }
batch := m.Init()().(tea.BatchMsg)
_, cmd := m.Update(batch[1]())
if cmd != nil || len(store.tokens) != 0 || !strings.Contains(m.View(), "panel not ready") {
t.Fatal("unready panel issued login")
}
m.probe = func() error { return nil }
_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
batch = cmd().(tea.BatchMsg)
_, cmd = m.Update(batch[1]())
res := cmd().(ownerResultMsg)
if res.setupTokenURL == "" || res.username != "owner" || len(store.tokens) != 1 {
t.Fatalf("result = %+v", res)
}
}
func TestReportSetupWithoutOwnerPointsAtBrowserSetup(t *testing.T) {
var b bytes.Buffer
reportSetupResult(&b, breakGlassResult{}, false, false, "https://op.console.example.com")
if !strings.Contains(b.String(), "sudo felis setup") || strings.Contains(b.String(), "join ") {
t.Fatalf("output = %s", b.String())
}
}
func TestLocalPanelSetupUsesPasskeyHostname(t *testing.T) {
t.Setenv("FELIS_PANEL_NODEPORT", "30445")
if got := localPanelURL("10.211.55.6.nip.io", ""); got != "https://op.console.10.211.55.6.nip.io:30445" {
t.Fatalf("local setup URL = %q; a bare IP cannot enroll the panel passkey", got)
}
}
func TestSetupGameAddress(t *testing.T) {
for _, tc := range []struct {
root string
port int
want string
}{
{"10.211.55.6.nip.io", 0, "10.211.55.6"},
{"10.211.55.6.nip.io", 25565, "10.211.55.6"},
{"10.211.55.6.sslip.io.", 25570, "10.211.55.6:25570"},
{"play.example.net", 0, "play.example.net"},
{"play.example.net", 25570, "play.example.net:25570"},
{"", 25570, ""},
} {
if got := setupGameAddress(tc.root, tc.port); got != tc.want {
t.Errorf("setupGameAddress(%q, %d) = %q, want %q", tc.root, tc.port, got, tc.want)
}
}
}
+5 -81
View File
@@ -2,14 +2,10 @@ package main
import ( import (
"context" "context"
"io"
"os"
"strings" "strings"
"time" "time"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
) )
// summaryModel is the terminal screen of the setup wizard. On a first run it // summaryModel is the terminal screen of the setup wizard. On a first run it
@@ -21,21 +17,15 @@ type summaryModel struct {
panelURL string panelURL string
ownerUsername string ownerUsername string
setupTokenURL string // one-time first-login URL; shown once setupTokenURL string // one-time first-login URL; shown once
auditWarning string
accessLabel string accessLabel string
storageLabel string // build-context storage backend recap; empty to omit storageLabel string // build-context storage backend recap; empty to omit
routedHosts []string routedHosts []string
alreadySetUp bool // re-run: Owner pre-existed alreadySetUp bool // re-run: Owner pre-existed
localHint bool // show the self-signed-cert note localHint bool // show the self-signed-cert note
// alerts is where the watchdog's alerts go; nil leaves the rows out. // alerts is where the watchdog's alerts go; nil leaves the rows out.
alerts *alertRoute alerts *alertRoute
copyText func(string) error
copyNotice string
copyFailed bool
} }
type summaryCopiedMsg struct{ err error }
func (m *summaryModel) Init() tea.Cmd { return nil } func (m *summaryModel) Init() tea.Cmd { return nil }
// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps; // arrowNavOK lets the root repurpose ←/→ to walk back through completed steps;
@@ -43,35 +33,9 @@ func (m *summaryModel) Init() tea.Cmd { return nil }
func (m *summaryModel) arrowNavOK() bool { return true } func (m *summaryModel) arrowNavOK() bool { return true }
func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if copied, ok := msg.(summaryCopiedMsg); ok {
m.copyFailed = copied.err != nil
switch {
case copied.err != nil:
m.copyNotice = "Could not copy: " + copied.err.Error()
case os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "":
m.copyNotice = "Copy sent to terminal. If it does not paste, your terminal needs OSC 52 support."
default:
m.copyNotice = "Link copied to clipboard."
}
return m, nil
}
if key, ok := msg.(tea.KeyMsg); ok { if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() { switch key.String() {
case "c", "C": case "c", "C":
link := m.panelURL
if m.setupTokenURL != "" {
link = m.setupTokenURL
}
if link == "" {
m.copyNotice, m.copyFailed = "No link to copy.", true
return m, nil
}
copyText := m.copyText
if copyText == nil {
copyText = copyTerminalText
}
return m, func() tea.Msg { return summaryCopiedMsg{err: copyText(link)} }
case "n", "N":
return m, func() tea.Msg { return reconfigureConnectMsg{} } return m, func() tea.Msg { return reconfigureConnectMsg{} }
case "s", "S": case "s", "S":
return m, func() tea.Msg { return reconfigureStorageMsg{} } return m, func() tea.Msg { return reconfigureStorageMsg{} }
@@ -87,12 +51,9 @@ func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *summaryModel) View() string { func (m *summaryModel) View() string {
var b strings.Builder var b strings.Builder
switch { if m.alreadySetUp {
case m.setupTokenURL != "":
b.WriteString(tuiOK.Render("✓ Deployment ready. Finish Owner login in your browser.") + "\n\n")
case m.alreadySetUp:
b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n") b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n")
default: } else {
b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n") b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n")
} }
@@ -124,52 +85,15 @@ func (m *summaryModel) View() string {
} }
b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n") b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n")
if m.setupTokenURL != "" { b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n")
b.WriteString(tuiHint.Render("Open the setup link, record your email, and create a passkey.\nMinecraft can be linked later from Account; it is not required for panel access.") + "\n")
} else {
b.WriteString(tuiHint.Render("Manage servers, users, and Minecraft identities in the panel.") + "\n")
}
if m.auditWarning != "" {
b.WriteString(tuiWarn.Render("Audit warning: "+m.auditWarning) + "\n")
}
if m.localHint { if m.localHint {
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n") b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
} }
if m.copyNotice != "" { b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
style := tuiOK
if m.copyFailed {
style = tuiWarn
}
b.WriteString("\n" + style.Render(m.copyNotice) + "\n")
}
copyLabel := "copy panel link"
if m.setupTokenURL != "" {
copyLabel = "copy setup link"
}
b.WriteString("\n" + tuiAction("c", copyLabel, "n", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
return b.String() return b.String()
} }
// SSH copies through the terminal; the remote host's desktop clipboard is unrelated.
// /dev/tty keeps the one-time link out of redirected stdout and install logs.
func copyTerminalText(text string) error {
if os.Getenv("SSH_TTY") == "" && os.Getenv("SSH_CONNECTION") == "" {
return clipboard.WriteAll(text)
}
tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
if err != nil {
return err
}
defer tty.Close()
sequence := ansi.SetSystemClipboard(text)
if os.Getenv("TMUX") != "" {
sequence = ansi.TmuxPassthrough(sequence)
}
_, err = io.WriteString(tty, sequence)
return err
}
// alertRoute is where this host's watchdog alerts go, as the summary shows it: // alertRoute is where this host's watchdog alerts go, as the summary shows it:
// by mail through the [smtp] relay to the Owners' verified addresses, and the // by mail through the [smtp] relay to the Owners' verified addresses, and the
// heartbeat that notices the host itself going down (docs/troubleshooting.md // heartbeat that notices the host itself going down (docs/troubleshooting.md
-64
View File
@@ -1,64 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestSummaryCopyLink(t *testing.T) {
t.Setenv("SSH_TTY", "")
t.Setenv("SSH_CONNECTION", "")
for _, tc := range []struct {
name, setup, want string
}{
{"first login", "https://panel.example/setup?token=test-token", "https://panel.example/setup?token=test-token"},
{"already set up", "", "https://panel.example"},
} {
t.Run(tc.name, func(t *testing.T) {
var copied string
m := &summaryModel{
panelURL: "https://panel.example", setupTokenURL: tc.setup,
copyText: func(text string) error { copied = text; return nil },
}
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
if cmd == nil {
t.Fatal("copy did not return a command")
}
msg := cmd()
if _, ok := msg.(summaryCopiedMsg); !ok {
t.Fatalf("c returned %T; must copy, not reconfigure", msg)
}
m.Update(msg)
if copied != tc.want || m.copyFailed || !strings.Contains(m.View(), "Link copied to clipboard.") {
t.Fatalf("copied %q, notice %q", copied, m.copyNotice)
}
_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
if _, ok := cmd().(reconfigureConnectMsg); !ok {
t.Fatal("n must still allow changing the connection")
}
})
}
}
func TestSummaryCopyFailureAndSSHFeedback(t *testing.T) {
t.Setenv("SSH_TTY", "/dev/pts/1")
m := &summaryModel{
panelURL: "https://panel.example",
copyText: func(string) error { return errors.New("clipboard unavailable") },
}
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("C")})
m.Update(cmd())
if !m.copyFailed || !strings.Contains(m.View(), "clipboard unavailable") {
t.Fatal("copy failure must be visible")
}
m.Update(summaryCopiedMsg{})
if m.copyFailed || !strings.Contains(m.View(), "Copy sent to terminal") || strings.Contains(m.View(), "Link copied to clipboard") {
t.Fatal("OSC 52 has no confirmation; do not claim the local clipboard was updated")
}
if _, cmd := (&summaryModel{}).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}); cmd != nil {
t.Fatal("a missing link must not trigger a clipboard write")
}
}
+209 -123
View File
@@ -7,12 +7,8 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os"
"os/exec"
"os/signal"
"sort" "sort"
"strings" "strings"
"syscall"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -27,15 +23,54 @@ import (
// leave the operator staring at a silent terminal. // leave the operator staring at a silent terminal.
const updateTimeout = 60 * time.Second const updateTimeout = 60 * time.Second
// updateTarget maps report selectors onto the components tracked by the planner. // updateTarget maps a user-facing selector (`--panel`) onto the planner's component
// Application always reuses bootstrap.sh for the compatible platform bundle. // name and the command that actually performs the update.
//
// The apply side is deliberately NOT implemented in this command. Every component
// here is installed by deploy/bootstrap.sh, which is idempotent, already handles the
// parts that are easy to get wrong (Velocity's pinned MINOR, the atomic jar install,
// the image re-import + registry push that a byte-identical StatefulSet template
// will not trigger on its own), and is the path that gets exercised on every
// install. A
// second installer living in this file would duplicate that policy, could drift from
// it silently, and would be reachable only on a live node where a mistake takes the
// proxy or the control plane down. So `felis update` reports, and hands the operator
// the tested command — it does not re-implement it.
type updateTarget struct { type updateTarget struct {
selector string // selector is the flag name without dashes.
help string selector string
// help is the flag's usage line.
help string
// component is the updates planner's name for this piece, or "" when the planner
// deliberately does not track it (Minecraft, which is pinned).
component string component string
note string // explanation for the untracked Minecraft selector // note explains what this selector covers, printed above the command.
note string
// command is the exact, already-tested way to apply it.
command string
// installer marks a command that re-runs the installer, which the trailer explains.
installer bool
} }
// installerRerun is the tested apply path for every selector Felis installs: re-run the
// installer. It is idempotent, and it is the only path that fetches a newer version --
// `felis setup` skips its host-bootstrap phase on a completed install (all four install
// markers already exist), so there it opens the config console and moves no component,
// and even on the bootstrap path it re-images felis-api from the binary setup is already
// running (FELIS_BOOTSTRAP_BINARY), which looks like an update and changes nothing.
//
// The URL is the one-liner both READMEs hand out, read at a tag rather than main: the
// script's release channel installs the newest release's binary, and main can carry
// installer changes that binary was never tested with. installerRef picks the tag and
// renderApplyGuidance substitutes it for {ref}. While the repo is private the URL
// answers 404 (raw.githubusercontent.com hides private repos), which is why the trailer
// below points at the README's token'd form for that case.
const installerRerun = "curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/{ref}/deploy/bootstrap.sh | sudo bash"
// installerRerunDeps is the same re-run with FELIS_UPGRADE_DEPS=1, which lets it move an
// installed k3s and cloudflared to the versions the release pins.
const installerRerunDeps = "curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/{ref}/deploy/bootstrap.sh | sudo FELIS_UPGRADE_DEPS=1 bash"
// updateTargets is the selector table. panel and plugins both resolve to felis-api // updateTargets is the selector table. panel and plugins both resolve to felis-api
// because they are not separately versioned: the panel is compiled into the felis // because they are not separately versioned: the panel is compiled into the felis
// binary with //go:embed, and the plugin jars are built from this same repo in the // binary with //go:embed, and the plugin jars are built from this same repo in the
@@ -45,52 +80,82 @@ var updateTargets = []updateTarget{
selector: "panel", selector: "panel",
help: "select the panel + control plane (felis-api)", help: "select the panel + control plane (felis-api)",
component: "felis-api", component: "felis-api",
}, note: "the panel is embedded in the felis binary (//go:embed), so updating it means rebuilding the felis image and rolling felis-api",
{ command: installerRerun,
selector: "self", installer: true,
help: "select the Felis binary and its core services",
component: "felis-api",
}, },
{ {
selector: "velocity", selector: "velocity",
help: "select the Velocity proxy", help: "select the Velocity proxy",
component: "velocity", component: "velocity",
note: "re-runs install_velocity: the build the release pins in deploy/game-stack.lock (FELIS_VELOCITY_VERSION=<minor> takes that minor's newest build instead), sha256-checked, atomic jar install, then restarts felis-velocity only if the jar or its config changed",
command: installerRerun,
installer: true,
}, },
{ {
selector: "plugins", selector: "plugins",
help: "select the Felis plugin jars (velocity/paper/limbo)", help: "select the Felis plugin jars (velocity/paper/limbo)",
component: "felis-api", component: "felis-api",
note: "felis-velocity.jar is a host-file swap, but felis-paper.jar and felis-limbo.jar are baked into the lobby/limbo images and need a rebuild + re-mirror into the in-cluster registry (the installer re-run does both)",
command: installerRerun,
installer: true,
}, },
{ {
selector: "k3s", selector: "k3s",
help: "select k3s", help: "select k3s",
component: "k3s", component: "k3s",
note: "FELIS_UPGRADE_DEPS=1 moves k3s to the version the Felis release pins, which can trail the newest upstream; it moves one minor version at a time and refuses a larger jump. Running game servers keep running while k3s restarts",
command: installerRerunDeps,
installer: true,
}, },
{ {
selector: "cloudflared", selector: "cloudflared",
help: "select cloudflared", help: "select cloudflared",
component: "cloudflared", component: "cloudflared",
note: "FELIS_UPGRADE_DEPS=1 swaps the binary for the sha256-pinned build the Felis release names and restarts cloudflared-felis; the panel's tunnel drops for a few seconds",
command: installerRerunDeps,
installer: true,
}, },
{ {
selector: "jre", selector: "jre",
help: "select the Temurin JRE Velocity runs on", help: "select the Temurin JRE Velocity runs on",
component: "jre", component: "jre",
note: "the installer installs the Temurin build the Felis release pins (sha256-checked) and restarts felis-velocity when it changed; a newer upstream build reaches the host with a release that pins it",
command: installerRerun,
installer: true,
}, },
{ {
selector: "postgres", selector: "postgres",
help: "select PostgreSQL", help: "select PostgreSQL",
component: "postgresql", component: "postgresql",
note: "PostgreSQL runs as the felis-postgres Deployment from the image the Felis release pins by digest; a newer minor reaches the host with a release that moves the pin, and the installer re-run restarts the database on it (a few seconds without the API). A new major is a dump and restore: docs/operations.md §4",
command: installerRerun,
installer: true,
}, },
{ {
selector: "mc", selector: "mc",
help: "select Minecraft (pinned; reported only)", help: "select Minecraft (pinned; reported only)",
component: "", // never tracked: see the pin note below component: "", // never tracked: see the pin note below
note: "Minecraft is pinned by policy (\"能不动的就别动\") and Felis never proposes a version change for it. A server's version is a property of that server's image — change it on the server, not through a platform update", note: "Minecraft is pinned by policy (\"能不动的就别动\") and Felis never proposes a version change for it. A server's version is a property of that server's image — change it on the server, not through a platform update",
command: "",
}, },
} }
// cmdUpdate checks by default; only --apply changes the host. --record remains // cmdUpdate reports what can be updated and what is already current.
// read-only so the daily timer cannot start an unattended upgrade. //
// Bare `felis update` prints the status of every tracked component. Selector flags
// (--panel/--velocity/--plugins/--k3s/--cloudflared/--jre/--postgres/--mc/--all) narrow that report to the components
// they name AND print how to apply each one. --force additionally prints the apply
// instruction for a selected component that is already up to date, for the
// reinstall/repair case.
//
// It never applies anything and never mutates the node, so unlike setup/breakGlass
// it needs no root, apart from PostgreSQL's version, which the felis-postgres container
// answers through the cluster's admin kubeconfig. The versions it reads come from this
// host: k3s, cloudflared and PostgreSQL answer `--version`, Velocity's version is read out of the installed jar's
// manifest, the JRE's out of its release file, and felis-api's is this binary's own
// build stamp — the same value `felis version` prints, which is what the user asked
// to be the source of truth.
func cmdUpdate(args []string, stdout, stderr io.Writer) int { func cmdUpdate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("update", flag.ContinueOnError) fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
@@ -98,19 +163,11 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
for _, t := range updateTargets { for _, t := range updateTargets {
flags[t.selector] = fs.Bool(t.selector, false, t.help) flags[t.selector] = fs.Bool(t.selector, false, t.help)
} }
var opts updateOptions all := fs.Bool("all", false, "select every component above")
fs.BoolVar(&opts.all, "all", false, "include the release-pinned k3s and cloudflared updates") force := fs.Bool("force", false, "print the apply command for a selected component even when it is already up to date")
fs.BoolVar(&opts.force, "force", false, "reinstall even at the same version; allow an explicitly requested Felis downgrade (does not bypass maintenance or dependency guards)") velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar to read the current version from")
fs.BoolVar(&opts.apply, "apply", false, "apply the inspected target after checking maintenance and taking a database/state backup") cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml, read for the maintenance window the panel stores")
fs.BoolVar(&opts.now, "now", false, "explicitly start manual maintenance now instead of using the configured window (requires --apply)") record := fs.Bool("record", false, "also store this check for the panel's Updates page (felis-update-check.timer runs it daily)")
fs.StringVar(&opts.release, "version", "", "install a published Felis release, e.g. v0.2.0")
fs.StringVar(&opts.expectedCommit, "expect-commit", "", "refuse application if the target differs from the full SHA printed by the check")
fs.StringVar(&opts.ref, "ref", "", "build an exact commit, tag or branch from source")
dev := fs.Bool("dev", false, "build the newest main commit (the check prints its full SHA)")
check := fs.Bool("check", false, "check and print the apply command without changing the host (default)")
velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar")
fs.StringVar(&opts.cfgPath, "config", "/etc/felis/felis.toml", "host config for the maintenance window and pre-update backup")
record := fs.Bool("record", false, "store this read-only check for the panel (used by the daily timer)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
@@ -118,108 +175,52 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "felis update: unexpected argument %q (this command takes flags only)\n", fs.Arg(0)) fmt.Fprintf(stderr, "felis update: unexpected argument %q (this command takes flags only)\n", fs.Arg(0))
return 2 return 2
} }
if err := opts.validate(*dev, *check, *record); err != nil {
fmt.Fprintf(stderr, "felis update: %v\n", err)
return 2
}
if *dev {
opts.ref = "main"
}
opts.selected = map[string]bool{}
for sel, on := range flags {
if *on || opts.all {
opts.selected[sel] = true
}
}
if len(opts.selected) == 1 && opts.selected["mc"] {
if opts.apply {
fmt.Fprintln(stderr, "felis update: Minecraft is managed through each server's image, not a platform update")
return 2
}
for _, t := range updateTargets {
if t.selector == "mc" {
fmt.Fprintln(stdout, t.note)
}
}
return 0
}
if opts.apply && os.Geteuid() != 0 {
fmt.Fprintln(stderr, "felis update: --apply must run as root (use sudo)")
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if !opts.apply {
if code := checkUpdates(ctx, opts, *velocityJar, *record, stdout, stderr); code != 0 {
return code
}
if *record {
return 0
}
}
source, err := updater.NewInstallerSource(os.Getenv("FELIS_REPO_URL"))
if err != nil {
fmt.Fprintf(stderr, "felis update: %v\n", err)
return 1
}
fmt.Fprintln(stdout, "Resolving the Felis target and downloading its matching installer...")
lookup, cancel := context.WithTimeout(ctx, updateTimeout)
target, err := source.Prepare(lookup, opts.release, opts.ref)
cancel()
if err != nil {
fmt.Fprintf(stderr, "felis update: cannot prepare an update: %v\n", err)
return 1
}
if opts.expectedCommit != "" && target.Revision != opts.expectedCommit {
fmt.Fprintf(stderr, "felis update: target moved since the check: expected %s, got %s; check again before applying\n", opts.expectedCommit, target.Revision)
return 1
}
syntax := exec.CommandContext(ctx, "bash", "-n")
syntax.Stdin, syntax.Stderr = strings.NewReader(target.Script), stderr
if err := syntax.Run(); err != nil {
fmt.Fprintf(stderr, "felis update: invalid installer: %v\n", err)
return 1
}
if os.Getenv("FELIS_REPO_URL") != "" {
opts.repoURL = source.RepoURL()
}
opts.preserveToken = os.Getenv("FELIS_GITHUB_TOKEN") != ""
fmt.Fprint(stdout, renderInstallPlan(target, opts))
if !opts.apply {
return 0
}
if err := applyHostUpdate(ctx, target, opts, source.RepoURL(), stdout, stderr); err != nil {
fmt.Fprintf(stderr, "felis update: %v\n", err)
return 1
}
fmt.Fprintln(stdout, "Felis binary and core components updated and verified.")
return 0
}
func checkUpdates(ctx context.Context, opts updateOptions, velocityJar string, record bool, stdout, stderr io.Writer) int { selected := map[string]bool{}
lookup, cancel := context.WithTimeout(ctx, updateTimeout) for sel, on := range flags {
if *on || *all {
selected[sel] = true
}
}
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
defer cancel() defer cancel()
src := updater.NewRoutingSource(updater.Topology()) src := updater.NewRoutingSource(updater.Topology())
rn := &updater.Runner{Gatherer: updater.NewHostGatherer(resolvedVersion(), velocityJar), Source: src} rn := &updater.Runner{
fmt.Fprintln(stdout, "Checking installed components and upstream versions (read-only)...") Gatherer: updater.NewHostGatherer(resolvedVersion(), *velocityJar),
res, err := rn.Run(lookup, time.Now(), updates.Window{}) Source: src,
// Notifier and Applier stay nil on purpose: a human typing this command IS the
// notification, and nothing here applies. The zero Window below means every
// Scheduled component degrades to a notify, so the report can never claim an
// apply is under way.
}
res, err := rn.Run(ctx, time.Now(), updates.Window{})
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis update: %v\n", err) fmt.Fprintf(stderr, "felis update: %v\n", err)
return 1 return 1
} }
now := time.Now() now := time.Now()
win, winErr := readUpdateWindow(ctx, opts.cfgPath) win, winErr := readUpdateWindow(ctx, *cfgPath)
fmt.Fprint(stdout, renderWindowLine(win, winErr, now)) fmt.Fprint(stdout, renderWindowLine(win, winErr, now))
fmt.Fprint(stdout, renderUpdateReport(res, opts.selected)) fmt.Fprint(stdout, renderUpdateReport(res, selected))
fmt.Fprint(stdout, renderNotes(src.Notes(), opts.selected)) fmt.Fprint(stdout, renderNotes(src.Notes(), selected))
if record { if len(selected) > 0 {
rctx, rcancel := context.WithTimeout(ctx, updateWindowTimeout) if winErr == nil && !win.Start.IsZero() && !win.Contains(now) {
fmt.Fprint(stdout, "Warning: this is outside the maintenance window; the commands below take effect as soon as you run them.\n")
}
fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force))
}
if *record {
// A fresh context: the discovery pass may have spent most of updateTimeout.
rctx, rcancel := context.WithTimeout(context.Background(), updateWindowTimeout)
defer rcancel() defer rcancel()
if err := recordUpdateStatus(rctx, opts.cfgPath, buildStatusReport(res, src.Notes(), resolvedVersion(), now)); err != nil { if err := recordUpdateStatus(rctx, *cfgPath, buildStatusReport(res, src.Notes(), resolvedVersion(), now)); err != nil {
fmt.Fprintf(stderr, "felis update: record the check for the panel: %v\n", err) fmt.Fprintf(stderr, "felis update: record the check for the panel: %v\n", err)
return 1 return 1
} }
fmt.Fprintln(stdout, "Recorded this check for the panel's Updates page.") fmt.Fprint(stdout, "Recorded this check for the panel's Updates page.\n")
} }
return 0 return 0
} }
@@ -389,14 +390,99 @@ func selectedCovers(selected map[string]bool, component string) bool {
return false return false
} }
// renderApplyGuidance prints, for each selected target, how to actually apply the
// update. A target that is already current is skipped unless --force was passed.
func renderApplyGuidance(res updater.Result, selected map[string]bool, force bool) string {
byComponent := map[string]updates.Action{}
for _, a := range res.RunResult.Plan {
byComponent[a.Component] = a
}
var b strings.Builder
var offeredInstaller bool
for _, t := range updateTargets {
if !selected[t.selector] {
continue
}
// Minecraft has no planner entry by design; state the pin and move on. There is
// no command to offer, so this must not arm the trailer below.
if t.component == "" {
fmt.Fprintf(&b, "\n--%s: %s.\n", t.selector, t.note)
continue
}
a, planned := byComponent[t.component]
// "Up to date" requires actually KNOWING the latest version. ActionNone covers
// both "nothing newer exists" and "the release feed could not be read", and
// collapsing those would report an unreachable upstream as currency — telling an
// operator they are current when nobody checked is the one answer an update tool
// must never give. LatestKnown is what separates them.
if planned && a.Kind == updates.ActionNone && a.LatestKnown && !force {
fmt.Fprintf(&b, "\n--%s: %s is already up to date; nothing to apply (use --force to reinstall anyway).\n", t.selector, t.component)
continue
}
fmt.Fprintf(&b, "\n--%s: %s\n", t.selector, t.note)
if planned && !a.LatestKnown {
// Unknown latest still offers the command: the operator asked about this
// component, and reinstalling the current release is a valid repair action.
fmt.Fprintf(&b, " note: cannot tell whether %s is current — its latest version could not be discovered (see above); this reinstalls it either way\n", t.component)
}
fmt.Fprintf(&b, " run: %s\n", strings.ReplaceAll(t.command, "{ref}", installerRef(byComponent)))
offeredInstaller = offeredInstaller || t.installer
}
// Only explain the command when one was actually offered; a --mc-only run has
// nothing to run and the trailer would be a non-sequitur.
//
// One trailer serves every selector now: setup is not an apply path at all on a
// completed install (shouldRunHostBootstrapBeforeConfig only enters the host
// bootstrap while an install marker is missing), so the installer re-run is the one
// worked path for every component Felis installs and there is no per-component exception left
// to scope. Two caveats stay because following the advice without them bites real
// hosts: the channel is not persisted anywhere (a bare re-run on a main host quietly
// moves it onto releases), and the private repo's one-liner needs the read token
// back in the environment before it can resolve anything.
if offeredInstaller {
b.WriteString("\nRe-running the installer applies each installer command above: it fetches the newest version on\nthe channel in effect and re-applies the bundle (release is the default). The channel\nis not persisted, so pass FELIS_VERSION_BOOTSTRAP=dev if this host tracks main. While\nthis repo is private, the one-liner above 404s without a token; the README's install\nsection has the token'd form that works. felis setup is not this path: on a completed\ninstall it opens the config console and installs nothing newer. Restart game servers\nafterwards.\n")
}
return b.String()
}
// installerRef is the git ref the installer re-run reads bootstrap.sh from: the newest
// stable felis release when the feed answered, which is the release that script then
// installs; else the release this host runs; main only when neither is a release tag.
func installerRef(byComponent map[string]updates.Action) string {
a, ok := byComponent["felis-api"]
if !ok {
return "main"
}
if a.LatestKnown && isReleaseTag(a.Latest) {
return a.Latest.String()
}
if isReleaseTag(a.Current) {
return a.Current.String()
}
return "main"
}
// isReleaseTag reports whether v was read from a stable vX.Y.Z tag, the only refs
// release.yml publishes a binary for. A source build stamps v0.0.0+g<commit>, which
// names no tag, so build metadata disqualifies a version too.
func isReleaseTag(v updates.Version) bool {
s := v.String()
if !strings.HasPrefix(s, "v") || v.IsPrerelease() || strings.Contains(s, "+") {
return false
}
_, err := updates.Parse(s)
return err == nil
}
// updateWindowTimeout bounds the maintenance-window read, so an unreachable // updateWindowTimeout bounds the maintenance-window read, so an unreachable
// database costs the report a line and never the report itself. // database costs the report a line and never the report itself.
const updateWindowTimeout = 3 * time.Second const updateWindowTimeout = 3 * time.Second
// readUpdateWindow reads the maintenance window the panel stores // readUpdateWindow reads the maintenance window the panel stores
// (platform_settings "update_window"). Felis applies nothing on its own: this // (platform_settings "update_window"). Felis applies nothing on its own: this
// command consumes it for both reporting and admission to an explicit apply. // command is the window's consumer, showing it and warning before an apply
// A missing row is an unset window. // outside it. A missing row is an unset window.
func readUpdateWindow(ctx context.Context, cfgPath string) (updates.Window, error) { func readUpdateWindow(ctx context.Context, cfgPath string) (updates.Window, error) {
cfg, err := config.Load(cfgPath) cfg, err := config.Load(cfgPath)
if err != nil { if err != nil {
@@ -432,12 +518,12 @@ func renderWindowLine(w updates.Window, err error, now time.Time) string {
case err != nil: case err != nil:
return fmt.Sprintf("Maintenance window: unknown (%v).\n", err) return fmt.Sprintf("Maintenance window: unknown (%v).\n", err)
case w.Start.IsZero() || w.End.IsZero(): case w.Start.IsZero() || w.End.IsZero():
return "Maintenance window: not set; configure it in the panel, or explicitly use --apply --now for manual maintenance.\n" return "Maintenance window: not set; apply whenever suits you.\n"
case w.Contains(now): case w.Contains(now):
return fmt.Sprintf("Maintenance window: open now, until %s.\n", w.End.Local().Format(layout)) return fmt.Sprintf("Maintenance window: open now, until %s.\n", w.End.Local().Format(layout))
case now.Before(w.Start): case now.Before(w.Start):
return fmt.Sprintf("Maintenance window: opens %s, until %s. Apply is blocked until this window opens (unless --now explicitly starts manual maintenance).\n", w.Start.Local().Format(layout), w.End.Local().Format(layout)) return fmt.Sprintf("Maintenance window: opens %s, until %s. Felis applies nothing on its own; run the apply commands inside it.\n", w.Start.Local().Format(layout), w.End.Local().Format(layout))
default: default:
return fmt.Sprintf("Maintenance window: ended %s; apply is blocked; set a new window in the panel or explicitly use --now.\n", w.End.Local().Format(layout)) return fmt.Sprintf("Maintenance window: ended %s; set a new one in the panel before applying.\n", w.End.Local().Format(layout))
} }
} }
-245
View File
@@ -1,245 +0,0 @@
package main
import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"time"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
type updateOptions struct {
release, ref, cfgPath, repoURL, expectedCommit string
preserveToken bool
apply, all, force, now bool
selected map[string]bool
}
func (o *updateOptions) validate(dev, check, record bool) error {
if (o.release != "" && o.ref != "") || (dev && (o.release != "" || o.ref != "")) {
return fmt.Errorf("choose only one of --version, --ref and --dev")
}
if o.apply && (check || record) {
return fmt.Errorf("--apply cannot be combined with --check or --record")
}
if o.expectedCommit != "" {
if _, err := hex.DecodeString(o.expectedCommit); err != nil || len(o.expectedCommit) != 40 || !o.apply {
return fmt.Errorf("--expect-commit requires --apply and a full commit SHA")
}
}
o.expectedCommit = strings.ToLower(o.expectedCommit)
if o.now && !o.apply {
return fmt.Errorf("--now requires --apply")
}
if record && (dev || o.release != "" || o.ref != "") {
return fmt.Errorf("--record checks installed components; use a separate target check")
}
if o.release != "" {
o.release = "v" + strings.TrimPrefix(o.release, "v")
v, err := updates.Parse(o.release)
if err != nil || v.IsPrerelease() || strings.Contains(o.release, "+") || strings.Count(o.release, ".") != 2 {
return fmt.Errorf("--version must be a published stable release such as v0.2.0; use --ref for other tags")
}
}
return nil
}
func (o updateOptions) dependencies() bool {
return o.all || o.selected["k3s"] || o.selected["cloudflared"]
}
func renderInstallPlan(target updater.InstallTarget, o updateOptions) string {
var b strings.Builder
label := target.Release
if label == "" {
label = "source build"
}
fmt.Fprintf(&b, "\nFelis target: %s\nCommit: %s\n", label, target.Revision)
b.WriteString("Scope: host CLI, API, operator, embedded panel, platform manifests/RBAC, Velocity + Felis plugins, login/lobby images, release-pinned JRE and PostgreSQL.\n")
if o.dependencies() {
b.WriteString("Host dependencies: also reconcile k3s and cloudflared to this target's pins.\n")
}
b.WriteString("Upstream availability above is advisory; application uses this target's compatible pins, not each upstream's newest release. User server images remain pinned.\n")
b.WriteString("Before applying: check maintenance, back up the database and host/server configuration, then check maintenance again.\n")
b.WriteString("API, proxy and system spaces may restart.\n")
if o.apply {
return b.String()
}
b.WriteString("No update is applied by this check.\n")
cmd := "sudo"
if o.preserveToken {
cmd += " --preserve-env=FELIS_GITHUB_TOKEN"
}
if o.repoURL != "" {
cmd += " env FELIS_REPO_URL=" + shellQuote(o.repoURL)
}
cmd += " felis update --apply"
assets := target.Release != "" && canUseReleaseAssets(target.Script, o.force)
if target.Release != "" && !assets {
b.WriteString("This release installer cannot pin the requested published-asset install; the apply command builds its exact source commit instead.\n")
}
if assets {
cmd += " --version " + target.Release + " --expect-commit " + target.Revision
} else {
cmd += " --ref " + target.Revision
}
if o.dependencies() {
cmd += " --all"
}
if o.force {
cmd += " --force"
}
if o.cfgPath != "/etc/felis/felis.toml" {
cmd += " --config " + shellQuote(o.cfgPath)
}
fmt.Fprintf(&b, "\nAfter reviewing, run inside the maintenance window:\n %s\n", cmd)
b.WriteString("Without an active window, apply is refused. For an explicit manual maintenance run, add --now. --force never bypasses these checks.\n")
return b.String()
}
// updateApplySteps isolates the three mutating/verification boundaries so the
// ordering and refusal paths can be tested without a live node.
type updateApplySteps struct {
window func(context.Context) (updates.Window, error)
backup, install, verify func(context.Context) error
}
func runUpdateApply(ctx context.Context, o updateOptions, steps updateApplySteps) error {
check := func() error {
win, err := steps.window(ctx)
if err != nil {
return fmt.Errorf("cannot verify maintenance window: %w", err)
}
if !o.now && (win.Start.IsZero() || win.End.IsZero() || !win.Contains(time.Now())) {
return fmt.Errorf("no active maintenance window; set one in the panel or explicitly use --apply --now for manual maintenance")
}
return ctx.Err()
}
if err := check(); err != nil {
return err
}
if err := steps.backup(ctx); err != nil {
return fmt.Errorf("pre-update backup failed; nothing applied: %w", err)
}
if err := check(); err != nil {
return err
}
if err := steps.install(ctx); err != nil {
return fmt.Errorf("installer failed; retain the pre-update backup and inspect its output before retrying: %w", err)
}
if err := steps.verify(ctx); err != nil {
return fmt.Errorf("installed binary verification failed: %w", err)
}
return nil
}
func canUseReleaseAssets(script string, force bool) bool {
return strings.Contains(script, `FELIS_RELEASE="${FELIS_RELEASE:-}"`) &&
(!force || strings.Contains(script, "${FELIS_FORCE_UPDATE:-0}"))
}
func applyHostUpdate(ctx context.Context, target updater.InstallTarget, o updateOptions, repoURL string, stdout, stderr io.Writer) error {
if target.Release != "" {
current, err := updates.Parse(resolvedVersion())
want, _ := updates.Parse(target.Release)
if err == nil && want.Compare(current) < 0 && !o.force {
return fmt.Errorf("%s is older than %s; an intentional Felis downgrade requires --force", target.Release, current)
}
if !canUseReleaseAssets(target.Script, o.force) {
return fmt.Errorf("this release installer cannot pin the requested published-asset install; use --ref %s to rebuild its exact source", target.Revision)
}
}
exe, err := os.Executable()
if err != nil {
return err
}
return runUpdateApply(ctx, o, updateApplySteps{
window: func(ctx context.Context) (updates.Window, error) { return readUpdateWindow(ctx, o.cfgPath) },
backup: func(ctx context.Context) error {
fmt.Fprintln(stdout, "[felis] taking the pre-update database and deployment-state backup")
cmd := exec.CommandContext(ctx, exe, "db", "backup", "--config", o.cfgPath, "--label", "pre-migrate")
cmd.Stdout, cmd.Stderr = stdout, stderr
return cmd.Run()
},
install: func(ctx context.Context) error {
fmt.Fprintln(stdout, "[felis] applying the inspected Felis target")
return runUpdateInstaller(ctx, target.Script, updateInstallerEnv(os.Environ(), target, o, repoURL), stdout, stderr)
},
verify: func(ctx context.Context) error {
out, err := exec.CommandContext(ctx, hostBinDir+"/felis", "version").Output()
if err != nil {
return err
}
line, _, _ := strings.Cut(string(out), "\n")
if !installedUpdateMatches(strings.TrimPrefix(line, "felis "), target) {
return fmt.Errorf("wanted %s / %s, got %q", target.Release, target.Revision, line)
}
fmt.Fprintln(stdout, line)
return nil
},
})
}
func installedUpdateMatches(version string, target updater.InstallTarget) bool {
if target.Release != "" {
return version == target.Release
}
_, sha, ok := strings.Cut(version, "+g")
return ok && len(sha) >= 7 && strings.HasPrefix(target.Revision, sha)
}
func updateInstallerEnv(env []string, target updater.InstallTarget, o updateOptions, repoURL string) []string {
// A setup hand-off or stale source override must never reinstall the running
// binary or a different tree than the one just inspected.
replace := map[string]string{
"FELIS_BOOTSTRAP_FROM_TUI": "", "FELIS_BOOTSTRAP_BINARY": "", "FELIS_SKIP_FETCH": "", "FELIS_ARTIFACT_DIR": "",
"FELIS_REF": target.Revision, "FELIS_RELEASE": "", "FELIS_VERSION_BOOTSTRAP": "dev",
"FELIS_REPO_URL": repoURL, "FELIS_NO_SETUP": "1", "FELIS_INSTALL_MODE": "full",
"FELIS_UPGRADE_DEPS": "0", "FELIS_FORCE_UPDATE": "0", "FELIS_IMAGE": "",
"FELIS_GAME_STACK": "pinned", "FELIS_VELOCITY_VERSION": "", "FELIS_JRE_VERSION": "",
"FELIS_K3S_VERSION": "", "FELIS_CLOUDFLARED_VERSION": "", "FELIS_PREFLIGHT": "strict", "FELIS_PRE_MIGRATE_BACKUP": "1",
}
if target.Release != "" {
replace["FELIS_REF"], replace["FELIS_RELEASE"], replace["FELIS_VERSION_BOOTSTRAP"] = "", target.Release, "release"
}
if o.dependencies() {
replace["FELIS_UPGRADE_DEPS"] = "1"
}
if o.force {
replace["FELIS_FORCE_UPDATE"] = "1"
}
out := make([]string, 0, len(env)+len(replace))
for _, item := range env {
key, _, _ := strings.Cut(item, "=")
if _, overridden := replace[key]; !overridden {
out = append(out, item)
}
}
for key, value := range replace {
out = append(out, key+"="+value)
}
return out
}
func runUpdateInstaller(ctx context.Context, script string, env []string, stdout, stderr io.Writer) error {
cmd := exec.CommandContext(ctx, "bash", "-s")
cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, strings.NewReader(script), stdout, stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
cmd.WaitDelay = 10 * time.Second
return cmd.Run()
}
-215
View File
@@ -1,215 +0,0 @@
package main
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
const updateTestSHA = "0123456789abcdef0123456789abcdef01234567"
func TestUpdateRefPlanPinsTheReviewedCommit(t *testing.T) {
target := updater.InstallTarget{Revision: updateTestSHA}
for _, opts := range []updateOptions{
{cfgPath: "/etc/felis/felis.toml"},
{cfgPath: "/etc/felis/felis.toml", force: true, all: true},
{cfgPath: "/etc/felis/felis.toml", selected: map[string]bool{"k3s": true}},
} {
out := renderInstallPlan(target, opts)
if !strings.Contains(out, "sudo felis update --apply --ref "+updateTestSHA) || strings.Contains(out, "--dev") {
t.Fatalf("moving target in apply command: %s", out)
}
if !strings.Contains(out, "No update is applied") || !strings.Contains(out, "User server images remain pinned") {
t.Fatalf("missing scope: %s", out)
}
if strings.Contains(out, " --all") != opts.dependencies() || strings.Contains(out, "--ref "+updateTestSHA+" --all --force") != opts.force {
t.Fatalf("apply command lost options: %s", out)
}
}
opts := updateOptions{cfgPath: "/path/'literal $(id).toml", repoURL: "https://github.com/example/Felis.git", preserveToken: true}
out := renderInstallPlan(updater.InstallTarget{Release: "v0.2.0", Revision: updateTestSHA, Script: `#!/bin/bash
FELIS_RELEASE="${FELIS_RELEASE:-}"`}, opts)
for _, want := range []string{"--version v0.2.0 --expect-commit " + updateTestSHA, "--preserve-env=FELIS_GITHUB_TOKEN", "FELIS_REPO_URL=" + shellQuote(opts.repoURL), "--config " + shellQuote(opts.cfgPath)} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q: %s", want, out)
}
}
older := renderInstallPlan(updater.InstallTarget{Release: "v0.2.0", Revision: updateTestSHA, Script: "#!/bin/bash\n# FELIS_RELEASE"}, updateOptions{cfgPath: "/etc/felis/felis.toml"})
if !strings.Contains(older, "--apply --ref "+updateTestSHA) || strings.Contains(older, "--version v0.2.0") {
t.Fatalf("old installer must offer a working pinned source apply: %s", older)
}
if strings.Contains(renderInstallPlan(target, updateOptions{apply: true}), "No update is applied") {
t.Fatal("apply must not claim it is only a check")
}
}
func TestUpdateRejectsConflictingOrUnsafeFlagsBeforeDiscovery(t *testing.T) {
for _, args := range [][]string{
{"--apply", "--check"}, {"--apply", "--record"}, {"--now"},
{"--dev", "--version", "v0.2.0"}, {"--dev", "--ref", "main"},
{"--ref", "main", "--version", "v0.2.0"}, {"--record", "--dev"},
{"--version", "v0.2.0-rc.1"}, {"--version", "v0.2.0+gabc1234"},
{"--apply", "--mc"}, {"apply"}, {"--apply", "--expect-commit", "short"}, {"--expect-commit", updateTestSHA},
} {
var out, errb strings.Builder
if got := cmdUpdate(args, &out, &errb); got != 2 || out.Len() != 0 {
t.Errorf("args %v: code %d, out %q, err %q", args, got, out.String(), errb.String())
}
}
o := updateOptions{release: "0.2.0"}
if err := o.validate(false, true, false); err != nil || o.release != "v0.2.0" {
t.Fatalf("normalize version: %v / %q", err, o.release)
}
}
func TestUpdateApplySafetyAndOrdering(t *testing.T) {
now := time.Now()
open := updates.Window{Start: now.Add(-time.Hour), End: now.Add(time.Hour)}
future := updates.Window{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour)}
ended := updates.Window{Start: now.Add(-2 * time.Hour), End: now.Add(-time.Hour)}
failed := errors.New("failed")
for _, tc := range []struct {
name string
opts updateOptions
windows []updates.Window
windowErr, backupErr, installErr, verifyErr error
want []string
wantErr bool
}{
{name: "active", windows: []updates.Window{open, open}, want: []string{"window", "backup", "window", "install", "verify"}},
{name: "unset", windows: []updates.Window{{}}, want: []string{"window"}, wantErr: true},
{name: "future", windows: []updates.Window{future}, want: []string{"window"}, wantErr: true},
{name: "ended", windows: []updates.Window{ended}, want: []string{"window"}, wantErr: true},
{name: "force cannot bypass", opts: updateOptions{force: true}, windows: []updates.Window{future}, want: []string{"window"}, wantErr: true},
{name: "manual maintenance", opts: updateOptions{now: true}, windows: []updates.Window{{}, {}}, want: []string{"window", "backup", "window", "install", "verify"}},
{name: "manual cannot bypass unreadable window", opts: updateOptions{now: true}, windowErr: failed, want: []string{"window"}, wantErr: true},
{name: "backup failure", windows: []updates.Window{open}, backupErr: failed, want: []string{"window", "backup"}, wantErr: true},
{name: "expires during backup", windows: []updates.Window{open, ended}, want: []string{"window", "backup", "window"}, wantErr: true},
{name: "install failure", windows: []updates.Window{open, open}, installErr: failed, want: []string{"window", "backup", "window", "install"}, wantErr: true},
{name: "verification failure", windows: []updates.Window{open, open}, verifyErr: failed, want: []string{"window", "backup", "window", "install", "verify"}, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
var calls []string
reads := 0
steps := updateApplySteps{
window: func(context.Context) (updates.Window, error) {
calls = append(calls, "window")
if tc.windowErr != nil {
return updates.Window{}, tc.windowErr
}
w := tc.windows[reads]
reads++
return w, nil
},
backup: func(context.Context) error { calls = append(calls, "backup"); return tc.backupErr },
install: func(context.Context) error { calls = append(calls, "install"); return tc.installErr },
verify: func(context.Context) error { calls = append(calls, "verify"); return tc.verifyErr },
}
err := runUpdateApply(context.Background(), tc.opts, steps)
if (err != nil) != tc.wantErr || !reflect.DeepEqual(calls, tc.want) {
t.Fatalf("calls %v, err %v; want %v, error %v", calls, err, tc.want, tc.wantErr)
}
})
}
}
func TestUpdateInstallerCannotUseStaleSourceOrBypassGuards(t *testing.T) {
env := []string{"FELIS_REF=stale", "FELIS_REF=duplicate", "FELIS_BOOTSTRAP_BINARY=/old", "FELIS_BOOTSTRAP_FROM_TUI=1", "FELIS_SKIP_FETCH=1", "FELIS_RELEASE=v0.1.0", "FELIS_GAME_STACK=latest", "FELIS_PREFLIGHT=warn", "FELIS_PRE_MIGRATE_BACKUP=0", "FELIS_GITHUB_TOKEN=private-token", "PATH=/usr/bin"}
for _, release := range []string{"", "v0.2.0"} {
out := updateInstallerEnv(env, updater.InstallTarget{Release: release, Revision: updateTestSHA}, updateOptions{force: true, all: true}, "https://github.com/FelisMC/Felis.git")
got := map[string]string{}
for _, item := range out {
key, value, _ := strings.Cut(item, "=")
if _, exists := got[key]; exists {
t.Fatalf("duplicate environment key %s", key)
}
got[key] = value
}
for key, want := range map[string]string{"FELIS_BOOTSTRAP_BINARY": "", "FELIS_BOOTSTRAP_FROM_TUI": "", "FELIS_SKIP_FETCH": "", "FELIS_RELEASE": release, "FELIS_GAME_STACK": "pinned", "FELIS_PREFLIGHT": "strict", "FELIS_PRE_MIGRATE_BACKUP": "1", "FELIS_FORCE_UPDATE": "1", "FELIS_UPGRADE_DEPS": "1", "FELIS_GITHUB_TOKEN": "private-token"} {
if got[key] != want {
t.Errorf("%s = %q; want %q", key, got[key], want)
}
}
wantRef := updateTestSHA
if release != "" {
wantRef = ""
}
if got["FELIS_REF"] != wantRef {
t.Fatalf("wrong source: %v", got)
}
}
}
func TestUpdateExecutesInstallerAndPropagatesFailure(t *testing.T) {
var stdout, stderr strings.Builder
err := runUpdateInstaller(context.Background(), "#!/bin/bash\nprintf 'target:%s' \"$FELIS_REF\"\nprintf 'diagnostic' >&2\nexit 7\n", append(os.Environ(), "FELIS_REF="+updateTestSHA), &stdout, &stderr)
if err == nil || stdout.String() != "target:"+updateTestSHA || stderr.String() != "diagnostic" {
t.Fatalf("out %q, stderr %q, err %v", stdout.String(), stderr.String(), err)
}
}
func TestUpdateCancellationStopsInstallerChildren(t *testing.T) {
dir := t.TempDir()
ready, stopped := filepath.Join(dir, "ready"), filepath.Join(dir, "child-stopped")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
script := `#!/bin/bash
trap 'exit 0' TERM
(
trap 'echo stopped > "$STOPPED"; exit 0' TERM
echo ready > "$READY"
sleep 30
) &
wait
`
go func() {
done <- runUpdateInstaller(ctx, script, append(os.Environ(), "READY="+ready, "STOPPED="+stopped), io.Discard, io.Discard)
}()
deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(ready); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("installer did not become ready")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("interrupted installation must not report success")
}
case <-time.After(3 * time.Second):
t.Fatal("installer children kept running after cancellation")
}
if _, err := os.Stat(stopped); err != nil {
t.Fatalf("installer child did not receive cancellation: %v", err)
}
}
func TestInstalledUpdateMustMatchExactTarget(t *testing.T) {
for _, tc := range []struct {
version, release string
want bool
}{
{"v0.2.0", "v0.2.0", true}, {"v0.2.1", "v0.2.0", false},
{"v0.0.0+g0123456", "", true}, {"v0.0.0+gunknown", "", false},
{"v0.0.0+g012", "", false}, {"v0.0.0+g9876543", "", false},
} {
if got := installedUpdateMatches(tc.version, updater.InstallTarget{Release: tc.release, Revision: updateTestSHA}); got != tc.want {
t.Errorf("%s / %s matched = %v", tc.version, tc.release, got)
}
}
}
+178
View File
@@ -60,6 +60,60 @@ func TestUpdateReportNamesBothFailureCauses(t *testing.T) {
} }
} }
func TestApplyGuidanceUpToDateNeedsForce(t *testing.T) {
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: true}})
sel := map[string]bool{"velocity": true}
quiet := renderApplyGuidance(res, sel, false)
if !strings.Contains(quiet, "already up to date") {
t.Fatalf("want an up-to-date notice:\n%s", quiet)
}
if strings.Contains(quiet, "run:") {
t.Fatalf("must not offer a command for an up-to-date component without --force:\n%s", quiet)
}
forced := renderApplyGuidance(res, sel, true)
if !strings.Contains(forced, "run:") {
t.Fatalf("--force must offer the reinstall command:\n%s", forced)
}
}
// An undiscoverable latest version must never be reported as "up to date". Both
// states arrive as ActionNone and only LatestKnown separates them, so this is a live
// confusion, not a hypothetical one — it shipped that way until a smoke test showed
// `--panel` calling felis-api current right after the release feed returned 404.
func TestApplyGuidanceUnknownLatestIsNotUpToDate(t *testing.T) {
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: false}})
out := renderApplyGuidance(res, map[string]bool{"velocity": true}, false)
if strings.Contains(out, "already up to date") {
t.Fatalf("must not claim currency when the latest version is unknown:\n%s", out)
}
if !strings.Contains(out, "cannot tell") {
t.Fatalf("want the uncertainty stated plainly:\n%s", out)
}
// One header per selector: the uncertainty is a note under it, not a second block.
if n := strings.Count(out, "--velocity:"); n != 1 {
t.Fatalf("want exactly 1 selector header, got %d:\n%s", n, out)
}
// The operator asked about this component, so the repair command still belongs.
if !strings.Contains(out, "run:") {
t.Fatalf("want the reinstall command offered despite the unknown latest:\n%s", out)
}
}
// Minecraft is pinned and has no planner entry, so --mc explains the pin and offers
// NO command — and therefore must not print the trailer that explains the command.
func TestApplyGuidanceMinecraftOffersNoCommand(t *testing.T) {
out := renderApplyGuidance(planResult(nil), map[string]bool{"mc": true}, true)
if !strings.Contains(out, "pinned by policy") {
t.Fatalf("want the pin explained:\n%s", out)
}
if strings.Contains(out, "run:") || strings.Contains(out, "Re-running the installer") {
t.Fatalf("--mc must offer no command and no command trailer:\n%s", out)
}
}
// Every selector in the table must be a real flag on the FlagSet, and every // Every selector in the table must be a real flag on the FlagSet, and every
// planner-backed selector must name a component the topology actually tracks — // planner-backed selector must name a component the topology actually tracks —
// otherwise a selector silently matches nothing at runtime. // otherwise a selector silently matches nothing at runtime.
@@ -75,6 +129,76 @@ func TestUpdateTargetsMatchTopology(t *testing.T) {
if !tracked[target.component] { if !tracked[target.component] {
t.Fatalf("selector --%s maps to %q, which Topology() does not track", target.selector, target.component) t.Fatalf("selector --%s maps to %q, which Topology() does not track", target.selector, target.component)
} }
if target.command == "" {
t.Fatalf("selector --%s is planner-backed but offers no apply command", target.selector)
}
}
}
// Re-running the installer is the one apply path this table may hand out. setup is NOT an
// updater on a completed install -- its host-bootstrap phase only runs while an install
// marker is missing, so it opens the config console and moves no component -- and even on
// the bootstrap path it re-images felis-api from the binary setup is already running. The
// table used to answer with "sudo felis setup" and scope a felis-api-only exception; both
// taught a model that does not survive contact with an installed host.
func TestApplyGuidancePointsEveryComponentAtTheInstaller(t *testing.T) {
api := renderApplyGuidance(
planResult([]updates.Action{{Component: "felis-api", Kind: updates.ActionNotify, LatestKnown: true}}),
map[string]bool{"panel": true}, false)
for _, want := range []string{"deploy/bootstrap.sh", "FELIS_VERSION_BOOTSTRAP=dev", "felis setup is not this path"} {
if !strings.Contains(api, want) {
t.Fatalf("--panel guidance missing %q:\n%s", want, api)
}
}
if strings.Contains(api, "run: sudo felis setup") {
t.Fatalf("setup must never be offered as the apply command:\n%s", api)
}
// The same path serves velocity; a scoped caveat would re-teach the old model that
// setup fixes velocity.
vel := renderApplyGuidance(
planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNotify, LatestKnown: true}}),
map[string]bool{"velocity": true}, false)
if !strings.Contains(vel, "run: curl -fsSL") || !strings.Contains(vel, "felis setup is not this path") {
t.Fatalf("velocity gets the same installer path:\n%s", vel)
}
// --mc offers no command at all, so neither trailer belongs.
mc := renderApplyGuidance(planResult(nil), map[string]bool{"mc": true}, true)
if strings.Contains(mc, "deploy/bootstrap.sh") || strings.Contains(mc, "FELIS_VERSION_BOOTSTRAP") {
t.Fatalf("--mc offers no command; the trailer is a non-sequitur:\n%s", mc)
}
}
// The re-run reads bootstrap.sh at the tag whose binary it installs. main can carry
// installer changes no release was tested with.
func TestApplyGuidanceReadsTheInstallerAtTheReleaseTag(t *testing.T) {
v := func(s string) updates.Version {
t.Helper()
out, err := updates.Parse(s)
if err != nil {
t.Fatal(err)
}
return out
}
cases := []struct {
name string
api []updates.Action
want string
}{
{"latest known", []updates.Action{{Component: "felis-api", Kind: updates.ActionNotify, Current: v("v1.3.0"), Latest: v("v1.4.0"), LatestKnown: true}}, "/FelisMC/Felis/v1.4.0/deploy/bootstrap.sh"},
{"latest unknown", []updates.Action{{Component: "felis-api", Kind: updates.ActionNone, Current: v("v1.3.0")}}, "/FelisMC/Felis/v1.3.0/deploy/bootstrap.sh"},
{"prerelease latest", []updates.Action{{Component: "felis-api", Kind: updates.ActionNone, Current: v("v1.3.0"), Latest: v("v1.4.0-rc.1"), LatestKnown: true}}, "/FelisMC/Felis/v1.3.0/deploy/bootstrap.sh"},
{"nothing known", nil, "/FelisMC/Felis/main/deploy/bootstrap.sh"},
}
for _, c := range cases {
out := renderApplyGuidance(planResult(c.api), map[string]bool{"velocity": true, "panel": true}, true)
if !strings.Contains(out, c.want) {
t.Errorf("%s: want %q in:\n%s", c.name, c.want, out)
}
if strings.Contains(out, "{ref}") {
t.Errorf("%s: placeholder left in:\n%s", c.name, out)
}
} }
} }
@@ -91,6 +215,28 @@ func TestUpdateSelectorsAreFlags(t *testing.T) {
} }
} }
// k3s and cloudflared move only when the re-run is told to; the release pins the JRE
// build and the PostgreSQL image, so their guidance is the plain re-run.
func TestApplyGuidanceForHostDependencies(t *testing.T) {
notify := func(c string) updater.Result {
return planResult([]updates.Action{{Component: c, Kind: updates.ActionNotify, LatestKnown: true}})
}
for _, sel := range []string{"k3s", "cloudflared"} {
out := renderApplyGuidance(notify(sel), map[string]bool{sel: true}, false)
if !strings.Contains(out, "sudo FELIS_UPGRADE_DEPS=1 bash") || !strings.Contains(out, "Re-running the installer") {
t.Errorf("--%s guidance must re-run the installer with FELIS_UPGRADE_DEPS=1:\n%s", sel, out)
}
}
jre := renderApplyGuidance(notify("jre"), map[string]bool{"jre": true}, false)
if !strings.Contains(jre, "| sudo bash") || strings.Contains(jre, "FELIS_UPGRADE_DEPS") {
t.Errorf("--jre guidance is the plain installer re-run:\n%s", jre)
}
pg := renderApplyGuidance(notify("postgresql"), map[string]bool{"postgres": true}, false)
if !strings.Contains(pg, "| sudo bash") || strings.Contains(pg, "FELIS_UPGRADE_DEPS") || strings.Contains(pg, "apt-get") || strings.Contains(pg, "systemctl") {
t.Errorf("--postgres guidance is the plain installer re-run that moves the image pin:\n%s", pg)
}
}
func TestRenderNotesHonoursSelectors(t *testing.T) { func TestRenderNotesHonoursSelectors(t *testing.T) {
notes := map[string]string{"postgresql": "PostgreSQL 13 reached end of life on 2025-11-13"} notes := map[string]string{"postgresql": "PostgreSQL 13 reached end of life on 2025-11-13"}
if out := renderNotes(notes, nil); !strings.Contains(out, "postgresql") || !strings.Contains(out, "note: PostgreSQL 13 reached end of life") { if out := renderNotes(notes, nil); !strings.Contains(out, "postgresql") || !strings.Contains(out, "note: PostgreSQL 13 reached end of life") {
@@ -104,6 +250,38 @@ func TestRenderNotesHonoursSelectors(t *testing.T) {
} }
} }
// A source build's v0.0.0+g<commit> names no tag, so the installer one-liner has to
// fall back to main instead of a 404ing ref.
func TestInstallerRefNamesATag(t *testing.T) {
v := func(s string) updates.Version {
t.Helper()
x, err := updates.Parse(s)
if err != nil {
t.Fatal(err)
}
return x
}
cases := []struct {
name string
api updates.Action
want string
}{
{"newest release", updates.Action{Current: v("v1.2.0"), Latest: v("v1.3.0"), LatestKnown: true}, "v1.3.0"},
{"feed down, host on a release", updates.Action{Current: v("v1.2.0")}, "v1.2.0"},
{"source build", updates.Action{Current: v("v0.0.0+gunknown")}, "main"},
{"source build with commit", updates.Action{Current: v("v0.0.0+g1a2b3c4")}, "main"},
{"prerelease", updates.Action{Current: v("v1.3.0-rc.1")}, "main"},
}
for _, c := range cases {
if got := installerRef(map[string]updates.Action{"felis-api": c.api}); got != c.want {
t.Errorf("%s: installerRef = %q, want %q", c.name, got, c.want)
}
}
if got := installerRef(nil); got != "main" {
t.Errorf("no felis-api row: installerRef = %q, want main", got)
}
}
func mustVersion(t *testing.T, s string) updates.Version { func mustVersion(t *testing.T, s string) updates.Version {
t.Helper() t.Helper()
v, err := updates.Parse(s) v, err := updates.Parse(s)
+4 -4
View File
@@ -25,11 +25,11 @@ func TestRenderWindowLinePlacesNowAgainstTheWindow(t *testing.T) {
want string want string
}{ }{
{"unreadable", updates.Window{}, errors.New("connection refused"), w.Start, "Maintenance window: unknown (connection refused).\n"}, {"unreadable", updates.Window{}, errors.New("connection refused"), w.Start, "Maintenance window: unknown (connection refused).\n"},
{"unset", updates.Window{}, nil, w.Start, "Maintenance window: not set; configure it in the panel, or explicitly use --apply --now for manual maintenance.\n"}, {"unset", updates.Window{}, nil, w.Start, "Maintenance window: not set; apply whenever suits you.\n"},
{"half set", updates.Window{Start: w.Start}, nil, w.Start, "Maintenance window: not set; configure it in the panel, or explicitly use --apply --now for manual maintenance.\n"}, {"half set", updates.Window{Start: w.Start}, nil, w.Start, "Maintenance window: not set; apply whenever suits you.\n"},
{"at the opening instant", w, nil, w.Start, "Maintenance window: open now, until 2026-09-27 04:00 CST.\n"}, {"at the opening instant", w, nil, w.Start, "Maintenance window: open now, until 2026-09-27 04:00 CST.\n"},
{"before", w, nil, w.Start.Add(-time.Minute), "Maintenance window: opens 2026-09-27 02:00 CST, until 2026-09-27 04:00 CST. Apply is blocked until this window opens (unless --now explicitly starts manual maintenance).\n"}, {"before", w, nil, w.Start.Add(-time.Minute), "Maintenance window: opens 2026-09-27 02:00 CST, until 2026-09-27 04:00 CST. Felis applies nothing on its own; run the apply commands inside it.\n"},
{"at the closing instant", w, nil, w.End, "Maintenance window: ended 2026-09-27 04:00 CST; apply is blocked; set a new window in the panel or explicitly use --now.\n"}, {"at the closing instant", w, nil, w.End, "Maintenance window: ended 2026-09-27 04:00 CST; set a new one in the panel before applying.\n"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
+79 -491
View File
@@ -32,9 +32,6 @@
# export FELIS_INSTALL_MODE=nano; curl -fsSL <raw-url>/deploy/bootstrap.sh | sudo -E bash # export FELIS_INSTALL_MODE=nano; curl -fsSL <raw-url>/deploy/bootstrap.sh | sudo -E bash
# FELIS_INSTALL_MODE full|nano — skip the prompt (default: ask on a tty, else full; nano # FELIS_INSTALL_MODE full|nano — skip the prompt (default: ask on a tty, else full; nano
# instead on a host that runs felis-nano and no full install) # instead on a host that runs felis-nano and no full install)
# FELIS_NO_SETUP 1 ends a full install at its summary. By default an install that
# leaves no Owner account goes on into `felis setup` when it runs on
# a terminal
# FELIS_NANO_LISTEN listen addr for `felis nano` (default: the address an installed # FELIS_NANO_LISTEN listen addr for `felis nano` (default: the address an installed
# felis-nano already uses, else 127.0.0.1:8081 — loopback only; set a # felis-nano already uses, else 127.0.0.1:8081 — loopback only; set a
# private-network IP to serve an off-host proxy) # private-network IP to serve an off-host proxy)
@@ -69,7 +66,6 @@
# FELIS_CLOUDFLARED_VERSION / FELIS_CLOUDFLARED_SHA256 cloudflared release installed # FELIS_CLOUDFLARED_VERSION / FELIS_CLOUDFLARED_SHA256 cloudflared release installed
# when none is present (default: 2026.9.1, digests pinned); the # when none is present (default: 2026.9.1, digests pinned); the
# sha256 is REQUIRED for any other version # sha256 is REQUIRED for any other version
# FELIS_FORCE_UPDATE=1 reinstalls the binary even when its version already matches.
# FELIS_UPGRADE_DEPS 1 moves an installed k3s and cloudflared to the versions above # FELIS_UPGRADE_DEPS 1 moves an installed k3s and cloudflared to the versions above
# (k3s one minor version at a time; neither is ever downgraded) and # (k3s one minor version at a time; neither is ever downgraded) and
# restarts cloudflared-felis onto the new binary (default: 0) # restarts cloudflared-felis onto the new binary (default: 0)
@@ -91,7 +87,7 @@
# Docker Hub, nor built (FELIS_GAME_STACK=latest aside: no release # Docker Hub, nor built (FELIS_GAME_STACK=latest aside: no release
# ships that stack, so its game images are built here). A file missing # ships that stack, so its game images are built here). A file missing
# from it or not matching its SHA256SUMS stops the install. # from it or not matching its SHA256SUMS stops the install.
# FELIS_GITHUB_TOKEN GitHub token; needed only when installing from a private fork # FELIS_GITHUB_TOKEN GitHub token; REQUIRED while the repo is private
# FELIS_REF branch/tag/sha — pins the build, overrides the channel, and forces a # FELIS_REF branch/tag/sha — pins the build, overrides the channel, and forces a
# source build (naming a ref asks for that tree, not a published asset) # source build (naming a ref asks for that tree, not a published asset)
# FELIS_RELEASE a published release tag (v1.2.3) the release channel installs, from # FELIS_RELEASE a published release tag (v1.2.3) the release channel installs, from
@@ -201,7 +197,7 @@ DOCKER_INSTALLED=""
# hour. # hour.
RELEASE_JSON_TAG="" RELEASE_JSON_TAG=""
RELEASE_JSON="" RELEASE_JSON=""
# Optional GitHub credential, needed only for a private fork: GitHub answers # Optional GitHub credential, needed while this repository is private: GitHub answers
# 404 (not 403) for a repo the caller cannot see, so without it both the release lookup # 404 (not 403) for a repo the caller cannot see, so without it both the release lookup
# and the clone fail as "not found". Exported because git's credential helper below runs # and the clone fail as "not found". Exported because git's credential helper below runs
# as a child process and reads it from the environment — which is also why it is never # as a child process and reads it from the environment — which is also why it is never
@@ -278,13 +274,6 @@ FELIS_OFFSITE_DB_KEEP="${FELIS_OFFSITE_DB_KEEP:-}"
# the key that pings the check; off removes it, and a re-run without it keeps it. # the key that pings the check; off removes it, and a re-run without it keeps it.
FELIS_WATCHDOG_HEARTBEAT_URL="${FELIS_WATCHDOG_HEARTBEAT_URL:-}" FELIS_WATCHDOG_HEARTBEAT_URL="${FELIS_WATCHDOG_HEARTBEAT_URL:-}"
INSTALL_MODE="${FELIS_INSTALL_MODE:-}" INSTALL_MODE="${FELIS_INSTALL_MODE:-}"
DISTRIBUTED="${FELIS_DISTRIBUTED:-0}"
WORKER_NAME="${FELIS_NODE_NAME:-}"
WORKER_SERVER="${FELIS_SERVER_URL:-}"
WORKER_TOKEN_FILE="${FELIS_BOOTSTRAP_TOKEN_FILE:-}"
WORKER_REGISTRY_IP="${FELIS_REGISTRY_CLUSTER_IP:-}"
NODE_EXTERNAL_IP="${FELIS_NODE_EXTERNAL_IP:-}"
WORKER_PEERS="${FELIS_PEER_CIDRS:-}"
# strict stops the install on any preflight problem (preflight below); warn reports them # strict stops the install on any preflight problem (preflight below); warn reports them
# and goes on, for a host the checks misjudge. # and goes on, for a host the checks misjudge.
FELIS_PREFLIGHT="${FELIS_PREFLIGHT:-strict}" FELIS_PREFLIGHT="${FELIS_PREFLIGHT:-strict}"
@@ -444,10 +433,6 @@ OFFSITE_ENV="${STATE_DIR}/offsite.env"
# Where summary_offsite shows a newly generated off-site key: the operator's terminal alone. # Where summary_offsite shows a newly generated off-site key: the operator's terminal alone.
# stdout and stderr are what `2>&1 | tee install.log`, cloud-init and CI keep on disk. # stdout and stderr are what `2>&1 | tee install.log`, cloud-init and CI keep on disk.
OFFSITE_KEY_TTY=/dev/tty OFFSITE_KEY_TTY=/dev/tty
# Where the setup console the installer starts at the end reads its keys (setup_terminal).
SETUP_TTY=/dev/tty
# Set by summary_next when the installer goes on into the setup console.
SETUP_CONSOLE=0
# Host copies of the credentials `felis setup` takes at the keyboard, one bare value per # Host copies of the credentials `felis setup` takes at the keyboard, one bare value per
# file, mode 0600 (cmd/felis/hostcreds.go); apply_setup_credential_secrets applies their # file, mode 0600 (cmd/felis/hostcreds.go); apply_setup_credential_secrets applies their
# Secrets from them on every run. # Secrets from them on every run.
@@ -517,9 +502,6 @@ K3S_REGISTRIES_FILE="/etc/rancher/k3s/registries.yaml"
# client certificate are variables for the same reason as the file above. # client certificate are variables for the same reason as the file above.
K3S_CONFIG_DROPIN="/etc/rancher/k3s/config.yaml.d/50-felis.yaml" K3S_CONFIG_DROPIN="/etc/rancher/k3s/config.yaml.d/50-felis.yaml"
K3S_UNIT_FILE="/etc/systemd/system/k3s.service" K3S_UNIT_FILE="/etc/systemd/system/k3s.service"
# The installer's environment for the k3s service (write_k3s_service_dropin); k3s's own
# installer rewrites the unit and its .env file, never this.
K3S_SERVICE_DROPIN="/etc/systemd/system/k3s.service.d/50-felis.conf"
K3S_KUBECONFIG="/etc/rancher/k3s/k3s.yaml" K3S_KUBECONFIG="/etc/rancher/k3s/k3s.yaml"
K3S_KUBELET_CERT="/var/lib/rancher/k3s/agent/client-kubelet.crt" K3S_KUBELET_CERT="/var/lib/rancher/k3s/agent/client-kubelet.crt"
# Where k3s imports image tarballs from as it starts (stage_k3s_airgap_images). # Where k3s imports image tarballs from as it starts (stage_k3s_airgap_images).
@@ -528,12 +510,6 @@ K3S_IMAGES_DIR="/var/lib/rancher/k3s/agent/images"
# stores the journal persistently. # stores the journal persistently.
JOURNALD_DROPIN="/etc/systemd/journald.conf.d/50-felis.conf" JOURNALD_DROPIN="/etc/systemd/journald.conf.d/50-felis.conf"
JOURNAL_DIR="/var/log/journal" JOURNAL_DIR="/var/log/journal"
# dpkg's journal of a run in progress: not empty after a dpkg run was cut off
# (finish_interrupted_dpkg).
DPKG_UPDATES_DIR="/var/lib/dpkg/updates"
# Held for the whole run (acquire_run_lock), so a second installer started while one is
# still going stops at once instead of working the same files and cluster beside it.
RUN_LOCK_FILE="/run/felis-bootstrap.lock"
APT_LOCK_FILES=( APT_LOCK_FILES=(
/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock-frontend
/var/lib/dpkg/lock /var/lib/dpkg/lock
@@ -602,10 +578,13 @@ on_error() {
} }
cleanup() { cleanup() {
local status=$? id path local status=$? id path unit
restore_previous_host_binary "$status" restore_previous_host_binary "$status"
if [ "$status" -ne 0 ]; then undo_postgres_move; fi if [ "$status" -ne 0 ]; then undo_postgres_move; fi
resume_package_background_timers for unit in "${PKG_TIMERS_TO_RESTORE[@]-}"; do
[ -n "$unit" ] || continue
systemctl start "$unit" >/dev/null 2>&1 || true
done
if command -v docker >/dev/null 2>&1; then if command -v docker >/dev/null 2>&1; then
for id in "${DOCKER_CONTAINERS[@]-}"; do for id in "${DOCKER_CONTAINERS[@]-}"; do
[ -n "$id" ] && docker rm "$id" >/dev/null 2>&1 || true [ -n "$id" ] && docker rm "$id" >/dev/null 2>&1 || true
@@ -665,19 +644,6 @@ trap 'on_error "$LINENO" "$?"' ERR
trap cleanup EXIT trap cleanup EXIT
k3s_cmd() { [ -x "$K3S_BIN" ] || die "k3s binary not found at ${K3S_BIN}"; "$K3S_BIN" "$@"; } k3s_cmd() { [ -x "$K3S_BIN" ] || die "k3s binary not found at ${K3S_BIN}"; "$K3S_BIN" "$@"; }
# acquire_run_lock takes RUN_LOCK_FILE for the rest of the run. A rerun started while an
# earlier one still runs (in tmux after the SSH session dropped, in a second terminal)
# would otherwise install the same packages, rewrite the same files and apply the same
# cluster objects beside it. The lock goes with the process, however it ends.
acquire_run_lock() {
if ! command -v flock >/dev/null 2>&1; then
warn "flock not found; nothing stops a second installer run beside this one"
return 0
fi
exec 9>"$RUN_LOCK_FILE"
flock -n 9 || die "another installer run is still going on this host; wait for it to finish, then rerun (ps -ef | grep bootstrap)"
}
kube() { k3s_cmd kubectl "$@"; } kube() { k3s_cmd kubectl "$@"; }
# Create or update a single-key Secret without putting the value in kubectl's argv. # Create or update a single-key Secret without putting the value in kubectl's argv.
@@ -852,17 +818,6 @@ pause_package_background_timers() {
done done
} }
# Starts the timers pause_package_background_timers stopped: from the EXIT cleanup, and
# before the setup console, which stays open as long as the operator likes.
resume_package_background_timers() {
local unit
for unit in "${PKG_TIMERS_TO_RESTORE[@]-}"; do
[ -n "$unit" ] || continue
systemctl start "$unit" >/dev/null 2>&1 || true
done
PKG_TIMERS_TO_RESTORE=()
}
pkg_lock_files() { pkg_lock_files() {
case "${PKG:-}" in case "${PKG:-}" in
apt) printf '%s\n' "${APT_LOCK_FILES[@]}" ;; apt) printf '%s\n' "${APT_LOCK_FILES[@]}" ;;
@@ -1586,23 +1541,10 @@ pkg_install() {
esac esac
} }
# finish_interrupted_dpkg completes a dpkg run that was cut off: an earlier install killed
# halfway through a package, a reboot during unattended-upgrades. Until then apt-get refuses
# everything with "dpkg was interrupted, you must manually run 'dpkg --configure -a'", so
# each rerun failed exactly as the run before it.
finish_interrupted_dpkg() {
[ -n "$(ls -A "$DPKG_UPDATES_DIR" 2>/dev/null)" ] || [ -n "$(dpkg --audit 2>/dev/null)" ] || return 0
warn "an earlier dpkg run was cut off; finishing it (dpkg --configure -a)"
wait_for_pkg_locks
NEEDRESTART_SUSPEND=1 DEBIAN_FRONTEND=noninteractive \
dpkg --force-confdef --force-confold --configure -a \
|| die "dpkg --configure -a failed; fix the package it names, then rerun the installer"
}
pkg_refresh_once() { pkg_refresh_once() {
[ -n "${_PKG_REFRESHED:-}" ] && return 0 [ -n "${_PKG_REFRESHED:-}" ] && return 0
case "$PKG" in case "$PKG" in
apt) finish_interrupted_dpkg; apt_get update -y ;; apt) apt_get update -y ;;
dnf|yum) : ;; # dnf/yum refresh metadata on demand dnf|yum) : ;; # dnf/yum refresh metadata on demand
zypper) wait_for_pkg_locks; zypper --non-interactive refresh ;; zypper) wait_for_pkg_locks; zypper --non-interactive refresh ;;
# Arch supports only whole-system upgrades (-Sy alone leaves a partial upgrade), so the # Arch supports only whole-system upgrades (-Sy alone leaves a partial upgrade), so the
@@ -1886,8 +1828,6 @@ install_docker() {
if command -v docker >/dev/null 2>&1; then if command -v docker >/dev/null 2>&1; then
ok "docker already installed" ok "docker already installed"
else else
local had_containerd=""
command -v containerd >/dev/null 2>&1 && had_containerd=1
case "$PKG" in case "$PKG" in
apt) install_docker_apt ;; apt) install_docker_apt ;;
dnf|yum) install_docker_rpm ;; dnf|yum) install_docker_rpm ;;
@@ -1895,13 +1835,8 @@ install_docker() {
pacman) install_docker_pacman ;; pacman) install_docker_pacman ;;
*) die "Docker installation is not supported with package manager: ${PKG}" ;; *) die "Docker installation is not supported with package manager: ${PKG}" ;;
esac esac
# Docker serves this installer's builds and nothing at runtime, so the one it installed
# does not come up at boot to hold ~200 MiB until the next run; a run that builds starts
# it (ensure_docker). Some packages enable it, and its containerd, as they install.
systemctl disable docker.service docker.socket 2>/dev/null || true
[ -n "$had_containerd" ] || systemctl disable containerd.service 2>/dev/null || true
fi fi
systemctl start docker systemctl enable --now docker
ok "docker running" ok "docker running"
} }
@@ -1938,19 +1873,11 @@ unplanned_build_room() {
die "${problem}; nothing has been built. Rerun the installer once the release's assets download, free space on ${mount}, or set FELIS_PREFLIGHT=warn to build anyway" die "${problem}; nothing has been built. Rerun the installer once the release's assets download, free space on ${mount}, or set FELIS_PREFLIGHT=warn to build anyway"
} }
# stop_docker hands back what Docker holds once a step is done with it, ~150 MiB for the daemon # stop_docker hands back the ~150 MiB the docker daemon holds once a step is done with it; the
# and ~45 MiB for its containerd; the next step that builds starts both again. A Docker this run # next step that builds starts it again. A Docker this run never started is left alone.
# never started is left alone, and so is a containerd holding any namespace besides Docker's own
# (moby, moby_history): something else on the host runs on it. k3s's containerd listens on a
# socket of its own and is never the one asked here.
stop_docker() { stop_docker() {
[ -n "$DOCKER_INSTALLED" ] || return 0 [ -n "$DOCKER_INSTALLED" ] || return 0
systemctl stop docker docker.socket 2>/dev/null || true systemctl stop docker docker.socket 2>/dev/null || true
local ns
ns="$(ctr --address /run/containerd/containerd.sock namespaces ls -q 2>/dev/null)" || return 0
if ! printf '%s\n' "$ns" | grep -qvE '^(moby.*)?$'; then
systemctl stop containerd 2>/dev/null || true
fi
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1995,23 +1922,12 @@ configure_k3s_firewall() {
install_k3s() { install_k3s() {
configure_k3s_firewall configure_k3s_firewall
# Before the installer runs: a fresh k3s reads both drop-ins on its first start. # Before the installer runs: a fresh k3s reads the drop-in on its first start.
K3S_RESTART_NEEDED=0 K3S_RESTART_NEEDED=0
write_k3s_config write_k3s_config
write_k3s_service_dropin
local installer_ran=0 local installer_ran=0
if [ -x "$K3S_BIN" ] && [ ! -f "$K3S_UNIT_FILE" ]; then if [ -x "$K3S_BIN" ]; then
# k3s's installer moves the binary into place before it writes k3s.service, so a run
# cut short between the two left a k3s nothing starts, and every rerun stopped at
# `systemctl enable`. Its installer run again around the binary in place finishes the
# job: no download, no version change, the cluster's data untouched.
"$K3S_BIN" --version >/dev/null 2>&1 \
|| die "${K3S_BIN} does not run and k3s.service is missing: an earlier k3s install was cut short. Remove ${K3S_BIN} and rerun to install k3s ${FELIS_K3S_VERSION}"
log "k3s.service is missing beside ${K3S_BIN}: an earlier k3s install was cut short; finishing it around the binary in place"
run_k3s_installer --keep-binary
installer_ran=1
elif [ -x "$K3S_BIN" ]; then
local current local current
current="$("$K3S_BIN" --version 2>/dev/null | awk 'NR == 1 { print $3 }')" current="$("$K3S_BIN" --version 2>/dev/null | awk 'NR == 1 { print $3 }')"
if [ "$current" = "$FELIS_K3S_VERSION" ]; then if [ "$current" = "$FELIS_K3S_VERSION" ]; then
@@ -2038,7 +1954,7 @@ install_k3s() {
# The installer restarts k3s itself; otherwise a changed drop-in or unit takes a # The installer restarts k3s itself; otherwise a changed drop-in or unit takes a
# restart to load. Pods keep running across it (k3s leaves the containers be). # restart to load. Pods keep running across it (k3s leaves the containers be).
if [ "$K3S_RESTART_NEEDED" = 1 ] && [ "$installer_ran" = 0 ]; then if [ "$K3S_RESTART_NEEDED" = 1 ] && [ "$installer_ran" = 0 ]; then
log "restarting k3s to load its new settings (${K3S_CONFIG_DROPIN}, ${K3S_SERVICE_DROPIN})" log "restarting k3s to load its new settings (${K3S_CONFIG_DROPIN})"
systemctl restart k3s systemctl restart k3s
fi fi
export KUBECONFIG="$K3S_KUBECONFIG" export KUBECONFIG="$K3S_KUBECONFIG"
@@ -2089,19 +2005,6 @@ write_k3s_config() {
{ {
echo "# Written by the Felis installer (deploy/bootstrap.sh); a rerun rewrites it." echo "# Written by the Felis installer (deploy/bootstrap.sh); a rerun rewrites it."
echo 'write-kubeconfig-mode: "0600"' echo 'write-kubeconfig-mode: "0600"'
if [ "${DISTRIBUTED:-0}" = 1 ]; then
[ -n "$NODE_EXTERNAL_IP" ] || NODE_EXTERNAL_IP="$NODE_IP"
printf 'node-external-ip: "%s"\n' "$NODE_EXTERNAL_IP"
echo 'flannel-backend: "wireguard-native"'
echo 'flannel-external-ip: true'
echo 'agent-token-file: "/etc/rancher/k3s/felis-agent-token"'
# Append to existing API-server hardening arguments in earlier config files.
echo 'kube-apiserver-arg+:'
echo ' - "enable-admission-plugins=NodeRestriction"'
if [ ! -s /etc/rancher/k3s/felis-agent-token ]; then
(umask 077; openssl rand -hex 32 > /etc/rancher/k3s/felis-agent-token)
fi
fi
if [ -n "$name" ]; then if [ -n "$name" ]; then
printf 'node-name: "%s"\n' "$name" printf 'node-name: "%s"\n' "$name"
fi fi
@@ -2121,37 +2024,6 @@ write_k3s_config() {
log "wrote ${file}${name:+ (node name pinned to ${name})}" log "wrote ${file}${name:+ (node name pinned to ${name})}"
} }
# write_k3s_service_dropin runs k3s, and the containerd it starts with its own environment,
# with the Go collector at half the default heap growth (GOGC=50). An idle k3s holds about
# 150 MiB live and by default lets its heap reach twice that before collecting; at 50 it
# collects at one and a half times. Measured on the verification host: k3s 430 -> 370 MiB and
# its containerd 114 -> 104 MiB, for about 2% of one core more while idle. It sets
# K3S_RESTART_NEEDED when the file changed, since k3s reads its environment only as it starts.
write_k3s_service_dropin() {
local file="$K3S_SERVICE_DROPIN" tmp
mkdir -p "$(dirname "$file")"
# Beside its destination, like write_k3s_config's; systemd reads only *.conf from the
# directory, so the temp name is never loaded.
tmp="$(mktemp "${file}.XXXXXX")"
remember_temp "$tmp"
{
echo "# Written by the Felis installer (deploy/bootstrap.sh); a rerun rewrites it."
echo "[Service]"
echo "Environment=GOGC=50"
} > "$tmp"
if [ -f "$file" ] && cmp -s "$tmp" "$file"; then
rm -f "$tmp"
restore_label "$file"
ok "k3s service environment already current"
return 0
fi
chmod 0644 "$tmp"
mv "$tmp" "$file"
systemctl daemon-reload
K3S_RESTART_NEEDED=1
log "wrote ${file} (GOGC=50)"
}
# A command-line flag outranks every config file, and k3s's installer writes # A command-line flag outranks every config file, and k3s's installer writes
# INSTALL_K3S_EXEC into the unit's ExecStart one quoted word per line, so installs from # INSTALL_K3S_EXEC into the unit's ExecStart one quoted word per line, so installs from
# before the drop-in keep "'--write-kubeconfig-mode' \" followed by "'644' \" there. # before the drop-in keep "'--write-kubeconfig-mode' \" followed by "'644' \" there.
@@ -2178,13 +2050,8 @@ strip_k3s_kubeconfig_mode_flag() {
# The script from the release's own tag rather than get.k3s.io, which serves whatever # The script from the release's own tag rather than get.k3s.io, which serves whatever
# master holds today. '+' is literal in a URL path, so the tag needs no escaping. On an # master holds today. '+' is literal in a URL path, so the tag needs no escaping. On an
# installed k3s the same script replaces the binary in place and restarts the service. # installed k3s the same script replaces the binary in place and restarts the service.
# --keep-binary keeps the k3s already at K3S_BIN (INSTALL_K3S_SKIP_DOWNLOAD=binary) and run_k3s_installer() {
# still does everything after it, the SELinux policy included.
run_k3s_installer() { # [--keep-binary]
local skip=""
[ "${1:-}" != --keep-binary ] || skip=binary
curl -sfL --retry 5 --retry-delay 2 "https://raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh" | \ curl -sfL --retry 5 --retry-delay 2 "https://raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh" | \
INSTALL_K3S_SKIP_DOWNLOAD="$skip" \
INSTALL_K3S_VERSION="$FELIS_K3S_VERSION" \ INSTALL_K3S_VERSION="$FELIS_K3S_VERSION" \
INSTALL_K3S_BIN_DIR="$K3S_BIN_DIR" \ INSTALL_K3S_BIN_DIR="$K3S_BIN_DIR" \
INSTALL_K3S_EXEC="--disable traefik --disable servicelb --disable metrics-server" \ INSTALL_K3S_EXEC="--disable traefik --disable servicelb --disable metrics-server" \
@@ -2410,14 +2277,6 @@ repo_slug() {
printf '%s\n' "$FELIS_REPO_URL" | sed -e 's#^.*github\.com[:/]##' -e 's#\.git$##' printf '%s\n' "$FELIS_REPO_URL" | sed -e 's#^.*github\.com[:/]##' -e 's#\.git$##'
} }
# fork_token_hint is what a failed GitHub fetch says about FELIS_GITHUB_TOKEN. The official
# repository is public and needs none, so the sentence appears only for a fork, where GitHub
# answers a private repository the caller cannot see with 404, as it does a missing one.
fork_token_hint() {
[ "$(repo_slug | tr '[:upper:]' '[:lower:]')" != "felismc/felis" ] || return 0
printf ' If %s is a private fork, set FELIS_GITHUB_TOKEN to a token with read access to it.' "$FELIS_REPO_URL"
}
# github_api GETs a REST path and prints the body. # github_api GETs a REST path and prints the body.
# #
# The token goes in through `curl --config -` rather than `-H "Authorization: ..."` # The token goes in through `curl --config -` rather than `-H "Authorization: ..."`
@@ -2613,7 +2472,7 @@ download_release_binary() {
# Convergence check, and the cheapest one available: no API call, no download, and it asks # Convergence check, and the cheapest one available: no API call, no download, and it asks
# the exact question that matters. Reruns are the common case for this installer. # the exact question that matters. Reruns are the common case for this installer.
if [ "${FELIS_FORCE_UPDATE:-0}" != 1 ] && [ -x "$HOST_BIN" ] && [ "$("$HOST_BIN" version 2>/dev/null | head -n 1)" = "felis ${FELIS_REF}" ]; then if [ -x "$HOST_BIN" ] && [ "$("$HOST_BIN" version 2>/dev/null | head -n 1)" = "felis ${FELIS_REF}" ]; then
HAVE_PREBUILT_BINARY=1 HAVE_PREBUILT_BINARY=1
ok "host binary is already ${FELIS_REF}; skipping the download" ok "host binary is already ${FELIS_REF}; skipping the download"
return 0 return 0
@@ -2868,11 +2727,12 @@ resolve_install_ref() {
# to an earlier release (docs/troubleshooting.md §16). validate_settings checked # to an earlier release (docs/troubleshooting.md §16). validate_settings checked
# the tag's form; this checks it was published. # the tag's form; this checks it was published.
load_release_json "$FELIS_RELEASE" || die "could not find the published Felis release ${FELIS_RELEASE}. load_release_json "$FELIS_RELEASE" || die "could not find the published Felis release ${FELIS_RELEASE}.
Check the tag against the repository's releases page.$(fork_token_hint)" Check the tag against the repository's releases page. If the repository is private, set FELIS_GITHUB_TOKEN to a token with read access to it."
FELIS_REF="$FELIS_RELEASE" FELIS_REF="$FELIS_RELEASE"
else else
log "resolving the newest published Felis release" log "resolving the newest published Felis release"
FELIS_REF="$(github_latest_tag)" || die "could not resolve the newest Felis release from api.github.com.$(fork_token_hint) FELIS_REF="$(github_latest_tag)" || die "could not resolve the newest Felis release.
If the repository is private, set FELIS_GITHUB_TOKEN to a token with read access to it.
If no release has been published yet, set FELIS_VERSION_BOOTSTRAP=dev to build main instead." If no release has been published yet, set FELIS_VERSION_BOOTSTRAP=dev to build main instead."
fi fi
# A release IS its tag, so the stamp is final here and stamp_version leaves it be. # A release IS its tag, so the stamp is final here and stamp_version leaves it be.
@@ -2904,11 +2764,11 @@ resolve_install_ref() {
# "+" the tail is build metadata, ignored for ordering, so the build reads as current # "+" the tail is build metadata, ignored for ordering, so the build reads as current
# against v1.2.3 and as behind against v1.3.0 — both correct. # against v1.2.3 and as behind against v1.3.0 — both correct.
# #
# `git describe` is also unreliable here: the primary fetch is --depth 1 and carries no # `git describe` is also unreliable here: the primary clone is --depth 1 and carries no
# tags, so describe falls back to a bare SHA, which updates.Parse rejects outright (it # tags, so describe falls back to a bare SHA, which updates.Parse rejects outright (it
# fails closed on a non-numeric core). checkout_ref does fall back to the whole history # fails closed on a non-numeric core). fetch_source does retry with a full clone when the
# when the shallow fetch fails, which WOULD carry tags — that is exactly the point: the # shallow one fails, which WOULD carry tags — that is exactly the point: the stamp must not
# stamp must not depend on which arm happened to win. rev-parse needs no history at all. # depend on which arm happened to win. rev-parse needs no history at all.
stamp_version() { stamp_version() {
local sha local sha
[ -n "$FELIS_VERSION" ] && return 0 [ -n "$FELIS_VERSION" ] && return 0
@@ -2929,56 +2789,27 @@ fetch_source() {
return 0 return 0
fi fi
resolve_install_ref resolve_install_ref
local work failed
failed="could not check out ${FELIS_REF} from ${FELIS_REPO_URL}: check that this ref exists there and that this host can reach it.$(fork_token_hint)"
if [ -d "${SRC_DIR}/.git" ]; then if [ -d "${SRC_DIR}/.git" ]; then
log "updating source in ${SRC_DIR}" log "updating source in ${SRC_DIR}"
checkout_ref "$SRC_DIR" || die "$failed" git_auth -C "$SRC_DIR" fetch --depth 1 origin "$FELIS_REF" \
|| die "could not fetch ${FELIS_REF} from ${FELIS_REPO_URL}; if the repository is private, set FELIS_GITHUB_TOKEN to a token with read access to it"
git -C "$SRC_DIR" checkout -f FETCH_HEAD
else else
# Whatever sits at SRC_DIR without a .git (a tree staged for FELIS_SKIP_FETCH, the
# remains of an interrupted clone) is replaced, and only once the new checkout is
# whole: git clone refuses a non-empty destination, which stopped the rerun outright.
log "cloning ${FELIS_REPO_URL} (${FELIS_REF})" log "cloning ${FELIS_REPO_URL} (${FELIS_REF})"
mkdir -p "$(dirname "$SRC_DIR")" mkdir -p "$(dirname "$SRC_DIR")"
# A run killed outright (no EXIT trap) leaves its half-made checkout; the run lock # The fallback checks the ref out explicitly. It used to be a bare full clone, which
# means none of these belongs to a run still going. # silently landed on the default branch: harmless when FELIS_REF was always "main",
rm -rf -- "${SRC_DIR}".new.* # but the release channel now asks for a tag, and a build stamped v1.2.3 that
work="$(mktemp -d "${SRC_DIR}.new.XXXXXX")" # actually contains main is worse than a failed install.
chmod 755 "$work" git_auth clone --depth 1 --branch "$FELIS_REF" "$FELIS_REPO_URL" "$SRC_DIR" 2>/dev/null \
if ! { git -C "$work" init -q && git -C "$work" remote add origin "$FELIS_REPO_URL" \ || { git_auth clone "$FELIS_REPO_URL" "$SRC_DIR" \
&& checkout_ref "$work"; }; then && git_auth -C "$SRC_DIR" checkout -f "$FELIS_REF"; } \
rm -rf "$work" || die "could not check out ${FELIS_REF} from ${FELIS_REPO_URL}; if the repository is private, set FELIS_GITHUB_TOKEN to a token with read access to it"
die "$failed"
fi
rm -rf "$SRC_DIR"
mv "$work" "$SRC_DIR"
fi fi
stamp_version stamp_version
ok "source ready at ${SRC_DIR}" ok "source ready at ${SRC_DIR}"
} }
# checkout_ref checks FELIS_REF out in the repository at $1, whose origin is FELIS_REPO_URL.
# A branch, a tag or a full commit id comes down at depth 1. An abbreviated commit id is no
# ref a server answers for, so it falls back to the whole history and is resolved there,
# with origin's branch ahead of a local one an earlier clone left behind. The ref is always
# checked out explicitly: a build stamped v1.2.3 that actually holds main is worse than a
# failed install.
checkout_ref() {
local commit
if git_auth -C "$1" fetch -q --depth 1 origin "$FELIS_REF" 2>/dev/null; then
git -C "$1" checkout -q -f FETCH_HEAD
return
fi
if [ -f "$1/.git/shallow" ]; then
git_auth -C "$1" fetch -q --unshallow --tags origin '+refs/heads/*:refs/remotes/origin/*' || return 1
else
git_auth -C "$1" fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*' || return 1
fi
commit="$(git -C "$1" rev-parse -q --verify "refs/remotes/origin/${FELIS_REF}^{commit}" \
|| git -C "$1" rev-parse -q --verify "${FELIS_REF}^{commit}")" || return 1
git -C "$1" checkout -q -f "$commit"
}
install_embedded_binary() { install_embedded_binary() {
local src local src
src="${FELIS_BOOTSTRAP_BINARY:-}" src="${FELIS_BOOTSTRAP_BINARY:-}"
@@ -3018,7 +2849,7 @@ EOF
chmod 0755 "${tmp}/felis" chmod 0755 "${tmp}/felis"
log "building ${FELIS_IMAGE} from the current felis binary" log "building ${FELIS_IMAGE} from the current felis binary"
docker build --progress=plain -t "$FELIS_IMAGE" "$tmp" docker build -t "$FELIS_IMAGE" "$tmp"
rm -rf "$tmp" rm -rf "$tmp"
} }
@@ -3032,7 +2863,7 @@ build_image_from_source() {
# Without the stamp main.version stays "dev", and `felis update` refuses to compare a # Without the stamp main.version stays "dev", and `felis update` refuses to compare a
# "dev" build against upstream rather than treating it as 0.0.0. So an unstamped image # "dev" build against upstream rather than treating it as 0.0.0. So an unstamped image
# is not a cosmetic problem: it silently disables update reporting for the install. # is not a cosmetic problem: it silently disables update reporting for the install.
docker build --progress=plain -t "$FELIS_IMAGE" \ docker build -t "$FELIS_IMAGE" \
--build-arg FELIS_VERSION="${FELIS_VERSION:-dev}" "$SRC_DIR" --build-arg FELIS_VERSION="${FELIS_VERSION:-dev}" "$SRC_DIR"
log "extracting the felis binary onto the host (${HOST_BIN})" log "extracting the felis binary onto the host (${HOST_BIN})"
@@ -3098,7 +2929,8 @@ build_image() {
remove_k3s_image "$FELIS_IMAGE" remove_k3s_image "$FELIS_IMAGE"
docker save "$FELIS_IMAGE" | k3s_cmd ctr images import - docker save "$FELIS_IMAGE" | k3s_cmd ctr images import -
stop_docker # Reclaim the ~150 MiB the docker daemon holds; reruns restart it on demand.
systemctl stop docker docker.socket 2>/dev/null || true
ok "image built, binary on host, image imported" ok "image built, binary on host, image imported"
} }
@@ -3505,7 +3337,7 @@ build_game_image() {
limbo) limbo)
img="$FELIS_LIMBO_IMAGE" img="$FELIS_LIMBO_IMAGE"
log "building ${img} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})" log "building ${img} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})"
docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \ docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \
--build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \ --build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \
--build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \ --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \
--build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \ --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \
@@ -3516,7 +3348,7 @@ build_game_image() {
lobby) lobby)
img="$FELIS_LOBBY_IMAGE" img="$FELIS_LOBBY_IMAGE"
log "building ${img} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)" log "building ${img} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)"
docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \ docker build -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \
--build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
--build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \
@@ -3528,7 +3360,7 @@ build_game_image() {
# operator initContainer's job, so this image carries no /menu plugin and no secret gate. # operator initContainer's job, so this image carries no /menu plugin and no secret gate.
img="$FELIS_PAPER_IMAGE" img="$FELIS_PAPER_IMAGE"
log "building ${img} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)" log "building ${img} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)"
docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \ docker build -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \
--build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
-t "$img" "$GAME_STACK_DIR" -t "$img" "$GAME_STACK_DIR"
@@ -3606,18 +3438,16 @@ atomic_install_file() {
mv -fT "$staged" "$target" mv -fT "$staged" "$target"
} }
# install_if_changed is atomic_install_file that leaves the target alone when it already # install_if_changed is atomic_install_file that leaves a target with the same bytes in
# has the same bytes, owner and mode, so its mtime keeps meaning "the content changed". # place, fixing only its mode and owner, so the target's mtime keeps meaning "the content
# felis domain check reads a proxy started before felis-link.properties' mtime as one # changed". felis domain check reads a proxy started before felis-link.properties' mtime
# still on the old names, and a re-run that rewrote the same bytes made every install look # as one still on the old names, and a re-run that rewrote the same bytes made every
# behind (and `felis domain set` restart the proxy for nothing). # install look behind (and `felis domain set` restart the proxy for nothing).
# It never fixes a target in place: the proxy's account owns these directories and can
# swap the file for a symlink after the checks, and a chown or chmod by path would follow
# it to, say, k3s.yaml. Owner and group compare by name, as the callers pass them.
install_if_changed() { install_if_changed() {
local source="$1" target="$2" mode="$3" owner="$4" group="$5" local source="$1" target="$2" mode="$3" owner="$4" group="$5"
if [ -f "$target" ] && [ ! -L "$target" ] && cmp -s "$source" "$target" \ if [ -f "$target" ] && [ ! -L "$target" ] && cmp -s "$source" "$target"; then
&& [ "$(stat -c '%U:%G %a' "$target")" = "${owner}:${group} ${mode#0}" ]; then chown "${owner}:${group}" "$target"
chmod "$mode" "$target"
return 0 return 0
fi fi
atomic_install_file "$@" atomic_install_file "$@"
@@ -3634,7 +3464,7 @@ build_velocity_plugin() {
docker run --rm \ docker run --rm \
-v "${GAME_STACK_DIR}:/src:z" \ -v "${GAME_STACK_DIR}:/src:z" \
-w /src/plugins/velocity \ -w /src/plugins/velocity \
"$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build --console=plain --init-script ../shared/build-progress.gradle \ "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build \
|| die "felis-velocity plugin build failed" || die "felis-velocity plugin build failed"
local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar ) local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar )
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \ [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \
@@ -3660,12 +3490,16 @@ build_velocity_plugin() {
# signed with a matching HMAC. Only protocol 47 was measured; the rest of Via's 1.7-1.12 range # signed with a matching HMAC. Only protocol 47 was measured; the rest of Via's 1.7-1.12 range
# is its own documented support. # is its own documented support.
# #
# Pin the three jars as one compatible set. ViaVersion/ViaBackwards 5.12.0 add # Pinned by hash and not by "latest" on purpose. These three jars sit in front of every packet
# the 26.3 protocol used by game-stack.lock; ViaRewind 4.2.0 explicitly supports # on the proxy, and they are the exact bytes FL-007 measured — a moving tag would quietly make
# that pair. The earlier FL-007 join measured 1.8 against Paper 1.21.11, not every # this an unmeasured configuration. Bumping a version means bumping its checksum here.
# client on 26.3. Release notes: #
# https://github.com/ViaVersion/ViaBackwards/releases/tag/5.12.0 # The three versions are a set, not three independent pins. ViaRewind is the component that
# https://github.com/ViaVersion/ViaRewind/releases/tag/4.2.0 # carries 1.8/1.7 support, and 4.1.2 against ViaVersion/ViaBackwards 5.11.0 fails to load
# Protocol1_9To1_8 — the single protocol every 1.8 client needs — with "Invalid version: 1"
# at proxy startup. 4.1.3 is the release that adds 5.11.0 compatibility; a two-arm run of the
# same proxy image logs that error three times on 4.1.2 and not at all on 4.1.3. Read the
# ViaRewind release notes before moving ViaVersion or ViaBackwards.
install_via_plugins() { install_via_plugins() {
prepare_velocity_layout prepare_velocity_layout
local name version want target url tmp have local name version want target url tmp have
@@ -3691,12 +3525,12 @@ install_via_plugins() {
|| die "${name} ${version} checksum mismatch: got ${have}, expected ${want}" || die "${name} ${version} checksum mismatch: got ${have}, expected ${want}"
atomic_install_file "$tmp" "$target" 0644 root root atomic_install_file "$tmp" "$target" 0644 root root
done <<'EOF' done <<'EOF'
ViaVersion 5.12.0 72c40a6a702d67f226fc9a0d8ad82aba1483fdabe2e6159bcdddb2dc070750b0 ViaVersion 5.11.0 18d19e90fc9467d68128c076630ae8700449c901402a3ef421837ce006bc8cae
ViaBackwards 5.12.0 194e9250224632274d7b3c17e411e031a9223c1863c6f5138d53c721f07ab78d ViaBackwards 5.11.0 b21983d561e3f92df257683f0133ab6c68ec68175e8acfd82c6231723bf83587
ViaRewind 4.2.0 d6634ba57bb82d5161c68dfb393571cdf40511a0beb1b04b8c7ed794a3532c6a ViaRewind 4.1.3 2d5970d22b4711c9ab2800932326c7b08acdace25ed7c6bbb8f6ea81054962b4
EOF EOF
pin_via_block_connections pin_via_block_connections
ok "Via staged with 26.3 support; verify client versions against your chosen backend images" ok "Via staged; clients from 1.8 up can join under modern forwarding"
} }
# pin_via_block_connections turns ViaVersion's serverside block-connection tracking off. # pin_via_block_connections turns ViaVersion's serverside block-connection tracking off.
@@ -4015,21 +3849,10 @@ install_velocity_service() {
# sees it -- unquoted, that spelling would hand java a stray "legacy112" argument and the unit # sees it -- unquoted, that spelling would hand java a stray "legacy112" argument and the unit
# would not start. Quoting keeps the whole property one argv item. # would not start. Quoting keeps the whole property one argv item.
local legacy_forwarding_servers="${FELIS_LEGACY_FORWARDING_SERVERS}" local legacy_forwarding_servers="${FELIS_LEGACY_FORWARDING_SERVERS}"
# The heap starts small and is not pre-touched: the proxy with its Via plugins holds about 50M # -Xms stays at 512M so a small proxy does not reserve its whole ceiling up front, unless
# live, and a pre-touched 512M start kept ~0.7 GB resident on an idle network. Up to a 1G # the ceiling itself is lower (the JVM refuses an initial heap above the maximum).
# ceiling (the default, sized for about 100 players) it runs the serial collector and only the local xmx="$FELIS_VELOCITY_XMX" xms="512M"
# C1 compiler, 173 MiB idle against 267 MiB under G1 (both measured on the verification host); [ "$(heap_megabytes "$xmx")" -ge 512 ] || xms="$xmx"
# with that little live, a young collection takes milliseconds, and the proxy's compression
# and encryption run in Velocity's native library whichever compiler is on. A larger ceiling
# is for a network where a serial full collection over a big heap would stall every player at
# once, so it keeps G1, whose periodic collection hands the growth back once players have left.
# FELIS_VELOCITY_XMX is at least 256M, so the start never exceeds the ceiling.
local xmx="$FELIS_VELOCITY_XMX" jvm
if [ "$(heap_megabytes "$xmx")" -le 1024 ]; then
jvm="-Xms16M -Xmx${xmx} -XX:+UseSerialGC -XX:TieredStopAtLevel=1"
else
jvm="-Xms64M -Xmx${xmx} -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:G1PeriodicGCInterval=60000"
fi
cat > "$VELOCITY_SERVICE" <<EOF cat > "$VELOCITY_SERVICE" <<EOF
[Unit] [Unit]
Description=Felis Velocity proxy (Mojang authentication + modern forwarding) Description=Felis Velocity proxy (Mojang authentication + modern forwarding)
@@ -4041,7 +3864,7 @@ Type=simple
User=${VELOCITY_USER} User=${VELOCITY_USER}
Group=${VELOCITY_USER} Group=${VELOCITY_USER}
WorkingDirectory=${VELOCITY_DIR} WorkingDirectory=${VELOCITY_DIR}
ExecStart=${JRE_DIR}/bin/java ${jvm} -Dmojang.sessionserver=http://${api_ip}:8081/session/minecraft/hasJoined "-Dfelis.legacy-forwarding.servers=${legacy_forwarding_servers}" -jar ${VELOCITY_DIR}/velocity.jar ExecStart=${JRE_DIR}/bin/java -Xms${xms} -Xmx${xmx} -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:+AlwaysPreTouch -Dmojang.sessionserver=http://${api_ip}:8081/session/minecraft/hasJoined "-Dfelis.legacy-forwarding.servers=${legacy_forwarding_servers}" -jar ${VELOCITY_DIR}/velocity.jar
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
NoNewPrivileges=yes NoNewPrivileges=yes
@@ -4707,13 +4530,6 @@ offsite_enabled() {
# database password. # database password.
write_felis_toml() { write_felis_toml() {
local target="$1" db_addr="$2" deployment="${3:-}" deployment_line="" smtp_block auth_body auth_source_blocks registry_block archive_block offsite_section local target="$1" db_addr="$2" deployment="${3:-}" deployment_line="" smtp_block auth_body auth_source_blocks registry_block archive_block offsite_section
local config_keys game_version_line=""
# main's installer also installs older releases, whose strict TOML parser rejects
# newer optional keys. Ask the installed binary rather than guessing from its stamp.
config_keys="$("$HOST_BIN" bootstrap-assets config-keys 2>/dev/null || true)"
if grep -Fxq 'velocity.game_version' <<<"$config_keys"; then
game_version_line="game_version = \"${MC_VERSION:-}\""
fi
if [ -n "$deployment" ]; then if [ -n "$deployment" ]; then
# Starts with the newline that ends the url line, so the pod copy has no blank line there. # Starts with the newline that ends the url line, so the pod copy has no blank line there.
deployment_line=" deployment_line="
@@ -4769,7 +4585,6 @@ login_image = "${FELIS_LIMBO_IMAGE}"
lobby_image = "${FELIS_LOBBY_IMAGE}" lobby_image = "${FELIS_LOBBY_IMAGE}"
# The public port players connect on; the panel shows it in server addresses. # The public port players connect on; the panel shows it in server addresses.
game_port = ${FELIS_GAME_PORT} game_port = ${FELIS_GAME_PORT}
${game_version_line}
[registry] [registry]
url = "${REGISTRY_URL}" url = "${REGISTRY_URL}"
@@ -5217,57 +5032,6 @@ EOF
# The daily database backup. The first run happens now, so a broken pipeline (pg_dump # The daily database backup. The first run happens now, so a broken pipeline (pg_dump
# missing, directory unwritable) shows up in this install rather than in the first # missing, directory unwritable) shows up in this install rather than in the first
# restore someone needs. # restore someone needs.
# The API mounts only this Unix socket, never the host kubeconfig or SSH keys.
install_node_control_service() {
local selinux_environment=""
if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then
command -v semodule >/dev/null 2>&1 || die "node control on SELinux requires semodule"
cat > "${STATE_DIR}/felis-node-control.cil" <<'EOF_NODE_SELINUX'
(type felis_node_control_socket_t)
(typeattributeset file_type (felis_node_control_socket_t))
(typeattributeset non_auth_file_type (felis_node_control_socket_t))
(allow container_t felis_node_control_socket_t (dir (search getattr open read)))
(allow container_t felis_node_control_socket_t (sock_file (write open read getattr)))
(allow container_t unconfined_service_t (unix_stream_socket (connectto)))
EOF_NODE_SELINUX
semodule -i "${STATE_DIR}/felis-node-control.cil" || die "could not install the node-control SELinux policy"
selinux_environment="Environment=FELIS_NODE_CONTROL_SELINUX=1"
fi
install -d -o root -g 65532 -m 0750 /run/felis-node-control
cat > /etc/tmpfiles.d/felis-node-control.conf <<'EOF_NODE_TMP'
d /run/felis-node-control 0750 root 65532 -
EOF_NODE_TMP
if [ -n "$selinux_environment" ] && command -v semanage >/dev/null 2>&1; then
# /run is an SELinux equivalence alias for /var/run. Register the canonical path.
semanage fcontext -a -t felis_node_control_socket_t '/var/run/felis-node-control(/.*)?' 2>/dev/null \
|| semanage fcontext -m -t felis_node_control_socket_t '/var/run/felis-node-control(/.*)?'
fi
if [ -n "$selinux_environment" ] && command -v chcon >/dev/null 2>&1; then
chcon -R -t felis_node_control_socket_t /run/felis-node-control 2>/dev/null || true
fi
cat > /etc/systemd/system/felis-node-control.service <<EOF_NODE_UNIT
[Unit]
Description=Felis host node operations
After=network-online.target k3s.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=${HOST_BIN} node-control --config ${STATE_DIR}/felis.host.toml --state /var/lib/felis/node-control --namespace ${MINECRAFT_NS} --control-namespace ${CONTROL_NS}
${selinux_environment}
Restart=on-failure
RestartSec=5
UMask=0077
[Install]
WantedBy=multi-user.target
EOF_NODE_UNIT
systemctl daemon-reload
systemctl enable felis-node-control.service
# A conversion task runs inside this service; restarting it would terminate the installer.
if [ "${FELIS_NODE_CONTROL_TASK:-0}" != 1 ]; then
systemctl restart felis-node-control.service
fi
}
install_db_backup_timer() { install_db_backup_timer() {
install -d -m 0700 "$FELIS_DB_BACKUP_DIR" install -d -m 0700 "$FELIS_DB_BACKUP_DIR"
cat > "$DB_BACKUP_SERVICE" <<EOF cat > "$DB_BACKUP_SERVICE" <<EOF
@@ -5433,39 +5197,13 @@ deploy_bundle() {
revoke_worlds_root_grant revoke_worlds_root_grant
if [ "${DISTRIBUTED:-0}" = 1 ]; then
local controller archive_key_file="${STATE_DIR}/archive-transfer.key"
controller="$(k3s_node_name)"
[ -n "$controller" ] || die "distributed deployment needs a stable controller node name"
kube label node "$controller" "felis.node-restriction.kubernetes.io/role=controller" "felis.node-restriction.kubernetes.io/identity=$controller" --overwrite
local system_deployment
for system_deployment in coredns local-path-provisioner; do
if kube -n kube-system get deployment "$system_deployment" >/dev/null 2>&1; then
kube -n kube-system patch deployment "$system_deployment" --type merge \
-p "{\"spec\":{\"template\":{\"spec\":{\"nodeSelector\":{\"felis.node-restriction.kubernetes.io/identity\":\"$controller\"}}}}}"
fi
done
if [ ! -s "$archive_key_file" ]; then (umask 077; openssl rand -hex 32 > "$archive_key_file"); fi
local archive_key
archive_key="$(cat "$archive_key_file")"
apply_literal_secret "$CONTROL_NS" felis-archive-key key "$archive_key"
apply_literal_secret "$MINECRAFT_NS" felis-archive-key key "$archive_key"
fi
log "rendering + applying the control-plane bundle" log "rendering + applying the control-plane bundle"
local -a manifest_args=( local -a manifest_args=(
--felis-image "$FELIS_IMAGE" --felis-image "$FELIS_IMAGE"
--postgres-image "$POSTGRES_IMAGE" --postgres-image "$POSTGRES_IMAGE"
--panel-node-port "$FELIS_PANEL_NODEPORT" --panel-node-port "$FELIS_PANEL_NODEPORT"
--velocity-cidr "${NODE_IP}/32" --velocity-cidr "${NODE_IP}/32"
--node-control-socket /run/felis-node-control/control.sock --node-control-node "$(k3s_node_name)"
) )
if [ "${DISTRIBUTED:-0}" = 1 ]; then
manifest_args+=(--distributed --controller-node "$controller" --egress-probe "felis-api.${CONTROL_NS}.svc:443")
# Every node address, including global addresses, must be excluded from game egress.
while read -r cidr; do
[ -z "$cidr" ] || manifest_args+=(--server-egress-deny-cidr "$cidr")
done < <(kube get nodes -o jsonpath='{range .items[*]}{range .status.addresses[*]}{.address}{"\n"}{end}{end}' | awk '/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {print $0"/32"}')
fi
local cidr local cidr
while read -r cidr; do while read -r cidr; do
[ -n "$cidr" ] && manifest_args+=(--server-egress-deny-cidr "$cidr") [ -n "$cidr" ] && manifest_args+=(--server-egress-deny-cidr "$cidr")
@@ -5830,6 +5568,14 @@ summary() {
log "The proxy authenticates against Mojang and forwards the verified profile to the" log "The proxy authenticates against Mojang and forwards the verified profile to the"
log "login gate; the backends are reachable in-cluster only. Follow it with:" log "login gate; the backends are reachable in-cluster only. Follow it with:"
log " sudo journalctl -u felis-velocity -f" log " sudo journalctl -u felis-velocity -f"
if [ "${FELIS_BOOTSTRAP_FROM_TUI:-}" = "1" ]; then
log "Returning to the setup console to create the Owner account and verify panel access."
else
log "Next: run 'sudo felis setup' on this host to create the Owner account."
fi
log "setup provisions the login/lobby servers, then asks the Owner to bind by joining"
log "the proxy in Minecraft — that is what makes the Owner's admin identity a real"
log "Mojang account rather than a password."
log "Use 'sudo felis breakGlass' only for emergency local Owner recovery/reset." log "Use 'sudo felis breakGlass' only for emergency local Owner recovery/reset."
if [ -n "${PREVIOUS_FELIS_IMAGE:-}" ]; then if [ -n "${PREVIOUS_FELIS_IMAGE:-}" ]; then
echo echo
@@ -5843,74 +5589,6 @@ summary() {
summary_alerts summary_alerts
summary_heartbeat summary_heartbeat
echo echo
summary_next
echo
}
# owner_state: whether the database holds a staff account (an Owner or an Admin), the test
# `felis setup` makes to choose between the Owner wizard and its status screen (AdminExists
# in internal/api/pgrepo.go). "unknown" when the database does not answer.
owner_state() {
local out
out="$(pg_exec psql -XtA -U postgres -d "$DB_NAME" -c "SELECT EXISTS (SELECT 1 FROM users WHERE role IN ('admin', 'owner'))" 2>/dev/null || true)"
case "$out" in
t) echo yes ;;
f) echo no ;;
*) echo unknown ;;
esac
}
# setup_terminal: whether the full-screen setup console has a terminal to draw on and to
# read keys from. Under `curl | sudo bash` stdin is the script, so the console reads
# SETUP_TTY; stdout must be the terminal itself, which `| tee install.log`, cloud-init and
# CI are not. /dev/tty is mode 0666 everywhere, so only opening it tells.
setup_terminal() { [ -t 1 ] && (: <"$SETUP_TTY") 2>/dev/null; }
# summary_next is the installer's last word: what the operator does now. It comes after the
# warnings, so it is what the terminal is left showing. Until a staff account exists that is
# `felis setup`, which creates the Owner; on a terminal the installer starts it itself
# (start_setup_console), as the README's install line promises. With an Owner in place it
# is the address to sign in at. Under felis setup the console carries on by itself.
summary_next() {
local owner rule="================================================================================"
if bootstrap_from_tui; then
log "Returning to the setup console to create the Owner account and verify panel access."
return 0
fi
owner="$(owner_state)"
log "$rule"
if [ "$owner" = yes ]; then
log "Felis is running. Sign in at https://$(auth_hostname admin_hostname "op.console.${FELIS_ROOT_DOMAIN}")"
log "Local access: https://$(auth_hostname admin_hostname "op.console.${FELIS_ROOT_DOMAIN}"):${FELIS_PANEL_NODEPORT}"
log "Passkey requires the configured hostname; direct IP access cannot use Passkey."
log "Email, edge and storage settings: sudo felis setup"
log "$rule"
return 0
fi
if [ "$owner" = no ] && [ -z "${FELIS_NO_SETUP:-}" ] && setup_terminal; then
SETUP_CONSOLE=1
log "Next: create the Owner account. The setup console starts now; if you leave it,"
log "run sudo felis setup to come back to it."
else
log "Next: create the Owner account. Run on this host:"
log " sudo felis setup"
fi
log "It starts the login and lobby servers, has you join ${NODE_IP}:${FELIS_GAME_PORT} in Minecraft to"
log "bind your Mojang account as the Owner, then sets up how the panel is reached."
log "$rule"
}
# start_setup_console runs `felis setup` when summary_next said it would. The install has
# succeeded by then, so the console's own failure never fails the run: the EXIT cleanup
# would take that for a failed install and undo the database move.
start_setup_console() {
[ "$SETUP_CONSOLE" = 1 ] || return 0
resume_package_background_timers
# The install is done, and felis setup can start the installer itself (its host
# bootstrap step): an inherited run lock would stop that run as a second one.
exec 9>&-
"$HOST_BIN" setup <"$SETUP_TTY" \
|| warn "the setup console exited with status $?; run 'sudo felis setup' to come back to it"
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -5920,7 +5598,6 @@ start_setup_console() {
# prompt (or FELIS_INSTALL_MODE=nano). # prompt (or FELIS_INSTALL_MODE=nano).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
prompt_install_mode() { prompt_install_mode() {
if [ "$INSTALL_MODE" = worker ]; then log "install mode: worker";return; fi
# felis setup carries on to the Owner and edge setup, which needs the control plane, so # felis setup carries on to the Owner and edge setup, which needs the control plane, so
# a nano install under it could only end in a setup error. # a nano install under it could only end in a setup error.
if bootstrap_from_tui; then if bootstrap_from_tui; then
@@ -5930,9 +5607,9 @@ prompt_install_mode() {
return 0 return 0
fi fi
case "$INSTALL_MODE" in case "$INSTALL_MODE" in
full|nano|worker) log "install mode: ${INSTALL_MODE} (from FELIS_INSTALL_MODE)"; return 0 ;; full|nano) log "install mode: ${INSTALL_MODE} (from FELIS_INSTALL_MODE)"; return 0 ;;
"") ;; "") ;;
*) die "FELIS_INSTALL_MODE must be 'full', 'nano' or 'worker', got: ${INSTALL_MODE}" ;; *) die "FELIS_INSTALL_MODE must be 'full' or 'nano', got: ${INSTALL_MODE}" ;;
esac esac
# A felis-nano unit with no full install beside it makes this re-run a nano update; # A felis-nano unit with no full install beside it makes this re-run a nano update;
@@ -6258,82 +5935,7 @@ ensure_k3s_on_path() {
esac esac
} }
# Worker is a daemon-only branch. It neither generates Felis service credentials nor applies a controller bundle.
main_worker() {
[ -n "$WORKER_NAME" ] && [[ "$WORKER_NAME" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || die "FELIS_NODE_NAME is required and must be a DNS node name"
[[ "$WORKER_SERVER" =~ ^https://([a-zA-Z0-9.:-]+|\[[0-9a-fA-F:]+\]):6443$ ]] || die "FELIS_SERVER_URL must be an HTTPS k3s endpoint on port 6443"
[[ "$WORKER_REGISTRY_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "FELIS_REGISTRY_CLUSTER_IP is required"
[ -n "$WORKER_PEERS" ] || die "FELIS_PEER_CIDRS must list exact cluster peer addresses"
[ -s "$WORKER_TOKEN_FILE" ] || die "FELIS_BOOTSTRAP_TOKEN_FILE must name a secure limited bootstrap token file"
local token saved mode
[ -f "$WORKER_TOKEN_FILE" ] && [ ! -L "$WORKER_TOKEN_FILE" ] || die "bootstrap token must be a regular file"
mode="$(stat -c %a "$WORKER_TOKEN_FILE")"
(( (8#$mode & 077) == 0 )) || die "bootstrap token must not be readable by group or others (use chmod 600)"
token="$(cat "$WORKER_TOKEN_FILE")"
[[ "$token" =~ ^K10[0-9a-f]{64}::[a-z0-9]{6}\.[a-z0-9]{16}$ ]] || die "worker accepts only CA-pinned bootstrap tokens, never server or static agent tokens"
[ ! -e /var/lib/rancher/k3s/server ] || die "this host has a k3s server; refusing to turn a controller into a worker"
if [ -d /var/lib/rancher/k3s/agent ]; then
saved="$(k3s_node_name)"
[ -n "$saved" ] && [ "$saved" = "$WORKER_NAME" ] || die "cannot change or guess an installed worker identity"
fi
if [ -f "$K3S_CONFIG_DROPIN" ]; then
saved="$(awk -F'"' '/^node-name:/ {print $2;exit}' "$K3S_CONFIG_DROPIN")"
[ -z "$saved" ] || [ "$saved" = "$WORKER_NAME" ] || die "existing node identity is $saved; refusing to rename it"
saved="$(awk -F'"' '/^server:/ {print $2;exit}' "$K3S_CONFIG_DROPIN")"
[ -z "$saved" ] || [ "$saved" = "$WORKER_SERVER" ] || die "existing worker belongs to another controller"
fi
detect_node_ip
[ -n "$NODE_EXTERNAL_IP" ] || NODE_EXTERNAL_IP="$NODE_IP"
PREFLIGHT_PROBLEMS=()
preflight_platform; preflight_memory; preflight_disk; preflight_networks; preflight_outbound
local unit
for unit in rke2-server rke2-agent k0scontroller k0sworker snap.microk8s.daemon-kubelite kubelet k3s; do
if systemctl is-active --quiet "$unit.service"; then preflight_fail "conflicting Kubernetes service: $unit"; fi
done
[ "${#PREFLIGHT_PROBLEMS[@]}" = 0 ] || die "worker preflight failed: ${PREFLIGHT_PROBLEMS[*]}"
install_base
ensure_time_sync
ensure_persistent_journal
# Reuse the release binary path for the local admission checks, without importing game/platform images.
bootstrap_from_tui || [ -n "$FELIS_ARTIFACT_DIR" ] || [ -n "${FELIS_SKIP_FETCH:-}" ] || resolve_install_ref
acquire_felis_binary
if [ -z "$HAVE_PREBUILT_BINARY" ]; then install_go_toolchain; build_nano_binary; fi
mkdir -p /etc/rancher/k3s/config.yaml.d
(umask 077; printf '%s\n' "$token" > /etc/rancher/k3s/felis-bootstrap-token)
unset token
cat > "$K3S_CONFIG_DROPIN" <<EOF_WORKER
server: "$WORKER_SERVER"
node-name: "$WORKER_NAME"
node-external-ip: "$NODE_EXTERNAL_IP"
token-file: "/etc/rancher/k3s/felis-bootstrap-token"
disable-default-registry-endpoint: true
node-taint:
- "felis.lolicon.best/unapproved=true:NoSchedule"
EOF_WORKER
chmod 0600 "$K3S_CONFIG_DROPIN"
cat > "$K3S_REGISTRIES_FILE" <<EOF_MIRROR
mirrors:
"$REGISTRY_URL":
endpoint:
- "http://${WORKER_REGISTRY_IP}:5000"
EOF_MIRROR
chmod 0600 "$K3S_REGISTRIES_FILE"
HOST_BIN_IN_USE=1
"$HOST_BIN" node firewall --peers "$WORKER_PEERS" --controller-ip "${WORKER_SERVER#https://}" --pod-cidr "$POD_CIDR" --node-port "$FELIS_PANEL_NODEPORT"
if [ ! -x "$K3S_BIN" ]; then
stage_k3s_airgap_images
curl -sfL --retry 5 --retry-delay 2 "https://raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh" | \
INSTALL_K3S_VERSION="$FELIS_K3S_VERSION" INSTALL_K3S_BIN_DIR="$K3S_BIN_DIR" INSTALL_K3S_EXEC=agent sh -
else
[ "$("$K3S_BIN" --version | awk 'NR==1 {print $3}')" = "$FELIS_K3S_VERSION" ] || die "worker k3s version differs from pinned controller version; upgrade in a maintenance window"
systemctl enable --now k3s-agent
systemctl restart k3s-agent
fi
ok "worker $WORKER_NAME joined under quarantine; run felis node approve on A"
}
main() { main() {
acquire_run_lock
ensure_k3s_on_path ensure_k3s_on_path
resolve_nano_listen resolve_nano_listen
validate_settings validate_settings
@@ -6343,15 +5945,10 @@ main() {
main_nano main_nano
return return
fi fi
if [ "$INSTALL_MODE" = worker ]; then main_worker; return; fi
detect_node_ip detect_node_ip
# Before the first change to the host: a problem found here costs a rerun, one found # Before the first change to the host: a problem found here costs a rerun, one found
# halfway through costs an install to unwind. # halfway through costs an install to unwind.
preflight preflight
check_postgres_major
if [ "$FELIS_UPGRADE_DEPS" = 1 ] && [ -x "$K3S_BIN" ]; then
k3s_upgrade_allowed "$("$K3S_BIN" --version 2>/dev/null | awk 'NR == 1 { print $3 }')" "$FELIS_K3S_VERSION" || true
fi
quiet_watchdog quiet_watchdog
pause_package_background_timers pause_package_background_timers
ensure_swap ensure_swap
@@ -6374,7 +5971,6 @@ main() {
ensure_panel_tls_cert ensure_panel_tls_cert
# No install_docker here: Docker comes in only for an image this run has to build # No install_docker here: Docker comes in only for an image this run has to build
# (ensure_docker), and an install from a release's assets builds none. # (ensure_docker), and an install from a release's assets builds none.
if [ -z "${FELIS_DISTRIBUTED+x}" ] && [ -f "$K3S_CONFIG_DROPIN" ] && grep -q 'flannel-backend: "wireguard-native"' "$K3S_CONFIG_DROPIN"; then DISTRIBUTED=1; fi
install_k3s install_k3s
# The registry mirror must exist before the bundle's pods start pulling (and # The registry mirror must exist before the bundle's pods start pulling (and
# before any re-run's rollouts). # before any re-run's rollouts).
@@ -6387,12 +5983,6 @@ main() {
import_release_images felis registry postgres import_release_images felis registry postgres
import_platform_images import_platform_images
build_image build_image
# Source builds install the new HOST_BIN here; an earlier call may execute the
# old release's binary, which has no distributed node commands.
if [ "${DISTRIBUTED:-0}" = 1 ]; then
[ -n "$WORKER_PEERS" ] || WORKER_PEERS="${NODE_EXTERNAL_IP:-$NODE_IP}/32"
"$HOST_BIN" node firewall --controller --controller-ip "${NODE_EXTERNAL_IP:-$NODE_IP}" --peers "$WORKER_PEERS" --pod-cidr "$POD_CIDR" --node-port "$FELIS_PANEL_NODEPORT" --control-namespace "$CONTROL_NS" --namespace "$MINECRAFT_NS"
fi
# After build_image imported the felis image: the registry pod's gate runs it. # After build_image imported the felis image: the registry pod's gate runs it.
pin_platform_images pin_platform_images
# Before build_game_stack: the builds user servers run must be read off the # Before build_game_stack: the builds user servers run must be read off the
@@ -6404,7 +5994,6 @@ main() {
# move, the database is the host PostgreSQL an earlier release installed. # move, the database is the host PostgreSQL an earlier release installed.
migrate_host_postgres migrate_host_postgres
run_migrations run_migrations
install_node_control_service
deploy_bundle deploy_bundle
# AFTER deploy_bundle: the registry the built images are mirrored into is part # AFTER deploy_bundle: the registry the built images are mirrored into is part
# of that bundle. # of that bundle.
@@ -6425,7 +6014,6 @@ main() {
install_watchdog_timer install_watchdog_timer
mark_bootstrap_done mark_bootstrap_done
summary summary
start_setup_console
} }
main "$@" main "$@"
+31 -664
View File
@@ -951,79 +951,6 @@ case "$out" in
*) echo "PASS a restart of Docker is not checked again" ;; *) echo "PASS a restart of Docker is not checked again" ;;
esac esac
# --- Docker holds no memory between builds -------------------------------------------------
# Docker serves the installer's builds and nothing at runtime. The one it installed does not
# start at boot, and once a step is done the daemon stops, and its containerd with it unless
# something besides Docker keeps a namespace there.
sdblock="$(awk '/^stop_docker\(\) \{/,/^}/' "$BS")"
[ -n "$sdblock" ] || { echo "FAIL: no stop_docker found in $BS"; exit 1; }
run_sd() { # DOCKER_INSTALLED namespaces-listed|FAIL
DOCKER_INSTALLED="$1" NS="$2" bash -c '
set -Eeuo pipefail
systemctl() { echo "SYSTEMCTL $*"; }
# Only the host containerd answers; k3s runs its own on another socket.
ctr() {
[ "$*" = "--address /run/containerd/containerd.sock namespaces ls -q" ] || { echo "CTR $*"; return 1; }
[ "$NS" != FAIL ] || return 1
printf "%b" "$NS"
}
'"$sdblock"'
stop_docker'
}
expect "a containerd serving Docker alone stops with it" "SYSTEMCTL stop docker docker.socket
SYSTEMCTL stop containerd" "$(run_sd 1 'moby\nmoby_history\n')"
out="$(run_sd 1 'default\nmoby\n')"
case "$out" in
*"stop containerd"*) echo "FAIL a containerd something else uses was stopped"; fails=$((fails + 1)) ;;
*"SYSTEMCTL stop docker docker.socket"*) echo "PASS a containerd with another tenant keeps running" ;;
*) echo "FAIL Docker was not stopped: $out"; fails=$((fails + 1)) ;;
esac
out="$(run_sd 1 FAIL)"
case "$out" in
*"stop containerd"*) echo "FAIL a containerd that could not be asked was stopped"; fails=$((fails + 1)) ;;
*"SYSTEMCTL stop docker docker.socket"*) echo "PASS a containerd that cannot be asked is left alone" ;;
*) echo "FAIL Docker was not stopped: $out"; fails=$((fails + 1)) ;;
esac
out="$(run_sd "" 'moby\n')"
[ -z "$out" ] && echo "PASS a Docker this run never started is left alone" \
|| { echo "FAIL a Docker this run never started was touched: $out"; fails=$((fails + 1)); }
idblock="$(awk '/^install_docker\(\) \{/,/^}/' "$BS")"
[ -n "$idblock" ] || { echo "FAIL: no install_docker found in $BS"; exit 1; }
run_id() { # docker-on-PATH(0|1) containerd-on-PATH(0|1)
fb="$(mktemp -d)"
for tool in docker containerd; do
{ [ "$tool" = docker ] && [ "$1" = 1 ]; } || { [ "$tool" = containerd ] && [ "$2" = 1 ]; } || continue
printf '#!/bin/sh\n' > "$fb/$tool"
chmod +x "$fb/$tool"
done
FB="$fb" bash -c '
set -Eeuo pipefail
PATH="$FB"
ok() { printf "OK: %s\n" "$*"; }; die() { printf "DIE: %s\n" "$*"; exit 1; }
systemctl() { echo "SYSTEMCTL $*"; }
install_docker_apt() { echo "INSTALL apt"; }
PKG=apt
'"$idblock"'
install_docker'
rm -rf "$fb"
}
out="$(run_id 0 0)"
expect "a Docker the installer brings is started without a place at boot" "INSTALL apt
SYSTEMCTL disable docker.service docker.socket
SYSTEMCTL disable containerd.service
SYSTEMCTL start docker" "$out"
case "$out" in *enable*) echo "FAIL the installed Docker was enabled at boot"; fails=$((fails + 1)) ;; *) echo "PASS the installed Docker is not enabled at boot" ;; esac
out="$(run_id 0 1)"
expect "a Docker installed beside an existing containerd leaves that containerd's boot alone" "INSTALL apt
SYSTEMCTL disable docker.service docker.socket
SYSTEMCTL start docker" "$out"
out="$(run_id 1 1)"
expect "a Docker already on the host is only started" "OK: docker already installed
SYSTEMCTL start docker" "$out"
case "$out" in *disable*|*enable*) echo "FAIL the host's own Docker had its boot changed"; fails=$((fails + 1)) ;; *) echo "PASS the host's own Docker keeps its boot setting" ;; esac
# --- a release binary is hashed against SHA256SUMS before anything runs it --------------- # --- a release binary is hashed against SHA256SUMS before anything runs it ---------------
# download_release_binary executes the asset as root to read its version stamp, so the # download_release_binary executes the asset as root to read its version stamp, so the
# checksum has to come first, and every failure has to fall back to the source build. # checksum has to come first, and every failure has to fall back to the source build.
@@ -1085,10 +1012,9 @@ dsum="$(sha256sum <"$ddir/asset" | cut -d' ' -f1)"
# command inside fetch_source. The log lands in $ddir/log; stdout says what the caller did. # command inside fetch_source. The log lands in $ddir/log; stdout says what the caller did.
run_download() { run_download() {
rm -f "$ddir/bin/felis" rm -f "$ddir/bin/felis"
if [ "${ALREADY_INSTALLED:-0}" = 1 ]; then cp "$ddir/asset" "$ddir/bin/felis"; chmod +x "$ddir/bin/felis"; fi
: > "$ddir/log" : > "$ddir/log"
SUMS="$1" ENTRY="${2:-acquire_felis_binary}" ASSET="$ddir/asset" LOG="$ddir/log" FELIS_REF=v9.9.9 \ SUMS="$1" ENTRY="${2:-acquire_felis_binary}" ASSET="$ddir/asset" LOG="$ddir/log" FELIS_REF=v9.9.9 \
HOST_BIN="$ddir/bin/felis" TMPDIR="$sdir" FELIS_FORCE_UPDATE="${FELIS_FORCE_UPDATE:-0}" INSTALL_FAILS="${INSTALL_FAILS:-}" FETCH_FAILS="${FETCH_FAILS:-}" bash -c ' HOST_BIN="$ddir/bin/felis" TMPDIR="$sdir" INSTALL_FAILS="${INSTALL_FAILS:-}" FETCH_FAILS="${FETCH_FAILS:-}" bash -c '
set -Eeuo pipefail set -Eeuo pipefail
ok() { printf "OK: %s\n" "$*" >> "$LOG"; } ok() { printf "OK: %s\n" "$*" >> "$LOG"; }
warn() { printf "WARN: %s\n" "$*" >> "$LOG"; } warn() { printf "WARN: %s\n" "$*" >> "$LOG"; }
@@ -1144,15 +1070,6 @@ same_log "the release binary is hashed before it is first executed" "$(printf '%
"OK: installed felis-linux-amd64 v9.9.9 at $ddir/bin/felis")" "OK: installed felis-linux-amd64 v9.9.9 at $ddir/bin/felis")"
if cmp -s "$ddir/asset" "$ddir/bin/felis"; then echo "PASS the installed binary is the download"; else echo "FAIL the installed binary is not the download"; fails=$((fails + 1)); fi if cmp -s "$ddir/asset" "$ddir/bin/felis"; then echo "PASS the installed binary is the download"; else echo "FAIL the installed binary is not the download"; fails=$((fails + 1)); fi
out="$(ALREADY_INSTALLED=1 run_download "")"
same_out "the matching host binary skips download" "PREBUILT[1]" $?
out="$(FELIS_FORCE_UPDATE=1 ALREADY_INSTALLED=1 run_download "$(printf '%s felis-linux-amd64\n' "$dsum")")"
same_out "force reinstalls the matching verified binary" "PREBUILT[1]" $?
same_log "force verifies the download before executing it" "$(printf '%s\n' \
"OK: felis-linux-amd64 matches release v9.9.9's SHA256SUMS" \
"RAN: version" \
"OK: installed felis-linux-amd64 v9.9.9 at $ddir/bin/felis")"
out="$(run_download "$(printf '%s felis-linux-amd64\n' deadbeef)")" out="$(run_download "$(printf '%s felis-linux-amd64\n' deadbeef)")"
same_out "a mismatched release binary asks for the source build" "FETCH_SOURCE same_out "a mismatched release binary asks for the source build" "FETCH_SOURCE
PREBUILT[]" $? PREBUILT[]" $?
@@ -1247,31 +1164,19 @@ kblock="$(awk '/^run_k3s_installer\(\) \{/,/^}/' "$BS")"
expect "k3s's install script comes from the pinned tag" 'raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh' "$kblock" expect "k3s's install script comes from the pinned tag" 'raw.githubusercontent.com/k3s-io/k3s/${FELIS_K3S_VERSION}/install.sh' "$kblock"
expect "k3s's install script is told the pinned version" 'INSTALL_K3S_VERSION="$FELIS_K3S_VERSION"' "$kblock" expect "k3s's install script is told the pinned version" 'INSTALL_K3S_VERSION="$FELIS_K3S_VERSION"' "$kblock"
case "$kblock" in *"https://get.k3s.io"*) echo "FAIL: get.k3s.io serves master's script; read it from the pinned tag"; fails=$((fails + 1)) ;; esac case "$kblock" in *"https://get.k3s.io"*) echo "FAIL: get.k3s.io serves master's script; read it from the pinned tag"; fails=$((fails + 1)) ;; esac
run_kinst() { # [--keep-binary]
bash -c '
curl() { :; }
sh() { printf "SKIP=[%s] VERSION=%s\n" "$INSTALL_K3S_SKIP_DOWNLOAD" "$INSTALL_K3S_VERSION"; }
FELIS_K3S_VERSION=v1.36.4+k3s1 K3S_BIN_DIR=/usr/local/bin
'"$kblock"'
run_k3s_installer "$@"' _ "$@"
}
expect "finishing a cut-short k3s install keeps the binary in place" "SKIP=[binary] VERSION=v1.36.4+k3s1" "$(run_kinst --keep-binary)"
expect "an install or an upgrade downloads k3s" "SKIP=[] VERSION=v1.36.4+k3s1" "$(run_kinst)"
# An installed k3s moves only under FELIS_UPGRADE_DEPS=1, one minor version at a time and # An installed k3s moves only under FELIS_UPGRADE_DEPS=1, one minor version at a time and
# never backwards; the refusal names the release to go through first. # never backwards; the refusal names the release to go through first.
kfake="$sdir/k3s" kfake="$sdir/k3s"
: > "$sdir/k3s.service"
run_k3s() { # installed-version pinned-version [FELIS_UPGRADE_DEPS] run_k3s() { # installed-version pinned-version [FELIS_UPGRADE_DEPS]
printf '#!/bin/sh\necho "k3s version %s (0123abcd)"\necho "go version go1.26"\n' "$1" > "$kfake" printf '#!/bin/sh\necho "k3s version %s (0123abcd)"\necho "go version go1.26"\n' "$1" > "$kfake"
chmod +x "$kfake" chmod +x "$kfake"
K3S_BIN="$kfake" K3S_UNIT_FILE="$sdir/k3s.service" FELIS_K3S_VERSION="$2" FELIS_UPGRADE_DEPS="${3:-0}" bash -c ' K3S_BIN="$kfake" FELIS_K3S_VERSION="$2" FELIS_UPGRADE_DEPS="${3:-0}" bash -c '
die() { printf "DIE: %s\n" "$*"; exit 1; } die() { printf "DIE: %s\n" "$*"; exit 1; }
log() { printf "LOG: %s\n" "$*"; } log() { printf "LOG: %s\n" "$*"; }
ok() { printf "OK: %s\n" "$*"; } ok() { printf "OK: %s\n" "$*"; }
configure_k3s_firewall() { :; } configure_k3s_firewall() { :; }
write_k3s_config() { :; } write_k3s_config() { :; }
write_k3s_service_dropin() { :; }
strip_k3s_kubeconfig_mode_flag() { :; } strip_k3s_kubeconfig_mode_flag() { :; }
run_k3s_installer() { printf "INSTALLER: %s\n" "$FELIS_K3S_VERSION"; } run_k3s_installer() { printf "INSTALLER: %s\n" "$FELIS_K3S_VERSION"; }
stage_k3s_airgap_images() { echo STAGE; } stage_k3s_airgap_images() { echo STAGE; }
@@ -1326,16 +1231,12 @@ expect "git never prompts without a token" "GIT: prompt=0" "$(run_git_auth '')"
expect "git never prompts with a token" "GIT: prompt=0" "$(run_git_auth ghp_example)" expect "git never prompts with a token" "GIT: prompt=0" "$(run_git_auth ghp_example)"
fblock="$(awk '/^fetch_source\(\) \{/,/^}/' "$BS")" fblock="$(awk '/^fetch_source\(\) \{/,/^}/' "$BS")"
cblock="$(awk '/^checkout_ref\(\) \{/,/^}/' "$BS")" [ -n "$fblock" ] || { echo "FAIL: no fetch_source found in $BS"; exit 1; }
hblock="$(awk '/^repo_slug\(\) \{/,/^}/; /^fork_token_hint\(\) \{/,/^}/' "$BS")" [ "$(printf '%s\n' "$fblock" | wc -l)" -lt 40 ] \
for blk in "$fblock" "$cblock" "$hblock"; do || { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; }
[ -n "$blk" ] || { echo "FAIL: fetch_source, checkout_ref or fork_token_hint is missing from $BS"; exit 1; }
[ "$(printf '%s\n' "$blk" | wc -l)" -lt 40 ] \
|| { echo "FAIL: an extracted block is not the function -- did its closing brace move?"; exit 1; }
done
run_fetch() { # src-dir repo-url run_fetch() { # src-dir
SRC_DIR="$1" FELIS_REF=main FELIS_REPO_URL="$2" bash -c ' SRC_DIR="$1" FELIS_REF=main FELIS_REPO_URL=https://example.invalid/felis.git bash -c '
die() { printf "DIE: %s\n" "$*"; exit 1; } die() { printf "DIE: %s\n" "$*"; exit 1; }
log() { :; } log() { :; }
ok() { :; } ok() { :; }
@@ -1343,95 +1244,14 @@ run_fetch() { # src-dir repo-url
stamp_version() { :; } stamp_version() { :; }
git_auth() { return 128; } git_auth() { return 128; }
git() { :; } git() { :; }
'"$hblock"'
'"$cblock"'
'"$fblock"' '"$fblock"'
fetch_source' fetch_source'
} }
expect "a failed clone from a fork names the token" "set FELIS_GITHUB_TOKEN" \ expect "a failed clone names the token" "set FELIS_GITHUB_TOKEN" "$(run_fetch "$sdir/src")"
"$(run_fetch "$sdir/src" https://github.com/someone/felis.git)"
out="$(run_fetch "$sdir/src" https://github.com/FelisMC/Felis.git)"
expect "a failed clone from the official repository says what to check" "check that this ref exists" "$out"
case "$out" in
*private*|*FELIS_GITHUB_TOKEN*) echo "FAIL the official repository is public, so no token is asked for: $out"; fails=$((fails + 1)) ;;
*) echo "PASS the official repository is public, so no token is asked for" ;;
esac
mkdir -p "$sdir/src/.git" mkdir -p "$sdir/src/.git"
expect "a failed fetch into an existing checkout of a fork names the token" "set FELIS_GITHUB_TOKEN" \ expect "a failed fetch into an existing checkout names the token" "set FELIS_GITHUB_TOKEN" \
"$(run_fetch "$sdir/src" https://github.com/someone/felis.git)" "$(run_fetch "$sdir/src")"
# --- a rerun takes over whatever sits at SRC_DIR, and an abbreviated commit id ----------------
# Real git against a local repository: git clone refuses a non-empty destination, so a tree
# staged for FELIS_SKIP_FETCH (or left by an interrupted clone) used to stop the rerun, and a
# short sha is no ref a server answers a shallow fetch for.
gdir="$(mktemp -d)"
trap 'rm -f "$jar" "$sfn"; rm -rf "$vdir" "$sdir" "$smtp_dir" "$gdir"' EXIT
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
git init -q -b main "$gdir/up"
for v in one two; do
echo "$v" >"$gdir/up/a.txt"
git -C "$gdir/up" add a.txt
git -C "$gdir/up" -c user.name=t -c user.email=[email protected] commit -q -m "$v"
done
first="$(git -C "$gdir/up" rev-parse --short=7 HEAD~1)"
second="$(git -C "$gdir/up" rev-parse --short=7 HEAD)"
run_real_fetch() { # src-dir ref
SRC_DIR="$1" FELIS_REF="$2" FELIS_REPO_URL="file://$gdir/up" FELIS_GITHUB_TOKEN="" bash -c '
set -Eeuo pipefail
die() { printf "DIE: %s\n" "$*"; exit 1; }
log() { :; }
ok() { :; }
resolve_install_ref() { :; }
stamp_version() { :; }
'"$gablock"'
'"$hblock"'
'"$cblock"'
'"$fblock"'
fetch_source
printf "AT %s %s | %s\n" "$(git -C "$SRC_DIR" rev-parse --short=7 HEAD)" "$(cat "$SRC_DIR/a.txt")" "$(ls -A "$SRC_DIR" | tr "\n" " ")"' 2>&1
}
mkdir -p "$gdir/src"
echo staged >"$gdir/src/staged.txt"
out="$(run_real_fetch "$gdir/src" main)"
expect "a staged tree without .git is replaced by the checkout" "AT ${second} two" "$out"
case "$out" in
*staged.txt*) echo "FAIL the staged tree's files are gone after the checkout: $out"; fails=$((fails + 1)) ;;
*) echo "PASS the staged tree's files are gone after the checkout" ;;
esac
expect "a rerun over a shallow checkout takes an abbreviated commit id" "AT ${first} one" \
"$(run_real_fetch "$gdir/src" "$first")"
# A run killed outright (no EXIT trap) leaves its half-made checkout beside SRC_DIR.
mkdir -p "$gdir/fresh.new.Ab12Cd"
echo killed >"$gdir/fresh.new.Ab12Cd/a.txt"
expect "a fresh clone takes an abbreviated commit id" "AT ${first} one" \
"$(run_real_fetch "$gdir/fresh" "$first")"
[ ! -e "$gdir/fresh.new.Ab12Cd" ] && echo "PASS and clears the half-made checkout a killed run left" \
|| { echo "FAIL the half-made checkout a killed run left is still there"; fails=$((fails + 1)); }
# A full clone left a local main behind origin's; with the shallow fetch failing, the whole
# history arm must still land on origin's main.
git clone -q "file://$gdir/up" "$gdir/stale"
git -C "$gdir/stale" reset -q --hard HEAD~1
gablock_noshallow="$gablock
git_auth() { case \" \$* \" in *\" --depth \"*) return 1 ;; esac; GIT_TERMINAL_PROMPT=0 git \"\$@\"; }"
gablock_real="$gablock"; gablock="$gablock_noshallow"
expect "the whole-history arm takes origin's branch over a stale local one" "AT ${second} two" \
"$(run_real_fetch "$gdir/stale" main)"
gablock="$gablock_real"
mkdir -p "$gdir/kept"
echo staged >"$gdir/kept/staged.txt"
expect "a ref that does not exist stops the install" "DIE: could not check out nope" \
"$(run_real_fetch "$gdir/kept" nope)"
[ "$(cat "$gdir/kept/staged.txt" 2>/dev/null)" = staged ] \
&& echo "PASS a failed checkout leaves what was there alone" \
|| { echo "FAIL a failed checkout must leave what was there alone"; fails=$((fails + 1)); }
leftover="$(find "$gdir" -maxdepth 1 -name '*.new.*')"
[ -z "$leftover" ] && echo "PASS no half-made checkout is left beside SRC_DIR" \
|| { echo "FAIL a half-made checkout was left behind: $leftover"; fails=$((fails + 1)); }
unset GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM
# --- default install keeps backups, and retention envs reach the renderer ---------------- # --- default install keeps backups, and retention envs reach the renderer ----------------
# A default install must render the world-archive PVC (without one, backup/restore answer an # A default install must render the world-archive PVC (without one, backup/restore answer an
@@ -1440,7 +1260,7 @@ unset GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM
mblock="$(awk '/^ log "rendering \+ applying the control-plane bundle"/,/kube apply -f -/' "$BS")" mblock="$(awk '/^ log "rendering \+ applying the control-plane bundle"/,/kube apply -f -/' "$BS")"
[ -n "$mblock" ] || { echo "FAIL: no manifest_args block found in $BS"; exit 1; } [ -n "$mblock" ] || { echo "FAIL: no manifest_args block found in $BS"; exit 1; }
[ "$(printf '%s\n' "$mblock" | wc -l)" -lt 100 ] \ [ "$(printf '%s\n' "$mblock" | wc -l)" -lt 60 ] \
|| { echo "FAIL: the extracted block is not the manifest_args block -- did it move?"; exit 1; } || { echo "FAIL: the extracted block is not the manifest_args block -- did it move?"; exit 1; }
run_bundle_flags() { # backup-pvc worlds-host-path run_bundle_flags() { # backup-pvc worlds-host-path
@@ -1682,7 +1502,6 @@ run_batch() { # PREBUILT_ROLES [ARTIFACT_MODE [ARTIFACT_CACHE]]
DOCKER_INSTALLED="" DOCKER_INSTALLED=""
systemctl() { printf "SYSTEMCTL %s\n" "$*"; } systemctl() { printf "SYSTEMCTL %s\n" "$*"; }
ensure_docker() { printf "ENSURE\n"; DOCKER_INSTALLED=1; } ensure_docker() { printf "ENSURE\n"; DOCKER_INSTALLED=1; }
ctr() { return 1; }
push_image_to_registry() { printf "PUSH %s\n" "$1"; } push_image_to_registry() { printf "PUSH %s\n" "$1"; }
push_version_tag() { printf "VERSION %s\n" "$1"; } push_version_tag() { printf "VERSION %s\n" "$1"; }
push_release_image() { printf "RELEASE %s\n" "$1"; } push_release_image() { printf "RELEASE %s\n" "$1"; }
@@ -1956,10 +1775,7 @@ expect "a write that fails halfway leaves the old file whole" \
out="$(run_atomic "sync() { return 1; }; write_file_atomic '$wadir/old.env' 0600 < '$wadir/in.replace'")" out="$(run_atomic "sync() { return 1; }; write_file_atomic '$wadir/old.env' 0600 < '$wadir/in.replace'")"
expect "content that did not reach the disk does not replace the old file" "DIE: could not write $wadir/old.env" "$out" expect "content that did not reach the disk does not replace the old file" "DIE: could not write $wadir/old.env" "$out"
expect "the old file survives a failed sync" "DB_PASSWORD=old-and-whole" "$(cat "$wadir/old.env")" expect "the old file survives a failed sync" "DB_PASSWORD=old-and-whole" "$(cat "$wadir/old.env")"
left="" left="$(cd "$wadir" && ls -a | grep '^old\.env\.' || true)"
for p in "$wadir"/old.env.*; do
if [ -e "$p" ]; then left="$left${left:+ }${p##*/}"; fi
done
if [ -z "$left" ]; then if [ -z "$left" ]; then
echo "PASS a failed write leaves no temp file beside the old one" echo "PASS a failed write leaves no temp file beside the old one"
else else
@@ -2042,16 +1858,11 @@ run_write() { # out-file [state-dir] [database-deployment]; under the installer'
[ -z "${CAT_FAILS:-}" ] || cat() { head -c 40; return 1; } [ -z "${CAT_FAILS:-}" ] || cat() { head -c 40; return 1; }
persisted_smtp_block() { :; } persisted_smtp_block() { :; }
persisted_auth_source_blocks() { :; } persisted_auth_source_blocks() { :; }
felis() {
[ "$*" = "bootstrap-assets config-keys" ] || return 2
[ "${OLD_CONFIG:-0}" != 1 ] || return 2
printf "%s\n" velocity.game_version
}
. "$FNFILE" . "$FNFILE"
FELIS_ROOT_DOMAIN=r.example.com DB_USER=u DB_PASSWORD=p DB_NAME=d MINECRAFT_NS=minecraft \ FELIS_ROOT_DOMAIN=r.example.com DB_USER=u DB_PASSWORD=p DB_NAME=d MINECRAFT_NS=minecraft \
FELIS_EGRESS_MODE=nodeport FELIS_LIMBO_IMAGE=li FELIS_LOBBY_IMAGE=lo FELIS_GAME_PORT=25570 MC_VERSION=26.3 \ FELIS_EGRESS_MODE=nodeport FELIS_LIMBO_IMAGE=li FELIS_LOBBY_IMAGE=lo FELIS_GAME_PORT=25570 \
REGISTRY_URL=registry.felis.svc:5000 BUILD_NS=felis-build FELIS_ARCHIVE_LOCAL_PATH=/a \ REGISTRY_URL=registry.felis.svc:5000 BUILD_NS=felis-build FELIS_ARCHIVE_LOCAL_PATH=/a \
HOST_BIN=felis FELIS_OFFSITE_BUCKET= write_felis_toml "$OUT_TOML" 127.0.0.1:15432 "$DEPLOY"' FELIS_OFFSITE_BUCKET= write_felis_toml "$OUT_TOML" 127.0.0.1:15432 "$DEPLOY"'
} }
run_write "$rdir/out.toml" run_write "$rdir/out.toml"
@@ -2090,14 +1901,6 @@ expect "a re-run carries the scheduled backup count" 'scheduled_keep = 14' "$out
expect "a re-run carries the scheduled backup retention" 'scheduled_retention = "45d"' "$out" expect "a re-run carries the scheduled backup retention" 'scheduled_retention = "45d"' "$out"
expect "the archive mount stays installer-owned" 'local_path = "/a"' "$out" expect "the archive mount stays installer-owned" 'local_path = "/a"' "$out"
expect "the panel learns the public game port" 'game_port = 25570' "$out" expect "the panel learns the public game port" 'game_port = 25570' "$out"
expect "the panel learns the built login protocol" 'game_version = "26.3"' "$out"
(OLD_CONFIG=1 run_write "$rdir/release.toml")
release_config="$(cat "$rdir/release.toml")"
case "$release_config" in
*game_version*) echo "FAIL an older binary must not receive velocity.game_version"; fails=$((fails + 1)) ;;
*) echo "PASS an older binary receives no unsupported game-version key" ;;
esac
expect "an older binary still receives the public game port" 'game_port = 25570' "$release_config"
expect "a re-run keeps the off-site bucket, set apart from the next section" '[offsite] expect "a re-run keeps the off-site bucket, set apart from the next section" '[offsite]
endpoint = "https://objects.example" endpoint = "https://objects.example"
bucket = "felis-offsite" bucket = "felis-offsite"
@@ -2258,8 +2061,7 @@ else
fi fi
# A re-run that writes the same felis-link.properties leaves the file alone: felis domain # A re-run that writes the same felis-link.properties leaves the file alone: felis domain
# check reads a proxy started before the file's mtime as still on the old names. stat answers # check reads a proxy started before the file's mtime as still on the old names.
# as for the file the first install left, root:v 0640, which the test's user cannot make.
run_link() { # velocity-dir [root-domain] run_link() { # velocity-dir [root-domain]
VD="$1" RD="${2:-r.example.com}" TMPDIR="$1" FNFILE="$fnfile" bash -c ' VD="$1" RD="${2:-r.example.com}" TMPDIR="$1" FNFILE="$fnfile" bash -c '
set -Eeuo pipefail set -Eeuo pipefail
@@ -2268,7 +2070,6 @@ run_link() { # velocity-dir [root-domain]
prepare_velocity_layout() { :; } prepare_velocity_layout() { :; }
atomic_install_file() { echo "REPLACED $(basename "$2")"; cp "$1" "$2"; } atomic_install_file() { echo "REPLACED $(basename "$2")"; cp "$1" "$2"; }
chown() { echo "CHOWN $*"; }; chmod() { echo "CHMOD $*"; } chown() { echo "CHOWN $*"; }; chmod() { echo "CHMOD $*"; }
stat() { echo "root:v 640"; }
. "$FNFILE" . "$FNFILE"
STATE_DIR="$VD" FELIS_ROOT_DOMAIN="$RD" FORWARDING_SECRET=f SERVICE_TOKEN=t LOGIN_SERVER=login \ STATE_DIR="$VD" FELIS_ROOT_DOMAIN="$RD" FORWARDING_SECRET=f SERVICE_TOKEN=t LOGIN_SERVER=login \
LOBBY_SERVER=lobby FELIS_GAME_PORT=25565 VELOCITY_DIR="$VD" VELOCITY_USER=v NODE_IP=10.0.0.5 LOBBY_SERVER=lobby FELIS_GAME_PORT=25565 VELOCITY_DIR="$VD" VELOCITY_USER=v NODE_IP=10.0.0.5
@@ -2285,10 +2086,8 @@ case "$out" in
*"REPLACED felis-link.properties"*) echo "FAIL: a re-run with the same names replaced felis-link.properties"; fails=$((fails + 1)) ;; *"REPLACED felis-link.properties"*) echo "FAIL: a re-run with the same names replaced felis-link.properties"; fails=$((fails + 1)) ;;
*) echo "PASS a re-run with the same names leaves felis-link.properties in place" ;; *) echo "PASS a re-run with the same names leaves felis-link.properties in place" ;;
esac esac
case "$out" in expect "the re-run still fixes the owner" "CHOWN root:v $lprops" "$out"
*CHOWN*|*CHMOD*) echo "FAIL: the re-run changed felis-link.properties by path:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;; expect "the re-run still fixes the mode" "CHMOD 0640 $lprops" "$out"
*) echo "PASS the re-run changes nothing by path" ;;
esac
expect "the kept file keeps its mtime" "$before" "$(ls -l --time-style=+%s "$lprops" 2>/dev/null || stat -f '%m' "$lprops")" expect "the kept file keeps its mtime" "$before" "$(ls -l --time-style=+%s "$lprops" 2>/dev/null || stat -f '%m' "$lprops")"
expect "a re-run on other names replaces felis-link.properties" "REPLACED felis-link.properties" "$(run_link "$ldir2" other.example.net)" expect "a re-run on other names replaces felis-link.properties" "REPLACED felis-link.properties" "$(run_link "$ldir2" other.example.net)"
expect "the replaced file has the new root domain" "root-domain=other.example.net" "$(grep '^root-domain=' "$lprops")" expect "the replaced file has the new root domain" "root-domain=other.example.net" "$(grep '^root-domain=' "$lprops")"
@@ -2547,115 +2346,10 @@ case "$(awk '/^validate_settings\(\) \{/,/^}/' "$BS")" in
*) echo "FAIL validate_settings must call validate_heartbeat_url"; fails=$((fails + 1)) ;; *) echo "FAIL validate_settings must call validate_heartbeat_url"; fails=$((fails + 1)) ;;
esac esac
case "$(awk '/^summary\(\) \{/,/^}/' "$BS")" in case "$(awk '/^summary\(\) \{/,/^}/' "$BS")" in
*summary_offsite*summary_alerts*summary_heartbeat*summary_next*) echo "PASS the install's summary ends on the alerts, the heartbeat, then the next step" ;; *summary_offsite*summary_alerts*summary_heartbeat*) echo "PASS the install's summary ends on the alerts, then the heartbeat" ;;
*) echo "FAIL summary must call summary_alerts, summary_heartbeat, then summary_next"; fails=$((fails + 1)) ;; *) echo "FAIL summary must call summary_alerts, then summary_heartbeat"; fails=$((fails + 1)) ;;
esac esac
# --- the installer's last word: what the operator does next ------------------------------
# An install that leaves no Owner ends on `felis setup`, and starts it when there is a
# terminal for it; one with an Owner ends on where to sign in. The setup console must never
# start into a log (`| tee`, cloud-init, CI), and its own failure must not fail the install.
case "$(awk '/^main\(\) \{/,/^}/' "$BS" | tail -n 3)" in
" summary
start_setup_console
}") echo "PASS the full install ends on the summary, then the setup console" ;;
*) echo "FAIL main must end with summary, then start_setup_console"; fails=$((fails + 1)) ;;
esac
nextblock=""
for fn in bootstrap_from_tui resume_package_background_timers owner_state setup_terminal summary_next start_setup_console; do
# A one-line function ends on its own line.
b="$(awk -v fn="$fn" '$0 ~ "^" fn "\\(\\) \\{" { print; if (/\}$/) exit; on = 1; next } on { print } on && /^}/ { exit }' "$BS")"
[ -n "$b" ] || { echo "FAIL: no ${fn} found in $BS"; exit 1; }
[ "$(printf '%s\n' "$b" | wc -l)" -lt 40 ] \
|| { echo "FAIL: the extracted block is not ${fn} -- did its closing brace move?"; exit 1; }
nextblock="${nextblock}${b}
"
done
next_dir="$(mktemp -d)"
printf 'keys from the terminal\n' > "$next_dir/tty"
cat > "$next_dir/felis" <<'XEOF'
#!/bin/sh
echo "felis $*" >> "$NEXT_JOURNAL"
if (: >&9) 2>/dev/null; then echo "fd 9 open" >> "$NEXT_JOURNAL"; fi
cat > "$NEXT_STDIN"
exit "${SETUP_EXIT:-0}"
XEOF
chmod +x "$next_dir/felis"
# run_next PG_ANSWER: summary_next then start_setup_console under the installer's own shell
# options and ERR trap. PG_ANSWER is what psql prints, or "fail". TERMINAL=1 stands in for
# a terminal on stdout, which a test's captured output never is.
run_next() {
: > "$next_dir/journal"
: > "$next_dir/stdin"
PG_ANSWER="$1" NEXT_JOURNAL="$next_dir/journal" NEXT_STDIN="$next_dir/stdin" NEXT_LOCK="$next_dir/lock" \
HOST_BIN="$next_dir/felis" SETUP_TTY="$next_dir/tty" bash -c '
set -Eeuo pipefail
trap "echo TRAP" ERR
exec 9>"$NEXT_LOCK" # the run lock acquire_run_lock holds
log() { printf "LOG: %s\n" "$*"; }; warn() { printf "WARN: %s\n" "$*"; }
auth_hostname() { printf "%s" "$2"; }
pg_exec() { echo "pg_exec $*" >> "$NEXT_JOURNAL"; [ "$PG_ANSWER" != fail ] || return 1; echo "$PG_ANSWER"; }
systemctl() { echo "systemctl $*" >> "$NEXT_JOURNAL"; }
DB_NAME=felis FELIS_ROOT_DOMAIN=example.net NODE_IP=10.0.0.5 FELIS_PANEL_NODEPORT=30443 FELIS_GAME_PORT=25565
SETUP_CONSOLE=0
PKG_TIMERS_TO_RESTORE=(apt-daily.timer)
'"$nextblock"'
if [ "${TERMINAL:-}" = 1 ]; then setup_terminal() { return 0; }; fi
summary_next
start_setup_console
echo "exit 0"' 2>&1
}
started() { grep -q '^felis setup$' "$next_dir/journal"; }
out="$(TERMINAL=1 run_next t)"
expect "with an Owner the summary ends on where to sign in" "LOG: Felis is running. Sign in at https://op.console.example.net" "$out"
expect " and where to change settings" "LOG: Email, edge and storage settings: sudo felis setup" "$out"
if started; then echo "FAIL with an Owner the setup console must not start: $out"; fails=$((fails + 1)); else echo "PASS and the setup console does not start"; fi
admin_roles="$(grep -o "role IN ('admin', 'owner')" "$(dirname "$BS")/../internal/api/pgrepo.go" | head -n 1)"
expect "the Owner test is felis setup's own (AdminExists)" "${admin_roles:-AdminExists query not found}" "$(cat "$next_dir/journal")"
out="$(TERMINAL=1 run_next f)"
expect "with no Owner on a terminal the summary says the setup console starts" "LOG: Next: create the Owner account. The setup console starts now" "$out"
expect " and how it binds the Owner" "has you join 10.0.0.5:25565 in Minecraft" "$out"
if started; then echo "PASS and starts it"; else echo "FAIL the setup console did not start: $out"; fails=$((fails + 1)); fi
expect " on the terminal's keys" "keys from the terminal" "$(cat "$next_dir/stdin")"
# felis setup can run the installer itself (its host bootstrap step), which would take an
# inherited run lock for a second installer run.
case "$(cat "$next_dir/journal")" in
*"fd 9 open"*) echo "FAIL the setup console must not inherit the installer's run lock"; fails=$((fails + 1)) ;;
*) echo "PASS without the installer's run lock" ;;
esac
case "$(cat "$next_dir/journal")" in
*"systemctl start apt-daily.timer"*"felis setup"*) echo "PASS after the package timers are back" ;;
*) echo "FAIL the package timers must restart before the setup console: $(cat "$next_dir/journal")"; fails=$((fails + 1)) ;;
esac
expect " and the install ends cleanly" "exit 0" "$out"
out="$(run_next f)"
expect "with no Owner and no terminal the summary names the command" "LOG: sudo felis setup" "$out"
if started; then echo "FAIL with no terminal the setup console must not start: $out"; fails=$((fails + 1)); else echo "PASS and the setup console does not start into the log"; fi
out="$(TERMINAL=1 FELIS_NO_SETUP=1 run_next f)"
expect "FELIS_NO_SETUP names the command" "LOG: sudo felis setup" "$out"
if started; then echo "FAIL FELIS_NO_SETUP must keep the setup console closed: $out"; fails=$((fails + 1)); else echo "PASS and keeps the setup console closed"; fi
out="$(TERMINAL=1 run_next fail)"
expect "a database that does not answer names the command" "LOG: sudo felis setup" "$out"
if started; then echo "FAIL an unknown Owner must not start the setup console: $out"; fails=$((fails + 1)); else echo "PASS and the setup console does not start"; fi
out="$(TERMINAL=1 FELIS_BOOTSTRAP_FROM_TUI=1 run_next f)"
expect "under felis setup the console carries on" "LOG: Returning to the setup console to create the Owner account" "$out"
if started; then echo "FAIL under felis setup a second console must not start: $out"; fails=$((fails + 1)); else echo "PASS and no second console starts"; fi
out="$(TERMINAL=1 SETUP_EXIT=3 run_next f)"
expect "a setup console that fails says how to come back" "WARN: the setup console exited with status 3; run 'sudo felis setup' to come back to it" "$out"
expect " and the install still succeeds" "exit 0" "$out"
case "$out" in
*TRAP*) echo "FAIL the setup console's failure must not reach the ERR trap: $out"; fails=$((fails + 1)) ;;
*) echo "PASS without the ERR trap" ;;
esac
rm -rf "$next_dir"
# The watchdog alerts by mail only, through the [smtp] relay. An install without one must # The watchdog alerts by mail only, through the [smtp] relay. An install without one must
# say that its alerts are only logged; one with a relay must not cry wolf. # say that its alerts are only logged; one with a relay must not cry wolf.
sablock="$(awk '/^summary_alerts\(\) \{/,/^}/' "$BS")" sablock="$(awk '/^summary_alerts\(\) \{/,/^}/' "$BS")"
@@ -3120,12 +2814,7 @@ run_velocity_service() { # is-active(0|1) [heap]
} }
out="$(run_velocity_service 1)" out="$(run_velocity_service 1)"
expect "a proxy with no recorded start is restarted" "SYSTEMCTL restart felis-velocity" "$out" expect "a proxy with no recorded start is restarted" "SYSTEMCTL restart felis-velocity" "$out"
expect "the default 1G ceiling runs the serial collector and C1 from a 16M start" \ expect "the default heap is 512M..1G" "java -Xms512M -Xmx1G " "$(cat "$vdir/unit")"
"java -Xms16M -Xmx1G -XX:+UseSerialGC -XX:TieredStopAtLevel=1 -Dmojang" "$(cat "$vdir/unit")"
case "$(cat "$vdir/unit")" in
*AlwaysPreTouch*|*UseG1GC*) echo "FAIL a 1G proxy pre-touches its heap or runs G1: $(grep ExecStart "$vdir/unit")"; fails=$((fails + 1)) ;;
*) echo "PASS a 1G proxy neither pre-touches its heap nor runs G1" ;;
esac
[ -s "$vdir/fp" ] && echo "PASS the restart records what the proxy runs" \ [ -s "$vdir/fp" ] && echo "PASS the restart records what the proxy runs" \
|| { echo "FAIL no fingerprint was recorded after the restart"; fails=$((fails + 1)); } || { echo "FAIL no fingerprint was recorded after the restart"; fails=$((fails + 1)); }
out="$(run_velocity_service 1)" out="$(run_velocity_service 1)"
@@ -3140,19 +2829,9 @@ expect "a stopped proxy is started whatever the fingerprint" "SYSTEMCTL restart
printf 'JAVA_VERSION="25.0.1"\n' > "$vdir/jre/release" printf 'JAVA_VERSION="25.0.1"\n' > "$vdir/jre/release"
expect "a patched JRE restarts the proxy" "SYSTEMCTL restart felis-velocity" "$(run_velocity_service 1)" expect "a patched JRE restarts the proxy" "SYSTEMCTL restart felis-velocity" "$(run_velocity_service 1)"
expect "a new heap size restarts the proxy" "SYSTEMCTL restart felis-velocity" "$(run_velocity_service 1 3G)" expect "a new heap size restarts the proxy" "SYSTEMCTL restart felis-velocity" "$(run_velocity_service 1 3G)"
expect "a ceiling above 1G runs G1, whose periodic collection hands idle growth back" \ expect "the unit carries the new ceiling" "java -Xms512M -Xmx3G " "$(cat "$vdir/unit")"
"java -Xms64M -Xmx3G -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:G1PeriodicGCInterval=60000 -Dmojang" "$(cat "$vdir/unit")"
case "$(cat "$vdir/unit")" in
*AlwaysPreTouch*|*UseSerialGC*|*TieredStopAtLevel*) echo "FAIL a 3G proxy carries a small proxy's flags or pre-touches: $(grep ExecStart "$vdir/unit")"; fails=$((fails + 1)) ;;
*) echo "PASS a 3G proxy keeps G1 and both compilers" ;;
esac
run_velocity_service 1 1024M >/dev/null
expect "1024M is still a small proxy" "java -Xms16M -Xmx1024M -XX:+UseSerialGC " "$(cat "$vdir/unit")"
run_velocity_service 1 1025M >/dev/null
expect "a megabyte past 1G is a large one" "java -Xms64M -Xmx1025M -XX:+UseG1GC " "$(cat "$vdir/unit")"
run_velocity_service 1 384M >/dev/null run_velocity_service 1 384M >/dev/null
expect "a small ceiling runs the small proxy's flags" \ expect "a ceiling below 512M is also the initial heap" "java -Xms384M -Xmx384M " "$(cat "$vdir/unit")"
"java -Xms16M -Xmx384M -XX:+UseSerialGC -XX:TieredStopAtLevel=1 -Dmojang" "$(cat "$vdir/unit")"
# `felis rotate-token velocity` rewrites service-token, which the plugin re-reads by itself: # `felis rotate-token velocity` rewrites service-token, which the plugin re-reads by itself:
# that line alone changing leaves the proxy running, and any other change restarts it. # that line alone changing leaves the proxy running, and any other change restarts it.
props="$vdir/v/plugins/felis-link/felis-link.properties" props="$vdir/v/plugins/felis-link/felis-link.properties"
@@ -3237,96 +2916,6 @@ esac
printf '16\n' > "$pmdir/PG_VERSION" printf '16\n' > "$pmdir/PG_VERSION"
expect "an Arch rerun holds PostgreSQL at the cluster's version" "PACMAN -Syu --noconfirm --ignore postgresql" "$(run_refresh)" expect "an Arch rerun holds PostgreSQL at the cluster's version" "PACMAN -Syu --noconfirm --ignore postgresql" "$(run_refresh)"
rm -rf "$pmdir" rm -rf "$pmdir"
expect "an apt refresh first finishes a dpkg run that was cut off" "FINISH
APT_GET update -y" "$(PKG=apt bash -c '
finish_interrupted_dpkg() { echo FINISH; }
apt_get() { echo "APT_GET $*"; }
'"$rfblock"'
pkg_refresh_once')"
# An install killed halfway through a package leaves dpkg's journal behind, and apt-get then
# refuses everything until `dpkg --configure -a` runs: the rerun runs it.
fdblock="$(awk '/^finish_interrupted_dpkg\(\) \{/,/^}/' "$BS")"
[ -n "$fdblock" ] || { echo "FAIL: no finish_interrupted_dpkg found in $BS"; exit 1; }
[ "$(printf '%s\n' "$fdblock" | wc -l)" -lt 15 ] \
|| { echo "FAIL: the extracted block is not finish_interrupted_dpkg -- did its closing brace move?"; exit 1; }
dpdir="$(mktemp -d)"
run_finish_dpkg() { # $1: what dpkg --audit prints, $2: dpkg --configure's exit status
AUDIT="$1" DPKG_RC="${2:-0}" DPKG_UPDATES_DIR="$dpdir/updates" bash -c '
set -Eeuo pipefail
warn() { printf "WARN: %s\n" "$*"; }; die() { printf "DIE: %s\n" "$*"; exit 1; }
wait_for_pkg_locks() { echo LOCKS; }
dpkg() {
if [ "$1" = --audit ]; then printf "%s" "$AUDIT"; return 0; fi
printf "DPKG: %s (frontend=%s)\n" "$*" "${DEBIAN_FRONTEND:-}"
return "$DPKG_RC"
}
'"$fdblock"'
finish_interrupted_dpkg
echo DONE' 2>&1
}
out="$(run_finish_dpkg "")"
expect "a host without dpkg's journal carries on" "DONE" "$out"
case "$out" in *DPKG:*) echo "FAIL a host without dpkg's journal must not run dpkg --configure: $out"; fails=$((fails + 1)) ;; *) echo "PASS without running dpkg --configure" ;; esac
mkdir -p "$dpdir/updates"
case "$(run_finish_dpkg "")" in *DPKG:*) echo "FAIL an empty dpkg journal must not run dpkg --configure"; fails=$((fails + 1)) ;; *) echo "PASS an empty dpkg journal runs nothing" ;; esac
: > "$dpdir/updates/0007"
out="$(run_finish_dpkg "")"
expect "a dpkg run cut off mid-package is finished first" "LOCKS
DPKG: --force-confdef --force-confold --configure -a (frontend=noninteractive)" "$out"
expect " and the install carries on" "DONE" "$out"
out="$(run_finish_dpkg "" 1)"
expect "a dpkg run that cannot be finished stops the install" "DIE: dpkg --configure -a failed" "$out"
case "$out" in *DONE*) echo "FAIL a dpkg run that cannot be finished must stop the install"; fails=$((fails + 1)) ;; esac
rm -f "$dpdir/updates/0007"
expect "a package dpkg --audit names as half configured is finished too" "DPKG: --force-confdef --force-confold --configure -a" \
"$(run_finish_dpkg "The following packages are only half configured")"
rm -rf "$dpdir"
# A second installer started while one still runs (in tmux after the SSH session dropped)
# stops at once.
alblock="$(awk '/^acquire_run_lock\(\) \{/,/^}/' "$BS")"
[ -n "$alblock" ] || { echo "FAIL: no acquire_run_lock found in $BS"; exit 1; }
[ "$(printf '%s\n' "$alblock" | wc -l)" -lt 15 ] \
|| { echo "FAIL: the extracted block is not acquire_run_lock -- did its closing brace move?"; exit 1; }
case "$(awk '/^main\(\) \{/,/^}/' "$BS" | sed -n 2p)" in
" acquire_run_lock") echo "PASS the installer takes its run lock before anything else" ;;
*) echo "FAIL main must start with acquire_run_lock"; fails=$((fails + 1)) ;;
esac
lkdir="$(mktemp -d)"
run_lock() { # $1: flock's exit status ("" = no flock on the host)
FLOCK_RC="$1" RUN_LOCK_FILE="$lkdir/run.lock" NOPATH="$lkdir/nopath" bash -c '
set -Eeuo pipefail
warn() { printf "WARN: %s\n" "$*"; }; die() { printf "DIE: %s\n" "$*"; exit 1; }
if [ -n "$FLOCK_RC" ]; then
flock() { printf "FLOCK: %s\n" "$*"; if (: >&9) 2>/dev/null; then echo "FD9 OPEN"; fi; return "$FLOCK_RC"; }
else
PATH="$NOPATH"
fi
'"$alblock"'
acquire_run_lock
echo CONTINUES' 2>&1
}
out="$(run_lock 0)"
expect "the installer takes its run lock on the lock file" "FLOCK: -n 9
FD9 OPEN
CONTINUES" "$out"
[ -e "$lkdir/run.lock" ] && echo "PASS which it creates" || { echo "FAIL the run lock file was not created"; fails=$((fails + 1)); }
out="$(run_lock 1)"
expect "a run lock held by another run stops the install" "DIE: another installer run is still going on this host" "$out"
case "$out" in *CONTINUES*) echo "FAIL a second installer run must stop: $out"; fails=$((fails + 1)) ;; esac
expect "a host without flock warns and carries on" "WARN: flock not found" "$(run_lock "")"
if command -v flock >/dev/null 2>&1; then
out="$(flock "$lkdir/held.lock" env RUN_LOCK_FILE="$lkdir/held.lock" bash -c '
die() { printf "DIE: %s\n" "$*"; exit 1; }; warn() { :; }
'"$alblock"'
acquire_run_lock
echo CONTINUES' 2>&1)"
expect "a lock another process holds stops the install (real flock)" "DIE: another installer run" "$out"
else
echo "SKIP a lock another process holds (real flock): flock is not installed here"
fi
rm -rf "$lkdir"
@@ -3340,7 +2929,10 @@ expect "a heap in megabytes is kept" "768" "$(heap 768m)"
for bad in 1 1K 0G 01G G -1G 1.5G 9999999G; do for bad in 1 1K 0G 01G G -1G 1.5G 9999999G; do
expect "the heap spelling '$bad' is refused" "0" "$(heap "$bad")" expect "the heap spelling '$bad' is refused" "0" "$(heap "$bad")"
done done
# The unit written for each ceiling is checked with the rerun tests above. case "$(awk '/^install_velocity_service\(\) \{/,/^}/' "$BS")" in
*'-Xms${xms} -Xmx${xmx} '*) echo "PASS the proxy unit takes its heap from FELIS_VELOCITY_XMX" ;;
*) echo "FAIL the proxy unit's heap is not FELIS_VELOCITY_XMX"; fails=$((fails + 1)) ;;
esac
# --- reproducible image ids --------------------------------------------------------------- # --- reproducible image ids ---------------------------------------------------------------
# restart_existing_system_servers compares image ids across runs; a default BuildKit # restart_existing_system_servers compares image ids across runs; a default BuildKit
@@ -3552,63 +3144,6 @@ out="$(run_k3s_config "$kdir/broken.crt" "$kdir/k3s" renamed-host)"
expect "a certificate openssl cannot parse is a warning, not a failed install" "WARN: could not read this node's k3s name" "$out" expect "a certificate openssl cannot parse is a warning, not a failed install" "WARN: could not read this node's k3s name" "$out"
expect "and the drop-in is still written" 'write-kubeconfig-mode: "0600"' "$(cat "$dropin")" expect "and the drop-in is still written" 'write-kubeconfig-mode: "0600"' "$(cat "$dropin")"
# k3s's Go collector runs at GOGC=50 through a systemd drop-in, written beside itself and
# loaded (with a k3s restart) only when it changed.
svblock="$(awk '/^write_k3s_service_dropin\(\) \{/,/^}/' "$BS")"
[ -n "$svblock" ] || { echo "FAIL: no write_k3s_service_dropin found in $BS"; exit 1; }
svdropin="$kdir/k3s.service.d/50-felis.conf"
run_k3s_service_dropin() {
DROPIN="$svdropin" bash -c '
set -Eeuo pipefail
ok() { echo "OK: $*"; }; log() { echo "LOG: $*"; }
remember_temp() { :; }
restore_label() { echo "RELABEL: $*"; }
systemctl() { echo "SYSTEMCTL: $*"; }
mktemp() { local p; p="$(command mktemp "$@")"; echo "MKTEMP: $(dirname "$p")" >&2; echo "$p"; }
K3S_SERVICE_DROPIN="$DROPIN"
'"$svblock"'
K3S_RESTART_NEEDED=0
write_k3s_service_dropin
echo "RESTART=$K3S_RESTART_NEEDED"' 2>&1
}
out="$(run_k3s_service_dropin)"
if [ "$(cat "$svdropin")" = '# Written by the Felis installer (deploy/bootstrap.sh); a rerun rewrites it.
[Service]
Environment=GOGC=50' ]; then
echo "PASS k3s runs its Go collector at GOGC=50"
else
echo "FAIL the k3s service drop-in is:"; cat "$svdropin"; fails=$((fails + 1))
fi
expect "a new service drop-in is loaded" "SYSTEMCTL: daemon-reload" "$out"
expect "a new service drop-in asks for a k3s restart" "RESTART=1" "$out"
if [ "$(printf '%s\n' "$out" | sed -n 's/^MKTEMP: //p' | sort -u)" = "$kdir/k3s.service.d" ]; then
echo "PASS the service drop-in is made beside itself, never under /tmp"
else
echo "FAIL temporary files for the service drop-in were made in: $(printf '%s\n' "$out" | sed -n 's/^MKTEMP: //p')"; fails=$((fails + 1))
fi
if [ "$(stat -c %a "$svdropin" 2>/dev/null || stat -f %Lp "$svdropin")" = 644 ]; then
echo "PASS the service drop-in is readable like the unit it extends"
else
echo "FAIL the service drop-in must be 0644"; fails=$((fails + 1))
fi
out="$(run_k3s_service_dropin)"
expect "an unchanged service drop-in restarts nothing" "RESTART=0" "$out"
expect "an unchanged service drop-in says so" "OK: k3s service environment already current" "$out"
case "$out" in
*daemon-reload*) echo "FAIL an unchanged service drop-in must not reload systemd"; fails=$((fails + 1)) ;;
*) echo "PASS an unchanged service drop-in reloads nothing" ;;
esac
expect "an unchanged service drop-in is still relabelled" "RELABEL: $svdropin" "$out"
if [ "$(ls "$kdir/k3s.service.d")" = "50-felis.conf" ]; then
echo "PASS no temporary file is left beside the service drop-in"
else
echo "FAIL k3s.service.d holds: $(ls "$kdir/k3s.service.d")"; fails=$((fails + 1))
fi
printf '[Service]\nEnvironment=GOGC=100\n' > "$svdropin"
out="$(run_k3s_service_dropin)"
expect "an edited service drop-in is put back" "Environment=GOGC=50" "$(cat "$svdropin")"
expect "and k3s is restarted onto it" "RESTART=1" "$out"
sblock="$(awk '/^strip_k3s_kubeconfig_mode_flag\(\) \{/,/^}/' "$BS")" sblock="$(awk '/^strip_k3s_kubeconfig_mode_flag\(\) \{/,/^}/' "$BS")"
[ -n "$sblock" ] || { echo "FAIL: no strip_k3s_kubeconfig_mode_flag found in $BS"; exit 1; } [ -n "$sblock" ] || { echo "FAIL: no strip_k3s_kubeconfig_mode_flag found in $BS"; exit 1; }
# ExecStart as k3s's installer writes it (copied off an install made with the old flag). # ExecStart as k3s's installer writes it (copied off an install made with the old flag).
@@ -3669,29 +3204,19 @@ iblock="$(awk '/^install_k3s\(\) \{/,/^}/' "$BS")"
iblock="$iblock iblock="$iblock
$(awk '/^version_newer\(\) \{/,/^}/' "$BS") $(awk '/^version_newer\(\) \{/,/^}/' "$BS")
$(awk '/^k3s_upgrade_allowed\(\) \{/,/^}/' "$BS")" $(awk '/^k3s_upgrade_allowed\(\) \{/,/^}/' "$BS")"
run_install_k3s() { # $1: installed version ("" = none, "broken" = a binary that does not run), $2: config drop-in changed (0|1), $3: FELIS_UPGRADE_DEPS, $4: service drop-in changed (0|1), $5: k3s.service present (1|0) run_install_k3s() { # $1: installed version ("" = none), $2: drop-in changed (0|1), $3: FELIS_UPGRADE_DEPS
INSTALLED="$1" CHANGED="$2" UPGRADE="${3:-0}" SVC_CHANGED="${4:-0}" UNIT="${5:-1}" KDIR="$kdir" bash -c ' INSTALLED="$1" CHANGED="$2" UPGRADE="${3:-0}" KDIR="$kdir" bash -c '
set -Eeuo pipefail set -Eeuo pipefail
ok() { echo "OK: $*"; }; log() { echo "LOG: $*"; }; warn() { echo "WARN: $*"; } ok() { echo "OK: $*"; }; log() { echo "LOG: $*"; }; warn() { echo "WARN: $*"; }
die() { echo "DIE: $*"; exit 1; } die() { echo "DIE: $*"; exit 1; }
FELIS_K3S_VERSION=v1.36.4+k3s1 FELIS_UPGRADE_DEPS="$UPGRADE" K3S_BIN_DIR="$KDIR" K3S_BIN="$KDIR/k3s-under-test" FELIS_K3S_VERSION=v1.36.4+k3s1 FELIS_UPGRADE_DEPS="$UPGRADE" K3S_BIN_DIR="$KDIR" K3S_BIN="$KDIR/k3s-under-test"
K3S_CONFIG_DROPIN=/etc/rancher/k3s/config.yaml.d/50-felis.yaml K3S_KUBECONFIG=/etc/rancher/k3s/k3s.yaml K3S_CONFIG_DROPIN=/etc/rancher/k3s/config.yaml.d/50-felis.yaml K3S_KUBECONFIG=/etc/rancher/k3s/k3s.yaml
K3S_SERVICE_DROPIN=/etc/systemd/system/k3s.service.d/50-felis.conf K3S_UNIT_FILE="$KDIR/k3s.service-under-test" rm -f "$K3S_BIN"
rm -f "$K3S_BIN" "$K3S_UNIT_FILE" if [ -n "$INSTALLED" ]; then printf "#!/bin/sh\necho \"k3s version %s (abc)\"\n" "$INSTALLED" > "$K3S_BIN"; chmod +x "$K3S_BIN"; fi
[ "$UNIT" = 0 ] || : > "$K3S_UNIT_FILE"
case "$INSTALLED" in
"") ;;
broken) printf "#!/bin/sh\nexit 1\n" > "$K3S_BIN"; chmod +x "$K3S_BIN" ;;
*) printf "#!/bin/sh\necho \"k3s version %s (abc)\"\n" "$INSTALLED" > "$K3S_BIN"; chmod +x "$K3S_BIN" ;;
esac
configure_k3s_firewall() { :; } configure_k3s_firewall() { :; }
write_k3s_config() { [ "$CHANGED" = 0 ] || K3S_RESTART_NEEDED=1; } write_k3s_config() { [ "$CHANGED" = 0 ] || K3S_RESTART_NEEDED=1; }
write_k3s_service_dropin() { echo "SERVICE-DROPIN"; [ "$SVC_CHANGED" = 0 ] || K3S_RESTART_NEEDED=1; }
strip_k3s_kubeconfig_mode_flag() { :; } strip_k3s_kubeconfig_mode_flag() { :; }
run_k3s_installer() { run_k3s_installer() { echo "INSTALLER"; printf "#!/bin/sh\n" > "$K3S_BIN"; command chmod +x "$K3S_BIN"; }
echo "INSTALLER${1:+ $1}"
[ "${1:-}" = --keep-binary ] || { printf "#!/bin/sh\n" > "$K3S_BIN"; command chmod +x "$K3S_BIN"; }
}
stage_k3s_airgap_images() { echo "STAGE"; } stage_k3s_airgap_images() { echo "STAGE"; }
systemctl() { echo "SYSTEMCTL: $*"; } systemctl() { echo "SYSTEMCTL: $*"; }
wait_for_node_ready() { echo "READY"; } wait_for_node_ready() { echo "READY"; }
@@ -3705,12 +3230,9 @@ expect "the admin kubeconfig is made root-only once the node is up" "READY
CHMOD: 0600 /etc/rancher/k3s/k3s.yaml" "$out" CHMOD: 0600 /etc/rancher/k3s/k3s.yaml" "$out"
out="$(run_install_k3s v1.36.4+k3s1 0)" out="$(run_install_k3s v1.36.4+k3s1 0)"
case "$out" in *"restart k3s"*) echo "FAIL unchanged k3s settings must not restart k3s"; fails=$((fails + 1)) ;; *) echo "PASS unchanged k3s settings restart nothing" ;; esac case "$out" in *"restart k3s"*) echo "FAIL unchanged k3s settings must not restart k3s"; fails=$((fails + 1)) ;; *) echo "PASS unchanged k3s settings restart nothing" ;; esac
expect "a new service environment alone restarts a running k3s" "SYSTEMCTL: restart k3s" "$(run_install_k3s v1.36.4+k3s1 0 0 1)"
out="$(run_install_k3s "" 1)" out="$(run_install_k3s "" 1)"
expect "a fresh host runs the k3s installer and waits for the node" "INSTALLER expect "a fresh host runs the k3s installer and waits for the node" "INSTALLER
SYSTEMCTL: enable --now k3s" "$out" SYSTEMCTL: enable --now k3s" "$out"
expect "a fresh k3s has its service environment before its first start" "SERVICE-DROPIN INSTALLER " \
"$(printf '%s\n' "$out" | grep -E '^(SERVICE-DROPIN|INSTALLER)$' | tr '\n' ' ')"
expect "a fresh host stages k3s's images before k3s first starts" "STAGE expect "a fresh host stages k3s's images before k3s first starts" "STAGE
INSTALLER" "$out" INSTALLER" "$out"
expect "a fresh host's kubeconfig is made root-only too" "CHMOD: 0600 /etc/rancher/k3s/k3s.yaml" "$out" expect "a fresh host's kubeconfig is made root-only too" "CHMOD: 0600 /etc/rancher/k3s/k3s.yaml" "$out"
@@ -3718,18 +3240,6 @@ case "$out" in *"restart k3s"*) echo "FAIL the k3s installer already started k3s
out="$(run_install_k3s v1.35.2+k3s1 1 1)" out="$(run_install_k3s v1.35.2+k3s1 1 1)"
expect "an upgrade runs the k3s installer" "INSTALLER" "$out" expect "an upgrade runs the k3s installer" "INSTALLER" "$out"
case "$out" in *"restart k3s"*) echo "FAIL the upgrade already restarted k3s on the new settings; no second restart"; fails=$((fails + 1)) ;; *) echo "PASS an upgraded k3s is not restarted a second time" ;; esac case "$out" in *"restart k3s"*) echo "FAIL the upgrade already restarted k3s on the new settings; no second restart"; fails=$((fails + 1)) ;; *) echo "PASS an upgraded k3s is not restarted a second time" ;; esac
# k3s's installer puts the binary in place before it writes k3s.service: a run cut short
# between the two is finished around that binary, at its own version.
out="$(run_install_k3s v1.36.4+k3s1 1 0 0 0)"
expect "a k3s binary without k3s.service has its installer finish around it" "INSTALLER --keep-binary
SYSTEMCTL: enable --now k3s" "$out"
case "$out" in *"restart k3s"*) echo "FAIL the finishing installer already started k3s; no second restart"; fails=$((fails + 1)) ;; *) echo "PASS and k3s is not restarted a second time" ;; esac
expect "an older binary without k3s.service is finished at its own version" "INSTALLER --keep-binary" \
"$(run_install_k3s v1.35.2+k3s1 0 1 0 0)"
out="$(run_install_k3s broken 0 0 0 0)"
expect "a k3s binary that does not run, without k3s.service, stops the install" \
"does not run and k3s.service is missing: an earlier k3s install was cut short" "$out"
case "$out" in *INSTALLER*) echo "FAIL a k3s binary that does not run must not be installed around"; fails=$((fails + 1)) ;; *) echo "PASS and the installer is not run around it" ;; esac
rm -rf "$kdir" rm -rf "$kdir"
jblock="$(awk '/^ensure_persistent_journal\(\) \{/,/^}/' "$BS")" jblock="$(awk '/^ensure_persistent_journal\(\) \{/,/^}/' "$BS")"
@@ -4930,7 +4440,6 @@ run_game_stack() { # PREBUILT_ROLES-after-import FELIS_GAME_STACK [ARTIFACT_MODE
game_stack_source() { :; } game_stack_source() { :; }
resolve_game_jars() { :; } resolve_game_jars() { :; }
import_release_images() { echo "IMPORT $*"; PREBUILT_ROLES="$AFTER"; } import_release_images() { echo "IMPORT $*"; PREBUILT_ROLES="$AFTER"; }
ctr() { return 1; }
ensure_docker() { echo ENSURE; DOCKER_INSTALLED=1; } ensure_docker() { echo ENSURE; DOCKER_INSTALLED=1; }
build_game_image() { echo "BUILD $1"; } build_game_image() { echo "BUILD $1"; }
install_velocity_plugin() { echo PLUGIN; } install_velocity_plugin() { echo PLUGIN; }
@@ -4981,39 +4490,6 @@ expect "from FELIS_ARTIFACT_DIR it stops the install" "DIE: FELIS_ARTIFACT_DIR:
expect "a source build builds the plugin" "ENSURE expect "a source build builds the plugin" "ENSURE
BUILD" "$(run_plugin "" 0)" BUILD" "$(run_plugin "" 0)"
# --- install_if_changed never fixes a target in place -----------------------------------------
# The proxy's account owns the directories these files land in and can swap one for a symlink
# after the checks, so a chown or chmod by path would land on whatever the link names. The cmp
# stub makes that swap right after the content check; chown and chmod report every call.
iicblock="$(bsfn install_if_changed)"
[ -n "$iicblock" ] || { echo "FAIL: no install_if_changed in $BS"; exit 1; }
mkdir "$adir/iic"
printf 'plugin\n' > "$adir/iic/src"
printf 'not the plugin\n' > "$adir/iic/decoy"
chmod 600 "$adir/iic/decoy"
mine="$(stat -c '%U:%G' "$adir/iic/src")"
run_iic() { # target's mode, the owner:group asked for, then "edited" or "swapped"
rm -f "$adir/iic/dst"
if [ "${3-}" = edited ]; then printf 'an older plugin\n' > "$adir/iic/dst"; else cp "$adir/iic/src" "$adir/iic/dst"; fi
chmod "$1" "$adir/iic/dst"
D="$adir/iic" OG="$2" HOW="${3-}" bash -c '
atomic_install_file() { echo "ATOMIC $2"; }
chown() { echo "CHOWN $*"; command chown "$@"; }
chmod() { echo "CHMOD $*"; command chmod "$@"; }
cmp() { command cmp "$@" || return; [ "$HOW" != swapped ] || ln -sfn "$D/decoy" "$3"; }
'"$iicblock"'
install_if_changed "$D/src" "$D/dst" 0644 "${OG%%:*}" "${OG#*:}"' 2>&1
}
iic_is() { # label want got
[ "$3" = "$2" ] && echo "PASS $1" || { printf 'FAIL %s: got\n%s\nwant\n%s\n' "$1" "$3" "$2"; fails=$((fails + 1)); }
}
iic_is "the same bytes, owner and mode are left alone" "" "$(run_iic 644 "$mine")"
iic_is "the same bytes with the wrong mode are reinstalled" "ATOMIC $adir/iic/dst" "$(run_iic 600 "$mine")"
iic_is "the same bytes with the wrong owner are reinstalled" "ATOMIC $adir/iic/dst" "$(run_iic 644 "felis-nobody:${mine#*:}")"
iic_is "new bytes are installed" "ATOMIC $adir/iic/dst" "$(run_iic 644 "$mine" edited)"
iic_is "a target swapped for a symlink after the checks is reinstalled" "ATOMIC $adir/iic/dst" "$(run_iic 644 "$mine" swapped)"
iic_is "and the file the link named keeps its mode" 600 "$(stat -c %a "$adir/iic/decoy")"
# --- k3s's own images from its GitHub release ---------------------------------------------------- # --- k3s's own images from its GitHub release ----------------------------------------------------
kablock="$(bsfn stage_k3s_airgap_images)" kablock="$(bsfn stage_k3s_airgap_images)"
mkdir -p "$adir/k3simg" "$adir/k3srel" mkdir -p "$adir/k3simg" "$adir/k3srel"
@@ -5244,115 +4720,6 @@ case "$out" in
esac esac
rm -rf "$credir" "$credcalls" rm -rf "$credir" "$credcalls"
# Existing clusters must pass compatibility before the install changes the host.
compatdir="$(mktemp -d)"
mkdir -p "$compatdir/pg/18/docker"
printf '18' > "$compatdir/pg/18/docker/PG_VERSION"
printf '#!/bin/sh\necho "k3s version v1.36.4+k3s1 (example)"\n' > "$compatdir/k3s"
chmod +x "$compatdir/k3s"
run_compatibility_guard() {
PG_DATA_DIR="$compatdir/pg" K3S_BIN="$compatdir/k3s" WANT_PG="$1" FELIS_K3S_VERSION="$2" FELIS_UPGRADE_DEPS=1 bash -c '
set -Eeuo pipefail
die() { echo "DIE: $*"; exit 1; }
ok() { :; }
version_newer() { return 1; }
postgres_image_major() { echo "$WANT_PG"; }
acquire_run_lock() { :; }
ensure_k3s_on_path() { :; }
resolve_nano_listen() { :; }
validate_settings() { :; }
detect_os() { :; }
prompt_install_mode() { INSTALL_MODE=full; }
detect_node_ip() { :; }
preflight() { :; }
quiet_watchdog() { echo HOST_CHANGE; exit 0; }
'"$(bsfn check_postgres_major)"'
'"$(bsfn k3s_upgrade_allowed)"'
'"$(bsfn main)"'
main
' 2>&1
}
out="$(run_compatibility_guard 19 v1.37.1+k3s1)"
expect "PostgreSQL major mismatch is refused before host changes" 'holds a PostgreSQL 18 cluster' "$out"
case "$out" in *HOST_CHANGE*) echo "FAIL PostgreSQL refusal came after a host change"; fails=$((fails + 1));; esac
out="$(run_compatibility_guard 18 v1.38.1+k3s1)"
expect "k3s minor skip is refused before host changes" 'skips a minor version' "$out"
case "$out" in *HOST_CHANGE*) echo "FAIL k3s refusal came after a host change"; fails=$((fails + 1));; esac
rm -rf "$compatdir"
# Worker admission reuses the installer but must never enter host control-plane setup.
before "distributed host firewall runs the newly built binary" \
' build_image' '"$HOST_BIN" node firewall --controller' "$(bsfn main)"
before "distributed host firewall is installed before platform deployment" \
'"$HOST_BIN" node firewall --controller' ' deploy_postgres' "$(bsfn main)"
expect "distributed admission preserves existing API-server arguments" \
"echo 'kube-apiserver-arg+:'" "$(bsfn write_k3s_config)"
worker="$(bsfn main_worker)"
expect "source worker install builds the requested binary even when an older binary exists" \
'if [ -z "$HAVE_PREBUILT_BINARY" ]; then install_go_toolchain; build_nano_binary; fi' "$worker"
before "worker identity is checked before the agent config is written" \
'refusing to rename it' 'cat > "$K3S_CONFIG_DROPIN"' "$worker"
expect "worker rejects server tokens and verifies the CA-pinned bootstrap shape" \
'K10[0-9a-f]{64}::[a-z0-9]{6}\.[a-z0-9]{16}' "$worker"
expect "worker cannot replace a controller" 'refusing to turn a controller into a worker' "$worker"
expect "worker is quarantined" 'felis.lolicon.best/unapproved=true:NoSchedule' "$worker"
expect "worker mirror preserves logical references and points at the cluster service" \
'http://${WORKER_REGISTRY_IP}:5000' "$worker"
expect "worker disables registry endpoint fallback" 'disable-default-registry-endpoint: true' "$worker"
for forbidden in deploy_bundle install_cloudflared install_velocity run_migrations load_or_make_secrets; do
case "$worker" in
*"$forbidden"*) echo "FAIL worker invokes $forbidden"; fails=$((fails + 1));;
*) echo "PASS worker does not invoke $forbidden";;
esac
done
badtoken="$(mktemp)"
printf 'server-token-not-bootstrap' > "$badtoken"
out="$(WORKER_TOKEN_FILE="$badtoken" bash -c '
die() { printf "DIE: %s\n" "$*"; exit 1; }
WORKER_NAME=b WORKER_SERVER=https://192.0.2.1:6443 WORKER_REGISTRY_IP=10.43.0.10 WORKER_PEERS=192.0.2.1/32
'"$worker"'
main_worker
')"
expect "worker refuses a copied server token before changing the machine" 'worker accepts only CA-pinned bootstrap tokens' "$out"
rm -f "$badtoken"
# Local panel URLs must keep the WebAuthn hostname, including before edge setup.
out="$(bash -c '
bootstrap_from_tui() { return 1; }
owner_state() { echo yes; }
auth_hostname() { echo "$2"; }
log() { printf "%s\n" "$*"; }
FELIS_ROOT_DOMAIN=10.211.55.6.nip.io NODE_IP=10.211.55.6 FELIS_PANEL_NODEPORT=30443
'"$(bsfn summary_next)"'
summary_next
')"
expect "local sign-in URL preserves the Passkey hostname" \
"Local access: https://op.console.10.211.55.6.nip.io:30443" "$out"
expect "local sign-in explains why IP access cannot use Passkey" \
"direct IP access cannot use Passkey" "$out"
# SELinux rejects /run paths when the policy aliases them to /var/run.
node_fcontext="$(bsfn install_node_control_service | awk '/^ if .*command -v semanage/,/^ fi/')"
[ -n "$node_fcontext" ] && [ "$(printf '%s\n' "$node_fcontext" | wc -l)" -lt 12 ] \
|| { echo "FAIL: node-control fcontext block could not be extracted"; exit 1; }
for existing in 0 1; do
out="$(EXISTING="$existing" bash -c '
selinux_environment=enabled
semanage() {
case "$5" in /run/felis-node-control*) echo "alias conflict"; return 1;; esac
echo "$*"
[ "$EXISTING" != 1 ] || [ "$2" = -m ]
}
'"$node_fcontext"
)"
expect "node-control registers the canonical SELinux path ($existing)" \
"fcontext -a -t felis_node_control_socket_t /var/run/felis-node-control(/.*)?" "$out"
if [ "$existing" = 1 ]; then
expect "node-control updates an existing canonical rule" \
"fcontext -m -t felis_node_control_socket_t /var/run/felis-node-control(/.*)?" "$out"
fi
done
# --------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------
if [ "$fails" -eq 0 ]; then if [ "$fails" -eq 0 ]; then
echo "ALL PASS" echo "ALL PASS"
+1 -1
View File
@@ -126,7 +126,7 @@ mkdir -p "${WORK}/velocity"
tar -C . --exclude=build --exclude=.gradle -cf - plugins/velocity plugins/shared | tar -C "${WORK}/velocity" -xf - tar -C . --exclude=build --exclude=.gradle -cf - plugins/velocity plugins/shared | tar -C "${WORK}/velocity" -xf -
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GRADLE_USER_HOME=/tmp/gradle \ docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GRADLE_USER_HOME=/tmp/gradle \
-v "${WORK}/velocity:/src" -w /src/plugins/velocity \ -v "${WORK}/velocity:/src" -w /src/plugins/velocity \
"$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build --console=plain --init-script ../shared/build-progress.gradle "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build
jars=( "${WORK}"/velocity/plugins/velocity/build/libs/felis-velocity-*.jar ) jars=( "${WORK}"/velocity/plugins/velocity/build/libs/felis-velocity-*.jar )
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] || die "the felis-velocity build must produce exactly one plugin jar" [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] || die "the felis-velocity build must produce exactly one plugin jar"
install -m 0644 "${jars[0]}" "${OUT}/felis-velocity.jar" install -m 0644 "${jars[0]}" "${OUT}/felis-velocity.jar"
@@ -281,8 +281,6 @@ spec:
storage: storage:
description: Storage configures the world PVC. description: Storage configures the world PVC.
properties: properties:
claimName:
type: string
size: size:
description: Size is the requested PVC capacity (e.g. "10Gi"). description: Size is the requested PVC capacity (e.g. "10Gi").
type: string type: string
@@ -291,8 +289,6 @@ spec:
uses the default. uses the default.
type: string type: string
type: object type: object
nodeName:
type: string
subdomain: subdomain:
description: |- description: |-
Subdomain is the per-server label under the deployment zone. It is the Subdomain is the per-server label under the deployment zone. It is the
@@ -305,8 +301,6 @@ spec:
status: status:
description: MinecraftServerStatus is the observed state (spec §4 status.*). description: MinecraftServerStatus is the observed state (spec §4 status.*).
properties: properties:
nodeName:
type: string
autoRestarts: autoRestarts:
description: |- description: |-
AutoRestarts counts how often the operator recreated the pod of a start AutoRestarts counts how often the operator recreated the pod of a start
-56
View File
@@ -1,56 +0,0 @@
#!/bin/bash
# Whether a failed installer run in the e2e workflow stopped on an upstream download that
# was refused or dropped, which says nothing about the commit under test. Each install step
# hands its log and the installer's status here when the installer fails:
#
# sudo ... bash deploy/bootstrap.sh 2>&1 | tee install.log || bash deploy/e2e_upstream.sh install.log $?
#
# It reads the installer's last ERR-trap line ("bootstrap failed near line N (exit E)") and
# the line just before it. When that line is curl's own error for the same status, and the
# error is one a mirror or GitHub answers with on a bad minute (a connection refused, reset
# or timed out, a 403, 408, 429 or 5xx), it leaves a warning and E2E_UPSTREAM_SKIP=1 in
# $GITHUB_ENV and exits 0: the job's later steps are gated on that variable, so the job ends
# green with the warning on the run. Anything else exits with the installer's status. A 404
# is a URL or a version the installer names, which a commit can break, so it fails.
#
# deploy/e2e_upstream_test.sh holds its checks, against bootstrap.sh's own trap message.
set -euo pipefail
log="${1:?usage: e2e_upstream.sh LOG STATUS}"
status="${2:?usage: e2e_upstream.sh LOG STATUS}"
# The last run of trap lines and the line before it. bash may fire the trap again for each
# function the failure unwinds through, and the EXIT cleanup can print after it. The log
# keeps the installer's colour codes, so nothing is anchored on the left.
code="" before=""
{ read -r code && IFS= read -r before; } < <(awk '
/bootstrap failed near line [0-9]+ \(exit [0-9]+\)$/ {
code = $0; sub(/.*\(exit /, "", code); sub(/\)$/, "", code)
found = code; foundprev = last
next
}
{ last = $0 }
END { if (found != "") { print found; print foundprev } }
' "$log") || true
upstream=""
case "$before" in
*"curl: (${code}) "*)
case "$code" in
# Could not resolve or connect, an HTTP/2 or TLS failure, a transfer cut short, no
# reply, a send or receive failure, a timeout.
5 | 6 | 7 | 16 | 18 | 28 | 35 | 52 | 55 | 56 | 92) upstream=yes ;;
# -f's HTTP error; curl before 7.75 appends the reason phrase.
22) if [[ "$before" =~ returned\ error:\ (403|408|429|5[0-9][0-9])([^0-9]|$) ]]; then upstream=yes; fi ;;
esac
;;
esac
if [ -n "$upstream" ]; then
echo "::warning::the installer stopped on an upstream download (curl: ${before#*curl: }); this job skips its remaining steps"
echo "E2E_UPSTREAM_SKIP=1" >> "${GITHUB_ENV:-/dev/null}"
exit 0
fi
echo "the installer failed (exit ${status}) on something other than a refused upstream download; its log is above"
[ "$status" -ne 0 ] 2>/dev/null || status=1
exit "$status"
-130
View File
@@ -1,130 +0,0 @@
#!/bin/bash
# Checks for deploy/e2e_upstream.sh. Run it as: bash deploy/e2e_upstream_test.sh
#
# The trap line in the logs is bootstrap.sh's own on_error message, printed the way its warn
# prints it, so rewording it there fails here rather than turning every refused download
# back into a red e2e run. The curl lines are curl's own wording.
set -u
here="$(dirname "$0")"
EU="${1:-${here}/e2e_upstream.sh}"
BS="${2:-${here}/bootstrap.sh}"
[ -f "$EU" ] || { echo "no such script: $EU"; exit 1; }
[ -f "$BS" ] || { echo "no such script: $BS"; exit 1; }
fails=0
expect() { # label needle haystack
case "$3" in
*"$2"*) echo "PASS $1" ;;
*) echo "FAIL $1: expected <$2> in:"; echo "$3"; fails=$((fails + 1)) ;;
esac
}
status() { # label want got
if [ "$2" = "$3" ]; then echo "PASS $1"; else echo "FAIL $1: exit $3, want $2"; fails=$((fails + 1)); fi
}
same() { # label want got
if [ "$2" = "$3" ]; then echo "PASS $1"; else printf 'FAIL %s: got <%s>, want <%s>\n' "$1" "$3" "$2"; fails=$((fails + 1)); fi
}
root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT
trap_body="$(grep -E '^[[:space:]]*warn "bootstrap failed near line' "$BS" | head -n 1)"
if [ -z "$trap_body" ]; then
echo "FAIL bootstrap.sh's on_error prints no 'bootstrap failed near line' warning"
fails=$((fails + 1))
fi
trap_body="${trap_body#*\"}"
trap_body="${trap_body%\"*}"
trapped() { # line code: on_error's warning as the installer prints it
# shellcheck disable=SC2034 # read by the eval
local line="$1" code="$2"
printf '\033[1;33m[warn]\033[0m %s\n' "$(eval "printf '%s' \"${trap_body}\"")"
}
step() { printf '\033[1;36m[felis]\033[0m %s\n' "$1"; }
run() { # log status: prints what the script says; $root/env is its GITHUB_ENV
: > "$root/env"
GITHUB_ENV="$root/env" bash "$EU" "$1" "$2" 2>&1
}
# The upgrade job's v0.2.0 install on 2026-10-02: GitHub refused cloudflared's download.
{
step "release channel: v0.2.0"
step "installing cloudflared 2026.9.1 (amd64)"
echo "curl: (22) The requested URL returned error: 403"
trapped 1727 22
} > "$root/403.log"
out="$(run "$root/403.log" 22)"
status "a 403 on a download skips" 0 $?
expect " with a warning that quotes curl" "::warning::the installer stopped on an upstream download (curl: (22) The requested URL returned error: 403)" "$out"
same " and gates the later steps" "E2E_UPSTREAM_SKIP=1" "$(cat "$root/env")"
for e in "(6) Could not resolve host: github.com" \
"(7) Failed to connect to github.com port 443 after 130 ms: Couldn't connect to server" \
"(28) Operation timed out after 300000 milliseconds with 0 out of 0 bytes received" \
"(35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to objects.githubusercontent.com:443" \
"(56) Recv failure: Connection reset by peer" \
"(22) The requested URL returned error: 429" \
"(22) The requested URL returned error: 503" \
"(22) The requested URL returned error: 502 Bad Gateway"; do
code="${e#(}"
code="${code%%)*}"
{ step "installing k3s"; echo "curl: $e"; trapped 900 "$code"; } > "$root/e.log"
out="$(run "$root/e.log" "$code")"
status "curl: $e skips" 0 $?
done
# What the installer prints after the trap, as it exits, changes nothing.
{
cat "$root/403.log"
printf '\033[1;33m[warn]\033[0m %s\n' "restored the previous felis binary at /usr/local/bin/felis; the database was not migrated, so rerunning the installer picks up where this run stopped"
} > "$root/cleanup.log"
out="$(run "$root/cleanup.log" 22)"
status "warnings after the trap still skip" 0 $?
# set -E: the trap again for a function the failure unwound through.
{ cat "$root/403.log"; trapped 1790 22; } > "$root/twice.log"
out="$(run "$root/twice.log" 22)"
status "the trap fired twice still skips" 0 $?
# A 404 is a URL or a version the installer names.
{ step "installing cloudflared 2026.9.1 (amd64)"; echo "curl: (22) The requested URL returned error: 404"; trapped 1727 22; } > "$root/404.log"
out="$(run "$root/404.log" 22)"
status "a 404 fails with the installer's status" 22 $?
expect " and says so" "the installer failed (exit 22) on something other than a refused upstream download" "$out"
same " and gates nothing" "" "$(cat "$root/env")"
for e in "401" "400" "410"; do
{ echo "curl: (22) The requested URL returned error: $e"; trapped 1727 22; } > "$root/e.log"
out="$(run "$root/e.log" 22)"
status "a $e fails" 22 $?
done
# A refused download the installer got past, then a failure of its own that happens to
# exit with curl's status: only the line before the trap counts.
{
echo "curl: (22) The requested URL returned error: 403"
step "building felis from source"
trapped 4100 22
} > "$root/later.log"
out="$(run "$root/later.log" 22)"
status "a refused download earlier in the log fails" 22 $?
# curl's error on the line before, but the installer stopped with another status.
{ echo "curl: (22) The requested URL returned error: 403"; trapped 1727 1; } > "$root/mismatch.log"
out="$(run "$root/mismatch.log" 1)"
status "a trap for another status fails" 1 $?
# The installer's own die, which the trap never sees.
{ step "installing k3s"; printf '\033[1;31m[fail]\033[0m %s\n' "k3s did not become ready"; } > "$root/die.log"
out="$(run "$root/die.log" 1)"
status "the installer's own failure fails" 1 $?
same " and gates nothing" "" "$(cat "$root/env")"
: > "$root/empty.log"
out="$(run "$root/empty.log" 141)"
status "an empty log fails with the installer's status" 141 $?
echo
if [ "$fails" -eq 0 ]; then echo "ALL PASS"; else echo "${fails} FAILED"; exit 1; fi
+1 -1
View File
@@ -51,7 +51,7 @@ RUN if [ -z "${LIMBO_VERSION:-}" ]; then \
echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \ echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \
fi \ fi \
&& cd plugins/limbo \ && cd plugins/limbo \
&& gradle --no-daemon --console=plain --init-script ../shared/build-progress.gradle -PlimboVersion="$LIMBO_VERSION" build \ && gradle --no-daemon -PlimboVersion="$LIMBO_VERSION" build \
&& cp build/libs/*.jar /felis-limbo.jar && cp build/libs/*.jar /felis-limbo.jar
# ---- assemble the runtime ---- # ---- assemble the runtime ----
-17
View File
@@ -164,20 +164,3 @@ set them by hand:
The Velocity gate/lobby wiring is printed by `felis setup` and enforces the The Velocity gate/lobby wiring is printed by `felis setup` and enforces the
invariant: fresh connections hit `login` first, and only an authenticated release invariant: fresh connections hit `login` first, and only an authenticated release
from that gate can enter the post-auth lobby or a remembered user backend. from that gate can enter the post-auth lobby or a remembered user backend.
## Customize in the panel
Administrators open **Login & lobby**, select **Login space**, and stop it before
editing. The form configures the login book title/author/heading/link text/help,
automatic book opening and the login timeout (30–3600 seconds). These settings
persist in `/data/felis-experience.json`; an explicit
`FELIS_LOGIN_TIMEOUT_SECONDS` environment variable takes precedence. The generated
code, generated login URL and chat guidance are preserved. The authentication
and transfer destination are not player-facing customization fields.
Use the linked file manager to upload a replacement `/data/spawn.schem`, edit
Limbo's `server.properties` or add Limbo-compatible plugins, then start the space.
Paper world ZIPs and Paper plugins do not work in Limbo. The page also exposes
logs, backups/restore and image/resource settings. New joins are unavailable
while this front door is stopped; a custom image must retain the login plugin
and support the proxy's forwarding protocol.
+1 -1
View File
@@ -40,7 +40,7 @@ WORKDIR /src
COPY plugins/paper/ ./plugins/paper/ COPY plugins/paper/ ./plugins/paper/
COPY plugins/shared/ ./plugins/shared/ COPY plugins/shared/ ./plugins/shared/
RUN cd plugins/paper \ RUN cd plugins/paper \
&& gradle --no-daemon --console=plain --init-script ../shared/build-progress.gradle build \ && gradle --no-daemon build \
&& cp build/libs/*.jar /felis-paper.jar && cp build/libs/*.jar /felis-paper.jar
# ---- assemble the runtime ---- # ---- assemble the runtime ----
+3 -28
View File
@@ -28,7 +28,7 @@ this at every layer:
``` ```
docker build -f deploy/lobby/Dockerfile \ docker build -f deploy/lobby/Dockerfile \
--build-arg PAPER_JAR_URL=https://<mirror>/paper-26.3-<build>.jar \ --build-arg PAPER_JAR_URL=https://<mirror>/paper-1.21.x-<build>.jar \
--build-arg PAPER_JAR_SHA256=<sha256 of that jar> \ --build-arg PAPER_JAR_SHA256=<sha256 of that jar> \
-t felis-lobby:demo . -t felis-lobby:demo .
# Publish into the cluster's registry (on the node; docker treats 127.0.0.1 as # Publish into the cluster's registry (on the node; docker treats 127.0.0.1 as
@@ -40,30 +40,6 @@ docker push 127.0.0.1:5000/felis/lobby:demo
sudo felis setup sudo felis setup
``` ```
## Customize in the panel
Administrators open **Login & lobby** (`/admin/lobby`). Stop the selected space
before reading or saving its settings, then start it to apply them. The lobby
form configures welcome text, menu titles, join behavior, game mode, building
protection, damage/hunger/void handling, difficulty, time/weather and world rules.
Settings live in `/data/felis-experience.json`, independently of the image, and
retain unknown keys when saved. Existing installations without this file use the
same protected-lobby defaults as before.
The page also exposes the existing file manager (including upload and ZIP
extraction), console, backups/restore, builder permissions and image/resource
settings. To replace a map: back up and stop the lobby, upload a world ZIP,
extract it at the volume root, verify the world directory directly contains
`level.dat`, and set `level-name` in `server.properties`. Use `setworldspawn x y z`
in the running lobby console to set its spawn. Plugin JARs go in `plugins/` and
must match Paper's version; the bundled Felis and LuckPerms JARs are refreshed
from the image at boot. A custom image must retain the menu/control plugin.
The operator reuses its `init-forwarding` YAML merge for the lobby, preserving
custom Paper globals while refreshing mandatory authentication settings. The
image only rewrites that file for standalone runs without a managed forwarding
initContainer. RCON secrets and the proxy forwarding secret remain managed.
## Configure (deployer's responsibility) ## Configure (deployer's responsibility)
- Game port must be `25565` (the CRD `GamePort`). - Game port must be `25565` (the CRD `GamePort`).
@@ -71,7 +47,7 @@ initContainer. RCON secrets and the proxy forwarding secret remain managed.
- The lobby speaks only the `felis:control` plugin-message channel; it holds no - The lobby speaks only the `felis:control` plugin-message channel; it holds no
felis-api token by design (spec §12). felis-api token by design (spec §12).
## Default lobby behavior ## What the lobby allows
felis-paper's `LobbyGuard` keeps the lobby a hub that nobody can hurt, get hurt in, felis-paper's `LobbyGuard` keeps the lobby a hub that nobody can hurt, get hurt in,
or leave a mark on: or leave a mark on:
@@ -89,7 +65,6 @@ or leave a mark on:
LuckPerms (`lp user <name> permission set felis.lobby.build true` on the lobby console) LuckPerms (`lp user <name> permission set felis.lobby.build true` on the lobby console)
or op them. or op them.
The entrypoint seeds `max-players=200` when absent, over Paper's default of 20; The entrypoint pins `max-players=200` on every boot, over Paper's default of 20: every
subsequent file-editor changes survive restarts: every
authenticated player passes through here, and a stopped server's players arrive all at authenticated player passes through here, and a stopped server's players arrive all at
once. once.
+9 -12
View File
@@ -76,10 +76,7 @@ set_prop online-mode false
# what one node serves at once, and a flood beyond it is refused at the door instead # what one node serves at once, and a flood beyond it is refused at the door instead
# of running the 1Gi lobby out of memory. What the world itself allows (no damage, no # of running the 1Gi lobby out of memory. What the world itself allows (no damage, no
# building, the /menu hint) is felis-paper's LobbyGuard. # building, the /menu hint) is felis-paper's LobbyGuard.
# Seed capacity once; administrators can tune it in the panel file editor. set_prop max-players 200
if ! grep -q '^max-players=' "$PROPS"; then
set_prop max-players 200
fi
# RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it # RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it
# for readiness and the player tally, and felis-api runs console/permission commands over # for readiness and the player tally, and felis-api runs console/permission commands over
@@ -108,21 +105,21 @@ else
echo " injects it from the <server>-rcon Secret when spec.rcon.enabled is true." >&2 echo " injects it from the <server>-rcon Secret when spec.rcon.enabled is true." >&2
fi fi
# The operator's existing init-forwarding step merges the proxy keys on every # Rewritten whole, not merged. Paper loads this file and fills every key it does
# start, preserving other Paper globals. Standalone runs retain the mandatory # not find with the default, then writes the full tree back — so a proxies-only file is a
# rewrite because no initContainer has verified their forwarding settings. # complete, stable input, and the lobby's other globals are simply always the defaults.
# That is true of a system server Felis owns end to end; if admins are ever allowed to tune
# the lobby's globals, this has to become a real YAML merge (yq) instead.
mkdir -p config mkdir -p config
if [ "${FELIS_MANAGED_FORWARDING:-false}" = true ]; then cat > config/paper-global.yml <<YAML
[ -f config/paper-global.yml ] || { echo "felis-lobby: missing managed forwarding config" >&2; exit 1; } # Written by felis-lobby's entrypoint on every boot. Do not hand-edit: the forwarding
else # secret is injected from the felis-forwarding-secret Secret and must match the proxy.
cat > config/paper-global.yml <<YAML
proxies: proxies:
velocity: velocity:
enabled: true enabled: true
online-mode: true online-mode: true
secret: "${SECRET}" secret: "${SECRET}"
YAML YAML
fi
echo "felis-lobby: server-port=${PORT}, velocity modern forwarding on (UUIDs are Mojang-verified)" echo "felis-lobby: server-port=${PORT}, velocity modern forwarding on (UUIDs are Mojang-verified)"
JAVA_MEMORY_ARG="" JAVA_MEMORY_ARG=""
-56
View File
@@ -1,56 +0,0 @@
#!/bin/bash
# Linux/root acceptance of resident-node and pre-DNAT paths. All packet rules,
# listeners and links live in disposable network namespaces, never the live host.
set -euo pipefail
bin="${1:?usage: test-node-firewall.sh /absolute/path/to/felis}"
[[ $bin = /* && -x $bin ]] || exit 2
[[ $(id -u) = 0 ]] || { echo 'requires root on Linux' >&2; exit 2; }
work=$(mktemp -d)
suffix="$$"
node="felis-fw-node-$suffix"
game="felis-fw-game-$suffix"
peer="felis-fw-peer-$suffix"
listener=''
cleanup() {
if [[ -n $listener ]]; then kill "$listener" 2>/dev/null || true; wait "$listener" 2>/dev/null || true; fi
for ns in "$game" "$peer" "$node"; do ip netns del "$ns" 2>/dev/null || true; done
rm -rf "$work"
}
trap cleanup EXIT
for ns in "$node" "$game" "$peer"; do ip netns add "$ns"; ip -n "$ns" link set lo up; done
ip link add fg-node type veth peer name fg-game
ip link set fg-node netns "$node"
ip link set fg-game netns "$game"
ip link add fp-node type veth peer name fp-peer
ip link set fp-node netns "$node"
ip link set fp-peer netns "$peer"
ip -n "$node" addr add 10.42.250.1/24 dev fg-node
ip -n "$game" addr add 10.42.250.2/24 dev fg-game
ip -n "$node" addr add 192.0.2.2/24 dev fp-node
ip -n "$peer" addr add 192.0.2.1/24 dev fp-peer
ip -n "$node" link set fg-node up
ip -n "$node" link set fp-node up
ip -n "$game" link set fg-game up
ip -n "$peer" link set fp-peer up
ip -n "$game" route add 192.0.2.0/24 via 10.42.250.1
ip -n "$game" route add 10.43.0.1/32 via 10.42.250.1
ip netns exec "$node" "$bin" node-probe --listen :18083 >"$work/listener.log" 2>&1 &
listener=$!
ip netns exec "$game" "$bin" node-probe --open 192.0.2.2:18083
ip netns exec "$peer" "$bin" node-probe --open 192.0.2.2:18083
"$bin" node firewall --dry-run --peers 192.0.2.1/32,192.0.2.2/32 \
--controller-ip 192.0.2.1 --pod-cidr 10.42.0.0/16 \
--node-port 30443 --api-service-ip 10.43.0.1 >"$work/firewall.sh"
ip netns exec "$node" bash "$work/firewall.sh"
# Simulate later kube-router insertion ahead of Felis filter hooks.
ip netns exec "$node" iptables -I INPUT 1 -j ACCEPT
ip netns exec "$node" iptables -t nat -A PREROUTING -p tcp --dport 30443 -j REDIRECT --to-ports 18083
ip netns exec "$node" iptables -t nat -A PREROUTING -d 10.43.0.1 -p tcp --dport 443 -j REDIRECT --to-ports 18083
ip netns exec "$game" "$bin" node-probe \
--closed 192.0.2.2:18083 --closed 192.0.2.2:30443 --closed 10.43.0.1:443
ip netns exec "$peer" "$bin" node-probe --closed 192.0.2.2:30443
# The listener remains healthy and the allowed peer still reaches it.
ip netns exec "$peer" "$bin" node-probe --open 192.0.2.2:18083
ip netns exec "$node" "$bin" node-probe --open 127.0.0.1:18083
echo 'PASS resident-node isolation and public NodePort denial survive pre-DNAT and early filter ACCEPT'
-13
View File
@@ -321,16 +321,6 @@ stop_database_pod() {
|| warn "${PG_DEPLOYMENT} did not stop within 2 minutes; its cluster recovers from its WAL on the next start" || warn "${PG_DEPLOYMENT} did not stop within 2 minutes; its cluster recovers from its WAL on the next start"
} }
# remove_k3s_service_dropin deletes the environment the installer gives the k3s service
# (bootstrap.sh, write_k3s_service_dropin). k3s-uninstall.sh removes the unit and its .env
# file and leaves the unit's drop-in directory.
remove_k3s_service_dropin() {
[ -f "${UNIT_DIR}/k3s.service.d/50-felis.conf" ] || return 0
rm -f "${UNIT_DIR}/k3s.service.d/50-felis.conf"
rmdir "${UNIT_DIR}/k3s.service.d" 2>/dev/null || true
ok "k3s service environment removed"
}
remove_k3s() { remove_k3s() {
local stamp local stamp
[ "$PURGE" = 1 ] || stop_database_pod [ "$PURGE" = 1 ] || stop_database_pod
@@ -346,7 +336,6 @@ remove_k3s() {
if [ -x "${K3S_BIN_DIR}/k3s-uninstall.sh" ]; then if [ -x "${K3S_BIN_DIR}/k3s-uninstall.sh" ]; then
log "running k3s-uninstall.sh" log "running k3s-uninstall.sh"
"${K3S_BIN_DIR}/k3s-uninstall.sh" >/dev/null 2>&1 || warn "k3s-uninstall.sh reported an error; check /var/lib/rancher and /etc/rancher" "${K3S_BIN_DIR}/k3s-uninstall.sh" >/dev/null 2>&1 || warn "k3s-uninstall.sh reported an error; check /var/lib/rancher and /etc/rancher"
remove_k3s_service_dropin
ok "k3s removed" ok "k3s removed"
else else
warn "k3s is at ${K3S_BIN_DIR}/k3s but ${K3S_BIN_DIR}/k3s-uninstall.sh is missing; remove k3s by hand" warn "k3s is at ${K3S_BIN_DIR}/k3s but ${K3S_BIN_DIR}/k3s-uninstall.sh is missing; remove k3s by hand"
@@ -533,8 +522,6 @@ main() {
case "$K3S_MODE" in case "$K3S_MODE" in
remove) remove_k3s ;; remove) remove_k3s ;;
keep) remove_from_cluster ;; keep) remove_from_cluster ;;
# k3s was removed some other way; what it left of the installer's goes too.
absent) remove_k3s_service_dropin ;;
esac esac
remove_nft_tables remove_nft_tables
remove_firewalld_rules "$game" "$nano" remove_firewalld_rules "$game" "$nano"
-20
View File
@@ -35,8 +35,6 @@ fresh_host() {
for u in felis-db-backup.timer felis-db-backup.service felis-velocity.service felis-postgres-firewall.service; do for u in felis-db-backup.timer felis-db-backup.service felis-velocity.service felis-postgres-firewall.service; do
printf '[Unit]\n' > "$root/h/units/$u" printf '[Unit]\n' > "$root/h/units/$u"
done done
mkdir -p "$root/h/units/k3s.service.d"
printf '[Service]\nEnvironment=GOGC=50\n' > "$root/h/units/k3s.service.d/50-felis.conf"
printf '[Service]\nExecStart=/usr/local/bin/cloudflared --config %s tunnel run\n' "$root/h/etc/cloudflared.yml" \ printf '[Service]\nExecStart=/usr/local/bin/cloudflared --config %s tunnel run\n' "$root/h/etc/cloudflared.yml" \
> "$root/h/units/cloudflared-felis.service" > "$root/h/units/cloudflared-felis.service"
printf 'tunnel: abc\ncredentials-file: %s\n' "$root/h/cf/abc.json" > "$root/h/etc/cloudflared.yml" printf 'tunnel: abc\ncredentials-file: %s\n' "$root/h/cf/abc.json" > "$root/h/etc/cloudflared.yml"
@@ -157,9 +155,6 @@ refute "keep-data leaves the database alone" "DROP DATABASE" "$calls"
|| { echo "FAIL /opt/felis or the host binary is still there"; fails=$((fails + 1)); } || { echo "FAIL /opt/felis or the host binary is still there"; fails=$((fails + 1)); }
[ -z "$(ls "$root/h/units")" ] && echo "PASS every Felis unit file is removed" \ [ -z "$(ls "$root/h/units")" ] && echo "PASS every Felis unit file is removed" \
|| { echo "FAIL units left: $(ls "$root/h/units")"; fails=$((fails + 1)); } || { echo "FAIL units left: $(ls "$root/h/units")"; fails=$((fails + 1)); }
[ ! -e "$root/h/units/k3s.service.d" ] \
&& echo "PASS the k3s service environment k3s's uninstaller leaves is removed with its directory" \
|| { echo "FAIL the k3s service drop-in is still there: $(ls -R "$root/h/units")"; fails=$((fails + 1)); }
expect "the timers are disabled" "SYSTEMCTL disable --now felis-db-backup.timer" "$calls" expect "the timers are disabled" "SYSTEMCTL disable --now felis-db-backup.timer" "$calls"
expect "the velocity user is removed" "USERDEL felis-velocity" "$calls" expect "the velocity user is removed" "USERDEL felis-velocity" "$calls"
expect "the run ends pointing at the reinstall steps" "Reinstall on top of kept data" "$out" expect "the run ends pointing at the reinstall steps" "Reinstall on top of kept data" "$out"
@@ -195,9 +190,6 @@ expect "Felis's volumes are retained before their claims go" 'KUBE patch pv pvc-
refute "a volume of another namespace is not touched" "patch pv pvc-9" "$calls" refute "a volume of another namespace is not touched" "patch pv pvc-9" "$calls"
expect "Felis's namespaces are deleted" "KUBE delete namespace felis minecraft felis-build" "$calls" expect "Felis's namespaces are deleted" "KUBE delete namespace felis minecraft felis-build" "$calls"
expect "the CRD is deleted" "KUBE delete crd minecraftservers.felis.lolicon.best" "$calls" expect "the CRD is deleted" "KUBE delete crd minecraftservers.felis.lolicon.best" "$calls"
[ -f "$root/h/units/k3s.service.d/50-felis.conf" ] \
&& echo "PASS a k3s that stays keeps its service environment, like its config" \
|| { echo "FAIL the kept k3s lost its service drop-in"; fails=$((fails + 1)); }
out="$(fresh_host; run_uninstall down --yes)" out="$(fresh_host; run_uninstall down --yes)"
expect "a k3s that does not answer stops the run" "k3s does not answer" "$out" expect "a k3s that does not answer stops the run" "k3s does not answer" "$out"
@@ -206,18 +198,6 @@ run_uninstall down --yes --keep-k3s >/dev/null
calls="$(cat "$root/calls")" calls="$(cat "$root/calls")"
refute "--keep-k3s never runs k3s's uninstaller" "RUN k3s-uninstall.sh" "$calls" refute "--keep-k3s never runs k3s's uninstaller" "RUN k3s-uninstall.sh" "$calls"
# --- k3s already gone ----------------------------------------------------------------------
fresh_host
rm -f "$root/h/bin/k3s"
printf '[Service]\nLimitNOFILE=4096\n' > "$root/h/units/k3s.service.d/90-admin.conf"
run_uninstall down --yes >/dev/null
[ ! -e "$root/h/units/k3s.service.d/50-felis.conf" ] \
&& echo "PASS a k3s removed some other way does not leave the installer's service environment" \
|| { echo "FAIL the k3s service drop-in outlived k3s"; fails=$((fails + 1)); }
[ -f "$root/h/units/k3s.service.d/90-admin.conf" ] \
&& echo "PASS a drop-in someone else wrote beside it stays, with the directory" \
|| { echo "FAIL the uninstall removed a k3s drop-in it did not write"; fails=$((fails + 1)); }
# --- purge ------------------------------------------------------------------------------- # --- purge -------------------------------------------------------------------------------
fresh_host fresh_host
out="$(run_uninstall "default felis minecraft" --purge --yes)" out="$(run_uninstall "default felis minecraft" --purge --yes)"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

-79
View File
@@ -1,79 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1040" height="640" viewBox="110 445 1040 640" role="img" aria-labelledby="title desc">
<title id="title">Felis</title>
<desc id="desc">A fluffy ivory kitten with sage green ears and eyes, pink cheeks, and green whiskers.</desc>
<defs>
<linearGradient id="rim" x1="250" y1="460" x2="940" y2="1090" gradientUnits="userSpaceOnUse">
<stop stop-color="#91bd86"/>
<stop offset=".5" stop-color="#8bbd83"/>
<stop offset="1" stop-color="#74ae77"/>
</linearGradient>
<linearGradient id="ear" x1="220" y1="540" x2="1010" y2="800" gradientUnits="userSpaceOnUse">
<stop stop-color="#83b57e"/>
<stop offset=".48" stop-color="#8cbd83"/>
<stop offset="1" stop-color="#80b47b"/>
</linearGradient>
<linearGradient id="earShade" x1="220" y1="610" x2="365" y2="740" gradientUnits="userSpaceOnUse">
<stop stop-color="#71aa75" stop-opacity=".32"/>
<stop offset="1" stop-color="#a6cb91" stop-opacity="0"/>
</linearGradient>
<linearGradient id="furShade" x1="220" y1="570" x2="1010" y2="1085" gradientUnits="userSpaceOnUse">
<stop stop-color="#e6ecda"/>
<stop offset=".52" stop-color="#e8eddf"/>
<stop offset="1" stop-color="#e4ebd8"/>
</linearGradient>
<radialGradient id="fur" cx=".48" cy=".56" r=".75">
<stop stop-color="#fdfbf4"/>
<stop offset="1" stop-color="#fcfaf3"/>
</radialGradient>
<linearGradient id="eye" x1="411" y1="860" x2="843" y2="993" gradientUnits="userSpaceOnUse">
<stop stop-color="#65a26c"/>
<stop offset=".48" stop-color="#6ba770"/>
<stop offset="1" stop-color="#63a16b"/>
</linearGradient>
<radialGradient id="cheek" cx=".45" cy=".42" r=".7">
<stop stop-color="#ffd2d2"/>
<stop offset="1" stop-color="#ffcccc"/>
</radialGradient>
<linearGradient id="whisker" x1="201" y1="1015" x2="282" y2="1015" gradientUnits="userSpaceOnUse">
<stop stop-color="#92bf87"/>
<stop offset="1" stop-color="#62a16c"/>
</linearGradient>
</defs>
<g id="felis">
<!-- Ear rims sit behind the pale ear fur and the forehead. -->
<path fill="url(#rim)" d="M174 633C176 566 202 481 233 459C263 438 329 462 384 484C443 509 488 550 517 580C553 560 601 548 643 549L631 569C666 566 704 573 734 583C792 517 880 470 949 454C977 448 1008 447 1024 462C1057 490 1075 568 1078 632L1027 801L935 777L749 625L509 624L315 774L218 814Z"/>
<path fill="url(#rim)" d="M194 782C188 799 191 821 199 837C187 854 175 869 163 882L220 866L265 800Z"/>
<path fill="url(#rim)" d="M1020 800C1037 815 1050 806 1062 786C1064 809 1059 829 1051 843L1080 882L1034 861Z"/>
<path fill="url(#furShade)" d="M236 479C211 512 189 568 177 623C168 678 173 743 191 785C199 805 210 815 225 812L258 778L386 651L285 518Z"/>
<path fill="url(#furShade)" d="M1018 480C1049 520 1067 578 1078 632C1083 689 1076 749 1059 789C1050 809 1036 814 1022 802L974 752L884 647L968 522Z"/>
<path fill="url(#fur)" d="M235 479C243 463 267 467 289 472C364 489 451 543 501 606L501 800L234 797C205 730 203 598 240 496C235 493 231 487 235 479Z"/>
<path fill="url(#fur)" d="M1018 479C1008 460 982 465 960 470C885 486 803 533 756 598L756 800L1019 796C1043 725 1042 596 1011 494C1017 491 1021 485 1018 479Z"/>
<path fill="url(#ear)" d="M234 797C215 752 215 693 222 651C229 606 244 557 261 542C274 531 284 533 296 547C326 581 356 630 374 684C353 664 329 655 293 649C299 672 308 686 323 694C289 719 257 759 234 797Z"/>
<path fill="url(#earShade)" d="M261 542C269 544 282 565 294 588C309 620 325 649 338 666C322 658 308 653 293 649C299 672 308 686 323 694C287 720 257 759 234 797C216 752 215 693 222 651C229 606 244 557 261 542Z"/>
<path fill="url(#ear)" d="M1019 796C1038 750 1036 688 1029 646C1021 600 1008 554 990 540C978 529 968 533 956 547C925 581 896 628 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796Z"/>
<path fill="#a6cb91" opacity=".64" d="M265 712C274 693 290 681 306 678C311 685 316 690 323 694C301 695 281 701 265 712Z"/>
<path fill="#a4c990" opacity=".64" d="M929 692C936 688 942 682 947 676C965 678 976 688 983 701C966 694 949 691 929 692Z"/>
<!-- Green fur tips show around the ivory face. -->
<path fill="url(#rim)" d="M111 896C117 926 132 948 153 959C132 984 118 1018 117 1048C139 1014 156 992 184 974L228 916Z"/>
<path fill="url(#rim)" d="M1145 916C1132 939 1113 953 1090 960C1113 989 1127 1020 1133 1053C1106 1019 1087 996 1057 977L1017 914Z"/>
<!-- One continuous face silhouette keeps the exported outline clean. -->
<path fill="url(#fur)" d="M118 1085C120 1037 141 989 174 953C142 941 121 922 111 896C156 891 193 871 221 839C244 813 264 789 287 772C268 778 251 785 234 797C255 759 287 719 323 694C308 686 299 672 293 649C329 655 353 664 374 684C394 654 443 624 501 606C544 579 591 558 637 551C627 567 614 580 599 592C656 566 729 580 797 614C827 632 855 656 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796C1038 833 1058 866 1083 887C1104 905 1126 913 1145 916C1129 935 1107 945 1079 948C1111 990 1129 1037 1133 1085Z"/>
<path fill="url(#furShade)" d="M668 633C692 632 711 639 720 654C730 670 735 687 740 703Z"/>
<path fill="url(#furShade)" d="M111 896C144 905 173 908 212 901L174 953C142 941 121 922 111 896Z"/>
<path fill="url(#furShade)" d="M163 882C192 861 209 840 225 812L244 794C258 784 271 777 287 772C246 827 212 868 163 882Z"/>
<path fill="url(#furShade)" d="M1037 887C1074 901 1107 914 1145 916C1129 935 1107 945 1079 948Z"/>
<path fill="url(#furShade)" d="M118 1085C121 1054 133 1023 151 997C146 1034 151 1063 172 1085Z"/>
<path fill="url(#furShade)" d="M1081 1085C1094 1069 1101 1049 1103 1028C1115 1045 1120 1063 1122 1085Z"/>
<ellipse fill="url(#eye)" cx="454" cy="922" rx="43.5" ry="66"/>
<ellipse fill="url(#eye)" cx="800.5" cy="922" rx="43.5" ry="66"/>
<path fill="url(#eye)" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/>
<ellipse fill="url(#cheek)" cx="364.5" cy="1015.5" rx="56.5" ry="38"/>
<ellipse fill="url(#cheek)" cx="889.5" cy="1015.5" rx="56.5" ry="38"/>
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
<g transform="translate(1254 0) scale(-1 1)">
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

-33
View File
@@ -1,33 +0,0 @@
# Felis 图标
基于参考图片手工临摹。矢量文件仅使用路径、形状、渐变和蒙版,没有嵌入位图。所有彩色导出均来自 [`../felis-logo.svg`](../felis-logo.svg)。
| 文件 | 用途 |
| --- | --- |
| `../felis-logo.svg` | 1040 × 640 紧裁透明矢量原稿 |
| `../felis-logo.png` | README 用图,保留现有 540 × 341 尺寸 |
| `felis-reference.svg` / `.png` | 保留参考图的 1254 × 1254 白底构图 |
| `felis-icon.svg` | 居中的方形透明图标 |
| `felis-app.svg` / `.png` | 浅绿圆角应用图标,1024 × 1024 |
| `felis-app-dark.svg` / `.png` | 深绿圆角应用图标,1024 × 1024 |
| `felis-maskable.svg` / `.png` | 不透明方形背景,猫脸位于中心安全圆内 |
| `felis-monochrome.svg` | 紧裁黑色单色版,眼睛和鼻子镂空 |
| `felis-mask.svg` | 方形单色版,供 Safari 固定标签使用 |
| `png/icon-{size}.png` | 透明图标:16、20、24、32、40、48、64、96、128、180、192、256、512、1024 px |
| `felis.ico` | Windows 多分辨率图标:16、24、32、48、64、128、256 px |
| `felis.icns` / `felis.iconset/` | macOS 图标及 16、32、128、256、512 pt 的 1× / 2× PNG |
| `preview.png` | 图标套装预览 |
网页资源已放入 `panel/public/`,并由 `panel/index.html` 引用:SVG / ICO / PNG favicon、180 px Apple Touch Icon、Safari 固定标签图标,以及 192 / 512 px 普通和 maskable 图标。`site.webmanifest` 提供应用名称和图标元数据,不提供离线缓存。
网页通过 `panel/src/assets/felis-logo.svg` 引用白底圆角方形图标。桌面侧栏和手机导航使用 28 px,登录与初始化页头使用 44 px。它沿用套装中 `felis-app.svg` 的构图,将图标底色改为白色。构建时生成带内容哈希的文件名,重新导出会同步更新这个文件。
标签页图标单独向下偏移以对齐视觉中心,SVG、PNG、ICO 和 Safari 固定标签使用相同位置;应用图标保持原来的构图。仅重新导出标签页资源可给下方命令加上 `--favicons-only`。
修改矢量原稿后可重新导出;需要 Node.js 和 `sharp`,macOS 的 ICNS 导出还使用系统 `iconutil`。在仓库根目录运行:
```sh
NODE_PATH=/path/to/node_modules node scripts/generate-icons.cjs
```
`NODE_PATH` 指向已安装 `sharp` 的 `node_modules`。导出工具不会增加面板的运行依赖;非 macOS 平台仍会生成其余格式和 `iconset`。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

-77
View File
@@ -1,77 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Felis">
<defs>
<linearGradient id="rim" x1="250" y1="460" x2="940" y2="1090" gradientUnits="userSpaceOnUse">
<stop stop-color="#91bd86"/>
<stop offset=".5" stop-color="#8bbd83"/>
<stop offset="1" stop-color="#74ae77"/>
</linearGradient>
<linearGradient id="ear" x1="220" y1="540" x2="1010" y2="800" gradientUnits="userSpaceOnUse">
<stop stop-color="#83b57e"/>
<stop offset=".48" stop-color="#8cbd83"/>
<stop offset="1" stop-color="#80b47b"/>
</linearGradient>
<linearGradient id="earShade" x1="220" y1="610" x2="365" y2="740" gradientUnits="userSpaceOnUse">
<stop stop-color="#71aa75" stop-opacity=".32"/>
<stop offset="1" stop-color="#a6cb91" stop-opacity="0"/>
</linearGradient>
<linearGradient id="furShade" x1="220" y1="570" x2="1010" y2="1085" gradientUnits="userSpaceOnUse">
<stop stop-color="#e6ecda"/>
<stop offset=".52" stop-color="#e8eddf"/>
<stop offset="1" stop-color="#e4ebd8"/>
</linearGradient>
<radialGradient id="fur" cx=".48" cy=".56" r=".75">
<stop stop-color="#fdfbf4"/>
<stop offset="1" stop-color="#fcfaf3"/>
</radialGradient>
<linearGradient id="eye" x1="411" y1="860" x2="843" y2="993" gradientUnits="userSpaceOnUse">
<stop stop-color="#65a26c"/>
<stop offset=".48" stop-color="#6ba770"/>
<stop offset="1" stop-color="#63a16b"/>
</linearGradient>
<radialGradient id="cheek" cx=".45" cy=".42" r=".7">
<stop stop-color="#ffd2d2"/>
<stop offset="1" stop-color="#ffcccc"/>
</radialGradient>
<linearGradient id="whisker" x1="201" y1="1015" x2="282" y2="1015" gradientUnits="userSpaceOnUse">
<stop stop-color="#92bf87"/>
<stop offset="1" stop-color="#62a16c"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="224" fill="#253d31"/><g transform="translate(64 236.30769230769226) scale(0.8615384615384616) translate(-110 -445)"><g id="felis">
<!-- Ear rims sit behind the pale ear fur and the forehead. -->
<path fill="url(#rim)" d="M174 633C176 566 202 481 233 459C263 438 329 462 384 484C443 509 488 550 517 580C553 560 601 548 643 549L631 569C666 566 704 573 734 583C792 517 880 470 949 454C977 448 1008 447 1024 462C1057 490 1075 568 1078 632L1027 801L935 777L749 625L509 624L315 774L218 814Z"/>
<path fill="url(#rim)" d="M194 782C188 799 191 821 199 837C187 854 175 869 163 882L220 866L265 800Z"/>
<path fill="url(#rim)" d="M1020 800C1037 815 1050 806 1062 786C1064 809 1059 829 1051 843L1080 882L1034 861Z"/>
<path fill="url(#furShade)" d="M236 479C211 512 189 568 177 623C168 678 173 743 191 785C199 805 210 815 225 812L258 778L386 651L285 518Z"/>
<path fill="url(#furShade)" d="M1018 480C1049 520 1067 578 1078 632C1083 689 1076 749 1059 789C1050 809 1036 814 1022 802L974 752L884 647L968 522Z"/>
<path fill="url(#fur)" d="M235 479C243 463 267 467 289 472C364 489 451 543 501 606L501 800L234 797C205 730 203 598 240 496C235 493 231 487 235 479Z"/>
<path fill="url(#fur)" d="M1018 479C1008 460 982 465 960 470C885 486 803 533 756 598L756 800L1019 796C1043 725 1042 596 1011 494C1017 491 1021 485 1018 479Z"/>
<path fill="url(#ear)" d="M234 797C215 752 215 693 222 651C229 606 244 557 261 542C274 531 284 533 296 547C326 581 356 630 374 684C353 664 329 655 293 649C299 672 308 686 323 694C289 719 257 759 234 797Z"/>
<path fill="url(#earShade)" d="M261 542C269 544 282 565 294 588C309 620 325 649 338 666C322 658 308 653 293 649C299 672 308 686 323 694C287 720 257 759 234 797C216 752 215 693 222 651C229 606 244 557 261 542Z"/>
<path fill="url(#ear)" d="M1019 796C1038 750 1036 688 1029 646C1021 600 1008 554 990 540C978 529 968 533 956 547C925 581 896 628 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796Z"/>
<path fill="#a6cb91" opacity=".64" d="M265 712C274 693 290 681 306 678C311 685 316 690 323 694C301 695 281 701 265 712Z"/>
<path fill="#a4c990" opacity=".64" d="M929 692C936 688 942 682 947 676C965 678 976 688 983 701C966 694 949 691 929 692Z"/>
<!-- Green fur tips show around the ivory face. -->
<path fill="url(#rim)" d="M111 896C117 926 132 948 153 959C132 984 118 1018 117 1048C139 1014 156 992 184 974L228 916Z"/>
<path fill="url(#rim)" d="M1145 916C1132 939 1113 953 1090 960C1113 989 1127 1020 1133 1053C1106 1019 1087 996 1057 977L1017 914Z"/>
<!-- One continuous face silhouette keeps the exported outline clean. -->
<path fill="url(#fur)" d="M118 1085C120 1037 141 989 174 953C142 941 121 922 111 896C156 891 193 871 221 839C244 813 264 789 287 772C268 778 251 785 234 797C255 759 287 719 323 694C308 686 299 672 293 649C329 655 353 664 374 684C394 654 443 624 501 606C544 579 591 558 637 551C627 567 614 580 599 592C656 566 729 580 797 614C827 632 855 656 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796C1038 833 1058 866 1083 887C1104 905 1126 913 1145 916C1129 935 1107 945 1079 948C1111 990 1129 1037 1133 1085Z"/>
<path fill="url(#furShade)" d="M668 633C692 632 711 639 720 654C730 670 735 687 740 703Z"/>
<path fill="url(#furShade)" d="M111 896C144 905 173 908 212 901L174 953C142 941 121 922 111 896Z"/>
<path fill="url(#furShade)" d="M163 882C192 861 209 840 225 812L244 794C258 784 271 777 287 772C246 827 212 868 163 882Z"/>
<path fill="url(#furShade)" d="M1037 887C1074 901 1107 914 1145 916C1129 935 1107 945 1079 948Z"/>
<path fill="url(#furShade)" d="M118 1085C121 1054 133 1023 151 997C146 1034 151 1063 172 1085Z"/>
<path fill="url(#furShade)" d="M1081 1085C1094 1069 1101 1049 1103 1028C1115 1045 1120 1063 1122 1085Z"/>
<ellipse fill="url(#eye)" cx="454" cy="922" rx="43.5" ry="66"/>
<ellipse fill="url(#eye)" cx="800.5" cy="922" rx="43.5" ry="66"/>
<path fill="url(#eye)" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/>
<ellipse fill="url(#cheek)" cx="364.5" cy="1015.5" rx="56.5" ry="38"/>
<ellipse fill="url(#cheek)" cx="889.5" cy="1015.5" rx="56.5" ry="38"/>
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
<g transform="translate(1254 0) scale(-1 1)">
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
</g>
</g></g>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

-77
View File
@@ -1,77 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Felis">
<defs>
<linearGradient id="rim" x1="250" y1="460" x2="940" y2="1090" gradientUnits="userSpaceOnUse">
<stop stop-color="#91bd86"/>
<stop offset=".5" stop-color="#8bbd83"/>
<stop offset="1" stop-color="#74ae77"/>
</linearGradient>
<linearGradient id="ear" x1="220" y1="540" x2="1010" y2="800" gradientUnits="userSpaceOnUse">
<stop stop-color="#83b57e"/>
<stop offset=".48" stop-color="#8cbd83"/>
<stop offset="1" stop-color="#80b47b"/>
</linearGradient>
<linearGradient id="earShade" x1="220" y1="610" x2="365" y2="740" gradientUnits="userSpaceOnUse">
<stop stop-color="#71aa75" stop-opacity=".32"/>
<stop offset="1" stop-color="#a6cb91" stop-opacity="0"/>
</linearGradient>
<linearGradient id="furShade" x1="220" y1="570" x2="1010" y2="1085" gradientUnits="userSpaceOnUse">
<stop stop-color="#e6ecda"/>
<stop offset=".52" stop-color="#e8eddf"/>
<stop offset="1" stop-color="#e4ebd8"/>
</linearGradient>
<radialGradient id="fur" cx=".48" cy=".56" r=".75">
<stop stop-color="#fdfbf4"/>
<stop offset="1" stop-color="#fcfaf3"/>
</radialGradient>
<linearGradient id="eye" x1="411" y1="860" x2="843" y2="993" gradientUnits="userSpaceOnUse">
<stop stop-color="#65a26c"/>
<stop offset=".48" stop-color="#6ba770"/>
<stop offset="1" stop-color="#63a16b"/>
</linearGradient>
<radialGradient id="cheek" cx=".45" cy=".42" r=".7">
<stop stop-color="#ffd2d2"/>
<stop offset="1" stop-color="#ffcccc"/>
</radialGradient>
<linearGradient id="whisker" x1="201" y1="1015" x2="282" y2="1015" gradientUnits="userSpaceOnUse">
<stop stop-color="#92bf87"/>
<stop offset="1" stop-color="#62a16c"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="224" fill="#e9f1e3"/><g transform="translate(64 236.30769230769226) scale(0.8615384615384616) translate(-110 -445)"><g id="felis">
<!-- Ear rims sit behind the pale ear fur and the forehead. -->
<path fill="url(#rim)" d="M174 633C176 566 202 481 233 459C263 438 329 462 384 484C443 509 488 550 517 580C553 560 601 548 643 549L631 569C666 566 704 573 734 583C792 517 880 470 949 454C977 448 1008 447 1024 462C1057 490 1075 568 1078 632L1027 801L935 777L749 625L509 624L315 774L218 814Z"/>
<path fill="url(#rim)" d="M194 782C188 799 191 821 199 837C187 854 175 869 163 882L220 866L265 800Z"/>
<path fill="url(#rim)" d="M1020 800C1037 815 1050 806 1062 786C1064 809 1059 829 1051 843L1080 882L1034 861Z"/>
<path fill="url(#furShade)" d="M236 479C211 512 189 568 177 623C168 678 173 743 191 785C199 805 210 815 225 812L258 778L386 651L285 518Z"/>
<path fill="url(#furShade)" d="M1018 480C1049 520 1067 578 1078 632C1083 689 1076 749 1059 789C1050 809 1036 814 1022 802L974 752L884 647L968 522Z"/>
<path fill="url(#fur)" d="M235 479C243 463 267 467 289 472C364 489 451 543 501 606L501 800L234 797C205 730 203 598 240 496C235 493 231 487 235 479Z"/>
<path fill="url(#fur)" d="M1018 479C1008 460 982 465 960 470C885 486 803 533 756 598L756 800L1019 796C1043 725 1042 596 1011 494C1017 491 1021 485 1018 479Z"/>
<path fill="url(#ear)" d="M234 797C215 752 215 693 222 651C229 606 244 557 261 542C274 531 284 533 296 547C326 581 356 630 374 684C353 664 329 655 293 649C299 672 308 686 323 694C289 719 257 759 234 797Z"/>
<path fill="url(#earShade)" d="M261 542C269 544 282 565 294 588C309 620 325 649 338 666C322 658 308 653 293 649C299 672 308 686 323 694C287 720 257 759 234 797C216 752 215 693 222 651C229 606 244 557 261 542Z"/>
<path fill="url(#ear)" d="M1019 796C1038 750 1036 688 1029 646C1021 600 1008 554 990 540C978 529 968 533 956 547C925 581 896 628 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796Z"/>
<path fill="#a6cb91" opacity=".64" d="M265 712C274 693 290 681 306 678C311 685 316 690 323 694C301 695 281 701 265 712Z"/>
<path fill="#a4c990" opacity=".64" d="M929 692C936 688 942 682 947 676C965 678 976 688 983 701C966 694 949 691 929 692Z"/>
<!-- Green fur tips show around the ivory face. -->
<path fill="url(#rim)" d="M111 896C117 926 132 948 153 959C132 984 118 1018 117 1048C139 1014 156 992 184 974L228 916Z"/>
<path fill="url(#rim)" d="M1145 916C1132 939 1113 953 1090 960C1113 989 1127 1020 1133 1053C1106 1019 1087 996 1057 977L1017 914Z"/>
<!-- One continuous face silhouette keeps the exported outline clean. -->
<path fill="url(#fur)" d="M118 1085C120 1037 141 989 174 953C142 941 121 922 111 896C156 891 193 871 221 839C244 813 264 789 287 772C268 778 251 785 234 797C255 759 287 719 323 694C308 686 299 672 293 649C329 655 353 664 374 684C394 654 443 624 501 606C544 579 591 558 637 551C627 567 614 580 599 592C656 566 729 580 797 614C827 632 855 656 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796C1038 833 1058 866 1083 887C1104 905 1126 913 1145 916C1129 935 1107 945 1079 948C1111 990 1129 1037 1133 1085Z"/>
<path fill="url(#furShade)" d="M668 633C692 632 711 639 720 654C730 670 735 687 740 703Z"/>
<path fill="url(#furShade)" d="M111 896C144 905 173 908 212 901L174 953C142 941 121 922 111 896Z"/>
<path fill="url(#furShade)" d="M163 882C192 861 209 840 225 812L244 794C258 784 271 777 287 772C246 827 212 868 163 882Z"/>
<path fill="url(#furShade)" d="M1037 887C1074 901 1107 914 1145 916C1129 935 1107 945 1079 948Z"/>
<path fill="url(#furShade)" d="M118 1085C121 1054 133 1023 151 997C146 1034 151 1063 172 1085Z"/>
<path fill="url(#furShade)" d="M1081 1085C1094 1069 1101 1049 1103 1028C1115 1045 1120 1063 1122 1085Z"/>
<ellipse fill="url(#eye)" cx="454" cy="922" rx="43.5" ry="66"/>
<ellipse fill="url(#eye)" cx="800.5" cy="922" rx="43.5" ry="66"/>
<path fill="url(#eye)" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/>
<ellipse fill="url(#cheek)" cx="364.5" cy="1015.5" rx="56.5" ry="38"/>
<ellipse fill="url(#cheek)" cx="889.5" cy="1015.5" rx="56.5" ry="38"/>
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
<g transform="translate(1254 0) scale(-1 1)">
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
</g>
</g></g>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

-77
View File
@@ -1,77 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Felis">
<defs>
<linearGradient id="rim" x1="250" y1="460" x2="940" y2="1090" gradientUnits="userSpaceOnUse">
<stop stop-color="#91bd86"/>
<stop offset=".5" stop-color="#8bbd83"/>
<stop offset="1" stop-color="#74ae77"/>
</linearGradient>
<linearGradient id="ear" x1="220" y1="540" x2="1010" y2="800" gradientUnits="userSpaceOnUse">
<stop stop-color="#83b57e"/>
<stop offset=".48" stop-color="#8cbd83"/>
<stop offset="1" stop-color="#80b47b"/>
</linearGradient>
<linearGradient id="earShade" x1="220" y1="610" x2="365" y2="740" gradientUnits="userSpaceOnUse">
<stop stop-color="#71aa75" stop-opacity=".32"/>
<stop offset="1" stop-color="#a6cb91" stop-opacity="0"/>
</linearGradient>
<linearGradient id="furShade" x1="220" y1="570" x2="1010" y2="1085" gradientUnits="userSpaceOnUse">
<stop stop-color="#e6ecda"/>
<stop offset=".52" stop-color="#e8eddf"/>
<stop offset="1" stop-color="#e4ebd8"/>
</linearGradient>
<radialGradient id="fur" cx=".48" cy=".56" r=".75">
<stop stop-color="#fdfbf4"/>
<stop offset="1" stop-color="#fcfaf3"/>
</radialGradient>
<linearGradient id="eye" x1="411" y1="860" x2="843" y2="993" gradientUnits="userSpaceOnUse">
<stop stop-color="#65a26c"/>
<stop offset=".48" stop-color="#6ba770"/>
<stop offset="1" stop-color="#63a16b"/>
</linearGradient>
<radialGradient id="cheek" cx=".45" cy=".42" r=".7">
<stop stop-color="#ffd2d2"/>
<stop offset="1" stop-color="#ffcccc"/>
</radialGradient>
<linearGradient id="whisker" x1="201" y1="1015" x2="282" y2="1015" gradientUnits="userSpaceOnUse">
<stop stop-color="#92bf87"/>
<stop offset="1" stop-color="#62a16c"/>
</linearGradient>
</defs>
<g transform="translate(64 236.30769230769226) scale(0.8615384615384616) translate(-110 -445)"><g id="felis">
<!-- Ear rims sit behind the pale ear fur and the forehead. -->
<path fill="url(#rim)" d="M174 633C176 566 202 481 233 459C263 438 329 462 384 484C443 509 488 550 517 580C553 560 601 548 643 549L631 569C666 566 704 573 734 583C792 517 880 470 949 454C977 448 1008 447 1024 462C1057 490 1075 568 1078 632L1027 801L935 777L749 625L509 624L315 774L218 814Z"/>
<path fill="url(#rim)" d="M194 782C188 799 191 821 199 837C187 854 175 869 163 882L220 866L265 800Z"/>
<path fill="url(#rim)" d="M1020 800C1037 815 1050 806 1062 786C1064 809 1059 829 1051 843L1080 882L1034 861Z"/>
<path fill="url(#furShade)" d="M236 479C211 512 189 568 177 623C168 678 173 743 191 785C199 805 210 815 225 812L258 778L386 651L285 518Z"/>
<path fill="url(#furShade)" d="M1018 480C1049 520 1067 578 1078 632C1083 689 1076 749 1059 789C1050 809 1036 814 1022 802L974 752L884 647L968 522Z"/>
<path fill="url(#fur)" d="M235 479C243 463 267 467 289 472C364 489 451 543 501 606L501 800L234 797C205 730 203 598 240 496C235 493 231 487 235 479Z"/>
<path fill="url(#fur)" d="M1018 479C1008 460 982 465 960 470C885 486 803 533 756 598L756 800L1019 796C1043 725 1042 596 1011 494C1017 491 1021 485 1018 479Z"/>
<path fill="url(#ear)" d="M234 797C215 752 215 693 222 651C229 606 244 557 261 542C274 531 284 533 296 547C326 581 356 630 374 684C353 664 329 655 293 649C299 672 308 686 323 694C289 719 257 759 234 797Z"/>
<path fill="url(#earShade)" d="M261 542C269 544 282 565 294 588C309 620 325 649 338 666C322 658 308 653 293 649C299 672 308 686 323 694C287 720 257 759 234 797C216 752 215 693 222 651C229 606 244 557 261 542Z"/>
<path fill="url(#ear)" d="M1019 796C1038 750 1036 688 1029 646C1021 600 1008 554 990 540C978 529 968 533 956 547C925 581 896 628 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796Z"/>
<path fill="#a6cb91" opacity=".64" d="M265 712C274 693 290 681 306 678C311 685 316 690 323 694C301 695 281 701 265 712Z"/>
<path fill="#a4c990" opacity=".64" d="M929 692C936 688 942 682 947 676C965 678 976 688 983 701C966 694 949 691 929 692Z"/>
<!-- Green fur tips show around the ivory face. -->
<path fill="url(#rim)" d="M111 896C117 926 132 948 153 959C132 984 118 1018 117 1048C139 1014 156 992 184 974L228 916Z"/>
<path fill="url(#rim)" d="M1145 916C1132 939 1113 953 1090 960C1113 989 1127 1020 1133 1053C1106 1019 1087 996 1057 977L1017 914Z"/>
<!-- One continuous face silhouette keeps the exported outline clean. -->
<path fill="url(#fur)" d="M118 1085C120 1037 141 989 174 953C142 941 121 922 111 896C156 891 193 871 221 839C244 813 264 789 287 772C268 778 251 785 234 797C255 759 287 719 323 694C308 686 299 672 293 649C329 655 353 664 374 684C394 654 443 624 501 606C544 579 591 558 637 551C627 567 614 580 599 592C656 566 729 580 797 614C827 632 855 656 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796C1038 833 1058 866 1083 887C1104 905 1126 913 1145 916C1129 935 1107 945 1079 948C1111 990 1129 1037 1133 1085Z"/>
<path fill="url(#furShade)" d="M668 633C692 632 711 639 720 654C730 670 735 687 740 703Z"/>
<path fill="url(#furShade)" d="M111 896C144 905 173 908 212 901L174 953C142 941 121 922 111 896Z"/>
<path fill="url(#furShade)" d="M163 882C192 861 209 840 225 812L244 794C258 784 271 777 287 772C246 827 212 868 163 882Z"/>
<path fill="url(#furShade)" d="M1037 887C1074 901 1107 914 1145 916C1129 935 1107 945 1079 948Z"/>
<path fill="url(#furShade)" d="M118 1085C121 1054 133 1023 151 997C146 1034 151 1063 172 1085Z"/>
<path fill="url(#furShade)" d="M1081 1085C1094 1069 1101 1049 1103 1028C1115 1045 1120 1063 1122 1085Z"/>
<ellipse fill="url(#eye)" cx="454" cy="922" rx="43.5" ry="66"/>
<ellipse fill="url(#eye)" cx="800.5" cy="922" rx="43.5" ry="66"/>
<path fill="url(#eye)" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/>
<ellipse fill="url(#cheek)" cx="364.5" cy="1015.5" rx="56.5" ry="38"/>
<ellipse fill="url(#cheek)" cx="889.5" cy="1015.5" rx="56.5" ry="38"/>
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
<g transform="translate(1254 0) scale(-1 1)">
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
</g>
</g></g>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Felis">
<defs><mask id="shape" maskUnits="userSpaceOnUse" x="110" y="445" width="1040" height="640"><path fill="#fff" d="M174 633C176 566 202 481 233 459C263 438 329 462 384 484C443 509 488 550 517 580C553 560 601 548 643 549L631 569C666 566 704 573 734 583C792 517 880 470 949 454C977 448 1008 447 1024 462C1057 490 1075 568 1078 632L1027 801L935 777L749 625L509 624L315 774L218 814Z"/><path fill="#fff" d="M194 782C188 799 191 821 199 837C187 854 175 869 163 882L220 866L265 800Z"/><path fill="#fff" d="M1020 800C1037 815 1050 806 1062 786C1064 809 1059 829 1051 843L1080 882L1034 861Z"/><path fill="#fff" d="M236 479C211 512 189 568 177 623C168 678 173 743 191 785C199 805 210 815 225 812L258 778L386 651L285 518Z"/><path fill="#fff" d="M1018 480C1049 520 1067 578 1078 632C1083 689 1076 749 1059 789C1050 809 1036 814 1022 802L974 752L884 647L968 522Z"/><path fill="#fff" d="M235 479C243 463 267 467 289 472C364 489 451 543 501 606L501 800L234 797C205 730 203 598 240 496C235 493 231 487 235 479Z"/><path fill="#fff" d="M1018 479C1008 460 982 465 960 470C885 486 803 533 756 598L756 800L1019 796C1043 725 1042 596 1011 494C1017 491 1021 485 1018 479Z"/><path fill="#fff" d="M234 797C215 752 215 693 222 651C229 606 244 557 261 542C274 531 284 533 296 547C326 581 356 630 374 684C353 664 329 655 293 649C299 672 308 686 323 694C289 719 257 759 234 797Z"/><path fill="#fff" d="M261 542C269 544 282 565 294 588C309 620 325 649 338 666C322 658 308 653 293 649C299 672 308 686 323 694C287 720 257 759 234 797C216 752 215 693 222 651C229 606 244 557 261 542Z"/><path fill="#fff" d="M1019 796C1038 750 1036 688 1029 646C1021 600 1008 554 990 540C978 529 968 533 956 547C925 581 896 628 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796Z"/><path fill="#fff" d="M265 712C274 693 290 681 306 678C311 685 316 690 323 694C301 695 281 701 265 712Z"/><path fill="#fff" d="M929 692C936 688 942 682 947 676C965 678 976 688 983 701C966 694 949 691 929 692Z"/><path fill="#fff" d="M111 896C117 926 132 948 153 959C132 984 118 1018 117 1048C139 1014 156 992 184 974L228 916Z"/><path fill="#fff" d="M1145 916C1132 939 1113 953 1090 960C1113 989 1127 1020 1133 1053C1106 1019 1087 996 1057 977L1017 914Z"/><path fill="#fff" d="M118 1085C120 1037 141 989 174 953C142 941 121 922 111 896C156 891 193 871 221 839C244 813 264 789 287 772C268 778 251 785 234 797C255 759 287 719 323 694C308 686 299 672 293 649C329 655 353 664 374 684C394 654 443 624 501 606C544 579 591 558 637 551C627 567 614 580 599 592C656 566 729 580 797 614C827 632 855 656 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796C1038 833 1058 866 1083 887C1104 905 1126 913 1145 916C1129 935 1107 945 1079 948C1111 990 1129 1037 1133 1085Z"/><path fill="#fff" d="M668 633C692 632 711 639 720 654C730 670 735 687 740 703Z"/><path fill="#fff" d="M111 896C144 905 173 908 212 901L174 953C142 941 121 922 111 896Z"/><path fill="#fff" d="M163 882C192 861 209 840 225 812L244 794C258 784 271 777 287 772C246 827 212 868 163 882Z"/><path fill="#fff" d="M1037 887C1074 901 1107 914 1145 916C1129 935 1107 945 1079 948Z"/><path fill="#fff" d="M118 1085C121 1054 133 1023 151 997C146 1034 151 1063 172 1085Z"/><path fill="#fff" d="M1081 1085C1094 1069 1101 1049 1103 1028C1115 1045 1120 1063 1122 1085Z"/><path fill="#fff" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/><path fill="#fff" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/><path fill="#fff" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/><path fill="#fff" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/><path fill="#fff" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/><ellipse fill="#000" cx="454" cy="922" rx="43.5" ry="66"/><ellipse fill="#000" cx="800.5" cy="922" rx="43.5" ry="66"/><path fill="#000" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/></mask></defs>
<g transform="translate(64 236.30769230769226) scale(0.8615384615384616) translate(-110 -445)"><rect x="110" y="445" width="1040" height="640" fill="#000" mask="url(#shape)"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-77
View File
@@ -1,77 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Felis">
<defs>
<linearGradient id="rim" x1="250" y1="460" x2="940" y2="1090" gradientUnits="userSpaceOnUse">
<stop stop-color="#91bd86"/>
<stop offset=".5" stop-color="#8bbd83"/>
<stop offset="1" stop-color="#74ae77"/>
</linearGradient>
<linearGradient id="ear" x1="220" y1="540" x2="1010" y2="800" gradientUnits="userSpaceOnUse">
<stop stop-color="#83b57e"/>
<stop offset=".48" stop-color="#8cbd83"/>
<stop offset="1" stop-color="#80b47b"/>
</linearGradient>
<linearGradient id="earShade" x1="220" y1="610" x2="365" y2="740" gradientUnits="userSpaceOnUse">
<stop stop-color="#71aa75" stop-opacity=".32"/>
<stop offset="1" stop-color="#a6cb91" stop-opacity="0"/>
</linearGradient>
<linearGradient id="furShade" x1="220" y1="570" x2="1010" y2="1085" gradientUnits="userSpaceOnUse">
<stop stop-color="#e6ecda"/>
<stop offset=".52" stop-color="#e8eddf"/>
<stop offset="1" stop-color="#e4ebd8"/>
</linearGradient>
<radialGradient id="fur" cx=".48" cy=".56" r=".75">
<stop stop-color="#fdfbf4"/>
<stop offset="1" stop-color="#fcfaf3"/>
</radialGradient>
<linearGradient id="eye" x1="411" y1="860" x2="843" y2="993" gradientUnits="userSpaceOnUse">
<stop stop-color="#65a26c"/>
<stop offset=".48" stop-color="#6ba770"/>
<stop offset="1" stop-color="#63a16b"/>
</linearGradient>
<radialGradient id="cheek" cx=".45" cy=".42" r=".7">
<stop stop-color="#ffd2d2"/>
<stop offset="1" stop-color="#ffcccc"/>
</radialGradient>
<linearGradient id="whisker" x1="201" y1="1015" x2="282" y2="1015" gradientUnits="userSpaceOnUse">
<stop stop-color="#92bf87"/>
<stop offset="1" stop-color="#62a16c"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" fill="#e9f1e3"/><g transform="translate(172 302.7692307692308) scale(0.6538461538461539) translate(-110 -445)"><g id="felis">
<!-- Ear rims sit behind the pale ear fur and the forehead. -->
<path fill="url(#rim)" d="M174 633C176 566 202 481 233 459C263 438 329 462 384 484C443 509 488 550 517 580C553 560 601 548 643 549L631 569C666 566 704 573 734 583C792 517 880 470 949 454C977 448 1008 447 1024 462C1057 490 1075 568 1078 632L1027 801L935 777L749 625L509 624L315 774L218 814Z"/>
<path fill="url(#rim)" d="M194 782C188 799 191 821 199 837C187 854 175 869 163 882L220 866L265 800Z"/>
<path fill="url(#rim)" d="M1020 800C1037 815 1050 806 1062 786C1064 809 1059 829 1051 843L1080 882L1034 861Z"/>
<path fill="url(#furShade)" d="M236 479C211 512 189 568 177 623C168 678 173 743 191 785C199 805 210 815 225 812L258 778L386 651L285 518Z"/>
<path fill="url(#furShade)" d="M1018 480C1049 520 1067 578 1078 632C1083 689 1076 749 1059 789C1050 809 1036 814 1022 802L974 752L884 647L968 522Z"/>
<path fill="url(#fur)" d="M235 479C243 463 267 467 289 472C364 489 451 543 501 606L501 800L234 797C205 730 203 598 240 496C235 493 231 487 235 479Z"/>
<path fill="url(#fur)" d="M1018 479C1008 460 982 465 960 470C885 486 803 533 756 598L756 800L1019 796C1043 725 1042 596 1011 494C1017 491 1021 485 1018 479Z"/>
<path fill="url(#ear)" d="M234 797C215 752 215 693 222 651C229 606 244 557 261 542C274 531 284 533 296 547C326 581 356 630 374 684C353 664 329 655 293 649C299 672 308 686 323 694C289 719 257 759 234 797Z"/>
<path fill="url(#earShade)" d="M261 542C269 544 282 565 294 588C309 620 325 649 338 666C322 658 308 653 293 649C299 672 308 686 323 694C287 720 257 759 234 797C216 752 215 693 222 651C229 606 244 557 261 542Z"/>
<path fill="url(#ear)" d="M1019 796C1038 750 1036 688 1029 646C1021 600 1008 554 990 540C978 529 968 533 956 547C925 581 896 628 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796Z"/>
<path fill="#a6cb91" opacity=".64" d="M265 712C274 693 290 681 306 678C311 685 316 690 323 694C301 695 281 701 265 712Z"/>
<path fill="#a4c990" opacity=".64" d="M929 692C936 688 942 682 947 676C965 678 976 688 983 701C966 694 949 691 929 692Z"/>
<!-- Green fur tips show around the ivory face. -->
<path fill="url(#rim)" d="M111 896C117 926 132 948 153 959C132 984 118 1018 117 1048C139 1014 156 992 184 974L228 916Z"/>
<path fill="url(#rim)" d="M1145 916C1132 939 1113 953 1090 960C1113 989 1127 1020 1133 1053C1106 1019 1087 996 1057 977L1017 914Z"/>
<!-- One continuous face silhouette keeps the exported outline clean. -->
<path fill="url(#fur)" d="M118 1085C120 1037 141 989 174 953C142 941 121 922 111 896C156 891 193 871 221 839C244 813 264 789 287 772C268 778 251 785 234 797C255 759 287 719 323 694C308 686 299 672 293 649C329 655 353 664 374 684C394 654 443 624 501 606C544 579 591 558 637 551C627 567 614 580 599 592C656 566 729 580 797 614C827 632 855 656 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796C1038 833 1058 866 1083 887C1104 905 1126 913 1145 916C1129 935 1107 945 1079 948C1111 990 1129 1037 1133 1085Z"/>
<path fill="url(#furShade)" d="M668 633C692 632 711 639 720 654C730 670 735 687 740 703Z"/>
<path fill="url(#furShade)" d="M111 896C144 905 173 908 212 901L174 953C142 941 121 922 111 896Z"/>
<path fill="url(#furShade)" d="M163 882C192 861 209 840 225 812L244 794C258 784 271 777 287 772C246 827 212 868 163 882Z"/>
<path fill="url(#furShade)" d="M1037 887C1074 901 1107 914 1145 916C1129 935 1107 945 1079 948Z"/>
<path fill="url(#furShade)" d="M118 1085C121 1054 133 1023 151 997C146 1034 151 1063 172 1085Z"/>
<path fill="url(#furShade)" d="M1081 1085C1094 1069 1101 1049 1103 1028C1115 1045 1120 1063 1122 1085Z"/>
<ellipse fill="url(#eye)" cx="454" cy="922" rx="43.5" ry="66"/>
<ellipse fill="url(#eye)" cx="800.5" cy="922" rx="43.5" ry="66"/>
<path fill="url(#eye)" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/>
<ellipse fill="url(#cheek)" cx="364.5" cy="1015.5" rx="56.5" ry="38"/>
<ellipse fill="url(#cheek)" cx="889.5" cy="1015.5" rx="56.5" ry="38"/>
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
<g transform="translate(1254 0) scale(-1 1)">
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
</g>
</g></g>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1040" height="640" viewBox="110 445 1040 640" role="img" aria-label="Felis">
<defs><mask id="shape" maskUnits="userSpaceOnUse" x="110" y="445" width="1040" height="640"><path fill="#fff" d="M174 633C176 566 202 481 233 459C263 438 329 462 384 484C443 509 488 550 517 580C553 560 601 548 643 549L631 569C666 566 704 573 734 583C792 517 880 470 949 454C977 448 1008 447 1024 462C1057 490 1075 568 1078 632L1027 801L935 777L749 625L509 624L315 774L218 814Z"/><path fill="#fff" d="M194 782C188 799 191 821 199 837C187 854 175 869 163 882L220 866L265 800Z"/><path fill="#fff" d="M1020 800C1037 815 1050 806 1062 786C1064 809 1059 829 1051 843L1080 882L1034 861Z"/><path fill="#fff" d="M236 479C211 512 189 568 177 623C168 678 173 743 191 785C199 805 210 815 225 812L258 778L386 651L285 518Z"/><path fill="#fff" d="M1018 480C1049 520 1067 578 1078 632C1083 689 1076 749 1059 789C1050 809 1036 814 1022 802L974 752L884 647L968 522Z"/><path fill="#fff" d="M235 479C243 463 267 467 289 472C364 489 451 543 501 606L501 800L234 797C205 730 203 598 240 496C235 493 231 487 235 479Z"/><path fill="#fff" d="M1018 479C1008 460 982 465 960 470C885 486 803 533 756 598L756 800L1019 796C1043 725 1042 596 1011 494C1017 491 1021 485 1018 479Z"/><path fill="#fff" d="M234 797C215 752 215 693 222 651C229 606 244 557 261 542C274 531 284 533 296 547C326 581 356 630 374 684C353 664 329 655 293 649C299 672 308 686 323 694C289 719 257 759 234 797Z"/><path fill="#fff" d="M261 542C269 544 282 565 294 588C309 620 325 649 338 666C322 658 308 653 293 649C299 672 308 686 323 694C287 720 257 759 234 797C216 752 215 693 222 651C229 606 244 557 261 542Z"/><path fill="#fff" d="M1019 796C1038 750 1036 688 1029 646C1021 600 1008 554 990 540C978 529 968 533 956 547C925 581 896 628 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796Z"/><path fill="#fff" d="M265 712C274 693 290 681 306 678C311 685 316 690 323 694C301 695 281 701 265 712Z"/><path fill="#fff" d="M929 692C936 688 942 682 947 676C965 678 976 688 983 701C966 694 949 691 929 692Z"/><path fill="#fff" d="M111 896C117 926 132 948 153 959C132 984 118 1018 117 1048C139 1014 156 992 184 974L228 916Z"/><path fill="#fff" d="M1145 916C1132 939 1113 953 1090 960C1113 989 1127 1020 1133 1053C1106 1019 1087 996 1057 977L1017 914Z"/><path fill="#fff" d="M118 1085C120 1037 141 989 174 953C142 941 121 922 111 896C156 891 193 871 221 839C244 813 264 789 287 772C268 778 251 785 234 797C255 759 287 719 323 694C308 686 299 672 293 649C329 655 353 664 374 684C394 654 443 624 501 606C544 579 591 558 637 551C627 567 614 580 599 592C656 566 729 580 797 614C827 632 855 656 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796C1038 833 1058 866 1083 887C1104 905 1126 913 1145 916C1129 935 1107 945 1079 948C1111 990 1129 1037 1133 1085Z"/><path fill="#fff" d="M668 633C692 632 711 639 720 654C730 670 735 687 740 703Z"/><path fill="#fff" d="M111 896C144 905 173 908 212 901L174 953C142 941 121 922 111 896Z"/><path fill="#fff" d="M163 882C192 861 209 840 225 812L244 794C258 784 271 777 287 772C246 827 212 868 163 882Z"/><path fill="#fff" d="M1037 887C1074 901 1107 914 1145 916C1129 935 1107 945 1079 948Z"/><path fill="#fff" d="M118 1085C121 1054 133 1023 151 997C146 1034 151 1063 172 1085Z"/><path fill="#fff" d="M1081 1085C1094 1069 1101 1049 1103 1028C1115 1045 1120 1063 1122 1085Z"/><path fill="#fff" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/><path fill="#fff" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/><path fill="#fff" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/><path fill="#fff" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/><path fill="#fff" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/><ellipse fill="#000" cx="454" cy="922" rx="43.5" ry="66"/><ellipse fill="#000" cx="800.5" cy="922" rx="43.5" ry="66"/><path fill="#000" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/></mask></defs>
<rect x="110" y="445" width="1040" height="640" fill="#000" mask="url(#shape)"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

-77
View File
@@ -1,77 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1254" height="1254" viewBox="0 0 1254 1254" role="img" aria-label="Felis">
<defs>
<linearGradient id="rim" x1="250" y1="460" x2="940" y2="1090" gradientUnits="userSpaceOnUse">
<stop stop-color="#91bd86"/>
<stop offset=".5" stop-color="#8bbd83"/>
<stop offset="1" stop-color="#74ae77"/>
</linearGradient>
<linearGradient id="ear" x1="220" y1="540" x2="1010" y2="800" gradientUnits="userSpaceOnUse">
<stop stop-color="#83b57e"/>
<stop offset=".48" stop-color="#8cbd83"/>
<stop offset="1" stop-color="#80b47b"/>
</linearGradient>
<linearGradient id="earShade" x1="220" y1="610" x2="365" y2="740" gradientUnits="userSpaceOnUse">
<stop stop-color="#71aa75" stop-opacity=".32"/>
<stop offset="1" stop-color="#a6cb91" stop-opacity="0"/>
</linearGradient>
<linearGradient id="furShade" x1="220" y1="570" x2="1010" y2="1085" gradientUnits="userSpaceOnUse">
<stop stop-color="#e6ecda"/>
<stop offset=".52" stop-color="#e8eddf"/>
<stop offset="1" stop-color="#e4ebd8"/>
</linearGradient>
<radialGradient id="fur" cx=".48" cy=".56" r=".75">
<stop stop-color="#fdfbf4"/>
<stop offset="1" stop-color="#fcfaf3"/>
</radialGradient>
<linearGradient id="eye" x1="411" y1="860" x2="843" y2="993" gradientUnits="userSpaceOnUse">
<stop stop-color="#65a26c"/>
<stop offset=".48" stop-color="#6ba770"/>
<stop offset="1" stop-color="#63a16b"/>
</linearGradient>
<radialGradient id="cheek" cx=".45" cy=".42" r=".7">
<stop stop-color="#ffd2d2"/>
<stop offset="1" stop-color="#ffcccc"/>
</radialGradient>
<linearGradient id="whisker" x1="201" y1="1015" x2="282" y2="1015" gradientUnits="userSpaceOnUse">
<stop stop-color="#92bf87"/>
<stop offset="1" stop-color="#62a16c"/>
</linearGradient>
</defs>
<rect width="1254" height="1254" fill="#fff"/><g id="felis">
<!-- Ear rims sit behind the pale ear fur and the forehead. -->
<path fill="url(#rim)" d="M174 633C176 566 202 481 233 459C263 438 329 462 384 484C443 509 488 550 517 580C553 560 601 548 643 549L631 569C666 566 704 573 734 583C792 517 880 470 949 454C977 448 1008 447 1024 462C1057 490 1075 568 1078 632L1027 801L935 777L749 625L509 624L315 774L218 814Z"/>
<path fill="url(#rim)" d="M194 782C188 799 191 821 199 837C187 854 175 869 163 882L220 866L265 800Z"/>
<path fill="url(#rim)" d="M1020 800C1037 815 1050 806 1062 786C1064 809 1059 829 1051 843L1080 882L1034 861Z"/>
<path fill="url(#furShade)" d="M236 479C211 512 189 568 177 623C168 678 173 743 191 785C199 805 210 815 225 812L258 778L386 651L285 518Z"/>
<path fill="url(#furShade)" d="M1018 480C1049 520 1067 578 1078 632C1083 689 1076 749 1059 789C1050 809 1036 814 1022 802L974 752L884 647L968 522Z"/>
<path fill="url(#fur)" d="M235 479C243 463 267 467 289 472C364 489 451 543 501 606L501 800L234 797C205 730 203 598 240 496C235 493 231 487 235 479Z"/>
<path fill="url(#fur)" d="M1018 479C1008 460 982 465 960 470C885 486 803 533 756 598L756 800L1019 796C1043 725 1042 596 1011 494C1017 491 1021 485 1018 479Z"/>
<path fill="url(#ear)" d="M234 797C215 752 215 693 222 651C229 606 244 557 261 542C274 531 284 533 296 547C326 581 356 630 374 684C353 664 329 655 293 649C299 672 308 686 323 694C289 719 257 759 234 797Z"/>
<path fill="url(#earShade)" d="M261 542C269 544 282 565 294 588C309 620 325 649 338 666C322 658 308 653 293 649C299 672 308 686 323 694C287 720 257 759 234 797C216 752 215 693 222 651C229 606 244 557 261 542Z"/>
<path fill="url(#ear)" d="M1019 796C1038 750 1036 688 1029 646C1021 600 1008 554 990 540C978 529 968 533 956 547C925 581 896 628 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796Z"/>
<path fill="#a6cb91" opacity=".64" d="M265 712C274 693 290 681 306 678C311 685 316 690 323 694C301 695 281 701 265 712Z"/>
<path fill="#a4c990" opacity=".64" d="M929 692C936 688 942 682 947 676C965 678 976 688 983 701C966 694 949 691 929 692Z"/>
<!-- Green fur tips show around the ivory face. -->
<path fill="url(#rim)" d="M111 896C117 926 132 948 153 959C132 984 118 1018 117 1048C139 1014 156 992 184 974L228 916Z"/>
<path fill="url(#rim)" d="M1145 916C1132 939 1113 953 1090 960C1113 989 1127 1020 1133 1053C1106 1019 1087 996 1057 977L1017 914Z"/>
<!-- One continuous face silhouette keeps the exported outline clean. -->
<path fill="url(#fur)" d="M118 1085C120 1037 141 989 174 953C142 941 121 922 111 896C156 891 193 871 221 839C244 813 264 789 287 772C268 778 251 785 234 797C255 759 287 719 323 694C308 686 299 672 293 649C329 655 353 664 374 684C394 654 443 624 501 606C544 579 591 558 637 551C627 567 614 580 599 592C656 566 729 580 797 614C827 632 855 656 878 681C900 662 922 653 961 647C954 671 945 684 929 692C966 717 996 757 1019 796C1038 833 1058 866 1083 887C1104 905 1126 913 1145 916C1129 935 1107 945 1079 948C1111 990 1129 1037 1133 1085Z"/>
<path fill="url(#furShade)" d="M668 633C692 632 711 639 720 654C730 670 735 687 740 703Z"/>
<path fill="url(#furShade)" d="M111 896C144 905 173 908 212 901L174 953C142 941 121 922 111 896Z"/>
<path fill="url(#furShade)" d="M163 882C192 861 209 840 225 812L244 794C258 784 271 777 287 772C246 827 212 868 163 882Z"/>
<path fill="url(#furShade)" d="M1037 887C1074 901 1107 914 1145 916C1129 935 1107 945 1079 948Z"/>
<path fill="url(#furShade)" d="M118 1085C121 1054 133 1023 151 997C146 1034 151 1063 172 1085Z"/>
<path fill="url(#furShade)" d="M1081 1085C1094 1069 1101 1049 1103 1028C1115 1045 1120 1063 1122 1085Z"/>
<ellipse fill="url(#eye)" cx="454" cy="922" rx="43.5" ry="66"/>
<ellipse fill="url(#eye)" cx="800.5" cy="922" rx="43.5" ry="66"/>
<path fill="url(#eye)" d="M591 971C591 960 610 955 626 955C642 955 661 960 661 971C661 987 642 1009 626 1009C610 1009 591 987 591 971Z"/>
<ellipse fill="url(#cheek)" cx="364.5" cy="1015.5" rx="56.5" ry="38"/>
<ellipse fill="url(#cheek)" cx="889.5" cy="1015.5" rx="56.5" ry="38"/>
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
<g transform="translate(1254 0) scale(-1 1)">
<path fill="url(#whisker)" d="M204 980C225 972 259 974 278 981C283 983 283 988 278 988C251 986 228 988 208 994C200 996 196 983 204 980Z"/>
<path fill="url(#whisker)" d="M204 1036C224 1022 251 1016 274 1015C280 1015 281 1021 275 1023C251 1030 230 1040 212 1050C204 1055 194 1043 204 1036Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Loaded 100 of 447 files, more files were not shown because too many files have changed in this diff. Show more