When waking a droplet hibernated with Tailscale lockdown, the snapshot's
UFW rules block SSH on eth0 while Tailscale is logged out — creating a
deadlock where SSH is needed to re-setup Tailscale but is blocked by the
firewall. Fix by injecting a cloud-init user_data script that opens SSH
on eth0 at first boot, which is cleaned up when lock_down_to_tailscale()
resets UFW during Tailscale re-setup.
Also adds wait_for_ssh() to verify SSH reachability before attempting
Tailscale setup (replacing a blind sleep), and enhances the security
warning when --no-tailscale is used with a previously locked snapshot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, if the temporary UFW rule or SSH config update failed during
hibernate preparation, the function returned True and hibernate proceeded
to snapshot — producing a snapshot with no public SSH access. On wake,
such droplets were completely unreachable (Tailscale needs re-auth but
SSH is blocked on eth0).
Now prepare_for_hibernate returns None on failure, and the caller aborts
the hibernate. Also adds a warning to the --continue path since it
cannot verify the temp rule on a powered-off droplet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Offer to delete leftover hibernation snapshot on droplet destroy
When a user hibernates a droplet then wakes it up but declines to delete
the snapshot, the snapshot is left behind. If they later `dropkit destroy`
the droplet, the orphaned snapshot persists silently, incurring storage
costs. After destroying the droplet, check for a matching hibernation
snapshot and prompt the user to delete it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Change snapshot deletion default to 'no' in destroy command
Respect the user's earlier choice: if they declined to delete the
snapshot during wakeup, defaulting to 'yes' here would second-guess
that decision. Default to 'no' so the prompt is informational rather
than presumptive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Update default Ubuntu image from 25.04 to 25.10
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Extract default region/size/image slugs into constants
Replace duplicated hardcoded strings across config.py, main.py, and
tests with DEFAULT_REGION, DEFAULT_SIZE, and DEFAULT_IMAGE constants
defined in config.py.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Remove escaped chars in comments
* Add Jinja2 template validation test and pre-commit hook
Catch template syntax errors early with a unit test that parses and
renders the default cloud-init template, and a pre-commit hook that
validates all templates in dropkit/templates/ on change.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Fix Jinja2 escaping of zsh PROMPT in cloud-init template
The zsh PROMPT string contains `%` sequences that Jinja2 interprets
as template syntax. Wrap in {% raw %}...{% endraw %} to preserve
the literal prompt definition.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add a warning to the cloud-init jinja template
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Ran /claude-md-management:revise-claude-md to audit CLAUDE.md against
the actual codebase. The file-level tree was stale (missing lock.py,
ui.py, version_check.py and several test files). Replace with a
directory-only layout that won't drift when files are added.
Also document the --cov-fail-under=29 threshold from pyproject.toml.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a Tailscale IP column to `dropkit ls` output. The IP is read from
the SSH config — if the configured hostname is a Tailscale IP (100.x.x.x),
it's displayed; otherwise shows "—".
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Update both `prepare_for_hibernate` and `destroy` to switch SSH config
to the droplet's public IP before logging out of Tailscale. Previously,
the Tailscale logout happened first, which killed the Tailscale IP route
that SSH was using — causing the logout command itself to hang or fail.
Also adds `--verbose` flag to the `hibernate` command.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* allow resizing hibernated droplets by modifying the size tag
* Prevent removal of protected tags (owner, firewall) in untag_resource
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Fix macOS Tailscale CLI discovery for App Store installs
On macOS, Tailscale installed via the App Store places its CLI at
/Applications/Tailscale.app/Contents/MacOS/Tailscale, which is not in
PATH. Add find_tailscale_cli() helper that checks PATH first, then
falls back to the macOS App Store location on darwin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address PR feedback: better error message, remove excessive test
Distinguish "Tailscale not found" from "not running" when the local
check fails, so users know whether to install or start Tailscale.
Remove the overly comprehensive binary_vanishes race condition test.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Rename .claude/settings.local.json to .claude/settings.json
- Add .claude/settings.local.json to .gitignore
- Replace uv run tobcloud with uv run dropkit in permissions
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Generalize email domain handling in documentation and tests
- Add .worktrees/ to .gitignore for git worktree support
- Update _sanitize_email_for_username docstring to be domain-agnostic
- Change CLAUDE.md example from trailofbits.com to example.com
- Add tests for google.com, gmail.com, corporate, and plus addressing
The code already supported any email domain; this updates docs and
tests to reflect that capability.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Address PR review comments
- Remove redundant trailofbits.com regex, simplify to split("@")[0]
- Rename test to clarify backwards compatibility intent
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Dan Guido <dan@trailofbits.com>
Replace manual file parsing with ssh-keygen -R which properly handles
both hashed (|1|...) and unhashed entries. macOS and many Linux systems
use HashKnownHosts by default, making the previous implementation unable
to remove entries.
Changes:
- Use ssh-keygen -R for each hostname instead of parsing the file
- Detect successful removal via "updated" in stdout
- Backup files are now .old (ssh-keygen default) instead of .bak
- Bracketed entries ([host]:port) now require exact format
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* Rename tobcloud references to dropkit in tests
Update test data and test method names to use the new project name.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Format test_ssh_config.py
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>