openship 0.6.6 → 0.6.7

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 (58) hide show
  1. package/dist/bare-7QD623QV.js +29 -0
  2. package/dist/{chunk-Q7WGTQQ5.js → chunk-2ANV5ZQ5.js} +1 -1
  3. package/dist/{chunk-UJ6JZM65.js → chunk-2VSW6Q6C.js} +601 -382
  4. package/dist/{chunk-PCPVTH6M.js → chunk-5T4UVOM3.js} +6 -4
  5. package/dist/{chunk-YMMQRE5J.js → chunk-BLEXTYRK.js} +1 -1
  6. package/dist/{chunk-OBPKYXQL.js → chunk-CIJV43LH.js} +71 -32
  7. package/dist/{chunk-NK5N5LXN.js → chunk-DDFUESPJ.js} +24 -9
  8. package/dist/{chunk-6FH4AXVY.js → chunk-E2X26PXR.js} +1 -1
  9. package/dist/{chunk-HKRQXPTI.js → chunk-FYPFEDPZ.js} +1 -1
  10. package/dist/{chunk-UVNMQM7W.js → chunk-FZ6QCF6B.js} +8 -6
  11. package/dist/{chunk-MLYSEZQB.js → chunk-GGIAIGUL.js} +15 -66
  12. package/dist/{chunk-2YM3NI2E.js → chunk-GIPJNNI5.js} +38 -44
  13. package/dist/{chunk-ZFJXPCQP.js → chunk-H3WT3A6R.js} +4 -3
  14. package/dist/chunk-H4FS5BED.js +936 -0
  15. package/dist/{chunk-CBLD2HS5.js → chunk-J2XM3QSP.js} +8 -5
  16. package/dist/{chunk-FE7ZZQSE.js → chunk-J3K6XABC.js} +3 -3
  17. package/dist/{chunk-77QDF6UD.js → chunk-LHFXSC36.js} +428 -93
  18. package/dist/{chunk-YIMOCZBZ.js → chunk-MEE6OZFP.js} +3 -3
  19. package/dist/chunk-N5YZMO33.js +111 -0
  20. package/dist/{chunk-QVW76WDL.js → chunk-NRTJRNAE.js} +58 -16
  21. package/dist/{chunk-WVAML4H6.js → chunk-PPEBIY5F.js} +247 -61
  22. package/dist/{chunk-4I2BM2DT.js → chunk-PVP4RGWT.js} +3 -3
  23. package/dist/{chunk-RSR7PFIQ.js → chunk-QHMDCYZ3.js} +4 -4
  24. package/dist/chunk-R4VDPFLS.js +106 -0
  25. package/dist/{chunk-VTGPEC6B.js → chunk-RDXT4BQB.js} +6 -4
  26. package/dist/{chunk-LFE5KQ7Y.js → chunk-S6B2Z3OO.js} +142 -57
  27. package/dist/chunk-UWEV6XTS.js +249 -0
  28. package/dist/{chunk-5DMWSTOM.js → chunk-VQM6JHJB.js} +940 -224
  29. package/dist/{chunk-SM26HDS2.js → chunk-VSTFHUCV.js} +44 -50
  30. package/dist/chunk-WU54BXFE.js +70 -0
  31. package/dist/{chunk-LUKYPVJ4.js → chunk-XWHWTHEL.js} +313 -469
  32. package/dist/{chunk-A4GXYE4L.js → chunk-ZPJ74LXE.js} +22 -20
  33. package/dist/cloud-KNNABE3O.js +40 -0
  34. package/dist/{detect-OIE5V3VZ.js → detect-G4DR3U2N.js} +9 -6
  35. package/dist/{docker-VTR2GK5Y.js → docker-YOVPCIB4.js} +11 -8
  36. package/dist/{docker-edge-executor-22IBXNYJ.js → docker-edge-executor-QFXD3337.js} +8 -6
  37. package/dist/edge-import-CRFVSR6P.js +56 -0
  38. package/dist/{edge-preflight-GBNCPNVU.js → edge-preflight-MXERVAMG.js} +15 -12
  39. package/dist/{ensure-container-edge-KPI2CVXJ.js → ensure-container-edge-F4JUNQIK.js} +13 -10
  40. package/dist/{executor-7LXXZPBR.js → executor-CVPAYYHR.js} +8 -6
  41. package/dist/host-channel-preflight-QTDNGLCK.js +54 -0
  42. package/dist/index.js +84 -48
  43. package/dist/{local-executor-WOFZD72X.js → local-executor-ODC6RANU.js} +3 -2
  44. package/dist/{nginx-KUFMLMAC.js → nginx-KLFF43TC.js} +11 -8
  45. package/dist/{openresty-lua-ATTH35BE.js → openresty-lua-VKWUV4GB.js} +6 -5
  46. package/dist/{privilege-I4W3ZNZK.js → privilege-7D3SSOL3.js} +6 -5
  47. package/dist/server/index.js +8813 -5232
  48. package/dist/setup-I5AJC6NJ.js +25 -0
  49. package/dist/{ssh-client-JBMA7SN4.js → ssh-client-625VWGKP.js} +3 -2
  50. package/dist/up-QXTOOVXC.js +61 -0
  51. package/package.json +1 -1
  52. package/dist/bare-OXJVW3AK.js +0 -25
  53. package/dist/chunk-LH2PLRQO.js +0 -434
  54. package/dist/cloud-75VIT4KV.js +0 -37
  55. package/dist/edge-import-WJUB4M7F.js +0 -52
  56. package/dist/host-channel-preflight-KE6OONYK.js +0 -50
  57. package/dist/setup-XGLGUUFW.js +0 -22
  58. package/dist/up-K4BV7466.js +0 -57
@@ -10,28 +10,28 @@ import {
10
10
  readComposeEnvFile,
11
11
  resolveLocalEnvironmentSync,
12
12
  thisHost
13
- } from "./chunk-5DMWSTOM.js";
13
+ } from "./chunk-VQM6JHJB.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-ZPJ74LXE.js";
20
20
  import {
21
21
  EDGE_CONTAINER_MOUNTS,
22
22
  EDGE_HOST_STATE_DIR
23
- } from "./chunk-RSR7PFIQ.js";
23
+ } from "./chunk-QHMDCYZ3.js";
24
24
  import {
25
25
  DEFAULT_DOCKER_SOCKET_PATH,
26
26
  resolveLocalDockerSocketPath
27
- } from "./chunk-77QDF6UD.js";
27
+ } from "./chunk-LHFXSC36.js";
28
28
  import {
29
29
  LocalExecutor
30
- } from "./chunk-6FH4AXVY.js";
30
+ } from "./chunk-E2X26PXR.js";
31
31
  import {
32
32
  invalidateEdgeContainer,
33
33
  sanitizeEdgeVhosts
34
- } from "./chunk-2YM3NI2E.js";
34
+ } from "./chunk-GIPJNNI5.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-2VSW6Q6C.js";
55
55
 
56
56
  // src/lib/host-channel-preflight.ts
57
57
  import { spawnSync as spawnSync4 } from "child_process";
@@ -775,32 +775,66 @@ function stripHostAuthorizedKeys(existing) {
775
775
  }
776
776
  var ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
777
777
  function chooseHostChannelUser(input) {
778
+ const keysFor = (home) => join4(home, ".ssh", "authorized_keys");
779
+ if (input.pinned) {
780
+ return [
781
+ {
782
+ user: input.pinned.user,
783
+ authKeysPath: input.pinned.user === "root" ? ROOT_AUTHORIZED_KEYS : keysFor(input.pinned.home),
784
+ // Unknowable for an arbitrary account; the dial measures it (see provisionHostSshChannel).
785
+ elevation: input.pinned.user === "root" ? "login" : "sudo",
786
+ source: "pin"
787
+ }
788
+ ];
789
+ }
790
+ const candidates = [];
778
791
  if (input.invokerUid === 0) {
779
- return {
792
+ candidates.push({
780
793
  user: "root",
781
794
  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" };
795
+ elevation: "login",
796
+ source: "root"
797
+ });
798
+ if (input.sudoUser && input.sudoUser.user !== "root") {
799
+ candidates.push({
800
+ user: input.sudoUser.user,
801
+ authKeysPath: keysFor(input.sudoUser.home),
802
+ elevation: "sudo",
803
+ source: "sudo-user"
804
+ });
805
+ }
806
+ } else {
807
+ candidates.push({
808
+ user: input.invokerName,
809
+ authKeysPath: keysFor(input.invokerHome),
810
+ // Reaches root through sudo at run time, so this is NOT `rootUnavailable` — the
811
+ // caller must not warn that mail and edge will fail, because they won't.
812
+ elevation: input.hasPasswordlessSudo ? "sudo" : "none",
813
+ source: "invoker"
814
+ });
792
815
  }
793
- return { ...invoker, rootUnavailable: true, elevation: "none" };
816
+ const carried = input.carried?.trim();
817
+ if (!carried) return candidates;
818
+ const already = candidates.findIndex((c) => c.user === carried);
819
+ if (already <= 0) return candidates;
820
+ const [promoted] = candidates.splice(already, 1);
821
+ return [{ ...promoted, source: "carried" }, ...candidates];
794
822
  }
795
823
  function hasPasswordlessSudo() {
796
824
  try {
797
- return spawnSync3("sudo", ["-n", "true"], { stdio: "ignore" }).status === 0;
825
+ return spawnSync3("sudo", ["-n", "-k", "true"], { stdio: "ignore" }).status === 0;
798
826
  } catch {
799
827
  return false;
800
828
  }
801
829
  }
802
- function plannedHostChannel(hostControl) {
803
- if (!hostControl || process.platform !== "linux") return null;
830
+ function sudoInvoker() {
831
+ const user = process.env.SUDO_USER?.trim();
832
+ if (!user || user === "root") return null;
833
+ const home = passwdHome(user);
834
+ return home ? { user, home } : null;
835
+ }
836
+ function plannedHostChannel(cfg) {
837
+ if (!cfg.hostControl || process.platform !== "linux") return null;
804
838
  let invoker;
805
839
  try {
806
840
  const info = userInfo();
@@ -808,14 +842,43 @@ function plannedHostChannel(hostControl) {
808
842
  } catch {
809
843
  invoker = { uid: -1, username: process.env.USER || process.env.LOGNAME || "root", home: homedir() };
810
844
  }
811
- const decision = chooseHostChannelUser({
845
+ const pin = cfg.hostSshUser?.trim();
846
+ const candidates = chooseHostChannelUser({
812
847
  invokerUid: invoker.uid,
813
848
  invokerName: invoker.username,
814
849
  invokerHome: invoker.home,
815
850
  // Only probe sudo when it could change the outcome — a root invoker never needs it.
816
- hasPasswordlessSudo: invoker.uid !== 0 && hasPasswordlessSudo()
851
+ hasPasswordlessSudo: invoker.uid !== 0 && hasPasswordlessSudo(),
852
+ // A pinned account is dialed, never written to on our guess about its home: root's
853
+ // file is fixed, and any other account's comes from the passwd database.
854
+ pinned: pin ? { user: pin, home: passwdHome(pin) ?? join4("/home", pin) } : null,
855
+ sudoUser: invoker.uid === 0 ? sudoInvoker() : null,
856
+ carried: cfg.hostSshUserCarried ?? null
817
857
  });
818
- return { ...decision, keyPath: join4(COMPOSE_DIR, "host-ssh", "id_ed25519") };
858
+ return { candidates, keyPath: join4(COMPOSE_DIR, "host-ssh", "id_ed25519") };
859
+ }
860
+ function passwdHome(user) {
861
+ try {
862
+ const r = spawnSync3("getent", ["passwd", user], { encoding: "utf8" });
863
+ if (r.status !== 0) return null;
864
+ return (r.stdout ?? "").split("\n")[0]?.split(":")[5]?.trim() || null;
865
+ } catch {
866
+ return null;
867
+ }
868
+ }
869
+ function previewHostChannel(cfg) {
870
+ const plan = plannedHostChannel(cfg);
871
+ if (!plan) return null;
872
+ const [first, ...rest] = plan.candidates;
873
+ if (!first) return null;
874
+ return {
875
+ user: first.user,
876
+ keyPath: plan.keyPath,
877
+ authKeysPath: first.authKeysPath,
878
+ rootUnavailable: first.elevation === "none",
879
+ elevation: first.elevation,
880
+ fallbacks: rest.map((c) => c.user)
881
+ };
819
882
  }
820
883
  function authorizeKeyAt(authKeysPath, pub) {
821
884
  mkdirSync3(dirname(authKeysPath), { recursive: true, mode: 448 });
@@ -855,15 +918,20 @@ function retireHostSshChannel(prev) {
855
918
  const keyDir = join4(COMPOSE_DIR, "host-ssh");
856
919
  if (!existsSync4(keyDir) && !prev.OPENSHIP_HOST_SSH_HOST) return;
857
920
  const invokerKeys = join4(homedir(), ".ssh", "authorized_keys");
858
- const asRoot = hostChannelAccount(prev) === HOST_CHANNEL_DEFAULT_ACCOUNT;
921
+ const account = hostChannelAccount(prev);
922
+ const asRoot = account === HOST_CHANNEL_DEFAULT_ACCOUNT;
923
+ const accountKeys = asRoot ? ROOT_AUTHORIZED_KEYS : (() => {
924
+ const home = passwdHome(account);
925
+ return home ? join4(home, ".ssh", "authorized_keys") : invokerKeys;
926
+ })();
859
927
  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;
928
+ const revoked = asRoot ? weAreRoot ? revokeKeyAt(ROOT_AUTHORIZED_KEYS) : hasPasswordlessSudo() && revokeRootKeyViaSudo() : revokeKeyAt(accountKeys);
929
+ const alsoInvoker = accountKeys === invokerKeys ? true : revokeKeyAt(invokerKeys);
862
930
  try {
863
931
  rmSync(keyDir, { recursive: true, force: true });
864
932
  } catch {
865
933
  }
866
- const stuck = asRoot ? ROOT_AUTHORIZED_KEYS : invokerKeys;
934
+ const stuck = accountKeys;
867
935
  console.log(
868
936
  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
937
  can no longer be used). Removing its line needs root \u2014 run:
@@ -871,17 +939,10 @@ function retireHostSshChannel(prev) {
871
939
  );
872
940
  }
873
941
  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
- };
942
+ const plan = plannedHostChannel(cfg);
943
+ if (!plan) return { channel: null };
944
+ const { candidates, keyPath } = plan;
945
+ const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT;
885
946
  try {
886
947
  mkdirSync3(join4(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
887
948
  if (!existsSync4(keyPath)) {
@@ -896,24 +957,98 @@ function provisionHostSshChannel(cfg) {
896
957
  }
897
958
  const pub = readFileSync3(`${keyPath}.pub`, "utf8").trim();
898
959
  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 });
960
+ const tried = [];
961
+ const losers = [];
962
+ let lastFailure = null;
963
+ for (const candidate of candidates) {
964
+ tried.push(candidate.user);
965
+ authorizeKeyAt(candidate.authKeysPath, pub);
966
+ if (probeHostSshd(port) === "absent") {
967
+ return { channel: { user: candidate.user, keyPath }, issue: { code: "no-sshd", detail: `port ${port}` } };
968
+ }
969
+ const auth = verifyChannelAuth(candidate.user, keyPath, port);
970
+ if (auth.state === "unreachable") {
971
+ return {
972
+ channel: { user: candidate.user, keyPath },
973
+ issue: { code: "unreachable", detail: auth.detail }
974
+ };
975
+ }
976
+ if (auth.state === "refused" || auth.state === "forced-command") {
977
+ lastFailure = { state: auth.state, detail: auth.detail };
978
+ losers.push(candidate);
979
+ continue;
980
+ }
981
+ const elevation = candidate.user === "root" ? "login" : auth.state === "ok" && auth.elevation === "refused" ? (
982
+ // MEASURED, over the session shape the api will use. This is the arm
983
+ // `hasPasswordlessSudo` used to get wrong from a cached ticket, which is what
984
+ // suppressed the warning below on boxes that genuinely could not reach root.
985
+ "none"
986
+ ) : candidate.elevation;
987
+ for (const loser of losers) revokeKeyAt(loser.authKeysPath);
988
+ if (elevation === "sudo") {
989
+ revokeRootKeyViaSudo();
990
+ }
991
+ if (elevation === "none") {
992
+ console.warn(
993
+ ` \u26A0 Host control needs root, and this channel has no route to it: it logs in as
994
+ \`${candidate.user}\`, and \`sudo -n\` was refused over that session. Mail and edge
995
+ host operations will fail. Grant that account passwordless sudo, or re-run
996
+ \`openship up\` as root, to fix. Continuing with a limited host channel.`
997
+ );
998
+ }
999
+ if (candidate.source === "sudo-user") {
1000
+ console.log(
1001
+ ` \u2192 Host channel settled on \`${candidate.user}\` rather than root: this host refused a
1002
+ root login, and that account's key was accepted. It elevates per operation with
1003
+ sudo, so no standing root credential is created. Pin one with
1004
+ \`openship up --host-ssh-user <name>\`.`
1005
+ );
1006
+ }
1007
+ return { channel: { user: candidate.user, keyPath }, issue: void 0 };
1008
+ }
1009
+ const carried = cfg.hostSshUserCarried?.trim();
1010
+ for (const loser of losers) {
1011
+ if (loser.user !== carried) revokeKeyAt(loser.authKeysPath);
905
1012
  }
906
- authorizeKeyAt(authKeysPath, pub);
907
- if (elevation === "sudo") revokeRootKeyViaSudo();
908
- return provisioned({ user, keyPath });
1013
+ return {
1014
+ channel: null,
1015
+ issue: {
1016
+ code: lastFailure?.state === "forced-command" ? "forced-command" : "auth-refused",
1017
+ ...lastFailure?.detail ? { detail: lastFailure.detail } : {},
1018
+ account: tried[tried.length - 1] ?? "",
1019
+ tried,
1020
+ ...tried.includes("root") ? (() => {
1021
+ const setting = permitRootLoginSetting(port);
1022
+ return setting ? { permitRootLogin: setting } : {};
1023
+ })() : {}
1024
+ }
1025
+ };
909
1026
  } catch (err) {
910
1027
  return { channel: null, issue: { code: "error", detail: err?.message } };
911
1028
  }
912
1029
  }
1030
+ function permitRootLoginSetting(port) {
1031
+ try {
1032
+ const r = spawnSync3(
1033
+ "sudo",
1034
+ ["-n", "sshd", "-T", "-C", `user=root,host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport=${port}`],
1035
+ { encoding: "utf8" }
1036
+ );
1037
+ if (r.status !== 0) return null;
1038
+ return /^permitrootlogin\s+(\S+)/m.exec(r.stdout ?? "")?.[1] ?? null;
1039
+ } catch {
1040
+ return null;
1041
+ }
1042
+ }
1043
+ function sshdRefusesRootLogin(value) {
1044
+ const v = value?.trim().toLowerCase();
1045
+ return v === "no" || v === "forced-commands-only";
1046
+ }
913
1047
  function verifyChannelAuth(user, keyPath, port) {
914
1048
  const r = spawnSync3(
915
1049
  "ssh",
916
1050
  [
1051
+ "-v",
917
1052
  "-i",
918
1053
  keyPath,
919
1054
  "-p",
@@ -931,16 +1066,34 @@ function verifyChannelAuth(user, keyPath, port) {
931
1066
  "-o",
932
1067
  "IdentitiesOnly=yes",
933
1068
  `${user}@127.0.0.1`,
934
- "true"
1069
+ CHANNEL_PROBE_COMMAND
935
1070
  ],
936
- { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"], timeout: 15e3 }
1071
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15e3 }
937
1072
  );
938
- if (r.status === 0) return { state: "ok" };
939
1073
  if (r.error?.code === "ENOENT" || r.status === null) {
940
1074
  return { state: "unknown" };
941
1075
  }
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}` };
1076
+ const stderr = r.stderr || "";
1077
+ const stdout = r.stdout || "";
1078
+ const sudo = /openship-sudo=(ok|refused)/.exec(stdout)?.[1];
1079
+ if (r.status === 0 && sudo) {
1080
+ return { state: "ok", elevation: sudo === "ok" ? "ok" : "refused" };
1081
+ }
1082
+ if (/Authenticated to |debug1: Authentication succeeded/.test(stderr)) {
1083
+ return r.status === 0 ? { state: "ok", elevation: "unknown" } : { state: "forced-command", detail: sshProbeDetail(stderr, r.status) };
1084
+ }
1085
+ if (/^ssh: connect to host/m.test(stderr)) {
1086
+ return { state: "unreachable", detail: sshProbeDetail(stderr, r.status) };
1087
+ }
1088
+ if (r.status === 0) return { state: "ok", elevation: "unknown" };
1089
+ return { state: "refused", detail: sshProbeDetail(stderr, r.status) };
1090
+ }
1091
+ 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";
1092
+ function sshProbeDetail(stderr, status) {
1093
+ const detail = stderr.split("\n").map((l) => l.trim()).filter(
1094
+ (l) => l && !/^debug\d*:/.test(l) && !/^OpenSSH_/.test(l) && !/^Warning: Permanently added/.test(l)
1095
+ ).join(" ");
1096
+ return detail || `ssh exited ${status}`;
944
1097
  }
945
1098
  function keygenWhy(r) {
946
1099
  if (r.error?.code === "ENOENT") {
@@ -977,7 +1130,16 @@ function hostIssueNote(text) {
977
1130
  }
978
1131
  function renderHostChannelIssue(issue, ctx) {
979
1132
  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.`;
1133
+ 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" ? (
1134
+ // Like `auth-refused` and unlike `no-sshd`: a key that can't run a command is
1135
+ // not a channel, so nothing was written and the headline must not imply it was.
1136
+ ` \u26A0 Host control could NOT be provisioned: this host's sshd allows the channel no commands.`
1137
+ ) : issue.code === "auth-refused" ? (
1138
+ // NOT "is provisioned, but": a refusal no longer leaves a channel behind. The
1139
+ // run tried every account it could authorize and none of them were let in, so
1140
+ // there is nothing provisioned to describe.
1141
+ ` \u26A0 Host control could NOT be provisioned: this host REFUSED the channel's key.`
1142
+ ) : ` \u26A0 Host control could NOT be provisioned on this box.`;
981
1143
  let cause;
982
1144
  switch (issue.code) {
983
1145
  case "keygen":
@@ -996,14 +1158,27 @@ function renderHostChannelIssue(issue, ctx) {
996
1158
  sshdEnableHint: thisHost().sshdEnableHint()
997
1159
  }).body;
998
1160
  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}.`;
1161
+ case "unreachable":
1162
+ 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
1163
  break;
1164
+ case "forced-command":
1165
+ 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>\`.`;
1166
+ break;
1167
+ case "auth-refused": {
1168
+ const accounts = issue.tried?.length ? issue.tried.map((a) => `\`${a}\``).join(" then ") : `\`${issue.account ?? "the channel account"}\``;
1169
+ const rootNote = issue.permitRootLogin ? ` This host's sshd reports \`PermitRootLogin ${issue.permitRootLogin}\`${sshdRefusesRootLogin(issue.permitRootLogin) ? ", which refuses a root login outright" : ""}.` : "";
1170
+ 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:
1171
+ \u2022 name an account that CAN log in: \`openship up --host-ssh-user <name>\`
1172
+ \u2022 permit root a key login: \`PermitRootLogin prohibit-password\` (still refuses passwords), or scope it to the container with a \`Match Address\` block
1173
+ \u2022 or leave host control off: \`openship up --no-host-control\`
1174
+ Until one of them is done the api container will be refused every time it dials ${ctx.target}.`;
1175
+ break;
1176
+ }
1002
1177
  case "error":
1003
1178
  cause = `Setting up the channel's key failed${detail}.`;
1004
1179
  break;
1005
1180
  }
1006
- const continuity = issue.code === "no-sshd" || issue.code === "auth-refused" ? "" : ctx.kept ? hostIssueNote(
1181
+ const continuity = issue.code === "no-sshd" || issue.code === "unreachable" ? "" : ctx.kept ? hostIssueNote(
1007
1182
  "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
1183
  ) : hostIssueNote(
1009
1184
  "OPENSHIP_HOST_SSH_HOST is therefore unset, and host operations refuse rather than run against the api container's own filesystem."
@@ -1345,7 +1520,14 @@ function resolveEnvConfig(prev, opts) {
1345
1520
  // so, and every `openship up` overwrote a hand-edited `.env` back to the
1346
1521
  // value that didn't work (#490/#482).
1347
1522
  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)
1523
+ hostSshPort: keepConfig(prev, "OPENSHIP_HOST_SSH_PORT", opts.hostSshPort) ?? String(HOST_CHANNEL_DEFAULT_PORT),
1524
+ // Sticky like its siblings, but kept in its OWN key rather than reusing
1525
+ // OPENSHIP_HOST_SSH_USER. That key records what provisioning SETTLED on, and the two
1526
+ // must not be conflated: reading a settled value back as a pin would freeze the account
1527
+ // a box happens to have — including the refused `root` on every install #527 broke — and
1528
+ // the fallback that repairs them could never run.
1529
+ ...keepConfig(prev, "OPENSHIP_HOST_SSH_USER_PIN", opts.hostSshUser) ? { hostSshUser: keepConfig(prev, "OPENSHIP_HOST_SSH_USER_PIN", opts.hostSshUser) } : {},
1530
+ ...prev.OPENSHIP_HOST_SSH_USER?.trim() ? { hostSshUserCarried: prev.OPENSHIP_HOST_SSH_USER.trim() } : {}
1349
1531
  };
1350
1532
  }
1351
1533
  function toPort(value) {
@@ -1409,6 +1591,7 @@ async function resolveComposePorts(prefs, opts = {}) {
1409
1591
  var OMITTABLE_MANAGED_KEYS = /* @__PURE__ */ new Set([
1410
1592
  "OPENSHIP_HOST_SSH_HOST",
1411
1593
  "OPENSHIP_HOST_SSH_USER",
1594
+ "OPENSHIP_HOST_SSH_USER_PIN",
1412
1595
  "OPENSHIP_HOST_SSH_PORT",
1413
1596
  "OPENSHIP_HOST_SSH_KEY",
1414
1597
  "OPENSHIP_HOST_KEY_PATH",
@@ -1421,7 +1604,7 @@ function operatorEnvPassthrough(managed, prev) {
1421
1604
  }
1422
1605
  var PRESERVED_ENV_HEADER = "# Preserved from your existing .env \u2014 set by you, not by `openship up` (#485).";
1423
1606
  function resolveImageVersion(opts) {
1424
- return opts.version?.trim() || process.env.OPENSHIP_VERSION?.trim() || (true ? "0.6.6" : "latest");
1607
+ return opts.version?.trim() || process.env.OPENSHIP_VERSION?.trim() || (true ? "0.6.7" : "latest");
1425
1608
  }
1426
1609
  function managedEnvLines(opts, host, cfg, prev) {
1427
1610
  const lines = [
@@ -1470,6 +1653,8 @@ function managedEnvLines(opts, host, cfg, prev) {
1470
1653
  if (host) {
1471
1654
  lines.push(
1472
1655
  `OPENSHIP_HOST_SSH_HOST=${cfg.hostSshHost}`,
1656
+ // What provisioning SETTLED on this run — a fact about the box, not a setting. The
1657
+ // operator's pin, when there is one, is written OUTSIDE this block (see below).
1473
1658
  `OPENSHIP_HOST_SSH_USER=${host.user}`,
1474
1659
  `OPENSHIP_HOST_SSH_PORT=${cfg.hostSshPort}`,
1475
1660
  "OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key",
@@ -1477,6 +1662,7 @@ function managedEnvLines(opts, host, cfg, prev) {
1477
1662
  ...hostGatewayEnv(prev)
1478
1663
  );
1479
1664
  }
1665
+ if (cfg.hostSshUser) lines.push(`OPENSHIP_HOST_SSH_USER_PIN=${cfg.hostSshUser}`);
1480
1666
  return lines;
1481
1667
  }
1482
1668
  function renderEnvAndCarried(opts, host, cfg, prev) {
@@ -1561,7 +1747,7 @@ function composePlan(opts) {
1561
1747
  const prev = readEnvFile();
1562
1748
  const cfg = resolveEnvConfig(prev, opts);
1563
1749
  const buildDir = opts.build === false ? null : sourceBuildDir();
1564
- const hostChannel = plannedHostChannel(cfg.hostControl);
1750
+ const hostChannel = previewHostChannel(cfg);
1565
1751
  const settings = [];
1566
1752
  const newSecrets = [];
1567
1753
  for (const line of renderEnv(opts, hostChannel, cfg).split("\n")) {
@@ -4,15 +4,15 @@ import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
6
  hostRefusal
7
- } from "./chunk-YMMQRE5J.js";
7
+ } from "./chunk-BLEXTYRK.js";
8
8
  import {
9
9
  elevatedExecutor,
10
10
  resolveEnvironment
11
- } from "./chunk-UVNMQM7W.js";
11
+ } from "./chunk-FZ6QCF6B.js";
12
12
  import {
13
13
  answered,
14
14
  refused
15
- } from "./chunk-UJ6JZM65.js";
15
+ } from "./chunk-2VSW6Q6C.js";
16
16
 
17
17
  // ../../packages/adapters/src/system/privilege.ts
18
18
  function rootChecked(executor, why) {
@@ -5,19 +5,19 @@ const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
6
  envOps,
7
7
  opScript
8
- } from "./chunk-YMMQRE5J.js";
8
+ } from "./chunk-BLEXTYRK.js";
9
9
  import {
10
10
  resolveEnvironment
11
- } from "./chunk-UVNMQM7W.js";
11
+ } from "./chunk-FZ6QCF6B.js";
12
12
  import {
13
13
  sq
14
- } from "./chunk-ZFJXPCQP.js";
14
+ } from "./chunk-H3WT3A6R.js";
15
15
  import {
16
16
  SYSTEM,
17
17
  answered,
18
18
  refused,
19
19
  safeErrorMessage
20
- } from "./chunk-UJ6JZM65.js";
20
+ } from "./chunk-2VSW6Q6C.js";
21
21
 
22
22
  // ../../packages/adapters/src/infra/openresty-lua.ts
23
23
  import { createHash, randomBytes } from "crypto";
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env sh
2
+ ":" //# ; exec "$(command -v node || command -v bun)" "$0" "$@"
3
+ import { createRequire as __ospCreateRequire } from "node:module";
4
+ const require = __ospCreateRequire(import.meta.url);
5
+
6
+ // ../../packages/adapters/src/system/reachability.ts
7
+ import { connect } from "net";
8
+ import { request } from "http";
9
+ function classifyDialError(code) {
10
+ switch (code) {
11
+ case "ECONNREFUSED":
12
+ case "ECONNRESET":
13
+ return "refused";
14
+ case "ENOTFOUND":
15
+ case "EAI_AGAIN":
16
+ return "unresolved";
17
+ case "EHOSTUNREACH":
18
+ case "ENETUNREACH":
19
+ case "ENETDOWN":
20
+ return "no_route";
21
+ // The kernel's own connect timeout, i.e. our `timeout` reported for us.
22
+ case "ETIMEDOUT":
23
+ return "timeout";
24
+ default:
25
+ return "error";
26
+ }
27
+ }
28
+ function probeTcpDetailed(host, port, timeoutMs = 2500) {
29
+ return new Promise((resolve) => {
30
+ let settled = false;
31
+ const done = (result, socket2) => {
32
+ if (settled) return;
33
+ settled = true;
34
+ try {
35
+ socket2?.destroy();
36
+ } catch {
37
+ }
38
+ resolve(result);
39
+ };
40
+ const socket = connect({ host, port });
41
+ socket.setTimeout(timeoutMs);
42
+ socket.once("connect", () => done({ ok: true }, socket));
43
+ socket.once(
44
+ "timeout",
45
+ () => done({ ok: false, reason: "timeout", message: `no response within ${timeoutMs}ms` }, socket)
46
+ );
47
+ socket.once("error", (err) => {
48
+ const errno = err;
49
+ done(
50
+ {
51
+ ok: false,
52
+ reason: classifyDialError(errno.code),
53
+ ...errno.code ? { code: errno.code } : {},
54
+ ...errno.message ? { message: errno.message } : {}
55
+ },
56
+ socket
57
+ );
58
+ });
59
+ });
60
+ }
61
+ function probeHostedHttp(opts) {
62
+ const { hostname, path = "/", port = 80, timeoutMs = 3e3 } = opts;
63
+ return new Promise((resolve) => {
64
+ let settled = false;
65
+ const done = (value) => {
66
+ if (settled) return;
67
+ settled = true;
68
+ resolve(value);
69
+ };
70
+ const req = request(
71
+ {
72
+ host: "127.0.0.1",
73
+ port,
74
+ path,
75
+ method: "GET",
76
+ timeout: timeoutMs,
77
+ // Claim https so the :80 block SERVES instead of answering our own
78
+ // HTTP→HTTPS redirect before the doc root is consulted — see the curl twin in
79
+ // output-exists.ts for why a 301 here read as a healthy site.
80
+ headers: { Host: hostname, "X-Forwarded-Proto": "https" }
81
+ },
82
+ (res) => {
83
+ const status = res.statusCode ?? 0;
84
+ res.resume();
85
+ done(
86
+ status > 0 ? { status, served: status >= 200 && status < 400 || status === 401 || status === 403 } : null
87
+ );
88
+ }
89
+ );
90
+ const fail = () => {
91
+ try {
92
+ req.destroy();
93
+ } catch {
94
+ }
95
+ done(null);
96
+ };
97
+ req.once("timeout", fail);
98
+ req.once("error", fail);
99
+ req.end();
100
+ });
101
+ }
102
+
103
+ export {
104
+ probeTcpDetailed,
105
+ probeHostedHttp
106
+ };
@@ -7,18 +7,20 @@ import {
7
7
  composeInstallExists,
8
8
  readComposeEnvFile,
9
9
  startUnitHint
10
- } from "./chunk-5DMWSTOM.js";
10
+ } from "./chunk-VQM6JHJB.js";
11
11
  import {
12
12
  INTERNAL_TOKEN_FILE,
13
13
  OS_DIR
14
14
  } from "./chunk-ELGRMZOW.js";
15
15
  import {
16
16
  LocalExecutor
17
- } from "./chunk-6FH4AXVY.js";
17
+ } from "./chunk-E2X26PXR.js";
18
18
  import {
19
- EDGE_CONTAINER_NAME,
20
19
  edgeCrashReason
21
- } from "./chunk-2YM3NI2E.js";
20
+ } from "./chunk-GIPJNNI5.js";
21
+ import {
22
+ EDGE_CONTAINER_NAME
23
+ } from "./chunk-UWEV6XTS.js";
22
24
 
23
25
  // src/lib/edge-import.ts
24
26
  import chalk from "chalk";