insta 0.0.72 → 0.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { ApiClient, ApiError, requireProject } from '../api.js';
2
- import { info, printJson, handleApproval, relayExitCode } from '../util.js';
3
- import { resolveComputeServiceId, q, parseVolumeGib } from './services.js';
2
+ import { info, printJson, handleApproval, relayExitCode, writeFileAtomicSync, resolveThroughSymlink } from '../util.js';
3
+ import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib } from './services.js';
4
4
  export const isWorker = (s) => s.port === 0;
5
5
  // One line per compute service for the disambiguation error: name, region, default URL, status —
6
6
  // enough to pick one without a second command. Pure, exported for tests.
@@ -740,4 +740,1134 @@ export async function computeLimits(serviceName, opts) {
740
740
  const l = res.body.limits;
741
741
  info(`compute ${res.body.service?.name ?? id}: ceiling set to ${l.cpu} vCPU / ${fmtMb(l.memoryMb)}`);
742
742
  }
743
+ // ---- ssh (interactive sessions) --------------------------------------------
744
+ import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
745
+ import { homedir } from 'node:os';
746
+ import { dirname, join } from 'node:path';
747
+ import { execFileSync } from 'node:child_process';
748
+ import { createHash, randomUUID } from 'node:crypto';
749
+ import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, } from './ssh-config.js';
750
+ /** Where this CLI keeps its own SSH material. Deliberately NOT ~/.ssh: we never
751
+ * touch a key the user already had, and a dedicated key pairs with
752
+ * IdentitiesOnly to avoid being identified by the wrong one. */
753
+ export const instaSSHDir = () => join(homedir(), '.insta', 'ssh');
754
+ export const instaKeyPath = () => join(instaSSHDir(), 'id_ed25519');
755
+ /** One certificate file per alias. A certificate is issued for ONE service, so
756
+ * a single shared file cannot serve a project with two compute services. */
757
+ export const instaCertPath = (alias) => join(instaSSHDir(), `${alias}-cert.pub`);
758
+ export const instaAliasStorePath = () => join(instaSSHDir(), 'aliases.json');
759
+ /** The known_hosts file the trust anchor goes into, and the one every stanza we
760
+ * own pins with UserKnownHostsFile.
761
+ *
762
+ * ONE definition on purpose. The anchor is only worth anything if the config
763
+ * sends ssh to the file it was written to, so "where the anchor lives" and
764
+ * "where the alias looks" must not be two expressions that can drift apart. */
765
+ export const userKnownHostsPath = () => join(homedir(), '.ssh', 'known_hosts');
766
+ /** The renewal-hook command prefix. ssh-config.ts appends the validated alias. */
767
+ export const ENSURE_CERT_COMMAND = 'insta __ssh-ensure-cert';
768
+ /** Never throws: a missing or hand-mangled store must degrade to "nothing is
769
+ * set up", not break `ssh` for every alias. */
770
+ export function readAliasStore(path = instaAliasStorePath()) {
771
+ try {
772
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
773
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
774
+ return {};
775
+ // Each RECORD is validated, not just the outer object. A cast here let a
776
+ // single hand-edited entry through to hostEntries, where a missing host
777
+ // rendered `HostName undefined` -- and one bad stanza is enough for
778
+ // OpenSSH to reject the whole file, so every OTHER alias stopped working
779
+ // too. Dropping the bad entry keeps the blast radius at the entry.
780
+ const out = {};
781
+ for (const [alias, r] of Object.entries(parsed)) {
782
+ if (isValidAliasRecord(r))
783
+ out[alias] = r;
784
+ }
785
+ return out;
786
+ }
787
+ catch {
788
+ return {};
789
+ }
790
+ }
791
+ /** Whether a stored record can still describe a working alias. */
792
+ export function isValidAliasRecord(r) {
793
+ if (!r || typeof r !== 'object' || Array.isArray(r))
794
+ return false;
795
+ const v = r;
796
+ return typeof v.projectId === 'string' && v.projectId !== ''
797
+ && typeof v.serviceId === 'string' && v.serviceId !== ''
798
+ && (v.branch === undefined || typeof v.branch === 'string')
799
+ && isSafeConfigValue(v.host) && isSafeConfigValue(v.username);
800
+ }
801
+ /** Refuse to point an existing alias at a different service.
802
+ *
803
+ * An alias is derived from the SERVICE NAME alone, which is unique only within
804
+ * a branch. Two projects that each call a service `api` -- the ordinary case,
805
+ * not a contrived one -- would otherwise have the second setup silently
806
+ * repoint `api.insta` at the first one's host, and the developer would land a
807
+ * shell in the WRONG PROJECT while every visible signal said the command
808
+ * worked. Refused by name rather than auto-renamed: picking which `api` they
809
+ * meant is the same guess one level up.
810
+ */
811
+ export function assertAliasFree(store, alias, want) {
812
+ const held = store[alias];
813
+ if (!held)
814
+ return;
815
+ const same = held.projectId === want.projectId
816
+ && held.serviceId === want.serviceId
817
+ && (held.branch ?? '') === (want.branch ?? '');
818
+ if (same)
819
+ return;
820
+ throw new Error(`the alias ${alias} is already set up for a different service ` +
821
+ `(project ${held.projectId}, service ${held.serviceId}${held.branch ? `, branch ${held.branch}` : ''}).\n` +
822
+ `Rename one of the services, or remove "${alias}" from ${instaAliasStorePath()} and run --setup again.`);
823
+ }
824
+ export function writeAliasStore(store, path = instaAliasStorePath()) {
825
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
826
+ writeFileAtomicSync(path, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
827
+ }
828
+ /** The ssh_config entries for everything set up so far. Rendered from the whole
829
+ * store, not just the service being set up: our block is replaced wholesale,
830
+ * so rendering one entry would delete the stanzas of every other service. */
831
+ export function hostEntries(store) {
832
+ return Object.entries(store)
833
+ // Both halves, and the second is not redundant with readAliasStore: this
834
+ // function is also called with a store held in memory, so the check has to
835
+ // sit where the rendering does.
836
+ .filter(([alias, r]) => isSafeAlias(alias) && isValidAliasRecord(r))
837
+ .sort(([a], [b]) => a.localeCompare(b))
838
+ .map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, certificateFile: instaCertPath(alias) }));
839
+ }
840
+ /**
841
+ * When `ssh-keygen -L` output says the certificate stops being valid, or
842
+ * `undefined` when that cannot be established.
843
+ *
844
+ * `undefined` is not "valid forever". Both the no-match case and a date the
845
+ * platform formats differently have to read as "cannot confirm", because a
846
+ * NaN comparison is false and would otherwise pass for healthy.
847
+ */
848
+ export function parseCertValidUntil(keygenOutput) {
849
+ const m = /Valid:.*to (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/.exec(keygenOutput);
850
+ if (!m || !m[1])
851
+ return undefined;
852
+ const until = new Date(m[1]).getTime();
853
+ return Number.isNaN(until) ? undefined : until;
854
+ }
855
+ const sshKeygenReadCert = (certPath) => execFileSync('ssh-keygen', ['-L', '-f', certPath], { encoding: 'utf8' });
856
+ /**
857
+ * Whether the certificate at `certPath` should be re-issued.
858
+ *
859
+ * Every uncertain case renews. "Cannot confirm it is valid" and "it is valid"
860
+ * must not collapse into the same answer: an unnecessary renewal costs one
861
+ * HTTPS call, and the opposite costs a login that fails with no explanation.
862
+ */
863
+ export function certNeedsRenewal(certPath, { now = Date.now(), marginMs = 5 * 60_000, read = sshKeygenReadCert } = {}) {
864
+ if (!existsSync(certPath))
865
+ return true;
866
+ try {
867
+ const until = parseCertValidUntil(read(certPath));
868
+ if (until === undefined)
869
+ return true;
870
+ return until - marginMs <= now;
871
+ }
872
+ catch {
873
+ return true;
874
+ }
875
+ }
876
+ /** The public key of the pair every certificate is issued for, DERIVED from
877
+ * the private key -- the one file that decides whether a certificate can be
878
+ * used at all.
879
+ *
880
+ * `id_ed25519.pub` is a convenience copy, and trusting it was a hole. A copy
881
+ * that had been replaced or corrupted was what got sent to the platform; the
882
+ * certificate came back for THAT key; certifiesPublicKey compared it against
883
+ * the same copy and agreed; and the working certificate was replaced by one
884
+ * the private key cannot use. `ssh-keygen -y` reads the private key and
885
+ * prints its public half, so what is sent, checked and recorded is the key
886
+ * ssh will actually offer. A copy that disagrees is rewritten from it, and a
887
+ * missing one -- a first generation interrupted between its two writes -- is
888
+ * recreated rather than failing every setup thereafter. A private key
889
+ * ssh-keygen cannot read fails HERE, before anything is minted or replaced,
890
+ * and says what to do.
891
+ *
892
+ * Generation is SERIALISED on the setup lock. Two first-ever setups on a
893
+ * fresh machine -- a script setting up `api` and `worker` side by side is the
894
+ * ordinary way to get there -- both found no key and both ran ssh-keygen at
895
+ * the same path; the second hit "already exists, overwrite?" on a closed
896
+ * stdin and failed. It is the same lock the setup transaction takes, which is
897
+ * safe only because every caller runs this BEFORE taking that lock itself:
898
+ * the lock is not re-entrant. A key already on disk takes no lock. */
899
+ function ensureKeyPair() {
900
+ const key = instaKeyPath();
901
+ if (!existsSync(key)) {
902
+ withAliasStoreLock(() => {
903
+ mkdirSync(instaSSHDir(), { recursive: true, mode: 0o700 });
904
+ // Re-checked under the lock: the process this one queued behind may have
905
+ // been the one generating it.
906
+ if (!existsSync(key)) {
907
+ execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', 'insta compute ssh', '-f', key], { stdio: 'pipe' });
908
+ }
909
+ });
910
+ }
911
+ chmodSync(key, 0o600);
912
+ let derived;
913
+ try {
914
+ derived = execFileSync('ssh-keygen', ['-y', '-f', key], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
915
+ }
916
+ catch (e) {
917
+ const err = e;
918
+ if (err.code === 'ENOENT')
919
+ throw new Error('ssh-keygen is not installed, so the ssh key cannot be read -- install OpenSSH and run again');
920
+ const why = String(err.stderr ?? '').trim().split('\n')[0] || err.message;
921
+ throw new Error(`the ssh key at ${key} cannot be read by ssh-keygen (${why}); move it aside and run --setup again to generate a new one`);
922
+ }
923
+ const pub = key + '.pub';
924
+ const stored = existsSync(pub) ? readFileSync(pub, 'utf8').trim() : undefined;
925
+ if (stored === undefined || keyMaterial(stored) !== keyMaterial(derived)) {
926
+ writeFileAtomicSync(pub, derived + '\n', { mode: 0o644 });
927
+ }
928
+ return derived;
929
+ }
930
+ /** `<type> <blob>` of a public-key record, comment dropped. */
931
+ const keyMaterial = (record) => record.split(/\s+/).slice(0, 2).join(' ');
932
+ async function mintCert(api, projectId, serviceId, publicKey, alias, signal) {
933
+ const res = await api.rawRequest('POST', `/projects/${projectId}/services/${serviceId}/ssh-cert`, { publicKey }, { signal });
934
+ if (res.status < 200 || res.status >= 300) {
935
+ throw new ApiError(res.status, res.body?.error ?? 'could not issue an ssh certificate');
936
+ }
937
+ // Validated BEFORE the write, not after. The certificate file is the live
938
+ // credential for an alias that may already be working, so a response we go
939
+ // on to reject must not have replaced it on the way -- the caller would be
940
+ // left with an error message and a broken alias. Same ordering rule as the
941
+ // collision check: nothing is written until the whole response is known-good.
942
+ const out = validateCertResponse(res.body);
943
+ return { ...out, staged: stageCertificate(instaCertPath(alias), out.certificate.trim() + '\n', { publicKey }) };
944
+ }
945
+ /** Everything the plane returns that we will write into ~/.ssh or ~/.insta. */
946
+ export function validateCertResponse(body) {
947
+ const b = (body ?? {});
948
+ // The CONTENT, not merely the presence. A non-empty string was enough to
949
+ // replace a working alias's live credential with something OpenSSH cannot
950
+ // parse -- failing later, inside ssh, with a message pointing at the file
951
+ // rather than at the plane that sent it.
952
+ if (!isSSHCertificateRecord(b.certificate)) {
953
+ throw new Error('the platform did not return a usable ssh certificate');
954
+ }
955
+ if (!isSafeConfigValue(b.host) || !isSafeSSHHost(b.host)) {
956
+ throw new Error(`the platform returned an unusable ssh host: ${JSON.stringify(String(b.host).slice(0, 64))}`);
957
+ }
958
+ // Principal syntax, which above all excludes a leading `-`: the printed
959
+ // command is argv for `ssh`, and `ssh` parses its own options no matter how
960
+ // the shell quoted them.
961
+ if (!isSafeSSHUsername(b.username)) {
962
+ throw new Error(`the platform returned an unusable ssh username: ${JSON.stringify(String(b.username).slice(0, 64))}`);
963
+ }
964
+ // Printed straight to a terminal, so control characters and escape sequences
965
+ // are refused rather than rendered.
966
+ if (!isSafeTimestamp(b.expiresAt))
967
+ throw new Error('the platform returned an unusable certificate expiry');
968
+ // Parsed here rather than at install time so a malformed key fails before
969
+ // anything is written, instead of after the alias is already recorded.
970
+ if (b.caPublicKey !== undefined)
971
+ parseCAPublicKey(b.caPublicKey);
972
+ return b;
973
+ }
974
+ const sshKeygenVerifyCert = (certPath) => {
975
+ execFileSync('ssh-keygen', ['-L', '-f', certPath], { stdio: 'ignore' });
976
+ };
977
+ /**
978
+ * Write a certificate into staging only once OpenSSH agrees it is one.
979
+ *
980
+ * The structural decode in isSSHCertificateRecord reads the blob's first field
981
+ * and stops. That rejects arbitrary base64, and it still accepts a blob whose
982
+ * type name is right and whose remaining bytes are noise -- there is no nonce,
983
+ * public key, serial, principal list, validity window or signature behind it.
984
+ * Such a response would replace a WORKING alias's live credential and fail
985
+ * later inside ssh, which is exactly the preservation guarantee this command
986
+ * makes.
987
+ *
988
+ * So the authority is `ssh-keygen -L`, run against a temporary file, and the
989
+ * real file is only replaced once it passes. Spawning it here costs nothing
990
+ * new: certNeedsRenewal already runs the same binary on this same path, every
991
+ * time a certificate exists. (An earlier round declined this on the grounds
992
+ * that a subprocess did not belong on the renewal path -- that reasoning was
993
+ * simply wrong about what the path already does.)
994
+ *
995
+ * A missing ssh-keygen is a REFUSAL, not a pass: it means we cannot confirm,
996
+ * and an unconfirmable certificate must not displace one that works. Nothing
997
+ * is lost by it either -- without OpenSSH installed the certificate has no
998
+ * consumer.
999
+ */
1000
+ export function stageCertificate(certPath, contents, { verify = sshKeygenVerifyCert, publicKey } = {}) {
1001
+ // BEFORE the file is even written, because this needs no file: `ssh-keygen -L`
1002
+ // proves the response is a certificate, not that it is a certificate for the
1003
+ // key we asked about. One issued for another key passes every other gate and
1004
+ // fails at authentication time instead -- after the working credential is
1005
+ // already gone.
1006
+ if (publicKey !== undefined && !certifiesPublicKey(contents, publicKey)) {
1007
+ throw new Error('the platform returned a certificate for a different key — the existing certificate was left untouched');
1008
+ }
1009
+ mkdirSync(dirname(certPath), { recursive: true, mode: 0o700 });
1010
+ // Followed to its target, for the same reason writeFileAtomicSync does it:
1011
+ // rename(2) replaces the LINK, so a certificate someone symlinked into a
1012
+ // dotfiles repo would be severed on the first renewal -- quietly, and only
1013
+ // on the path that runs unattended.
1014
+ const target = resolveThroughSymlink(certPath);
1015
+ const staging = `${target}.staging-${process.pid}-${randomUUID()}`;
1016
+ const discard = () => {
1017
+ try {
1018
+ unlinkSync(staging);
1019
+ }
1020
+ catch { /* committed, or never created */ }
1021
+ };
1022
+ try {
1023
+ writeFileSync(staging, contents, { mode: 0o644 });
1024
+ }
1025
+ catch (e) {
1026
+ discard();
1027
+ throw e;
1028
+ }
1029
+ try {
1030
+ verify(staging);
1031
+ }
1032
+ catch (e) {
1033
+ discard();
1034
+ const why = e?.code === 'ENOENT'
1035
+ ? 'ssh-keygen is not installed, so the certificate cannot be checked'
1036
+ : 'the platform returned a certificate OpenSSH cannot parse';
1037
+ throw new Error(`${why} — the existing certificate was left untouched`);
1038
+ }
1039
+ return { commit: () => renameSync(staging, target), discard };
1040
+ }
1041
+ /** One line covers every node in every region, which is the whole reason for a
1042
+ * host CA: the TOFU alternative is a fingerprint per node and a REMOTE HOST
1043
+ * IDENTIFICATION HAS CHANGED for a random fraction of reconnects behind a load
1044
+ * balancer. Re-run on every renewal too, so a rotated CA is trusted before the
1045
+ * retired one stops signing rather than at the user's next `--setup`. */
1046
+ export function installCertAuthority(hostPattern, caPublicKey) {
1047
+ const knownHosts = userKnownHostsPath();
1048
+ // The read and the write are ONE operation, and the lock is what makes them
1049
+ // one. Without it two aliases renewing together both read these contents and
1050
+ // each renames its own result over the other's -- see withKnownHostsLock.
1051
+ return withKnownHostsLock(() => {
1052
+ mkdirSync(dirname(knownHosts), { recursive: true, mode: 0o700 });
1053
+ const existedBefore = pathExists(knownHosts);
1054
+ const existing = readUserText(knownHosts);
1055
+ const plan = planCertAuthority(existing, hostPattern, caPublicKey);
1056
+ if (plan.next === existing)
1057
+ return () => { };
1058
+ writeFileAtomicSync(knownHosts, plan.next, { mode: 0o600 });
1059
+ // A ROTATION retires the anchor that vouches for the certificate currently
1060
+ // installed, and the steps that follow it -- the config write, the
1061
+ // certificate rename -- can still fail. Without a way back, a failed
1062
+ // command leaves the old certificate in place with its CA gone: an alias
1063
+ // that worked a moment ago now cannot authenticate, and nothing said so.
1064
+ return () => withKnownHostsLock(() => {
1065
+ const reverted = revertCertAuthority(readUserText(knownHosts), plan);
1066
+ // A file this command CREATED and would now leave empty goes away with
1067
+ // the anchor: a failed first setup must not leave an empty known_hosts
1068
+ // behind as its only trace. One `ssh` has since written to stays.
1069
+ if (!existedBefore && reverted === '') {
1070
+ try {
1071
+ unlinkSync(knownHosts);
1072
+ }
1073
+ catch { /* never created */ }
1074
+ }
1075
+ else {
1076
+ writeFileAtomicSync(knownHosts, reverted, { mode: 0o600 });
1077
+ }
1078
+ });
1079
+ });
1080
+ }
1081
+ /** The SSHDeps seam lets a test stub return anything at all, so what comes back
1082
+ * is checked here rather than trusted. */
1083
+ function record(undo, back) {
1084
+ if (typeof back === 'function')
1085
+ undo.push(back);
1086
+ }
1087
+ /** Run `steps` in order; when one throws, take back the ones before it in
1088
+ * reverse and rethrow. Every step that can be taken back returns how, because
1089
+ * the steps AFTER an anchor rotation can still fail and the rotation is what
1090
+ * retires the CA vouching for the certificate already installed. Undone in
1091
+ * reverse so each step sees the world its own undo expects.
1092
+ *
1093
+ * Undo failures are swallowed on purpose: the error that got us here is the
1094
+ * one worth reporting, and an undo that cannot run leaves exactly the state
1095
+ * we would have had without one. */
1096
+ function commitWithUndo(steps) {
1097
+ const undo = [];
1098
+ try {
1099
+ for (const step of steps)
1100
+ record(undo, step());
1101
+ }
1102
+ catch (e) {
1103
+ for (const back of undo.reverse()) {
1104
+ try {
1105
+ back();
1106
+ }
1107
+ catch { /* nothing better to do on the way out */ }
1108
+ }
1109
+ throw e;
1110
+ }
1111
+ }
1112
+ /** How long an anchor update waits for another insta process before giving up.
1113
+ * Short, because this still runs inside OpenSSH's config parse: a caller that
1114
+ * cannot get in gives the renewal up and the login proceeds on the certificate
1115
+ * already installed, which is strictly better than making `ssh` wait. */
1116
+ const KNOWN_HOSTS_LOCK_WAIT_MS = 1_000;
1117
+ const LOCK_POLL_MS = 20;
1118
+ /** The locks that guard FILES break only when their holder's process is gone,
1119
+ * never on age -- see isAbandoned for why a slow holder must stay a holder. */
1120
+ const FILE_LOCK_STALE_MS = Infinity;
1121
+ /** Sleep without yielding to the event loop, which is what the callers here
1122
+ * want: the section being waited for is synchronous. */
1123
+ const sleepSync = (ms) => {
1124
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1125
+ };
1126
+ /**
1127
+ * Hold the shared-file lock across a read-modify-write of ~/.ssh/known_hosts.
1128
+ *
1129
+ * The renewal lock is per ALIAS; known_hosts is ONE file every alias writes to.
1130
+ * Two aliases expiring together — the ordinary case for a project with two
1131
+ * compute services — therefore read the same contents, each edits its own copy
1132
+ * and each renames over the other. The loser's anchor is gone, and its
1133
+ * certificate has already been issued against a CA that is no longer trusted:
1134
+ * during a rotation that alias simply stops connecting, with nothing said. The
1135
+ * same window discards unrelated lines the user's own editor had just added.
1136
+ *
1137
+ * It WAITS, unlike the renewal lock, because what is at stake is a lost anchor
1138
+ * rather than a duplicate request. The wait is bounded and short, and a caller
1139
+ * that cannot get in gives the whole renewal up, which leaves the previous
1140
+ * certificate — and the CA that signed it — in place.
1141
+ *
1142
+ * It covers insta processes. `ssh` appends host keys to the same file without
1143
+ * any lock and cannot be made to take one; that window is inherent to
1144
+ * known_hosts and is narrowed here, not closed.
1145
+ *
1146
+ * RE-ENTRANT, because the callers that matter hold it across a whole
1147
+ * transaction and installCertAuthority takes it again from inside — see the
1148
+ * depth counter below.
1149
+ */
1150
+ export function withKnownHostsLock(fn, { waitMs = KNOWN_HOSTS_LOCK_WAIT_MS, sleep = sleepSync } = {}) {
1151
+ if (knownHostsLockDepth > 0)
1152
+ return fn();
1153
+ return withLockedFile('known_hosts.lock', () => {
1154
+ knownHostsLockDepth++;
1155
+ try {
1156
+ return fn();
1157
+ }
1158
+ finally {
1159
+ knownHostsLockDepth--;
1160
+ }
1161
+ }, {
1162
+ waitMs,
1163
+ sleep,
1164
+ staleMs: FILE_LOCK_STALE_MS,
1165
+ busy: 'another insta process is updating ~/.ssh/known_hosts, so the trust anchor was left as it was',
1166
+ });
1167
+ }
1168
+ /** How deep THIS process is inside the known_hosts lock.
1169
+ *
1170
+ * A transaction holds the lock and then calls installCertAuthority, which
1171
+ * takes it again; on an exclusive-create lock file that is a self-deadlock,
1172
+ * and one that resolves as the busy error rather than a hang, so it would read
1173
+ * as "another process is updating known_hosts" when the other process is us.
1174
+ *
1175
+ * A plain counter is enough because every section it guards is synchronous:
1176
+ * nothing else in this process can run between the increment and the
1177
+ * decrement, so the count cannot be observed mid-flight or left behind by an
1178
+ * interleaved caller. */
1179
+ let knownHostsLockDepth = 0;
1180
+ /** How long a setup waits for another setup. Longer than the known_hosts wait
1181
+ * because the section it guards CONTAINS that wait, plus two file writes. */
1182
+ const ALIAS_STORE_LOCK_WAIT_MS = 10_000;
1183
+ /**
1184
+ * Hold a lock across the whole read-modify-write of the alias store AND the
1185
+ * ssh_config block rendered from it.
1186
+ *
1187
+ * These are one transaction, not two files that happen to be written together:
1188
+ * the config block is rendered from the WHOLE store, so a stale read produces a
1189
+ * config missing somebody else's alias. Two `--setup` runs for different
1190
+ * services -- a project with an `api` and a `worker` is the ordinary case --
1191
+ * both read the same aliases.json, each adds only its own entry, and each
1192
+ * writes both files. Whichever finished second wins outright: the other alias
1193
+ * is gone from the store and from the config, and BOTH commands printed the
1194
+ * alias they had configured.
1195
+ *
1196
+ * It waits rather than giving up, because the loser of the race is a lost
1197
+ * alias, and it is the caller's job to re-read the store once inside — see
1198
+ * computeSSH, where the collision check runs again under the lock.
1199
+ */
1200
+ export function withAliasStoreLock(fn, { waitMs = ALIAS_STORE_LOCK_WAIT_MS, sleep = sleepSync } = {}) {
1201
+ return withLockedFile('aliases.lock', fn, {
1202
+ waitMs,
1203
+ sleep,
1204
+ staleMs: FILE_LOCK_STALE_MS,
1205
+ busy: 'another insta process is setting up an ssh alias, so nothing was changed. Try again in a moment',
1206
+ });
1207
+ }
1208
+ /** Hold a named lock in ~/.insta/ssh for the duration of `fn`, waiting for it.
1209
+ *
1210
+ * LOCK ORDER, and it is the whole reason this is one helper: a caller that
1211
+ * needs both takes `aliases.lock` FIRST and `known_hosts.lock` inside it.
1212
+ * Nothing acquires them the other way round, so the pair cannot deadlock. */
1213
+ function withLockedFile(name, fn, { waitMs, staleMs, sleep, busy }) {
1214
+ const path = join(instaSSHDir(), name);
1215
+ const deadline = Date.now() + waitMs;
1216
+ for (;;) {
1217
+ const release = acquireLockFile(path, Date.now(), staleMs);
1218
+ if (release) {
1219
+ try {
1220
+ return fn();
1221
+ }
1222
+ finally {
1223
+ release();
1224
+ }
1225
+ }
1226
+ // Named, because a lock whose holder cannot be shown dead (a pid reused by
1227
+ // an unrelated process, a home shared from another machine) stays busy on
1228
+ // purpose, and the way out is the user's to take.
1229
+ if (Date.now() >= deadline)
1230
+ throw new Error(`${busy} (if no other insta is running, remove ${path})`);
1231
+ sleep(LOCK_POLL_MS);
1232
+ }
1233
+ }
1234
+ function installConfigBlock(store) {
1235
+ const cfg = join(homedir(), '.ssh', 'config');
1236
+ mkdirSync(dirname(cfg), { recursive: true, mode: 0o700 });
1237
+ // EXISTENCE and CONTENTS are tracked apart, because an empty file is not a
1238
+ // missing one and the undo below does opposite things for the two. A
1239
+ // dotfiles-managed ~/.ssh/config symlinked at a target that has not been
1240
+ // populated yet reads as '' exactly like a file we are about to create.
1241
+ // lstat, not existsSync: a link is something that was there, whatever its
1242
+ // target says.
1243
+ const existedBefore = pathExists(cfg);
1244
+ const existing = readUserText(cfg);
1245
+ const block = renderConfigBlock({
1246
+ entries: hostEntries(store),
1247
+ identityFile: instaKeyPath(),
1248
+ knownHostsFile: userKnownHostsPath(),
1249
+ ensureCertCommand: ENSURE_CERT_COMMAND,
1250
+ });
1251
+ // Backed up: this is the file that decides whether the user can ssh anywhere
1252
+ // at all, and our block goes at the TOP of it.
1253
+ writeFileAtomicSync(cfg, upsertConfigBlock(existing, block), { mode: 0o600, backup: true });
1254
+ // Safe to restore wholesale, unlike known_hosts: nothing else writes this
1255
+ // file without the alias-store lock, and the caller holds it.
1256
+ return () => {
1257
+ if (!existedBefore) {
1258
+ try {
1259
+ unlinkSync(cfg);
1260
+ }
1261
+ catch { /* never created */ }
1262
+ }
1263
+ else {
1264
+ // Through the SAME symlink-aware writer as the forward write, so an
1265
+ // existing file is restored where the block was actually written --
1266
+ // restoring the link's target rather than replacing the link.
1267
+ writeFileAtomicSync(cfg, existing, { mode: 0o600 });
1268
+ }
1269
+ };
1270
+ }
1271
+ /** Is there a live ssh_config block backing the alias store right now?
1272
+ *
1273
+ * Read under the alias-store lock, like every other access to this file, so it
1274
+ * cannot observe a half-written block. A config we cannot read at all counts
1275
+ * as no block: the answer only ever ADDS writes, so the safe way to be wrong
1276
+ * is to leave ~/.ssh alone. */
1277
+ function configBlockInstalled() {
1278
+ try {
1279
+ return hasOwnedBlock(readFileSync(join(homedir(), '.ssh', 'config'), 'utf8'));
1280
+ }
1281
+ catch {
1282
+ return false;
1283
+ }
1284
+ }
1285
+ /** Does this path name anything at all -- file, directory or symlink, including
1286
+ * one that dangles? `existsSync` follows links and so answers a different
1287
+ * question: it calls a link to a missing target "not there", and deleting on
1288
+ * that answer destroys the link. */
1289
+ function pathExists(path) {
1290
+ try {
1291
+ lstatSync(path);
1292
+ return true;
1293
+ }
1294
+ catch {
1295
+ return false;
1296
+ }
1297
+ }
1298
+ /** How to put a file back exactly as it is now: its bytes, or its absence.
1299
+ *
1300
+ * The BYTES, not readAliasStore's reading of them. The reader drops entries
1301
+ * it cannot use, on purpose, so an undo that wrote the reader's output back
1302
+ * would delete a hand-edited entry the user was about to fix and turn a store
1303
+ * that never existed into `{}` -- on the failure path, where the command has
1304
+ * just promised it changed nothing. */
1305
+ function snapshotForUndo(path) {
1306
+ const existed = pathExists(path);
1307
+ let bytes;
1308
+ if (existed) {
1309
+ try {
1310
+ bytes = readFileSync(path);
1311
+ }
1312
+ catch {
1313
+ bytes = undefined;
1314
+ }
1315
+ }
1316
+ return () => {
1317
+ if (!existed) {
1318
+ try {
1319
+ unlinkSync(path);
1320
+ }
1321
+ catch { /* never created */ }
1322
+ }
1323
+ else if (bytes !== undefined) {
1324
+ writeFileAtomicSync(path, bytes, { mode: 0o600 });
1325
+ }
1326
+ };
1327
+ }
1328
+ /** The text of a user-owned file this command is about to rewrite, or '' when
1329
+ * there is none -- REFUSING one that is not UTF-8. Decoding such a file and
1330
+ * writing it back turns every byte that did not decode into U+FFFD: a
1331
+ * Latin-1 comment in a twenty-year-old ~/.ssh/config would be rewritten,
1332
+ * silently, by a command that promised to add one block at the top. */
1333
+ function readUserText(path) {
1334
+ if (!existsSync(path))
1335
+ return '';
1336
+ const raw = readFileSync(path);
1337
+ const text = raw.toString('utf8');
1338
+ if (!Buffer.from(text, 'utf8').equals(raw)) {
1339
+ throw new Error(`refusing to edit ${path}: it is not valid UTF-8, and rewriting it would alter bytes this command does not own`);
1340
+ }
1341
+ return text;
1342
+ }
1343
+ /**
1344
+ * The renewal hook OpenSSH runs while PARSING the config, before it connects.
1345
+ *
1346
+ * Two properties, both load-bearing, and both about the fact that this runs on
1347
+ * EVERY ssh invocation — including `scp`, `ssh -G` and an IDE's connections:
1348
+ *
1349
+ * - Cheap when there is nothing to do. The local certificate is checked FIRST;
1350
+ * a valid one returns before any config, project or API work happens. This
1351
+ * is also why the config block invokes the HIDDEN `__ssh-ensure-cert`
1352
+ * command rather than `compute ssh --ensure-cert`: `guard` awaits
1353
+ * trackCommand() after every action, which reads global and project config,
1354
+ * can create ~/.insta/telemetry.json and issues a PostHog request with a
1355
+ * timeout of up to 1.5s. Returning early from the action does not skip any
1356
+ * of that. Telemetry already skips command paths beginning `__` (the same
1357
+ * rule __update-check relies on), so the fast path is genuinely local only
1358
+ * when the hook enters through that name.
1359
+ * - Silent and fail-safe. An unlinked directory, an expired login or a network
1360
+ * outage must not print anything or fail the parse: the existing certificate
1361
+ * stays in place and the login then fails with SSH's own message, not a CLI
1362
+ * error spliced into the middle of an ssh session.
1363
+ */
1364
+ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIMEOUT_MS) {
1365
+ let release;
1366
+ try {
1367
+ if (!isSafeAlias(alias))
1368
+ return;
1369
+ if (!certNeedsRenewal(instaCertPath(alias)))
1370
+ return;
1371
+ // An IDE opens several connections at once and `scp` adds more, so the
1372
+ // near-expiry certificate is observed by every one of them simultaneously
1373
+ // and each would mint its own replacement -- redundant requests against a
1374
+ // rate-limited endpoint, racing each other's known_hosts writes.
1375
+ //
1376
+ // NON-BLOCKING on purpose: losing the race returns immediately rather than
1377
+ // waiting. This runs inside OpenSSH's config parse, so a lock that waits is
1378
+ // a lock that can hang `ssh` itself -- strictly worse than the duplicate
1379
+ // request it would prevent. The loser simply lets the winner renew.
1380
+ release = acquireRenewalLock(alias);
1381
+ if (!release)
1382
+ return;
1383
+ // Re-checked after the lock. Without this the second process through the
1384
+ // door renews again over the certificate the first just wrote -- the lock
1385
+ // would serialise the stampede instead of collapsing it.
1386
+ if (!certNeedsRenewal(instaCertPath(alias)))
1387
+ return;
1388
+ // The alias is the ONLY input: it carries the project, branch and service
1389
+ // the certificate was issued for, so renewal cannot drift to another one.
1390
+ const rec = readAliasStore()[alias];
1391
+ if (!rec)
1392
+ return;
1393
+ const api = await ApiClient.load();
1394
+ // A DEADLINE, because this runs inside OpenSSH's config parse. A server
1395
+ // that accepts the connection and then says nothing would otherwise block
1396
+ // ssh, scp, `ssh -G` and every IDE connection for as long as it liked --
1397
+ // the catch below only helps once the request has actually rejected.
1398
+ // AbortSignal rather than a Promise.race: a race returns while leaving the
1399
+ // socket open, so the process lingers anyway.
1400
+ const out = await mintCert(api, rec.projectId, rec.serviceId, ensureKeyPair(), alias, AbortSignal.timeout(timeoutMs));
1401
+ try {
1402
+ // The commit is a setup transaction in miniature, under the SAME lock a
1403
+ // setup holds -- aliases.lock -- and for two reasons.
1404
+ //
1405
+ // A renewal can MOVE the alias. The response names the host and the
1406
+ // principal this certificate was issued for, and the installed stanza
1407
+ // routes on the ones recorded at setup. Committing the certificate alone
1408
+ // left `ssh <alias>` connecting to yesterday's host as yesterday's user
1409
+ // carrying a certificate that names today's -- while anchoring today's
1410
+ // host, since the anchor below is derived from the response. So the
1411
+ // store and, where the block is installed, the config move with the
1412
+ // certificate, exactly as a plain re-issue does in computeSSH.
1413
+ //
1414
+ // And the mint ran OUTSIDE any lock (no network under a lock), so the
1415
+ // world is re-read in here. A `--setup` that finished meanwhile may have
1416
+ // replaced the certificate with a newer one, and a stale mint must not
1417
+ // undo that; a record removed or re-pointed meanwhile is one this
1418
+ // certificate was not issued for.
1419
+ //
1420
+ // Lock order stays acyclic: the per-alias renewal lock is only ever
1421
+ // taken first, and only by renewals; then aliases.lock; then
1422
+ // known_hosts.lock inside it. The wait is the SHORT one, because this
1423
+ // still runs inside OpenSSH's config parse: a renewal that cannot get in
1424
+ // gives up whole, leaving the certificate and the anchor it found.
1425
+ withAliasStoreLock(() => {
1426
+ const before = readAliasStore();
1427
+ const held = before[alias];
1428
+ if (!held || held.projectId !== rec.projectId || held.serviceId !== rec.serviceId)
1429
+ return;
1430
+ if (!certNeedsRenewal(instaCertPath(alias)))
1431
+ return;
1432
+ const moved = held.host !== out.host || held.username !== out.username;
1433
+ const store = moved
1434
+ ? { ...before, [alias]: { ...held, host: out.host, username: out.username } }
1435
+ : before;
1436
+ const installed = configBlockInstalled();
1437
+ const ca = out.caPublicKey;
1438
+ // A move that may need a NEW anchor is a setup-grade change, and a
1439
+ // response that moves the host but carries no CA cannot complete it:
1440
+ // going ahead would route the alias to a host nothing in known_hosts
1441
+ // vouches for -- a host-key prompt on every connection, the failure the
1442
+ // anchor exists to prevent -- while reporting nothing. Abandoned whole,
1443
+ // exactly as `--setup` refuses without a CA: the alias keeps what it
1444
+ // has, and the next `--setup` repairs it. An unmoved renewal without a
1445
+ // CA still goes ahead, because its anchor is already installed.
1446
+ if (moved && installed && ca === undefined)
1447
+ return;
1448
+ // The ANCHOR before the certificate. Committing the certificate first
1449
+ // left the alias holding a credential signed by a CA this machine does
1450
+ // not trust whenever the anchor write failed -- and the catch below
1451
+ // would have swallowed that too. The rename that ends it is the step
1452
+ // that does not fail halfway, but it can still fail outright, and on a
1453
+ // ROTATION that leaves the retired CA gone with the certificate it
1454
+ // signed still installed -- so every step before it is taken back.
1455
+ //
1456
+ // The config is re-rendered only when something in it CHANGES. OpenSSH
1457
+ // is reading that very file right now; POSIX lets a rename replace an
1458
+ // open file, Windows does not, and there the undo chain turns a moved
1459
+ // host into a renewal that gives up -- one failed renewal and a
1460
+ // `--setup` to repair it, never a half-moved alias.
1461
+ const steps = [];
1462
+ if (moved)
1463
+ steps.push(() => { const back = snapshotForUndo(instaAliasStorePath()); writeAliasStore(store); return back; });
1464
+ if (ca !== undefined)
1465
+ steps.push(() => installCertAuthority(hostPatternFor(out.host), ca));
1466
+ if (moved && installed)
1467
+ steps.push(() => installConfigBlock(store));
1468
+ steps.push(() => out.staged.commit());
1469
+ // ONE section from reading the anchor to committing the certificate
1470
+ // that depends on it, rollback included -- see the note in computeSSH.
1471
+ if (ca !== undefined)
1472
+ withKnownHostsLock(() => commitWithUndo(steps));
1473
+ else
1474
+ commitWithUndo(steps);
1475
+ }, { waitMs: KNOWN_HOSTS_LOCK_WAIT_MS });
1476
+ }
1477
+ finally {
1478
+ out.staged.discard();
1479
+ }
1480
+ }
1481
+ catch {
1482
+ // Deliberately swallowed. See above.
1483
+ }
1484
+ finally {
1485
+ release?.();
1486
+ }
1487
+ }
1488
+ /** A renewal lock older than this is broken even if its holder is alive. That
1489
+ * is safe HERE and nowhere else -- see isAbandoned: what it guards is a
1490
+ * duplicate request, and a wedged renewal lock would silently stop one alias
1491
+ * renewing, which is a worse failure than a duplicate mint. */
1492
+ const RENEWAL_LOCK_STALE_MS = 60_000;
1493
+ /** How long the renewal request may take before it is abandoned. Sized for the
1494
+ * path it sits on: OpenSSH is parsing its config and the user is waiting, so a
1495
+ * renewal that cannot finish quickly is better skipped -- the existing
1496
+ * certificate is still in place and the login proceeds on it. */
1497
+ const RENEWAL_REQUEST_TIMEOUT_MS = 5_000;
1498
+ /** Take the per-alias renewal lock, or return undefined if someone else holds
1499
+ * a fresh one. Never waits — see the call site. */
1500
+ export function acquireRenewalLock(alias, now = Date.now()) {
1501
+ return acquireLockFile(join(instaSSHDir(), `${alias}.renew.lock`), now, RENEWAL_LOCK_STALE_MS);
1502
+ }
1503
+ /** Take a lock file, or return undefined when someone else holds it.
1504
+ * Never waits: whether waiting is the right answer depends on what is being
1505
+ * protected, so it belongs to the caller.
1506
+ *
1507
+ * A lock is `${pid}:${uuid}` in a file created with O_EXCL. It is BROKEN --
1508
+ * taken over from a holder that will never release it -- under isAbandoned's
1509
+ * rule, and the takeover never unlinks the path: see breakStaleLock.
1510
+ *
1511
+ * Exported for the concurrency tests. `now` and `staleMs` are the AGE rule:
1512
+ * the locks that guard files pass Infinity, the renewal lock a minute, and
1513
+ * the tests whatever window they need to cross. */
1514
+ export function acquireLockFile(path, now, staleMs) {
1515
+ // A token, not just the pid. A renewal slower than the staleness window has
1516
+ // its lock broken by the next caller; without an ownership check the
1517
+ // original holder's release would then delete the NEW holder's lock, leaving
1518
+ // the file unlocked while two renewals ran -- the lock defeating itself
1519
+ // precisely when it is under load. The pid is in it so a later caller can
1520
+ // ask whether the holder still exists.
1521
+ const token = `${process.pid}:${randomUUID()}`;
1522
+ const release = () => {
1523
+ try {
1524
+ if (readFileSync(path, 'utf8') === token)
1525
+ unlinkSync(path);
1526
+ }
1527
+ catch { /* already gone, or taken over by someone else */ }
1528
+ };
1529
+ const take = () => {
1530
+ try {
1531
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1532
+ // wx is the atomic part: exclusive create fails if the file exists, so
1533
+ // exactly one process can win regardless of how many arrive together.
1534
+ writeFileSync(path, token, { flag: 'wx', mode: 0o600 });
1535
+ return release;
1536
+ }
1537
+ catch {
1538
+ return undefined;
1539
+ }
1540
+ };
1541
+ const held = take();
1542
+ if (held)
1543
+ return held;
1544
+ // A lock path that is a LINK is refused before anything reads or writes
1545
+ // through it. `wx` already refuses to create over a link, and the takeover
1546
+ // below writes into the inode the path names -- through a link that is
1547
+ // somebody else's file.
1548
+ if (isSymlink(path))
1549
+ return undefined;
1550
+ const observed = observeLock(path);
1551
+ // Released between the exclusive create and the read: ordinary contention,
1552
+ // and `wx` is the only arbiter that needs to settle it. Nothing is being
1553
+ // taken from anybody here, so retrying is safe.
1554
+ if (!observed)
1555
+ return take();
1556
+ if (!isAbandoned(observed, now, staleMs))
1557
+ return undefined;
1558
+ return breakStaleLock(path, observed.token, token) ? release : undefined;
1559
+ }
1560
+ function isSymlink(path) {
1561
+ try {
1562
+ return lstatSync(path).isSymbolicLink();
1563
+ }
1564
+ catch {
1565
+ return false;
1566
+ }
1567
+ }
1568
+ /** The lock as one consistent observation: the token that is in the file and
1569
+ * the mtime that goes with it. */
1570
+ function observeLock(path) {
1571
+ try {
1572
+ // CONTENTS FIRST, and the order is the point. A takeover landing between
1573
+ // the two reads then pairs the OLD token with the NEW mtime, and the
1574
+ // staleness test refuses to break it -- the safe way to be wrong. Read the
1575
+ // other way round it pairs an old mtime with the new holder's token, and
1576
+ // the takeover proceeds against a lock that was taken a moment ago.
1577
+ const token = readFileSync(path, 'utf8');
1578
+ return { token, mtimeMs: statSync(path).mtimeMs };
1579
+ }
1580
+ catch {
1581
+ return undefined;
1582
+ }
1583
+ }
1584
+ /** Whether a lock may be taken from its holder.
1585
+ *
1586
+ * The holder's PROCESS is the first question, and for the locks that guard
1587
+ * files it is the only one. A holder that is merely SLOW is still a holder:
1588
+ * judging it by the age of its lock and breaking it admits a second writer to
1589
+ * aliases.json, ssh_config or known_hosts while the first is still inside the
1590
+ * section -- and, worse, still able to RELEASE, which is what made the old
1591
+ * takeover racy: its release between the breaker's check and the breaker's
1592
+ * act let a third process create a fresh lock there for the breaker to
1593
+ * destroy. A holder whose process is GONE can do neither, and that is what
1594
+ * makes breaking its lock safe. Those callers pass `staleMs = Infinity`: they
1595
+ * never break a live holder, and when a pid cannot be judged -- a lock taken
1596
+ * from another machine sharing the home, a pid reused by an unrelated process
1597
+ * -- they stay busy and name the file to remove.
1598
+ *
1599
+ * The renewal lock ALSO breaks by age, because what it guards is a duplicate
1600
+ * request rather than a file: two renewals that both get through each commit
1601
+ * under aliases.lock, where the second finds a fresh certificate and gives
1602
+ * up. A wedged renewal lock, on the other hand, silently stops one alias
1603
+ * renewing, so it must not depend on a pid that may be unjudgeable.
1604
+ *
1605
+ * A lock with no readable pid -- a process that died between the exclusive
1606
+ * create and the write, or a hand-edited file -- can never be released by
1607
+ * anyone, and is judged by age against a bound no live creator could spend
1608
+ * between two consecutive syscalls. */
1609
+ function isAbandoned({ token, mtimeMs }, now, staleMs) {
1610
+ const pid = holderPid(token);
1611
+ if (pid !== undefined && !processAlive(pid))
1612
+ return true;
1613
+ const bound = pid === undefined ? Math.min(staleMs, ORPHAN_LOCK_STALE_MS) : staleMs;
1614
+ return now - mtimeMs >= bound;
1615
+ }
1616
+ /** How old a lock with no readable owner must be before it is broken. */
1617
+ const ORPHAN_LOCK_STALE_MS = 60_000;
1618
+ function holderPid(token) {
1619
+ const m = /^(\d+):/.exec(token);
1620
+ const pid = m ? Number(m[1]) : NaN;
1621
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
1622
+ }
1623
+ /** Whether a process with this pid exists on this machine. EPERM means it
1624
+ * exists and is not ours; anything but ESRCH is "cannot tell", which reads as
1625
+ * alive -- cannot-confirm is not a licence to take somebody's lock. */
1626
+ function processAlive(pid) {
1627
+ try {
1628
+ process.kill(pid, 0);
1629
+ return true;
1630
+ }
1631
+ catch (e) {
1632
+ return e?.code !== 'ESRCH';
1633
+ }
1634
+ }
1635
+ /** Take over the lock `stale` names, having judged it abandoned, by writing
1636
+ * `mine` into it. True when `path` now carries `mine`.
1637
+ *
1638
+ * Two things have to hold at once, and the two primitives are chosen for
1639
+ * exactly them.
1640
+ *
1641
+ * Breaking a lock is itself mutually exclusive. Every contender that finds
1642
+ * the same dead holder's lock passes isAbandoned together, and without a
1643
+ * serialisation point the first to act takes a fresh lock and the ones behind
1644
+ * it destroy THAT and take their own -- the lock admitting everybody
1645
+ * precisely when it is contended. So the right to break is claimed with a
1646
+ * HARDLINK named after the token being broken: link(2) is atomic and fails
1647
+ * with EEXIST, so of all the contenders that observed this token exactly one
1648
+ * proceeds; and it is non-destructive -- a hardlink adds a name, it does not
1649
+ * move or remove the lock, so there is no window in which the file is
1650
+ * missing for something to slip in through.
1651
+ *
1652
+ * And the takeover NEVER UNLINKS THE PATH. The first version verified the
1653
+ * token, unlinked the lock and created its own, and nothing tied that unlink
1654
+ * to the inode it had verified: a holder releasing in between let a third
1655
+ * process create a fresh lock at the path, which the breaker then deleted.
1656
+ * Here the breaker writes its token INTO the inode the two names share.
1657
+ * `path` keeps its inode throughout, so there is no moment at which the lock
1658
+ * at `path` can have become somebody else's between the check and the act:
1659
+ * the holder judged dead cannot release, every other breaker is behind the
1660
+ * claim, and a release by anyone else checks for its own token first. Should
1661
+ * the old holder's release ever run, it reads a foreign token and leaves the
1662
+ * file alone.
1663
+ *
1664
+ * Residual, and deliberately not "fixed": a breaker killed between the link
1665
+ * and its write leaves the claim behind, and that one token can then no
1666
+ * longer be broken (removing the claim by hand is the way out, and the busy
1667
+ * message names the lock). Every scheme for reaping an abandoned claim needs
1668
+ * to decide the claim is dead and then remove it -- the same check-then-act
1669
+ * this function exists to eliminate, one level up. A claim left behind AFTER
1670
+ * the write is inert: the lock now carries the breaker's token, and the next
1671
+ * breaker keys its claim on that. A wedged lock is recoverable and says so;
1672
+ * two writers in the same section silently lose the user's config. */
1673
+ function breakStaleLock(path, stale, mine) {
1674
+ const claim = `${path}.stale.${createHash('sha256').update(stale).digest('hex').slice(0, 32)}`;
1675
+ try {
1676
+ linkSync(path, claim);
1677
+ }
1678
+ catch {
1679
+ // EEXIST: somebody else is already breaking this one. ENOENT: it is gone.
1680
+ // Anything else (a filesystem with no hardlinks) means we cannot establish
1681
+ // who is entitled to break it -- and cannot-establish is not a licence to
1682
+ // take somebody's lock. Failing closed costs a renewal that gets skipped
1683
+ // or a `--setup` that reports the file as busy; failing open costs the
1684
+ // user's ssh_config. The caller retries if it waits.
1685
+ return false;
1686
+ }
1687
+ try {
1688
+ // Still the lock that was judged abandoned: tokens are unique per
1689
+ // acquisition, so a takeover in between shows up as different bytes.
1690
+ if (readFileSync(path, 'utf8') !== stale)
1691
+ return false;
1692
+ writeFileSync(claim, mine);
1693
+ // Read back through `path`, the name everybody else uses: the lock is ours
1694
+ // only if that is what they will see.
1695
+ return readFileSync(path, 'utf8') === mine;
1696
+ }
1697
+ catch {
1698
+ return false;
1699
+ }
1700
+ finally {
1701
+ try {
1702
+ unlinkSync(claim);
1703
+ }
1704
+ catch { /* the inode keeps its other name */ }
1705
+ }
1706
+ }
1707
+ export async function computeSSH(serviceName, opts, deps = {}) {
1708
+ if (opts.ensureCert !== undefined)
1709
+ return ensureCertForAlias(opts.ensureCert);
1710
+ const mint = deps.mint ?? mintCert;
1711
+ const emit = deps.emit ?? info;
1712
+ const api = await (deps.loadApi ?? ApiClient.load)();
1713
+ const p = await (deps.loadProject ?? requireProject)();
1714
+ const branch = opts.branch ?? p.branch;
1715
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
1716
+ const svc = resolveSoleService(services, 'compute', serviceName);
1717
+ const alias = aliasFor(svc.name);
1718
+ // BEFORE the mint, and the order is the fix. mintCert writes
1719
+ // `<alias>-cert.pub` as part of succeeding, so checking afterwards meant a
1720
+ // collision had already overwritten the certificate of the alias it was
1721
+ // about to refuse -- the previously working `api.insta` could no longer
1722
+ // authenticate, and the command that broke it exited with an error saying it
1723
+ // had done nothing. Nothing is written until the alias is known to be ours.
1724
+ assertAliasFree(readAliasStore(), alias, { projectId: p.projectId, serviceId: svc.id, branch });
1725
+ const out = await mint(api, p.projectId, svc.id, (deps.keyPair ?? ensureKeyPair)(), alias);
1726
+ /** Whether ~/.ssh ends up describing this alias — decided under the lock, and
1727
+ * the only honest basis for advertising `ssh <alias>`. */
1728
+ let installed = false;
1729
+ try {
1730
+ // --setup PROMISES a trust anchor, so a response without one cannot be
1731
+ // reported as configured. Skipping installCA and carrying on left plain
1732
+ // `ssh`/`scp` facing a host-key prompt on every new node behind the load
1733
+ // balancer -- the exact failure the anchor exists to prevent -- while the
1734
+ // command printed the short alias and claimed success. Checked before
1735
+ // anything is installed, so the refusal is clean.
1736
+ if (opts.setup && !out.caPublicKey) {
1737
+ throw new Error('the platform did not return an ssh certificate authority key, so `--setup` cannot install the trust anchor it promises.\n' +
1738
+ 'Retry, and contact support if it persists; the certificate itself was issued and `insta compute ssh ' + svc.name + '` still prints a usable command.');
1739
+ }
1740
+ // ALL of it under one lock, and the store is re-read inside: the check
1741
+ // above ran before the mint, which is where it has to be to avoid
1742
+ // overwriting the certificate of an alias it is about to refuse -- but the
1743
+ // network round trip between them is easily long enough for another setup
1744
+ // to claim the alias, or to add one of its own that a stale store would
1745
+ // then erase from both files.
1746
+ withAliasStoreLock(() => {
1747
+ const before = readAliasStore();
1748
+ assertAliasFree(before, alias, { projectId: p.projectId, serviceId: svc.id, branch });
1749
+ const store = {
1750
+ ...before,
1751
+ [alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username },
1752
+ };
1753
+ // Whether ~/.ssh is BACKING this store, not merely whether --setup was
1754
+ // passed. The store and the certificate are rewritten by every issuance,
1755
+ // `--setup` or not; the config block and the anchor used to be rewritten
1756
+ // only by `--setup`. So a plain re-issue that came back with a moved
1757
+ // host, a renamed principal or a rotated CA updated half of what an
1758
+ // installed `ssh api.insta` depends on and left the rest describing
1759
+ // yesterday -- the alias went on routing to the old host holding a
1760
+ // certificate minted for the new one, and the command printed success.
1761
+ //
1762
+ // Once the block exists it is a rendering of the whole store, so any
1763
+ // store write has to re-render it. All four artifacts then move together
1764
+ // under the one lock, with the same undo chain as a setup.
1765
+ installed = opts.setup || (deps.configInstalled ?? configBlockInstalled)();
1766
+ // Every step that can be taken back registers how -- see commitWithUndo.
1767
+ const ca = out.caPublicKey;
1768
+ const steps = [
1769
+ () => { const back = snapshotForUndo(instaAliasStorePath()); writeAliasStore(store); return back; },
1770
+ ];
1771
+ if (installed) {
1772
+ // planCertAuthority parses the key and refuses a bad one, so a hostile
1773
+ // or malformed response fails HERE instead of appending lines to
1774
+ // known_hosts.
1775
+ if (ca)
1776
+ steps.push(() => (deps.installCA ?? installCertAuthority)(hostPatternFor(out.host), ca));
1777
+ steps.push(() => (deps.installConfig ?? installConfigBlock)(store));
1778
+ }
1779
+ // Last, and for the same reason as in the renewal hook: a certificate
1780
+ // whose anchor never landed authenticates nothing, so it does not
1781
+ // replace one that still works.
1782
+ steps.push(() => out.staged.commit());
1783
+ const commitAll = () => commitWithUndo(steps);
1784
+ // The ROLLBACK is inside the anchor's lock too, not just the write it
1785
+ // takes back. Holding the lock per edit and dropping it before the
1786
+ // transaction settled made the undo a decision about a file someone else
1787
+ // had meanwhile committed against: this command rotates CA_PREV to CA and
1788
+ // keeps an undo restoring CA_PREV; a renewal arriving after that write
1789
+ // sees CA already anchored, is handed a do-nothing undo for it, and
1790
+ // commits a certificate signed by CA; this command then fails, its undo
1791
+ // retires CA -- and the renewal's certificate, minted and committed
1792
+ // perfectly correctly, now authenticates nothing. The renewal hook now
1793
+ // queues on aliases.lock for its own commit too, so that interleaving is
1794
+ // excluded twice over; this lock stays because it guards the FILE,
1795
+ // whoever the writer turns out to be.
1796
+ //
1797
+ // Held only when there is an anchor to rotate. A re-issue that writes no
1798
+ // anchor has nothing for a rollback to strand, and taking the lock anyway
1799
+ // would give it a way to fail that it does not have today.
1800
+ if (installed && out.caPublicKey)
1801
+ withKnownHostsLock(commitAll);
1802
+ else
1803
+ commitAll();
1804
+ });
1805
+ }
1806
+ finally {
1807
+ out.staged.discard();
1808
+ }
1809
+ if (opts.json)
1810
+ return printJson({ alias, host: out.host, username: out.username, expiresAt: out.expiresAt, configured: installed });
1811
+ for (const line of sshAdvice({
1812
+ alias, host: out.host, username: out.username, expiresAt: out.expiresAt, serviceName: svc.name,
1813
+ configured: installed, identityFile: instaKeyPath(), certificateFile: instaCertPath(alias),
1814
+ }))
1815
+ emit(line);
1816
+ }
1817
+ /** What to tell the user once the certificate is in hand.
1818
+ *
1819
+ * Split out from computeSSH because the choice is the whole point and the
1820
+ * orchestration around it is network glue (untested here, as in
1821
+ * computeStart/computeExec/computeVolume). The rule: only advertise the alias
1822
+ * when the alias was actually INSTALLED. Without --setup nothing was written
1823
+ * to ssh_config, so `ssh api.insta` does not resolve -- printing it anyway is
1824
+ * advice that fails on first use and reads as a broken feature rather than a
1825
+ * skipped step.
1826
+ */
1827
+ export function sshAdvice(r) {
1828
+ const head = r.configured
1829
+ ? [`ssh ${r.alias} → ${r.username}@${r.host}`]
1830
+ : [
1831
+ // Every option here is load-bearing. The key lives at
1832
+ // ~/.insta/ssh/id_ed25519 and the certificate at <alias>-cert.pub;
1833
+ // NEITHER is a path OpenSSH looks in by default, so a bare
1834
+ // `ssh user@host` offers the user's own keys and not the credential
1835
+ // this command just issued -- it fails, having printed success.
1836
+ // IdentitiesOnly stops a loaded agent from spending the server's
1837
+ // MaxAuthTries on unrelated keys before ours is ever tried.
1838
+ `ssh -i ${shQuote(r.identityFile)} -o CertificateFile=${shQuote(r.certificateFile)} -o IdentitiesOnly=yes ${shQuote(`${r.username}@${r.host}`)}`,
1839
+ ` run \`insta compute ssh ${r.serviceName} --setup\` once for the shorter \`ssh ${r.alias}\`, automatic renewal, and scp/-L support`,
1840
+ ];
1841
+ return [...head, ` certificate valid until ${r.expiresAt}`];
1842
+ }
1843
+ /** POSIX single-quoting, for a command line we PRINT for a human to paste.
1844
+ * A home directory with a space in it is the ordinary case this exists for. */
1845
+ function shQuote(v) {
1846
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(v) ? v : `'${v.replace(/'/g, `'\\''`)}'`;
1847
+ }
1848
+ /** `ssh.us-west-1.compute.example` -> `ssh.*.compute.example`.
1849
+ *
1850
+ * Widening the REGION label is the whole point: one anchor then covers every
1851
+ * region without a line per gateway. But widening is only safe while the
1852
+ * wildcard stays deep inside a domain the gateway occupies, and blindly
1853
+ * replacing the second label does not guarantee that. `ssh.example.com` --
1854
+ * a hostname isSafeSSHHost accepts -- became `ssh.*.com`, which makes the
1855
+ * platform's CA authoritative for ssh.vendor.com and every other
1856
+ * `ssh.<anything>.com`.
1857
+ *
1858
+ * So the wildcard is introduced ONLY when at least two fixed labels remain
1859
+ * after it. Anything else anchors the EXACT host: strictly narrower, always
1860
+ * correct, and it costs nothing but one extra known_hosts line per region for
1861
+ * a deployment whose names are shaped that way. Narrower-and-works beats
1862
+ * wider-and-guesses. */
1863
+ export function hostPatternFor(host, suffixes) {
1864
+ // Counting labels is not enough to know where the registrable domain ends:
1865
+ // `ssh.*.co.uk` keeps two labels after the wildcard and still ranges over
1866
+ // every co.uk registrant. Only a suffix we KNOW we own may be widened;
1867
+ // everything else is anchored exactly.
1868
+ if (!mayWidenCAHost(host, suffixes))
1869
+ return host;
1870
+ const parts = host.split('.');
1871
+ return [parts[0], '*', ...parts.slice(2)].join('.');
1872
+ }
743
1873
  //# sourceMappingURL=compute.js.map