Keep container SSH keys across restarts #66
No files matched your search
@@ -302,7 +302,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
approach converges on the worst state: a 400 MB browser present with its libraries gone.
|
||||
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
|
||||
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||
|
||||
**`/home/claude` in the image is seed-only.** It is the mount point of the named volume
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn validate_settings_update(
|
||||
// fallback for **every** project without an override
|
||||
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
|
||||
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
|
||||
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
|
||||
// then merges `/tmp/.host-ssh` into `~/.ssh`, recursively copying it into the
|
||||
// home volume this release exists to bound.
|
||||
//
|
||||
// Grandfathered the same way project paths are: a value carried over
|
||||
|
||||
@@ -205,3 +205,137 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
let _ = buf.flush();
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::ENTRYPOINT;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// The `sync_host_ssh` function from `entrypoint.sh`, cut out between its
|
||||
/// markers so it can be run against temp dirs instead of the real ~/.ssh.
|
||||
fn sync_fn() -> &'static str {
|
||||
let start = ENTRYPOINT.find("# >>> sync_host_ssh").expect("start marker");
|
||||
let end = ENTRYPOINT.find("# <<< sync_host_ssh").expect("end marker");
|
||||
&ENTRYPOINT[start..end]
|
||||
}
|
||||
|
||||
fn sync(src: &Path, dest: &Path) -> String {
|
||||
let script = format!("{}\nsync_host_ssh \"$1\" \"$2\"\n", sync_fn());
|
||||
let out = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.arg("sync")
|
||||
.arg(src)
|
||||
.arg(dest)
|
||||
.output()
|
||||
.expect("run bash");
|
||||
assert!(out.status.success(), "sync failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn read(p: &Path) -> String {
|
||||
fs::read_to_string(p).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_created_keys_survive_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
|
||||
fs::create_dir_all(&src).unwrap();
|
||||
fs::write(src.join("id_host"), "host-key").unwrap();
|
||||
|
||||
sync(&src, &dest);
|
||||
fs::write(dest.join("id_container"), "made-in-container").unwrap();
|
||||
sync(&src, &dest);
|
||||
|
||||
assert_eq!(read(&dest.join("id_container")), "made-in-container");
|
||||
assert_eq!(read(&dest.join("id_host")), "host-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_file_wins_over_same_named_host_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
|
||||
fs::create_dir_all(&src).unwrap();
|
||||
fs::write(src.join("id_host"), "host-key").unwrap();
|
||||
sync(&src, &dest);
|
||||
|
||||
fs::write(dest.join("id_ed25519"), "container-key").unwrap();
|
||||
fs::write(src.join("id_ed25519"), "host-ed25519").unwrap();
|
||||
let out = sync(&src, &dest);
|
||||
|
||||
assert_eq!(read(&dest.join("id_ed25519")), "container-key");
|
||||
assert!(out.contains("keeping container's"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_changes_propagate_until_edited_in_container() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
|
||||
fs::create_dir_all(&src).unwrap();
|
||||
fs::write(src.join("config"), "v1").unwrap();
|
||||
fs::write(src.join("id_old"), "old").unwrap();
|
||||
sync(&src, &dest);
|
||||
|
||||
fs::write(src.join("config"), "v2").unwrap();
|
||||
fs::remove_file(src.join("id_old")).unwrap();
|
||||
sync(&src, &dest);
|
||||
assert_eq!(read(&dest.join("config")), "v2");
|
||||
assert!(!dest.join("id_old").exists(), "host-removed, unedited file is dropped");
|
||||
|
||||
fs::write(dest.join("config"), "edited in container").unwrap();
|
||||
fs::write(src.join("config"), "v3").unwrap();
|
||||
sync(&src, &dest);
|
||||
assert_eq!(read(&dest.join("config")), "edited in container");
|
||||
|
||||
fs::remove_file(src.join("config")).unwrap();
|
||||
sync(&src, &dest);
|
||||
assert_eq!(read(&dest.join("config")), "edited in container");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_hosts_is_unioned() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
|
||||
fs::create_dir_all(&src).unwrap();
|
||||
fs::create_dir_all(&dest).unwrap();
|
||||
fs::write(src.join("known_hosts"), "hostA key\n").unwrap();
|
||||
fs::write(dest.join("known_hosts"), "containerB key\n").unwrap();
|
||||
|
||||
sync(&src, &dest);
|
||||
let kh = read(&dest.join("known_hosts"));
|
||||
assert!(kh.contains("hostA key") && kh.contains("containerB key"), "{kh}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_from_wipe_and_copy_backs_up_differing_files() {
|
||||
// No manifest yet: the state an existing project is in on its first
|
||||
// start with this entrypoint.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
|
||||
fs::create_dir_all(&src).unwrap();
|
||||
fs::create_dir_all(&dest).unwrap();
|
||||
fs::write(src.join("id_rsa"), "host").unwrap();
|
||||
fs::write(dest.join("id_rsa"), "container").unwrap();
|
||||
fs::write(dest.join("id_extra"), "extra").unwrap();
|
||||
|
||||
sync(&src, &dest);
|
||||
|
||||
assert_eq!(read(&dest.join("id_rsa")), "host");
|
||||
assert_eq!(read(&dest.join("id_rsa.triple-c-backup")), "container");
|
||||
assert_eq!(read(&dest.join("id_extra")), "extra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_host_files_are_copied() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
|
||||
fs::create_dir_all(src.join("config.d")).unwrap();
|
||||
fs::write(src.join("config.d/work"), "Host work").unwrap();
|
||||
|
||||
sync(&src, &dest);
|
||||
assert_eq!(read(&dest.join("config.d/work")), "Host work");
|
||||
}
|
||||
}
|
||||
+90
-4
@@ -220,14 +220,100 @@ install_corporate_ca() {
|
||||
install_corporate_ca
|
||||
|
||||
# ── SSH key setup ──────────────────────────────────────────────────────────
|
||||
# Host SSH dir is mounted read-only at /tmp/.host-ssh.
|
||||
# Copy to /home/claude/.ssh so we can fix permissions.
|
||||
# Host SSH dir is mounted read-only at /tmp/.host-ssh and merged into
|
||||
# /home/claude/.ssh (which lives on the home volume) so we can fix permissions.
|
||||
#
|
||||
# This used to `rm -rf ~/.ssh` and copy the host dir over it on every start,
|
||||
# which silently deleted any key generated or saved inside the container and
|
||||
# overwrote container edits to config/known_hosts. Now it is a merge:
|
||||
# - Files the container created are never touched, even if the host later
|
||||
# gains a file of the same name (the host copy is skipped with a warning).
|
||||
# - A file previously copied from the host is refreshed when the host copy
|
||||
# changes, and removed when the host drops it — but only while it is still
|
||||
# byte-identical to what we copied. Once edited in the container it is the
|
||||
# container's.
|
||||
# - known_hosts is unioned, never replaced (ssh appends to it at runtime).
|
||||
# `.triple-c-host-files` records "<sha256>\t<relative path>" for every file we
|
||||
# copied, which is how the two kinds of file are told apart. Without it (first
|
||||
# start after upgrading from the wipe-and-copy entrypoint) a differing file is
|
||||
# backed up to `<name>.triple-c-backup` before the host copy replaces it.
|
||||
#
|
||||
# The function body between the markers is executed by a unit test in
|
||||
# app/src-tauri/src/docker/image.rs; keep it self-contained.
|
||||
# >>> sync_host_ssh
|
||||
sync_host_ssh() {
|
||||
local src="$1" dest="$2"
|
||||
local manifest="$dest/.triple-c-host-files"
|
||||
local legacy=0 new_manifest f rel target src_sum cur_sum recorded
|
||||
|
||||
_ssh_sum() {
|
||||
if [ -L "$1" ]; then
|
||||
printf 'link:%s' "$(readlink "$1")" | sha256sum | cut -d' ' -f1
|
||||
else
|
||||
sha256sum < "$1" | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p "$dest"
|
||||
[ -f "$manifest" ] || legacy=1
|
||||
new_manifest=$(mktemp)
|
||||
|
||||
while IFS= read -r -d '' f; do
|
||||
rel="${f#"$src"/}"
|
||||
[ "$rel" = "known_hosts" ] && continue
|
||||
target="$dest/$rel"
|
||||
src_sum=$(_ssh_sum "$f")
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then
|
||||
cur_sum=$(_ssh_sum "$target")
|
||||
if [ "$cur_sum" != "$src_sum" ]; then
|
||||
recorded=""
|
||||
[ "$legacy" = 0 ] && recorded=$(awk -F'\t' -v r="$rel" '$2 == r { print $1; exit }' "$manifest")
|
||||
if [ "$legacy" = 1 ]; then
|
||||
cp -a "$target" "$target.triple-c-backup"
|
||||
echo "entrypoint: ~/.ssh/$rel differs from host copy; kept old version as $rel.triple-c-backup"
|
||||
elif [ -z "$recorded" ] || [ "$recorded" != "$cur_sum" ]; then
|
||||
echo "entrypoint: keeping container's ~/.ssh/$rel (host copy not applied)"
|
||||
continue
|
||||
fi
|
||||
rm -f "$target"
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp -a "$f" "$target"
|
||||
fi
|
||||
else
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp -a "$f" "$target"
|
||||
fi
|
||||
printf '%s\t%s\n' "$src_sum" "$rel" >> "$new_manifest"
|
||||
done < <(find "$src" \( -type f -o -type l \) -print0)
|
||||
|
||||
# Drop files the host no longer provides, unless edited in the container.
|
||||
if [ "$legacy" = 0 ]; then
|
||||
while IFS=$'\t' read -r recorded rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
if [ ! -e "$src/$rel" ] && [ ! -L "$src/$rel" ] \
|
||||
&& { [ -e "$dest/$rel" ] || [ -L "$dest/$rel" ]; } \
|
||||
&& [ "$(_ssh_sum "$dest/$rel")" = "$recorded" ]; then
|
||||
rm -f "$dest/$rel"
|
||||
fi
|
||||
done < "$manifest"
|
||||
fi
|
||||
|
||||
if [ -f "$src/known_hosts" ]; then
|
||||
cat "$src/known_hosts" >> "$dest/known_hosts"
|
||||
sort -u -o "$dest/known_hosts" "$dest/known_hosts"
|
||||
fi
|
||||
|
||||
mv "$new_manifest" "$manifest"
|
||||
unset -f _ssh_sum
|
||||
}
|
||||
# <<< sync_host_ssh
|
||||
|
||||
if [ -d /tmp/.host-ssh ]; then
|
||||
rm -rf /home/claude/.ssh
|
||||
cp -a /tmp/.host-ssh /home/claude/.ssh
|
||||
sync_host_ssh /tmp/.host-ssh /home/claude/.ssh
|
||||
chown -R claude:claude /home/claude/.ssh
|
||||
chmod 700 /home/claude/.ssh
|
||||
find /home/claude/.ssh -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \;
|
||||
find /home/claude/.ssh -type f -name "*.triple-c-backup" -exec chmod 600 {} \;
|
||||
find /home/claude/.ssh -type f -name "*.pub" -exec chmod 644 {} \;
|
||||
if [ -f /home/claude/.ssh/known_hosts ]; then
|
||||
chmod 644 /home/claude/.ssh/known_hosts
|
||||
|
||||
Reference in new issue
Block a user