openship 0.6.6 → 0.6.8
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/bare-FA66N7GC.js +29 -0
- package/dist/{chunk-77QDF6UD.js → chunk-3MP6ACKM.js} +587 -156
- package/dist/{chunk-YIMOCZBZ.js → chunk-5DJOB7IX.js} +3 -3
- package/dist/{chunk-WVAML4H6.js → chunk-5I3COAFH.js} +274 -65
- package/dist/{chunk-5DMWSTOM.js → chunk-5UXHT6ED.js} +940 -224
- package/dist/{chunk-LUKYPVJ4.js → chunk-6GU6JSK6.js} +329 -466
- package/dist/{chunk-RSR7PFIQ.js → chunk-7PEXM2UI.js} +213 -45
- package/dist/{chunk-VTGPEC6B.js → chunk-7YBWXEM6.js} +6 -4
- package/dist/chunk-AHJYP7DG.js +938 -0
- package/dist/chunk-BO6CY4V2.js +265 -0
- package/dist/{chunk-FE7ZZQSE.js → chunk-D3OEMXYP.js} +3 -3
- package/dist/chunk-D6HU4VUZ.js +249 -0
- package/dist/{chunk-LFE5KQ7Y.js → chunk-ENKJ7XIF.js} +152 -57
- package/dist/{chunk-YMMQRE5J.js → chunk-GNOMEMNI.js} +1 -1
- package/dist/{chunk-QVW76WDL.js → chunk-IAMOUDHY.js} +190 -64
- package/dist/{chunk-OBPKYXQL.js → chunk-MUWVC5R4.js} +219 -58
- package/dist/chunk-N5YZMO33.js +111 -0
- package/dist/{chunk-A4GXYE4L.js → chunk-NIYD7MD2.js} +206 -52
- package/dist/{chunk-UVNMQM7W.js → chunk-NPSRGIHV.js} +8 -6
- package/dist/{chunk-2GWYYHOR.js → chunk-NRU2EBYZ.js} +2 -2
- package/dist/{chunk-CBLD2HS5.js → chunk-NUT6HKK6.js} +8 -5
- package/dist/{chunk-ZFJXPCQP.js → chunk-P7RCYELG.js} +4 -3
- package/dist/chunk-R4VDPFLS.js +106 -0
- package/dist/{chunk-HKRQXPTI.js → chunk-R5DC5PEF.js} +1 -1
- package/dist/{chunk-NK5N5LXN.js → chunk-RCMERVBL.js} +24 -9
- package/dist/{chunk-RXQBD4ZY.js → chunk-RHLNVZMS.js} +2 -2
- package/dist/chunk-SK4FFCFR.js +70 -0
- package/dist/{chunk-4I2BM2DT.js → chunk-T46BQOLY.js} +3 -3
- package/dist/{chunk-6FH4AXVY.js → chunk-T6DVUX4F.js} +1 -1
- package/dist/{chunk-PCPVTH6M.js → chunk-TAZZZKYO.js} +6 -4
- package/dist/{chunk-MLYSEZQB.js → chunk-UAWY6X7V.js} +43 -72
- package/dist/{chunk-Q7WGTQQ5.js → chunk-VAI5VOOI.js} +1 -1
- package/dist/{chunk-UJ6JZM65.js → chunk-WEZFTL4V.js} +1325 -513
- package/dist/{chunk-2YM3NI2E.js → chunk-X53JXRS4.js} +38 -44
- package/dist/{cloud-KRTZ2IL5.js → cloud-2CRDKOWQ.js} +1 -1
- package/dist/cloud-REV3DPJH.js +42 -0
- package/dist/{detect-OIE5V3VZ.js → detect-BBFT5GEU.js} +9 -6
- package/dist/{docker-VTR2GK5Y.js → docker-EG3QSE6I.js} +11 -8
- package/dist/{docker-edge-executor-22IBXNYJ.js → docker-edge-executor-E5TOTLMR.js} +8 -6
- package/dist/edge-import-YJVJUGYC.js +56 -0
- package/dist/{edge-preflight-GBNCPNVU.js → edge-preflight-QUW4BM7N.js} +15 -12
- package/dist/{ensure-container-edge-KPI2CVXJ.js → ensure-container-edge-FOEFQ3PI.js} +15 -10
- package/dist/{executor-7LXXZPBR.js → executor-NTWDJ3DB.js} +8 -6
- package/dist/host-channel-preflight-OMCZMMKR.js +54 -0
- package/dist/index.js +198 -76
- package/dist/{local-executor-WOFZD72X.js → local-executor-OQ2GRN7K.js} +3 -2
- package/dist/{nginx-KUFMLMAC.js → nginx-4U4C2XUS.js} +11 -8
- package/dist/{noop-NJ72F46T.js → noop-CLLJHCPL.js} +1 -1
- package/dist/{openresty-lua-ATTH35BE.js → openresty-lua-LLWT5XQW.js} +8 -5
- package/dist/{privilege-I4W3ZNZK.js → privilege-MNDVMDST.js} +6 -5
- package/dist/server/index.js +40049 -30378
- package/dist/server/migrations/0109_service_build_args.sql +9 -0
- package/dist/server/migrations/0110_service_deployment_host_ports.sql +18 -0
- package/dist/server/migrations/0111_host_port_claim.sql +381 -0
- package/dist/server/migrations/0112_backup_run_execution_lease.sql +12 -0
- package/dist/server/migrations/0113_docker_migration_execution_lease.sql +11 -0
- package/dist/server/migrations/0114_build_session_execution_lease_index.sql +6 -0
- package/dist/server/migrations/0115_orphan_target_key.sql +3 -0
- package/dist/server/migrations/0116_domain_ssl_challenge.sql +2 -0
- package/dist/server/migrations/meta/_journal.json +56 -0
- package/dist/setup-IFIMPQZJ.js +25 -0
- package/dist/{ssh-client-JBMA7SN4.js → ssh-client-2XWZCMGI.js} +3 -2
- package/dist/up-HQS7IR7H.js +61 -0
- package/package.json +1 -1
- package/dist/bare-OXJVW3AK.js +0 -25
- package/dist/chunk-LH2PLRQO.js +0 -434
- package/dist/chunk-SM26HDS2.js +0 -185
- package/dist/cloud-75VIT4KV.js +0 -37
- package/dist/edge-import-WJUB4M7F.js +0 -52
- package/dist/host-channel-preflight-KE6OONYK.js +0 -50
- package/dist/setup-XGLGUUFW.js +0 -22
- package/dist/up-K4BV7466.js +0 -57
|
@@ -6,13 +6,13 @@ import {
|
|
|
6
6
|
detectBuildKillHint,
|
|
7
7
|
runBuildPipeline,
|
|
8
8
|
transferLocalDirectory
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-AHJYP7DG.js";
|
|
10
10
|
import {
|
|
11
11
|
LocalExecutor
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-T6DVUX4F.js";
|
|
13
13
|
import {
|
|
14
14
|
wrapLocalBuildCommand
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-P7RCYELG.js";
|
|
16
16
|
|
|
17
17
|
// ../../packages/adapters/src/runtime/local-build.ts
|
|
18
18
|
var LOCAL_BUILD_DIR = "/tmp/openship/.builds";
|
|
@@ -10,28 +10,28 @@ import {
|
|
|
10
10
|
readComposeEnvFile,
|
|
11
11
|
resolveLocalEnvironmentSync,
|
|
12
12
|
thisHost
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-5UXHT6ED.js";
|
|
14
14
|
import {
|
|
15
15
|
OS_DIR
|
|
16
16
|
} from "./chunk-ELGRMZOW.js";
|
|
17
17
|
import {
|
|
18
18
|
systemCatalog
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-NIYD7MD2.js";
|
|
20
20
|
import {
|
|
21
21
|
EDGE_CONTAINER_MOUNTS,
|
|
22
22
|
EDGE_HOST_STATE_DIR
|
|
23
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-7PEXM2UI.js";
|
|
24
24
|
import {
|
|
25
25
|
DEFAULT_DOCKER_SOCKET_PATH,
|
|
26
26
|
resolveLocalDockerSocketPath
|
|
27
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-3MP6ACKM.js";
|
|
28
28
|
import {
|
|
29
29
|
LocalExecutor
|
|
30
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-T6DVUX4F.js";
|
|
31
31
|
import {
|
|
32
32
|
invalidateEdgeContainer,
|
|
33
33
|
sanitizeEdgeVhosts
|
|
34
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-X53JXRS4.js";
|
|
35
35
|
import {
|
|
36
36
|
DEFAULT_IMAGE_REGISTRY,
|
|
37
37
|
HOST_CHANNEL_BLOCKED,
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
hostFirewallRule,
|
|
52
52
|
hostFirewallRuleRemoval,
|
|
53
53
|
wrapText
|
|
54
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-WEZFTL4V.js";
|
|
55
55
|
|
|
56
56
|
// src/lib/host-channel-preflight.ts
|
|
57
57
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
@@ -66,13 +66,14 @@ import {
|
|
|
66
66
|
existsSync as existsSync4,
|
|
67
67
|
mkdirSync as mkdirSync3,
|
|
68
68
|
readFileSync as readFileSync3,
|
|
69
|
+
realpathSync,
|
|
69
70
|
renameSync,
|
|
70
71
|
rmSync,
|
|
71
72
|
statSync,
|
|
72
73
|
writeFileSync as writeFileSync2
|
|
73
74
|
} from "fs";
|
|
74
75
|
import { homedir, userInfo } from "os";
|
|
75
|
-
import { dirname, join as join4, resolve as resolve2 } from "path";
|
|
76
|
+
import { dirname, join as join4, relative, resolve as resolve2 } from "path";
|
|
76
77
|
|
|
77
78
|
// src/lib/ports.ts
|
|
78
79
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -400,8 +401,30 @@ async function rebuildFromSource(info) {
|
|
|
400
401
|
// src/lib/compose.ts
|
|
401
402
|
var EDGE_SITES_HOST_DIR = `${EDGE_HOST_STATE_DIR}/sites-enabled`;
|
|
402
403
|
var EDGE_ACME_HOST_DIR = `${EDGE_HOST_STATE_DIR}/acme`;
|
|
404
|
+
function canonicalComposeHostPath(target, platform = process.platform, resolvePath = realpathSync) {
|
|
405
|
+
if (platform !== "darwin") return target;
|
|
406
|
+
try {
|
|
407
|
+
return resolvePath(target);
|
|
408
|
+
} catch {
|
|
409
|
+
let parent = target;
|
|
410
|
+
while (parent !== dirname(parent)) {
|
|
411
|
+
parent = dirname(parent);
|
|
412
|
+
try {
|
|
413
|
+
return join4(resolvePath(parent), relative(parent, target));
|
|
414
|
+
} catch {
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
throw new Error(`Could not resolve edge bind-mount source ${target} on this Mac.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function composeEdgeMounts() {
|
|
421
|
+
return EDGE_CONTAINER_MOUNTS.map((mount) => ({
|
|
422
|
+
...mount,
|
|
423
|
+
host: canonicalComposeHostPath(mount.host)
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
403
426
|
function edgeVolumeYaml(indent) {
|
|
404
|
-
return
|
|
427
|
+
return composeEdgeMounts().map((m) => `${indent}- ${m.host}:${m.container}:z`).join("\n");
|
|
405
428
|
}
|
|
406
429
|
var DOCKER_CONFIG_MOUNT_MARKER = "__OPENSHIP_DOCKER_CONFIG_MOUNT__";
|
|
407
430
|
function dockerConfigMountYaml(indent) {
|
|
@@ -775,32 +798,66 @@ function stripHostAuthorizedKeys(existing) {
|
|
|
775
798
|
}
|
|
776
799
|
var ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
777
800
|
function chooseHostChannelUser(input) {
|
|
801
|
+
const keysFor = (home) => join4(home, ".ssh", "authorized_keys");
|
|
802
|
+
if (input.pinned) {
|
|
803
|
+
return [
|
|
804
|
+
{
|
|
805
|
+
user: input.pinned.user,
|
|
806
|
+
authKeysPath: input.pinned.user === "root" ? ROOT_AUTHORIZED_KEYS : keysFor(input.pinned.home),
|
|
807
|
+
// Unknowable for an arbitrary account; the dial measures it (see provisionHostSshChannel).
|
|
808
|
+
elevation: input.pinned.user === "root" ? "login" : "sudo",
|
|
809
|
+
source: "pin"
|
|
810
|
+
}
|
|
811
|
+
];
|
|
812
|
+
}
|
|
813
|
+
const candidates = [];
|
|
778
814
|
if (input.invokerUid === 0) {
|
|
779
|
-
|
|
815
|
+
candidates.push({
|
|
780
816
|
user: "root",
|
|
781
817
|
authKeysPath: ROOT_AUTHORIZED_KEYS,
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
};
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
818
|
+
elevation: "login",
|
|
819
|
+
source: "root"
|
|
820
|
+
});
|
|
821
|
+
if (input.sudoUser && input.sudoUser.user !== "root") {
|
|
822
|
+
candidates.push({
|
|
823
|
+
user: input.sudoUser.user,
|
|
824
|
+
authKeysPath: keysFor(input.sudoUser.home),
|
|
825
|
+
elevation: "sudo",
|
|
826
|
+
source: "sudo-user"
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
} else {
|
|
830
|
+
candidates.push({
|
|
831
|
+
user: input.invokerName,
|
|
832
|
+
authKeysPath: keysFor(input.invokerHome),
|
|
833
|
+
// Reaches root through sudo at run time, so this is NOT `rootUnavailable` — the
|
|
834
|
+
// caller must not warn that mail and edge will fail, because they won't.
|
|
835
|
+
elevation: input.hasPasswordlessSudo ? "sudo" : "none",
|
|
836
|
+
source: "invoker"
|
|
837
|
+
});
|
|
792
838
|
}
|
|
793
|
-
|
|
839
|
+
const carried = input.carried?.trim();
|
|
840
|
+
if (!carried) return candidates;
|
|
841
|
+
const already = candidates.findIndex((c) => c.user === carried);
|
|
842
|
+
if (already <= 0) return candidates;
|
|
843
|
+
const [promoted] = candidates.splice(already, 1);
|
|
844
|
+
return [{ ...promoted, source: "carried" }, ...candidates];
|
|
794
845
|
}
|
|
795
846
|
function hasPasswordlessSudo() {
|
|
796
847
|
try {
|
|
797
|
-
return spawnSync3("sudo", ["-n", "true"], { stdio: "ignore" }).status === 0;
|
|
848
|
+
return spawnSync3("sudo", ["-n", "-k", "true"], { stdio: "ignore" }).status === 0;
|
|
798
849
|
} catch {
|
|
799
850
|
return false;
|
|
800
851
|
}
|
|
801
852
|
}
|
|
802
|
-
function
|
|
803
|
-
|
|
853
|
+
function sudoInvoker() {
|
|
854
|
+
const user = process.env.SUDO_USER?.trim();
|
|
855
|
+
if (!user || user === "root") return null;
|
|
856
|
+
const home = passwdHome(user);
|
|
857
|
+
return home ? { user, home } : null;
|
|
858
|
+
}
|
|
859
|
+
function plannedHostChannel(cfg) {
|
|
860
|
+
if (!cfg.hostControl || process.platform !== "linux") return null;
|
|
804
861
|
let invoker;
|
|
805
862
|
try {
|
|
806
863
|
const info = userInfo();
|
|
@@ -808,14 +865,43 @@ function plannedHostChannel(hostControl) {
|
|
|
808
865
|
} catch {
|
|
809
866
|
invoker = { uid: -1, username: process.env.USER || process.env.LOGNAME || "root", home: homedir() };
|
|
810
867
|
}
|
|
811
|
-
const
|
|
868
|
+
const pin = cfg.hostSshUser?.trim();
|
|
869
|
+
const candidates = chooseHostChannelUser({
|
|
812
870
|
invokerUid: invoker.uid,
|
|
813
871
|
invokerName: invoker.username,
|
|
814
872
|
invokerHome: invoker.home,
|
|
815
873
|
// Only probe sudo when it could change the outcome — a root invoker never needs it.
|
|
816
|
-
hasPasswordlessSudo: invoker.uid !== 0 && hasPasswordlessSudo()
|
|
874
|
+
hasPasswordlessSudo: invoker.uid !== 0 && hasPasswordlessSudo(),
|
|
875
|
+
// A pinned account is dialed, never written to on our guess about its home: root's
|
|
876
|
+
// file is fixed, and any other account's comes from the passwd database.
|
|
877
|
+
pinned: pin ? { user: pin, home: passwdHome(pin) ?? join4("/home", pin) } : null,
|
|
878
|
+
sudoUser: invoker.uid === 0 ? sudoInvoker() : null,
|
|
879
|
+
carried: cfg.hostSshUserCarried ?? null
|
|
817
880
|
});
|
|
818
|
-
return {
|
|
881
|
+
return { candidates, keyPath: join4(COMPOSE_DIR, "host-ssh", "id_ed25519") };
|
|
882
|
+
}
|
|
883
|
+
function passwdHome(user) {
|
|
884
|
+
try {
|
|
885
|
+
const r = spawnSync3("getent", ["passwd", user], { encoding: "utf8" });
|
|
886
|
+
if (r.status !== 0) return null;
|
|
887
|
+
return (r.stdout ?? "").split("\n")[0]?.split(":")[5]?.trim() || null;
|
|
888
|
+
} catch {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function previewHostChannel(cfg) {
|
|
893
|
+
const plan = plannedHostChannel(cfg);
|
|
894
|
+
if (!plan) return null;
|
|
895
|
+
const [first, ...rest] = plan.candidates;
|
|
896
|
+
if (!first) return null;
|
|
897
|
+
return {
|
|
898
|
+
user: first.user,
|
|
899
|
+
keyPath: plan.keyPath,
|
|
900
|
+
authKeysPath: first.authKeysPath,
|
|
901
|
+
rootUnavailable: first.elevation === "none",
|
|
902
|
+
elevation: first.elevation,
|
|
903
|
+
fallbacks: rest.map((c) => c.user)
|
|
904
|
+
};
|
|
819
905
|
}
|
|
820
906
|
function authorizeKeyAt(authKeysPath, pub) {
|
|
821
907
|
mkdirSync3(dirname(authKeysPath), { recursive: true, mode: 448 });
|
|
@@ -855,15 +941,20 @@ function retireHostSshChannel(prev) {
|
|
|
855
941
|
const keyDir = join4(COMPOSE_DIR, "host-ssh");
|
|
856
942
|
if (!existsSync4(keyDir) && !prev.OPENSHIP_HOST_SSH_HOST) return;
|
|
857
943
|
const invokerKeys = join4(homedir(), ".ssh", "authorized_keys");
|
|
858
|
-
const
|
|
944
|
+
const account = hostChannelAccount(prev);
|
|
945
|
+
const asRoot = account === HOST_CHANNEL_DEFAULT_ACCOUNT;
|
|
946
|
+
const accountKeys = asRoot ? ROOT_AUTHORIZED_KEYS : (() => {
|
|
947
|
+
const home = passwdHome(account);
|
|
948
|
+
return home ? join4(home, ".ssh", "authorized_keys") : invokerKeys;
|
|
949
|
+
})();
|
|
859
950
|
const weAreRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
860
|
-
const revoked = asRoot ? weAreRoot ? revokeKeyAt(ROOT_AUTHORIZED_KEYS) : hasPasswordlessSudo() && revokeRootKeyViaSudo() : revokeKeyAt(
|
|
861
|
-
const alsoInvoker =
|
|
951
|
+
const revoked = asRoot ? weAreRoot ? revokeKeyAt(ROOT_AUTHORIZED_KEYS) : hasPasswordlessSudo() && revokeRootKeyViaSudo() : revokeKeyAt(accountKeys);
|
|
952
|
+
const alsoInvoker = accountKeys === invokerKeys ? true : revokeKeyAt(invokerKeys);
|
|
862
953
|
try {
|
|
863
954
|
rmSync(keyDir, { recursive: true, force: true });
|
|
864
955
|
} catch {
|
|
865
956
|
}
|
|
866
|
-
const stuck =
|
|
957
|
+
const stuck = accountKeys;
|
|
867
958
|
console.log(
|
|
868
959
|
revoked && alsoInvoker ? " Host control off: dropped OPENSHIP_HOST_SSH_* from .env, deleted the host key and\n revoked its authorized_keys line." : ` Host control off: dropped OPENSHIP_HOST_SSH_* from .env and deleted the host key (it
|
|
869
960
|
can no longer be used). Removing its line needs root \u2014 run:
|
|
@@ -871,17 +962,10 @@ function retireHostSshChannel(prev) {
|
|
|
871
962
|
);
|
|
872
963
|
}
|
|
873
964
|
function provisionHostSshChannel(cfg) {
|
|
874
|
-
const
|
|
875
|
-
if (!
|
|
876
|
-
const {
|
|
877
|
-
const
|
|
878
|
-
const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT;
|
|
879
|
-
if (probeHostSshd(port) === "absent") {
|
|
880
|
-
return { channel, issue: { code: "no-sshd", detail: `port ${port}` } };
|
|
881
|
-
}
|
|
882
|
-
const auth = verifyChannelAuth(channel.user, channel.keyPath, port);
|
|
883
|
-
return auth.state === "refused" ? { channel, issue: { code: "auth-refused", detail: auth.detail, account: channel.user } } : { channel };
|
|
884
|
-
};
|
|
965
|
+
const plan = plannedHostChannel(cfg);
|
|
966
|
+
if (!plan) return { channel: null };
|
|
967
|
+
const { candidates, keyPath } = plan;
|
|
968
|
+
const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT;
|
|
885
969
|
try {
|
|
886
970
|
mkdirSync3(join4(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
|
|
887
971
|
if (!existsSync4(keyPath)) {
|
|
@@ -896,24 +980,98 @@ function provisionHostSshChannel(cfg) {
|
|
|
896
980
|
}
|
|
897
981
|
const pub = readFileSync3(`${keyPath}.pub`, "utf8").trim();
|
|
898
982
|
if (!pub) return { channel: null, issue: { code: "empty-key" } };
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
983
|
+
const tried = [];
|
|
984
|
+
const losers = [];
|
|
985
|
+
let lastFailure = null;
|
|
986
|
+
for (const candidate of candidates) {
|
|
987
|
+
tried.push(candidate.user);
|
|
988
|
+
authorizeKeyAt(candidate.authKeysPath, pub);
|
|
989
|
+
if (probeHostSshd(port) === "absent") {
|
|
990
|
+
return { channel: { user: candidate.user, keyPath }, issue: { code: "no-sshd", detail: `port ${port}` } };
|
|
991
|
+
}
|
|
992
|
+
const auth = verifyChannelAuth(candidate.user, keyPath, port);
|
|
993
|
+
if (auth.state === "unreachable") {
|
|
994
|
+
return {
|
|
995
|
+
channel: { user: candidate.user, keyPath },
|
|
996
|
+
issue: { code: "unreachable", detail: auth.detail }
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
if (auth.state === "refused" || auth.state === "forced-command") {
|
|
1000
|
+
lastFailure = { state: auth.state, detail: auth.detail };
|
|
1001
|
+
losers.push(candidate);
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const elevation = candidate.user === "root" ? "login" : auth.state === "ok" && auth.elevation === "refused" ? (
|
|
1005
|
+
// MEASURED, over the session shape the api will use. This is the arm
|
|
1006
|
+
// `hasPasswordlessSudo` used to get wrong from a cached ticket, which is what
|
|
1007
|
+
// suppressed the warning below on boxes that genuinely could not reach root.
|
|
1008
|
+
"none"
|
|
1009
|
+
) : candidate.elevation;
|
|
1010
|
+
for (const loser of losers) revokeKeyAt(loser.authKeysPath);
|
|
1011
|
+
if (elevation === "sudo") {
|
|
1012
|
+
revokeRootKeyViaSudo();
|
|
1013
|
+
}
|
|
1014
|
+
if (elevation === "none") {
|
|
1015
|
+
console.warn(
|
|
1016
|
+
` \u26A0 Host control needs root, and this channel has no route to it: it logs in as
|
|
1017
|
+
\`${candidate.user}\`, and \`sudo -n\` was refused over that session. Mail and edge
|
|
1018
|
+
host operations will fail. Grant that account passwordless sudo, or re-run
|
|
1019
|
+
\`openship up\` as root, to fix. Continuing with a limited host channel.`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
if (candidate.source === "sudo-user") {
|
|
1023
|
+
console.log(
|
|
1024
|
+
` \u2192 Host channel settled on \`${candidate.user}\` rather than root: this host refused a
|
|
1025
|
+
root login, and that account's key was accepted. It elevates per operation with
|
|
1026
|
+
sudo, so no standing root credential is created. Pin one with
|
|
1027
|
+
\`openship up --host-ssh-user <name>\`.`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
return { channel: { user: candidate.user, keyPath }, issue: void 0 };
|
|
1031
|
+
}
|
|
1032
|
+
const carried = cfg.hostSshUserCarried?.trim();
|
|
1033
|
+
for (const loser of losers) {
|
|
1034
|
+
if (loser.user !== carried) revokeKeyAt(loser.authKeysPath);
|
|
905
1035
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1036
|
+
return {
|
|
1037
|
+
channel: null,
|
|
1038
|
+
issue: {
|
|
1039
|
+
code: lastFailure?.state === "forced-command" ? "forced-command" : "auth-refused",
|
|
1040
|
+
...lastFailure?.detail ? { detail: lastFailure.detail } : {},
|
|
1041
|
+
account: tried[tried.length - 1] ?? "",
|
|
1042
|
+
tried,
|
|
1043
|
+
...tried.includes("root") ? (() => {
|
|
1044
|
+
const setting = permitRootLoginSetting(port);
|
|
1045
|
+
return setting ? { permitRootLogin: setting } : {};
|
|
1046
|
+
})() : {}
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
909
1049
|
} catch (err) {
|
|
910
1050
|
return { channel: null, issue: { code: "error", detail: err?.message } };
|
|
911
1051
|
}
|
|
912
1052
|
}
|
|
1053
|
+
function permitRootLoginSetting(port) {
|
|
1054
|
+
try {
|
|
1055
|
+
const r = spawnSync3(
|
|
1056
|
+
"sudo",
|
|
1057
|
+
["-n", "sshd", "-T", "-C", `user=root,host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport=${port}`],
|
|
1058
|
+
{ encoding: "utf8" }
|
|
1059
|
+
);
|
|
1060
|
+
if (r.status !== 0) return null;
|
|
1061
|
+
return /^permitrootlogin\s+(\S+)/m.exec(r.stdout ?? "")?.[1] ?? null;
|
|
1062
|
+
} catch {
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
function sshdRefusesRootLogin(value) {
|
|
1067
|
+
const v = value?.trim().toLowerCase();
|
|
1068
|
+
return v === "no" || v === "forced-commands-only";
|
|
1069
|
+
}
|
|
913
1070
|
function verifyChannelAuth(user, keyPath, port) {
|
|
914
1071
|
const r = spawnSync3(
|
|
915
1072
|
"ssh",
|
|
916
1073
|
[
|
|
1074
|
+
"-v",
|
|
917
1075
|
"-i",
|
|
918
1076
|
keyPath,
|
|
919
1077
|
"-p",
|
|
@@ -931,16 +1089,34 @@ function verifyChannelAuth(user, keyPath, port) {
|
|
|
931
1089
|
"-o",
|
|
932
1090
|
"IdentitiesOnly=yes",
|
|
933
1091
|
`${user}@127.0.0.1`,
|
|
934
|
-
|
|
1092
|
+
CHANNEL_PROBE_COMMAND
|
|
935
1093
|
],
|
|
936
|
-
{ encoding: "utf8", stdio: ["ignore", "
|
|
1094
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15e3 }
|
|
937
1095
|
);
|
|
938
|
-
if (r.status === 0) return { state: "ok" };
|
|
939
1096
|
if (r.error?.code === "ENOENT" || r.status === null) {
|
|
940
1097
|
return { state: "unknown" };
|
|
941
1098
|
}
|
|
942
|
-
const
|
|
943
|
-
|
|
1099
|
+
const stderr = r.stderr || "";
|
|
1100
|
+
const stdout = r.stdout || "";
|
|
1101
|
+
const sudo = /openship-sudo=(ok|refused)/.exec(stdout)?.[1];
|
|
1102
|
+
if (r.status === 0 && sudo) {
|
|
1103
|
+
return { state: "ok", elevation: sudo === "ok" ? "ok" : "refused" };
|
|
1104
|
+
}
|
|
1105
|
+
if (/Authenticated to |debug1: Authentication succeeded/.test(stderr)) {
|
|
1106
|
+
return r.status === 0 ? { state: "ok", elevation: "unknown" } : { state: "forced-command", detail: sshProbeDetail(stderr, r.status) };
|
|
1107
|
+
}
|
|
1108
|
+
if (/^ssh: connect to host/m.test(stderr)) {
|
|
1109
|
+
return { state: "unreachable", detail: sshProbeDetail(stderr, r.status) };
|
|
1110
|
+
}
|
|
1111
|
+
if (r.status === 0) return { state: "ok", elevation: "unknown" };
|
|
1112
|
+
return { state: "refused", detail: sshProbeDetail(stderr, r.status) };
|
|
1113
|
+
}
|
|
1114
|
+
var CHANNEL_PROBE_COMMAND = "if sudo -n -k true 2>/dev/null; then echo openship-sudo=ok; else echo openship-sudo=refused; fi; exit 0";
|
|
1115
|
+
function sshProbeDetail(stderr, status) {
|
|
1116
|
+
const detail = stderr.split("\n").map((l) => l.trim()).filter(
|
|
1117
|
+
(l) => l && !/^debug\d*:/.test(l) && !/^OpenSSH_/.test(l) && !/^Warning: Permanently added/.test(l)
|
|
1118
|
+
).join(" ");
|
|
1119
|
+
return detail || `ssh exited ${status}`;
|
|
944
1120
|
}
|
|
945
1121
|
function keygenWhy(r) {
|
|
946
1122
|
if (r.error?.code === "ENOENT") {
|
|
@@ -977,7 +1153,16 @@ function hostIssueNote(text) {
|
|
|
977
1153
|
}
|
|
978
1154
|
function renderHostChannelIssue(issue, ctx) {
|
|
979
1155
|
const detail = issue.detail ? ` (${issue.detail})` : "";
|
|
980
|
-
const headline = issue.code === "no-sshd" ? ` \u26A0 Host control is provisioned, but nothing on this host is listening for SSH${detail}.` : issue.code === "
|
|
1156
|
+
const headline = issue.code === "no-sshd" ? ` \u26A0 Host control is provisioned, but nothing on this host is listening for SSH${detail}.` : issue.code === "unreachable" ? ` \u26A0 Host control is provisioned, but nothing answered at the SSH address we dialed${detail}.` : issue.code === "forced-command" ? (
|
|
1157
|
+
// Like `auth-refused` and unlike `no-sshd`: a key that can't run a command is
|
|
1158
|
+
// not a channel, so nothing was written and the headline must not imply it was.
|
|
1159
|
+
` \u26A0 Host control could NOT be provisioned: this host's sshd allows the channel no commands.`
|
|
1160
|
+
) : issue.code === "auth-refused" ? (
|
|
1161
|
+
// NOT "is provisioned, but": a refusal no longer leaves a channel behind. The
|
|
1162
|
+
// run tried every account it could authorize and none of them were let in, so
|
|
1163
|
+
// there is nothing provisioned to describe.
|
|
1164
|
+
` \u26A0 Host control could NOT be provisioned: this host REFUSED the channel's key.`
|
|
1165
|
+
) : ` \u26A0 Host control could NOT be provisioned on this box.`;
|
|
981
1166
|
let cause;
|
|
982
1167
|
switch (issue.code) {
|
|
983
1168
|
case "keygen":
|
|
@@ -996,14 +1181,27 @@ function renderHostChannelIssue(issue, ctx) {
|
|
|
996
1181
|
sshdEnableHint: thisHost().sshdEnableHint()
|
|
997
1182
|
}).body;
|
|
998
1183
|
break;
|
|
999
|
-
case "
|
|
1000
|
-
cause = `
|
|
1184
|
+
case "unreachable":
|
|
1185
|
+
cause = `sshd is listening on this host, but the verification dial to 127.0.0.1 was not answered${detail}. That is an ADDRESS problem, not a key problem: a \`ListenAddress\` that excludes loopback, or a local firewall rule. The api container dials ${ctx.target}, so check that sshd accepts connections there \u2014 \`sudo sshd -T | grep -i listenaddress\` shows what it binds.`;
|
|
1001
1186
|
break;
|
|
1187
|
+
case "forced-command":
|
|
1188
|
+
cause = `sshd ACCEPTED the channel's key for \`${issue.account ?? "the channel account"}\` and then closed the session without running anything${detail}. That is what \`PermitRootLogin forced-commands-only\` does to a key with no \`command=\` of its own, and it is indistinguishable from a working channel until a host operation runs. Either permit this account a normal login, or use an account that has one: \`openship up --host-ssh-user <name>\`.`;
|
|
1189
|
+
break;
|
|
1190
|
+
case "auth-refused": {
|
|
1191
|
+
const accounts = issue.tried?.length ? issue.tried.map((a) => `\`${a}\``).join(" then ") : `\`${issue.account ?? "the channel account"}\``;
|
|
1192
|
+
const rootNote = issue.permitRootLogin ? ` This host's sshd reports \`PermitRootLogin ${issue.permitRootLogin}\`${sshdRefusesRootLogin(issue.permitRootLogin) ? ", which refuses a root login outright" : ""}.` : "";
|
|
1193
|
+
cause = `The key was authorized for ${accounts} and sshd is listening, but every dial was refused${detail}.${rootNote} sshd is not telling us which of several causes it is \u2014 \`Permission denied (publickey)\` is the same message for an account that may not log in, an \`authorized_keys\` sshd doesn't read for it, and a home directory whose permissions it rejects. Three things fix it:
|
|
1194
|
+
\u2022 name an account that CAN log in: \`openship up --host-ssh-user <name>\`
|
|
1195
|
+
\u2022 permit root a key login: \`PermitRootLogin prohibit-password\` (still refuses passwords), or scope it to the container with a \`Match Address\` block
|
|
1196
|
+
\u2022 or leave host control off: \`openship up --no-host-control\`
|
|
1197
|
+
Until one of them is done the api container will be refused every time it dials ${ctx.target}.`;
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1002
1200
|
case "error":
|
|
1003
1201
|
cause = `Setting up the channel's key failed${detail}.`;
|
|
1004
1202
|
break;
|
|
1005
1203
|
}
|
|
1006
|
-
const continuity = issue.code === "no-sshd" || issue.code === "
|
|
1204
|
+
const continuity = issue.code === "no-sshd" || issue.code === "unreachable" ? "" : ctx.kept ? hostIssueNote(
|
|
1007
1205
|
"Kept the host channel this install already had in `.env` rather than dropping it: its key is still authorized, so the api container keeps the access it had instead of losing it to this failure."
|
|
1008
1206
|
) : hostIssueNote(
|
|
1009
1207
|
"OPENSHIP_HOST_SSH_HOST is therefore unset, and host operations refuse rather than run against the api container's own filesystem."
|
|
@@ -1345,7 +1543,14 @@ function resolveEnvConfig(prev, opts) {
|
|
|
1345
1543
|
// so, and every `openship up` overwrote a hand-edited `.env` back to the
|
|
1346
1544
|
// value that didn't work (#490/#482).
|
|
1347
1545
|
hostSshHost: keepConfig(prev, "OPENSHIP_HOST_SSH_HOST", opts.hostSshHost) ?? HOST_CHANNEL_DEFAULT_HOST,
|
|
1348
|
-
hostSshPort: keepConfig(prev, "OPENSHIP_HOST_SSH_PORT", opts.hostSshPort) ?? String(HOST_CHANNEL_DEFAULT_PORT)
|
|
1546
|
+
hostSshPort: keepConfig(prev, "OPENSHIP_HOST_SSH_PORT", opts.hostSshPort) ?? String(HOST_CHANNEL_DEFAULT_PORT),
|
|
1547
|
+
// Sticky like its siblings, but kept in its OWN key rather than reusing
|
|
1548
|
+
// OPENSHIP_HOST_SSH_USER. That key records what provisioning SETTLED on, and the two
|
|
1549
|
+
// must not be conflated: reading a settled value back as a pin would freeze the account
|
|
1550
|
+
// a box happens to have — including the refused `root` on every install #527 broke — and
|
|
1551
|
+
// the fallback that repairs them could never run.
|
|
1552
|
+
...keepConfig(prev, "OPENSHIP_HOST_SSH_USER_PIN", opts.hostSshUser) ? { hostSshUser: keepConfig(prev, "OPENSHIP_HOST_SSH_USER_PIN", opts.hostSshUser) } : {},
|
|
1553
|
+
...prev.OPENSHIP_HOST_SSH_USER?.trim() ? { hostSshUserCarried: prev.OPENSHIP_HOST_SSH_USER.trim() } : {}
|
|
1349
1554
|
};
|
|
1350
1555
|
}
|
|
1351
1556
|
function toPort(value) {
|
|
@@ -1409,6 +1614,7 @@ async function resolveComposePorts(prefs, opts = {}) {
|
|
|
1409
1614
|
var OMITTABLE_MANAGED_KEYS = /* @__PURE__ */ new Set([
|
|
1410
1615
|
"OPENSHIP_HOST_SSH_HOST",
|
|
1411
1616
|
"OPENSHIP_HOST_SSH_USER",
|
|
1617
|
+
"OPENSHIP_HOST_SSH_USER_PIN",
|
|
1412
1618
|
"OPENSHIP_HOST_SSH_PORT",
|
|
1413
1619
|
"OPENSHIP_HOST_SSH_KEY",
|
|
1414
1620
|
"OPENSHIP_HOST_KEY_PATH",
|
|
@@ -1421,7 +1627,7 @@ function operatorEnvPassthrough(managed, prev) {
|
|
|
1421
1627
|
}
|
|
1422
1628
|
var PRESERVED_ENV_HEADER = "# Preserved from your existing .env \u2014 set by you, not by `openship up` (#485).";
|
|
1423
1629
|
function resolveImageVersion(opts) {
|
|
1424
|
-
return opts.version?.trim() || process.env.OPENSHIP_VERSION?.trim() || (true ? "0.6.
|
|
1630
|
+
return opts.version?.trim() || process.env.OPENSHIP_VERSION?.trim() || (true ? "0.6.8" : "latest");
|
|
1425
1631
|
}
|
|
1426
1632
|
function managedEnvLines(opts, host, cfg, prev) {
|
|
1427
1633
|
const lines = [
|
|
@@ -1470,6 +1676,8 @@ function managedEnvLines(opts, host, cfg, prev) {
|
|
|
1470
1676
|
if (host) {
|
|
1471
1677
|
lines.push(
|
|
1472
1678
|
`OPENSHIP_HOST_SSH_HOST=${cfg.hostSshHost}`,
|
|
1679
|
+
// What provisioning SETTLED on this run — a fact about the box, not a setting. The
|
|
1680
|
+
// operator's pin, when there is one, is written OUTSIDE this block (see below).
|
|
1473
1681
|
`OPENSHIP_HOST_SSH_USER=${host.user}`,
|
|
1474
1682
|
`OPENSHIP_HOST_SSH_PORT=${cfg.hostSshPort}`,
|
|
1475
1683
|
"OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key",
|
|
@@ -1477,6 +1685,7 @@ function managedEnvLines(opts, host, cfg, prev) {
|
|
|
1477
1685
|
...hostGatewayEnv(prev)
|
|
1478
1686
|
);
|
|
1479
1687
|
}
|
|
1688
|
+
if (cfg.hostSshUser) lines.push(`OPENSHIP_HOST_SSH_USER_PIN=${cfg.hostSshUser}`);
|
|
1480
1689
|
return lines;
|
|
1481
1690
|
}
|
|
1482
1691
|
function renderEnvAndCarried(opts, host, cfg, prev) {
|
|
@@ -1561,7 +1770,7 @@ function composePlan(opts) {
|
|
|
1561
1770
|
const prev = readEnvFile();
|
|
1562
1771
|
const cfg = resolveEnvConfig(prev, opts);
|
|
1563
1772
|
const buildDir = opts.build === false ? null : sourceBuildDir();
|
|
1564
|
-
const hostChannel =
|
|
1773
|
+
const hostChannel = previewHostChannel(cfg);
|
|
1565
1774
|
const settings = [];
|
|
1566
1775
|
const newSecrets = [];
|
|
1567
1776
|
for (const line of renderEnv(opts, hostChannel, cfg).split("\n")) {
|
|
@@ -1588,7 +1797,7 @@ function composePlan(opts) {
|
|
|
1588
1797
|
secretRotation: opts.resetSecrets ? null : secretRotationRisk(prev),
|
|
1589
1798
|
pgDataRisk: pgDataDetectionRisk(prev),
|
|
1590
1799
|
existing: Object.keys(prev).length > 0,
|
|
1591
|
-
mountDirs:
|
|
1800
|
+
mountDirs: composeEdgeMounts().map((m) => m.host),
|
|
1592
1801
|
buildDir,
|
|
1593
1802
|
hostChannel
|
|
1594
1803
|
};
|
|
@@ -1668,7 +1877,7 @@ function composePrefetch(opts) {
|
|
|
1668
1877
|
}
|
|
1669
1878
|
function ensureEdgeMountDirs() {
|
|
1670
1879
|
const failed = [];
|
|
1671
|
-
for (const { host } of
|
|
1880
|
+
for (const { host } of composeEdgeMounts()) {
|
|
1672
1881
|
try {
|
|
1673
1882
|
mkdirSync3(host, { recursive: true });
|
|
1674
1883
|
} catch (err) {
|