feat(license): persist VPP config + license in PERSISTENT_DATA_DIR so a runtime update can't wipe it - #179
feat(license): persist VPP config + license in PERSISTENT_DATA_DIR so a runtime update can't wipe it#179marconetsf wants to merge 2 commits into
Conversation
…runtime updates The v4 device license blob was written next to the plugin config under $OPENPLC_DIR/build/vpp, which install.sh wipes (`rm -rf build`) on a runtime version update -- silently deleting a purchased license and dropping the device to demo after the 2h window. Relocate the VPP config and its license sibling into PERSISTENT_DATA_DIR/vpp (which survives updates, like the DB and .env) and rewrite each config_path in vpp_plugins.conf to that absolute path. The closed .so still finds them because the C loader passes config_path to the plugin verbatim (it only containment-checks `path`, the .so, which stays under build/vpp). Runtime-only: no editor, package or C change. - config.py: add VPP_DATA_DIR = PERSISTENT_DATA_DIR/"vpp" - apply_vpp_plugin_conf: copy config+license into VPP_DATA_DIR (dest derived from the plugin basename, never the editor path), rewrite config_path, best-effort migrate a pre-existing build/vpp .license - resolve_license_path: accept VPP_DATA_DIR as a second known root so 0x49/0x4A resolve there; still refuses traversal / paths outside both roots - tests: relocation+rewrite, survival across a license-less upload, guard accepts persistent + fails closed, 0x49/0x4A roundtrip in the persistent dir; new tests/pytest/conftest.py points the runtime dirs at a temp location Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015uUH3ZL5ehreMUf2dtanWD
|
The design here is right, and the claim the whole thing rests on — that the C loader passes What I verified
1. 🔴 Blocking — the containment guard on
|
| # | Action | Where | Blocking? |
|---|---|---|---|
| 1 | Restore containment on old_license, and resolve against runtime_root rather than the cwd |
plcapp_management.py (~line 415) |
Yes — one line |
| 2 | Correct the deploy note: the wipe is conditional on CMakeCache.txt |
PR description | No |
| 3 | Move the mkdir out of module scope |
config.py:112 |
No |
| — | Security section: config_path is confined to the runtime root, not build/vpp |
PR description | No |
One caveat on my verification: I could not run the suite (local Python 3.9.6 against a >=3.10 requirement), so 41 passed / 1 skipped remains author-attested. What I checked directly was the C side, the buffer size, the path guards by reasoning through their edge cases, and py_compile on all four Python files.
…ir (review) Addresses Gustavo's review on #179. 1. (blocking) The one-time migration read its source from config_path, which validate_vpp_plugins_conf only confines to the runtime root (not build/vpp), so a forged vpp_plugins.conf could point it at any .license under the root and have it copied where 0x4A reads it back. Derive the source from the FIXED build/vpp/<name>.license instead (name is already basename-checked) -- that is exactly where a pre-change license lived and it cannot be steered elsewhere. New tests: the legitimate build/vpp migration still works, and a forged config_path pointing at another .license is ignored (verified failing on the pre-fix code via a worktree at the previous commit). 2. config.py: VPP_DATA_DIR no longer mkdir's at import (module scope). The dir is created on demand by apply_vpp_plugin_conf / _write_license_atomically; a bare import-time mkdir turns a permission failure into a hard import crash -- the very thing tests/pytest/conftest.py had to work around. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015uUH3ZL5ehreMUf2dtanWD
Problem
On runtime-v4 the device license blob is written next to the plugin config under
$OPENPLC_DIR/build/vpp/<plugin>.license.install.shdoesrm -rf $OPENPLC_DIR/buildon a version update/rebuild, which silently deletes a purchased license and drops the device to demo after the 2h window.build/is build output, never treated as state.PERSISTENT_DATA_DIR(/var/lib/openplc-runtimenative,/var/run/runtimecontainer volume) already survives updates and holds the DB +.env, but the license did not live there.Fix
Move the VPP config and its license sibling into
PERSISTENT_DATA_DIR/vpp/, and rewrite eachconfig_pathinvpp_plugins.confto that absolute path. The closed.sostill finds them because the C loader passesconfig_pathto the plugin verbatim — it only containment-checkspath(the.so), which stays underbuild/vpp(it is code, rebuilt each upload).Runtime-only. No editor, package, or C change. The license is no longer stored in the volatile build tree at all, so a version update can no longer delete it.
Changes
config.py: addVPP_DATA_DIR = PERSISTENT_DATA_DIR / "vpp".plcapp_management.py(apply_vpp_plugin_conf): copy config + license intoVPP_DATA_DIR(destination derived from the plugin basename, never the editor-supplied path), rewriteconfig_pathviaPluginsConfiguration.to_file, and best-effort migrate a pre-existingbuild/vpp.license.vpp_license_debug.py(resolve_license_path): acceptVPP_DATA_DIRas a second known root so0x49/0x4Aresolve there; still refuses..and any path outside both roots.Security
No wider write surface.
validate_vpp_plugins_confis unchanged: it confines the.sopathtobuild/vpp, butconfig_pathonly to the runtime root (a wider set — the whole installation). So nothing here trustsconfig_pathas a location: the persistent write destination is built by the runtime from a sanitized plugin basename, and the one-time migration reads only the fixedbuild/vpp/<name>.license— a forgedconfig_pathcan steer neither the write nor the migration read. The read guard inresolve_license_pathis widened to a second known root (VPP_DATA_DIR), not to "anywhere".Tests
tests/pytest/plugins/: relocation +config_pathrewrite; license survives a license-less re-upload; guard accepts the persistent dir and fails closed;0x49/0x4Around-trip in the persistent dir. Newtests/pytest/conftest.pypoints the runtime dirs at a temp location sowebserver.configimports safely across the whole suite. Local run: 41 passed / 1 skipped (symlink test needs admin on Windows); the C-parity anchor tests run in CI.Deploy / migration note
A device that is already licensed keeps working, via one of two paths depending on
install.sh. Thebuild/wipe is conditional onbuild/CMakeCache.txt(install.sh:327-330), not unconditional:build/vpp/<name>.licensesurvives, and the one-time migration copies it into the persistent dir on the next upload (this is exactly what that block is for)..licenseis gone before the new code runs, and the device re-activates automatically from its existing entitlement on the next editor connect (0x4A empty → activate → 0x49). No repurchase.Either way, from then on the license lives in the persistent dir and survives updates.
🤖 Generated with Claude Code