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.
- package/dist/agent.js +4 -1
- package/dist/api.js +11 -4
- package/dist/commands/agent-policy.js +119 -19
- package/dist/commands/auth.js +115 -7
- package/dist/commands/compute.js +1132 -2
- package/dist/commands/env.js +1 -0
- package/dist/commands/github.js +56 -12
- package/dist/commands/ssh-config.js +710 -0
- package/dist/commands/upgrade.js +15 -2
- package/dist/config.js +1 -0
- package/dist/index.js +22 -4
- package/dist/util.js +99 -0
- package/package.json +1 -1
package/dist/commands/compute.js
CHANGED
|
@@ -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
|