diff --git a/scripts/validate-axebc2-core31-dev.py b/scripts/validate-axebc2-core31-dev.py index 7c7778f..6b9330b 100644 --- a/scripts/validate-axebc2-core31-dev.py +++ b/scripts/validate-axebc2-core31-dev.py @@ -70,6 +70,12 @@ def require(condition, message): require("create_host_path: false" in compose, "build metadata bind must fail closed") require("/etc/5tratumos/build.json" in compose, "build metadata must be mounted") require('JWT_SECRET: "${JWT_SECRET}"' in compose, "init must receive the platform JWT secret") +require( + "chown -R 1000:1000 /data/pool/config" in compose + and compose.index("chown -R 1000:1000 /data/pool/config") + < compose.index("exec /bin/sh /opt/axebc2/init.sh"), + "versioned Compose init must keep fresh and preserved CKPool config writable", +) require( "chown -R 1000:1000 /data/pool/www" in compose and compose.count("$$(stat -c '%u:%g' /data/pool/www") == 3, @@ -83,6 +89,10 @@ def require(condition, message): ".5tratumos-rollback-policy.json" in (APP / "data/init/init.sh").read_text(encoding="utf-8"), "init must use the policy filename consumed by AxeBC2 and 5tratumOS", ) +require( + 'chown -R 1000:1000 "${data_dir}/pool/config"' in (APP / "data/init/init.sh").read_text(encoding="utf-8"), + "seeded init must retain targeted CKPool config ownership repair", +) require( "alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1" in compose, diff --git a/tests/test_axebc2_core31_init.py b/tests/test_axebc2_core31_init.py index c5b97c5..7d6cb45 100644 --- a/tests/test_axebc2_core31_init.py +++ b/tests/test_axebc2_core31_init.py @@ -216,6 +216,44 @@ def test_current_ckpool_config_still_repairs_sharelog_ownership(self): self.assertEqual(chown_log.read_text(encoding="utf-8").splitlines().count(repair), 1) self.assertEqual(config.read_text(encoding="utf-8"), original) + def test_fresh_install_repairs_mutable_pool_config_ownership(self): + fake_bin = self.tmp / "fake-bin" + fake_bin.mkdir() + chown_log = self.tmp / "chown.log" + fake_chown = fake_bin / "chown" + fake_chown.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "$*" >> "$AXEBC2_TEST_CHOWN_LOG"\n', + encoding="utf-8", + ) + fake_chown.chmod(0o755) + fake_stat = fake_bin / "stat" + fake_stat.write_text("#!/bin/sh\nprintf '1000:1000\\n'\n", encoding="utf-8") + fake_stat.chmod(0o755) + + self.run_init( + extra_env={ + "AXEBC2_TEST_SKIP_CHOWN": "false", + "AXEBC2_TEST_CHOWN_LOG": str(chown_log), + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + } + ) + + config_dir = self.data / "pool/config" + self.assertTrue((config_dir / "ckpool.conf").is_file()) + self.assertIn( + f"-R 1000:1000 {config_dir}", + chown_log.read_text(encoding="utf-8").splitlines(), + ) + # Atomic payout saves need directory-level create and replace access. + replacement = config_dir / ".ckpool.conf.atomic-test" + replacement.write_text("replacement\n", encoding="utf-8") + replacement.replace(config_dir / "ckpool.conf") + self.assertEqual( + (config_dir / "ckpool.conf").read_text(encoding="utf-8"), + "replacement\n", + ) + def test_missing_or_malformed_build_metadata_fails_closed(self): self.build.write_text("not-json", encoding="utf-8") env = os.environ.copy() diff --git a/tests/test_axebc2_platform_integration.py b/tests/test_axebc2_platform_integration.py index 12a9e8a..7350101 100644 --- a/tests/test_axebc2_platform_integration.py +++ b/tests/test_axebc2_platform_integration.py @@ -62,9 +62,14 @@ def load_policy_module(platform: Path): class AxeBC2PlatformIntegrationTests(unittest.TestCase): def test_upgrade_repair_is_in_versioned_compose_not_only_seeded_data(self): source = COMPOSE.read_text(encoding="utf-8") + self.assertIn("chown -R 1000:1000 /data/pool/config", source) self.assertEqual(source.count("$$(stat -c '%u:%g' /data/pool/www"), 3) self.assertIn("chown -R 1000:1000 /data/pool/www", source) self.assertNotIn('"$(stat -c', source) + self.assertLess( + source.index("chown -R 1000:1000 /data/pool/config"), + source.index("exec /bin/sh /opt/axebc2/init.sh"), + ) self.assertLess( source.index("chown -R 1000:1000 /data/pool/www"), source.index("exec /bin/sh /opt/axebc2/init.sh"), diff --git a/willitmod-dev-bc2/data/init/init.sh b/willitmod-dev-bc2/data/init/init.sh index 939c265..ef36839 100644 --- a/willitmod-dev-bc2/data/init/init.sh +++ b/willitmod-dev-bc2/data/init/init.sh @@ -206,12 +206,19 @@ if [ -f "$settings" ]; then fi fi -# CKPool runs as uid/gid 1000 and creates per-height sharelog directories +# The app atomically replaces files in pool/config as uid/gid 1000, so the +# directory itself must remain writable even when a fresh install seeded it as +# root. This tree is tiny and safe to repair on every initializer run. +# +# CKPool also runs as uid/gid 1000 and creates per-height sharelog directories # directly under /www. Existing installs can already have a current config and # therefore skip the regeneration branch above while /www remains root-owned. # Check only the three known writable directories on normal starts; recursively # repair the existing sharelog tree once if an upgrade left any of them behind. if [ "${AXEBC2_TEST_SKIP_CHOWN:-false}" != "true" ]; then + chown -R 1000:1000 "${data_dir}/pool/config" 2>/dev/null || + fail "cannot assign CKPool config data to the app user" + repair_sharelog_ownership=false for writable_dir in \ "${data_dir}/pool/www" \ diff --git a/willitmod-dev-bc2/docker-compose.yml b/willitmod-dev-bc2/docker-compose.yml index 60db512..b2e4391 100644 --- a/willitmod-dev-bc2/docker-compose.yml +++ b/willitmod-dev-bc2/docker-compose.yml @@ -56,7 +56,11 @@ services: # update, including its previously seeded init script. Keep this # upgrade repair in the versioned Compose command so 0.1.10 installs # receive it even when /data/init/init.sh is intentionally retained. - mkdir -p /data/pool/www/pool /data/pool/www/users + mkdir -p /data/pool/config /data/pool/www/pool /data/pool/www/users + # Keep the small mutable config directory writable by the unprivileged + # app. This is required on fresh installs as well as preserved upgrades. + chown -R 1000:1000 /data/pool/config || + { echo >&2 "cannot assign CKPool config data to uid/gid 1000"; exit 1; } if [ "$$(stat -c '%u:%g' /data/pool/www 2>/dev/null || true)" != "1000:1000" ] || [ "$$(stat -c '%u:%g' /data/pool/www/pool 2>/dev/null || true)" != "1000:1000" ] || [ "$$(stat -c '%u:%g' /data/pool/www/users 2>/dev/null || true)" != "1000:1000" ]; then