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.
Files changed (72) hide show
  1. package/dist/bare-FA66N7GC.js +29 -0
  2. package/dist/{chunk-77QDF6UD.js → chunk-3MP6ACKM.js} +587 -156
  3. package/dist/{chunk-YIMOCZBZ.js → chunk-5DJOB7IX.js} +3 -3
  4. package/dist/{chunk-WVAML4H6.js → chunk-5I3COAFH.js} +274 -65
  5. package/dist/{chunk-5DMWSTOM.js → chunk-5UXHT6ED.js} +940 -224
  6. package/dist/{chunk-LUKYPVJ4.js → chunk-6GU6JSK6.js} +329 -466
  7. package/dist/{chunk-RSR7PFIQ.js → chunk-7PEXM2UI.js} +213 -45
  8. package/dist/{chunk-VTGPEC6B.js → chunk-7YBWXEM6.js} +6 -4
  9. package/dist/chunk-AHJYP7DG.js +938 -0
  10. package/dist/chunk-BO6CY4V2.js +265 -0
  11. package/dist/{chunk-FE7ZZQSE.js → chunk-D3OEMXYP.js} +3 -3
  12. package/dist/chunk-D6HU4VUZ.js +249 -0
  13. package/dist/{chunk-LFE5KQ7Y.js → chunk-ENKJ7XIF.js} +152 -57
  14. package/dist/{chunk-YMMQRE5J.js → chunk-GNOMEMNI.js} +1 -1
  15. package/dist/{chunk-QVW76WDL.js → chunk-IAMOUDHY.js} +190 -64
  16. package/dist/{chunk-OBPKYXQL.js → chunk-MUWVC5R4.js} +219 -58
  17. package/dist/chunk-N5YZMO33.js +111 -0
  18. package/dist/{chunk-A4GXYE4L.js → chunk-NIYD7MD2.js} +206 -52
  19. package/dist/{chunk-UVNMQM7W.js → chunk-NPSRGIHV.js} +8 -6
  20. package/dist/{chunk-2GWYYHOR.js → chunk-NRU2EBYZ.js} +2 -2
  21. package/dist/{chunk-CBLD2HS5.js → chunk-NUT6HKK6.js} +8 -5
  22. package/dist/{chunk-ZFJXPCQP.js → chunk-P7RCYELG.js} +4 -3
  23. package/dist/chunk-R4VDPFLS.js +106 -0
  24. package/dist/{chunk-HKRQXPTI.js → chunk-R5DC5PEF.js} +1 -1
  25. package/dist/{chunk-NK5N5LXN.js → chunk-RCMERVBL.js} +24 -9
  26. package/dist/{chunk-RXQBD4ZY.js → chunk-RHLNVZMS.js} +2 -2
  27. package/dist/chunk-SK4FFCFR.js +70 -0
  28. package/dist/{chunk-4I2BM2DT.js → chunk-T46BQOLY.js} +3 -3
  29. package/dist/{chunk-6FH4AXVY.js → chunk-T6DVUX4F.js} +1 -1
  30. package/dist/{chunk-PCPVTH6M.js → chunk-TAZZZKYO.js} +6 -4
  31. package/dist/{chunk-MLYSEZQB.js → chunk-UAWY6X7V.js} +43 -72
  32. package/dist/{chunk-Q7WGTQQ5.js → chunk-VAI5VOOI.js} +1 -1
  33. package/dist/{chunk-UJ6JZM65.js → chunk-WEZFTL4V.js} +1325 -513
  34. package/dist/{chunk-2YM3NI2E.js → chunk-X53JXRS4.js} +38 -44
  35. package/dist/{cloud-KRTZ2IL5.js → cloud-2CRDKOWQ.js} +1 -1
  36. package/dist/cloud-REV3DPJH.js +42 -0
  37. package/dist/{detect-OIE5V3VZ.js → detect-BBFT5GEU.js} +9 -6
  38. package/dist/{docker-VTR2GK5Y.js → docker-EG3QSE6I.js} +11 -8
  39. package/dist/{docker-edge-executor-22IBXNYJ.js → docker-edge-executor-E5TOTLMR.js} +8 -6
  40. package/dist/edge-import-YJVJUGYC.js +56 -0
  41. package/dist/{edge-preflight-GBNCPNVU.js → edge-preflight-QUW4BM7N.js} +15 -12
  42. package/dist/{ensure-container-edge-KPI2CVXJ.js → ensure-container-edge-FOEFQ3PI.js} +15 -10
  43. package/dist/{executor-7LXXZPBR.js → executor-NTWDJ3DB.js} +8 -6
  44. package/dist/host-channel-preflight-OMCZMMKR.js +54 -0
  45. package/dist/index.js +198 -76
  46. package/dist/{local-executor-WOFZD72X.js → local-executor-OQ2GRN7K.js} +3 -2
  47. package/dist/{nginx-KUFMLMAC.js → nginx-4U4C2XUS.js} +11 -8
  48. package/dist/{noop-NJ72F46T.js → noop-CLLJHCPL.js} +1 -1
  49. package/dist/{openresty-lua-ATTH35BE.js → openresty-lua-LLWT5XQW.js} +8 -5
  50. package/dist/{privilege-I4W3ZNZK.js → privilege-MNDVMDST.js} +6 -5
  51. package/dist/server/index.js +40049 -30378
  52. package/dist/server/migrations/0109_service_build_args.sql +9 -0
  53. package/dist/server/migrations/0110_service_deployment_host_ports.sql +18 -0
  54. package/dist/server/migrations/0111_host_port_claim.sql +381 -0
  55. package/dist/server/migrations/0112_backup_run_execution_lease.sql +12 -0
  56. package/dist/server/migrations/0113_docker_migration_execution_lease.sql +11 -0
  57. package/dist/server/migrations/0114_build_session_execution_lease_index.sql +6 -0
  58. package/dist/server/migrations/0115_orphan_target_key.sql +3 -0
  59. package/dist/server/migrations/0116_domain_ssl_challenge.sql +2 -0
  60. package/dist/server/migrations/meta/_journal.json +56 -0
  61. package/dist/setup-IFIMPQZJ.js +25 -0
  62. package/dist/{ssh-client-JBMA7SN4.js → ssh-client-2XWZCMGI.js} +3 -2
  63. package/dist/up-HQS7IR7H.js +61 -0
  64. package/package.json +1 -1
  65. package/dist/bare-OXJVW3AK.js +0 -25
  66. package/dist/chunk-LH2PLRQO.js +0 -434
  67. package/dist/chunk-SM26HDS2.js +0 -185
  68. package/dist/cloud-75VIT4KV.js +0 -37
  69. package/dist/edge-import-WJUB4M7F.js +0 -52
  70. package/dist/host-channel-preflight-KE6OONYK.js +0 -50
  71. package/dist/setup-XGLGUUFW.js +0 -22
  72. package/dist/up-K4BV7466.js +0 -57
@@ -6,13 +6,13 @@ import {
6
6
  detectBuildKillHint,
7
7
  runBuildPipeline,
8
8
  transferLocalDirectory
9
- } from "./chunk-LH2PLRQO.js";
9
+ } from "./chunk-AHJYP7DG.js";
10
10
  import {
11
11
  LocalExecutor
12
- } from "./chunk-6FH4AXVY.js";
12
+ } from "./chunk-T6DVUX4F.js";
13
13
  import {
14
14
  wrapLocalBuildCommand
15
- } from "./chunk-ZFJXPCQP.js";
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-5DMWSTOM.js";
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-A4GXYE4L.js";
19
+ } from "./chunk-NIYD7MD2.js";
20
20
  import {
21
21
  EDGE_CONTAINER_MOUNTS,
22
22
  EDGE_HOST_STATE_DIR
23
- } from "./chunk-RSR7PFIQ.js";
23
+ } from "./chunk-7PEXM2UI.js";
24
24
  import {
25
25
  DEFAULT_DOCKER_SOCKET_PATH,
26
26
  resolveLocalDockerSocketPath
27
- } from "./chunk-77QDF6UD.js";
27
+ } from "./chunk-3MP6ACKM.js";
28
28
  import {
29
29
  LocalExecutor
30
- } from "./chunk-6FH4AXVY.js";
30
+ } from "./chunk-T6DVUX4F.js";
31
31
  import {
32
32
  invalidateEdgeContainer,
33
33
  sanitizeEdgeVhosts
34
- } from "./chunk-2YM3NI2E.js";
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-UJ6JZM65.js";
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 EDGE_CONTAINER_MOUNTS.map((m) => `${indent}- ${m.host}:${m.container}:z`).join("\n");
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
- return {
815
+ candidates.push({
780
816
  user: "root",
781
817
  authKeysPath: ROOT_AUTHORIZED_KEYS,
782
- rootUnavailable: false,
783
- elevation: "login"
784
- };
785
- }
786
- const invoker = {
787
- user: input.invokerName,
788
- authKeysPath: join4(input.invokerHome, ".ssh", "authorized_keys")
789
- };
790
- if (input.hasPasswordlessSudo) {
791
- return { ...invoker, rootUnavailable: false, elevation: "sudo" };
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
- return { ...invoker, rootUnavailable: true, elevation: "none" };
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 plannedHostChannel(hostControl) {
803
- if (!hostControl || process.platform !== "linux") return null;
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 decision = chooseHostChannelUser({
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 { ...decision, keyPath: join4(COMPOSE_DIR, "host-ssh", "id_ed25519") };
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 asRoot = hostChannelAccount(prev) === HOST_CHANNEL_DEFAULT_ACCOUNT;
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(invokerKeys);
861
- const alsoInvoker = asRoot ? revokeKeyAt(invokerKeys) : true;
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 = asRoot ? ROOT_AUTHORIZED_KEYS : invokerKeys;
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 target = plannedHostChannel(cfg.hostControl);
875
- if (!target) return { channel: null };
876
- const { user, keyPath, authKeysPath, elevation, rootUnavailable } = target;
877
- const provisioned = (channel) => {
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
- if (rootUnavailable) {
900
- console.warn(
901
- " \u26A0 Host control needs root, but this user isn't root and passwordless sudo isn't\n available. Mail and edge host operations will fail. Re-run `openship up` as root\n (or enable passwordless sudo) to fix. Continuing with a limited host channel."
902
- );
903
- authorizeKeyAt(authKeysPath, pub);
904
- return provisioned({ user, keyPath });
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
- authorizeKeyAt(authKeysPath, pub);
907
- if (elevation === "sudo") revokeRootKeyViaSudo();
908
- return provisioned({ user, keyPath });
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
- "true"
1092
+ CHANNEL_PROBE_COMMAND
935
1093
  ],
936
- { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"], timeout: 15e3 }
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 detail = (r.stderr || "").split("\n").map((l) => l.trim()).filter((l) => l && !/^Warning: Permanently added/.test(l)).join(" ");
943
- return { state: "refused", detail: detail || `ssh exited ${r.status}` };
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 === "auth-refused" ? ` \u26A0 Host control is provisioned, but this host REFUSED the channel's key.` : ` \u26A0 Host control could NOT be provisioned on this box.`;
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 "auth-refused":
1000
- cause = `The key was authorized for \`${issue.account ?? "the channel account"}\` and sshd is listening, but the dial was refused${detail}. Either that account's \`authorized_keys\` is not the file we wrote to, or sshd permits it no login \u2014 \`sshd -T | grep -i permitrootlogin\` shows the second. Until it is fixed the api container will be refused every time it dials ${ctx.target}.`;
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 === "auth-refused" ? "" : ctx.kept ? hostIssueNote(
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.6" : "latest");
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 = plannedHostChannel(cfg.hostControl);
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: EDGE_CONTAINER_MOUNTS.map((m) => m.host),
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 EDGE_CONTAINER_MOUNTS) {
1880
+ for (const { host } of composeEdgeMounts()) {
1672
1881
  try {
1673
1882
  mkdirSync3(host, { recursive: true });
1674
1883
  } catch (err) {