fix: refresh server IP from cloud API before reconnect SSH (#2625)

Fixes #2624

When reconnecting to an existing server via `spawn ls` or `spawn last`,
the CLI now queries the cloud provider API for the server's current IP
before attempting SSH. This prevents silent SSH timeouts when a server's
IP changes (e.g., after a restart or elastic IP reallocation).

Changes:
- Add `getServerIp()` to DigitalOcean, Hetzner, AWS, and GCP modules
- Add `updateRecordIp()` to history.ts to persist IP changes
- Add `refreshConnectionIp()` in list.ts that authenticates with the
  cloud provider and refreshes the IP before enter/reconnect/fix actions
- If the server no longer exists, mark it deleted and inform the user
- If refresh fails (e.g., no credentials), fall back to cached IP

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-14 13:45:59 -07:00 committed by GitHub
parent a738e658a3
commit f3a9db4b91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 203 additions and 1 deletions

View file

@ -1171,6 +1171,25 @@ export async function promptSpawnName(): Promise<void> {
// ─── Lifecycle ──────────────────────────────────────────────────────────────
/** Fetch the current public IP of an existing Lightsail instance. Returns null if it no longer exists. */
export async function getServerIp(instanceName: string): Promise<string | null> {
const r = await asyncTryCatch(() => lightsailGetInstance(instanceName));
if (!r.ok) {
const msg = getErrorMessage(r.error);
if (
msg.includes("404") ||
msg.includes("not found") ||
msg.includes("Not Found") ||
msg.includes("NotFoundException")
) {
return null;
}
throw r.error;
}
const ip = r.data.ip;
return ip || null;
}
export async function destroyServer(name?: string): Promise<void> {
const target = name || _state.instanceName;
if (!target) {

View file

@ -4,7 +4,14 @@ import type { Manifest } from "../manifest.js";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { clearHistory, filterHistory, getActiveServers, removeRecord } from "../history.js";
import {
clearHistory,
filterHistory,
getActiveServers,
markRecordDeleted,
removeRecord,
updateRecordIp,
} from "../history.js";
import { agentKeys, cloudKeys, loadManifest } from "../manifest.js";
import { asyncTryCatch, tryCatch, unwrapOr } from "../shared/result.js";
import { cmdConnect, cmdEnterAgent, cmdOpenDashboard } from "./connect.js";
@ -242,6 +249,96 @@ export async function resolveListFilters(
};
}
// ── IP refresh ──────────────────────────────────────────────────────────────
/**
* Refresh the IP address for a connection by querying the cloud provider API.
* Updates the in-memory connection object and persists the change to history.
* Returns "ok" if the IP was refreshed (or unchanged), "gone" if the server
* no longer exists, or "skip" if refresh is not applicable (local, sprite, etc.).
*/
async function refreshConnectionIp(record: SpawnRecord): Promise<"ok" | "gone" | "skip"> {
const conn = record.connection;
if (!conn?.cloud || conn.cloud === "local" || conn.cloud === "sprite" || conn.deleted) {
return "skip";
}
const serverId = conn.server_id || conn.server_name || "";
if (!serverId) {
return "skip";
}
let currentIp: string | null = null;
switch (conn.cloud) {
case "digitalocean": {
const { ensureDoToken, getServerIp } = await import("../digitalocean/digitalocean.js");
await ensureDoToken();
currentIp = await getServerIp(serverId);
break;
}
case "hetzner": {
const { ensureHcloudToken, getServerIp } = await import("../hetzner/hetzner.js");
await ensureHcloudToken();
currentIp = await getServerIp(serverId);
break;
}
case "aws": {
const { ensureAwsCli, authenticate, getServerIp } = await import("../aws/aws.js");
await ensureAwsCli();
await authenticate();
currentIp = await getServerIp(serverId);
break;
}
case "gcp": {
const { ensureGcloudCli, authenticate, resolveProject, getServerIp } = await import("../gcp/gcp.js");
const zone = conn.metadata?.zone || "us-central1-a";
const project = conn.metadata?.project || "";
if (!project) {
return "skip";
}
process.env.GCP_ZONE = zone;
process.env.GCP_PROJECT = project;
await ensureGcloudCli();
await authenticate();
// Set SPAWN_NON_INTERACTIVE to suppress project prompt during refresh
const prevNonInteractive = process.env.SPAWN_NON_INTERACTIVE;
process.env.SPAWN_NON_INTERACTIVE = "1";
const resolveResult = await asyncTryCatch(() => resolveProject());
if (prevNonInteractive === undefined) {
delete process.env.SPAWN_NON_INTERACTIVE;
} else {
process.env.SPAWN_NON_INTERACTIVE = prevNonInteractive;
}
if (!resolveResult.ok) {
return "skip";
}
currentIp = await getServerIp(serverId, zone, project);
break;
}
default:
return "skip";
}
if (currentIp === null) {
// Server no longer exists
p.log.warn("Server no longer exists on the cloud provider.");
markRecordDeleted(record);
if (conn) {
conn.deleted = true;
}
return "gone";
}
if (currentIp !== conn.ip) {
p.log.info(`Server IP changed: ${conn.ip} -> ${currentIp}`);
conn.ip = currentIp;
updateRecordIp(record, currentIp);
}
return "ok";
}
// ── Record actions ───────────────────────────────────────────────────────────
/** Outcome of handleRecordAction — determines whether the picker loops or exits. */
@ -349,6 +446,19 @@ export async function handleRecordAction(
return RecordActionOutcome.Back;
}
// Refresh IP from cloud API before connecting (enter/reconnect/fix)
if (action === "enter" || action === "reconnect" || action === "fix") {
const refreshResult = await asyncTryCatch(() => refreshConnectionIp(selected));
if (refreshResult.ok && refreshResult.data === "gone") {
p.log.info(`Use ${pc.cyan(`spawn ${selected.agent} ${selected.cloud}`)} to start a new one.`);
return RecordActionOutcome.Back;
}
if (!refreshResult.ok) {
// Non-fatal: proceed with cached IP if refresh fails
p.log.warn(`Could not refresh server IP: ${getErrorMessage(refreshResult.error)}`);
}
}
if (action === "enter") {
const enterResult = await asyncTryCatch(() => cmdEnterAgent(selected.connection, selected.agent, manifest));
if (!enterResult.ok) {

View file

@ -1314,6 +1314,22 @@ export async function promptSpawnName(): Promise<void> {
// ─── Lifecycle ───────────────────────────────────────────────────────────────
/** Fetch the current public IP of an existing droplet. Returns null if the droplet no longer exists. */
export async function getServerIp(dropletId: string): Promise<string | null> {
const r = await asyncTryCatch(() => doApi("GET", `/droplets/${dropletId}`, undefined, 1));
if (!r.ok) {
const msg = getErrorMessage(r.error);
if (msg.includes("404") || msg.includes("not found") || msg.includes("Not Found")) {
return null;
}
throw r.error;
}
const data = parseJsonObj(r.data);
const v4Networks = toObjectArray(data?.droplet?.networks?.v4);
const publicNet = v4Networks.find((n) => n.type === "public");
return publicNet?.ip_address && isString(publicNet.ip_address) ? publicNet.ip_address : null;
}
export async function destroyServer(dropletId?: string): Promise<void> {
const id = dropletId || _state.dropletId;
if (!id) {

View file

@ -1053,6 +1053,27 @@ export async function interactiveSession(cmd: string): Promise<number> {
// ─── Lifecycle ──────────────────────────────────────────────────────────────
/** Fetch the current public IP of an existing GCP instance. Returns null if it no longer exists. */
export async function getServerIp(instanceName: string, zone: string, project: string): Promise<string | null> {
const result = gcloudSync([
"compute",
"instances",
"describe",
instanceName,
`--zone=${zone}`,
`--project=${project}`,
"--format=get(networkInterfaces[0].accessConfigs[0].natIP)",
]);
if (result.exitCode !== 0) {
if (/not found|404|was not found/i.test(result.stderr)) {
return null;
}
throw new Error(`GCP API error: ${result.stderr}`);
}
const ip = result.stdout.trim();
return ip || null;
}
export async function destroyInstance(name?: string): Promise<void> {
const instanceName = name || _state.instanceName;
const zone = _state.zone || process.env.GCP_ZONE || DEFAULT_ZONE;

View file

@ -699,6 +699,26 @@ export async function promptSpawnName(): Promise<void> {
// ─── Lifecycle ───────────────────────────────────────────────────────────────
/** Fetch the current public IP of an existing Hetzner server. Returns null if the server no longer exists. */
export async function getServerIp(serverId: string): Promise<string | null> {
const r = await asyncTryCatch(() => hetznerApi("GET", `/servers/${serverId}`, undefined, 1));
if (!r.ok) {
const msg = getErrorMessage(r.error);
if (msg.includes("404") || msg.includes("not found") || msg.includes("Not Found")) {
return null;
}
throw r.error;
}
const data = parseJsonObj(r.data);
const server = toRecord(data?.server);
if (!server) {
return null;
}
const publicNet = toRecord(server.public_net);
const ipv4 = toRecord(publicNet?.ipv4);
return isString(ipv4?.ip) ? ipv4.ip : null;
}
export async function destroyServer(serverId?: string): Promise<void> {
const id = serverId || _state.serverId;
if (!id) {

View file

@ -416,6 +416,22 @@ export function markRecordDeleted(record: SpawnRecord): boolean {
return true;
}
/** Update the IP address on a history record's connection. Returns true if the record was found and updated. */
export function updateRecordIp(record: SpawnRecord, newIp: string): boolean {
const history = loadHistory();
const index = findRecordIndex(history, record);
if (index < 0) {
return false;
}
const found = history[index];
if (!found.connection) {
return false;
}
found.connection.ip = newIp;
writeHistory(history);
return true;
}
export function getActiveServers(): SpawnRecord[] {
const records = loadHistory();
return records.filter((r) => r.connection?.cloud && r.connection.cloud !== "local" && !r.connection.deleted);