Remove host files and uninstall agent when deleting hosts
This commit is contained in:
+120
-7
@@ -21,7 +21,7 @@ done
|
||||
case "$controller" in https://*) ;; *) echo '--controller must be an HTTPS URL.' >&2; exit 2 ;; esac
|
||||
[ -n "$enrollment_token" ] || { echo '--token is required.' >&2; exit 2; }
|
||||
case "$data_dir" in /*) ;; *) echo '--data-dir must be absolute.' >&2; exit 2 ;; esac
|
||||
case "$data_dir" in *[!a-zA-Z0-9/_.-]*|/|/etc|/usr|/var|/var/lib) echo 'Choose a dedicated data directory.' >&2; exit 2 ;; esac
|
||||
case "$data_dir" in *[!a-zA-Z0-9/_.-]*|*/../*|*/./*|*/..|*/.|*//*|*/) echo 'Choose a canonical, dedicated data directory.' >&2; exit 2 ;; esac
|
||||
[ ! -L "$data_dir" ] || { echo 'Data directory must not be a symlink.' >&2; exit 2; }
|
||||
. /etc/os-release
|
||||
[ "${ID:-}" = debian ] || { echo 'Supported platform: Debian 12 or 13.' >&2; exit 1; }
|
||||
@@ -32,13 +32,82 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends ca-certificates curl python3
|
||||
python3 - "$controller" "$data_dir" <<'PY'
|
||||
import json, pathlib, sys
|
||||
config = pathlib.Path('/etc/electric-cherry/agent.json')
|
||||
if config.exists():
|
||||
saved = json.loads(config.read_text())
|
||||
if saved.get('controller', '').rstrip('/') != sys.argv[1].rstrip('/') or pathlib.Path(saved.get('root', '')).resolve() != pathlib.Path(sys.argv[2]).resolve():
|
||||
python3 - "$controller" "$data_dir" /etc/electric-cherry/agent.json <<'PY'
|
||||
# INSTALLATION_PREFLIGHT: this runs before touching managed directories or services.
|
||||
import json, pathlib, re, stat, sys
|
||||
|
||||
controller, raw_root, config_name = sys.argv[1:]
|
||||
root = pathlib.Path(raw_root)
|
||||
config = pathlib.Path(config_name)
|
||||
reserved = {
|
||||
'/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib64',
|
||||
'/media', '/mnt', '/opt', '/proc', '/root', '/run', '/sbin', '/srv',
|
||||
'/sys', '/tmp', '/usr', '/usr/local', '/usr/local/bin', '/usr/local/lib',
|
||||
'/usr/local/libexec', '/var', '/var/cache', '/var/lib', '/var/log', '/var/tmp',
|
||||
}
|
||||
if (not re.fullmatch(r'/[A-Za-z0-9/_.-]+', raw_root)
|
||||
or str(root) != raw_root or any(part in ('.', '..') for part in raw_root.split('/')[1:])
|
||||
or len(root.parts) < 3 or raw_root in reserved or config.parent == root or config.parent in root.parents):
|
||||
raise SystemExit('Choose a canonical, dedicated data directory outside the agent configuration directory.')
|
||||
installed_paths = [config,
|
||||
pathlib.Path('/usr/local/libexec/electric-cherry-agent.py'),
|
||||
pathlib.Path('/usr/local/libexec/electric-cherry-uninstall.py'),
|
||||
pathlib.Path('/etc/systemd/system/electric-cherry-agent.service'),
|
||||
pathlib.Path('/etc/systemd/system/electric-cherry-uninstall.service')]
|
||||
if any(root == path or root in path.parents for path in installed_paths):
|
||||
raise SystemExit('Choose a dedicated data directory outside the agent executable and service directories.')
|
||||
|
||||
def trusted_directories(path):
|
||||
for current in [*reversed(path.parents), path]:
|
||||
try:
|
||||
info = current.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise SystemExit('Installation directories and their parents must be root-owned, not symlinks, and not writable by other users.')
|
||||
|
||||
def trusted_json(path):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise SystemExit('Existing installation metadata must be regular, root-owned files that other users cannot modify.')
|
||||
value = json.loads(path.read_text())
|
||||
if not isinstance(value, dict):
|
||||
raise SystemExit('Existing installation metadata is invalid.')
|
||||
return value
|
||||
|
||||
trusted_directories(root)
|
||||
trusted_directories(config.parent)
|
||||
mountinfo = pathlib.Path('/proc/1/mountinfo')
|
||||
if mountinfo.exists():
|
||||
for line in mountinfo.read_text().splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) < 5:
|
||||
continue
|
||||
mount = re.sub(r'\\([0-7]{3})', lambda match: chr(int(match[1], 8)), fields[4])
|
||||
if mount == raw_root or mount.startswith(raw_root + '/'):
|
||||
raise SystemExit('Use a dedicated subdirectory of the mounted disk, with no filesystems mounted inside it, so host deletion can remove the entire data directory.')
|
||||
request = config.with_name('decommission.json')
|
||||
if request.exists() or request.is_symlink():
|
||||
raise SystemExit('Host deletion is in progress. Finish or repair that deletion before installing again.')
|
||||
saved = None
|
||||
if config.exists() or config.is_symlink():
|
||||
saved = trusted_json(config)
|
||||
if saved.get('controller', '').rstrip('/') != controller.rstrip('/') or saved.get('root') != raw_root:
|
||||
raise SystemExit('Existing enrollment uses a different controller or data directory. Preserve the existing installation and migrate it explicitly.')
|
||||
if not saved.get('hostId') or not saved.get('credential'):
|
||||
raise SystemExit('Existing enrollment is incomplete. Repair its credentials before upgrading.')
|
||||
marker = root / '.electric-cherry.json'
|
||||
if marker.exists() or marker.is_symlink():
|
||||
owned = trusted_json(marker)
|
||||
if not saved or owned != {'version': 1, 'root': raw_root, 'hostId': saved['hostId'], 'controller': controller.rstrip('/')}:
|
||||
raise SystemExit('Data directory ownership does not match this enrollment. Preserve it and migrate explicitly.')
|
||||
elif root.exists() and any(root.iterdir()):
|
||||
# Upgrade installations predating ownership markers only when the entire
|
||||
# root has the known layout. Never adopt a directory of unrelated files.
|
||||
if not saved or any(path.name not in {'agent', 'instances', 'libraries'} for path in root.iterdir()):
|
||||
raise SystemExit('The data directory contains files not owned by this installation. Choose an empty, dedicated directory.')
|
||||
for directory in ('agent', 'instances', 'libraries'):
|
||||
trusted_directories(root / directory)
|
||||
PY
|
||||
if ! command -v docker >/dev/null; then
|
||||
if [ -e /var/lib/docker ] || [ -e /etc/docker/daemon.json ]; then
|
||||
@@ -76,9 +145,36 @@ install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-
|
||||
install -d -m 0755 /usr/local/libexec
|
||||
install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-agent.py
|
||||
}
|
||||
# A separate worker survives stopping/removing the agent service. Its only
|
||||
# entry point is a fixed, authenticated deletion request prepared by the agent.
|
||||
install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-uninstall.py
|
||||
printf '%s' "$enrollment_token" > "$temporary_dir/enrollment-token"
|
||||
unset enrollment_token
|
||||
python3 /usr/local/libexec/electric-cherry-agent.py --enroll --controller "$controller" --root "$data_dir" --token-file "$temporary_dir/enrollment-token" ${reenroll:+"$reenroll"}
|
||||
python3 - "$data_dir" /etc/electric-cherry/agent.json <<'PY'
|
||||
# INSTALLATION_OWNERSHIP: bind deletion authority to this exact enrollment/root.
|
||||
import json, os, pathlib, sys, uuid
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
config = json.loads(pathlib.Path(sys.argv[2]).read_text())
|
||||
if config.get('root') != str(root) or not config.get('hostId') or not config.get('controller'):
|
||||
raise SystemExit('Enrollment did not establish ownership of this data directory.')
|
||||
marker = root / '.electric-cherry.json'
|
||||
temporary = root / ('.electric-cherry.' + uuid.uuid4().hex + '.tmp')
|
||||
try:
|
||||
with temporary.open('x') as stream:
|
||||
os.chmod(temporary, 0o600)
|
||||
json.dump({'version': 1, 'root': str(root), 'hostId': config['hostId'], 'controller': config['controller'].rstrip('/')}, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, marker)
|
||||
directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
PY
|
||||
cat > /etc/systemd/system/electric-cherry-agent.service <<EOF
|
||||
[Unit]
|
||||
Description=Electric Cherry Host Agent
|
||||
@@ -99,6 +195,23 @@ ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=$data_dir /etc/electric-cherry
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
cat > /etc/systemd/system/electric-cherry-uninstall.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Remove an Electric Cherry Host Installation
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 /usr/local/libexec/electric-cherry-uninstall.py --uninstall /etc/electric-cherry/decommission.json
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
TimeoutStartSec=0
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
Reference in New Issue
Block a user