my-singbox 2.0.0 → 3.0.0

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/cli.js CHANGED
@@ -49,10 +49,10 @@ var require_identity = __commonJS({
49
49
  var NODE_TYPE = /* @__PURE__ */ Symbol.for("yaml.node.type");
50
50
  var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
51
51
  var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
52
- var isMap3 = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
52
+ var isMap2 = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
53
53
  var isPair = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === PAIR;
54
- var isScalar3 = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SCALAR;
55
- var isSeq3 = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SEQ;
54
+ var isScalar2 = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SCALAR;
55
+ var isSeq2 = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SEQ;
56
56
  function isCollection(node) {
57
57
  if (node && typeof node === "object")
58
58
  switch (node[NODE_TYPE]) {
@@ -73,7 +73,7 @@ var require_identity = __commonJS({
73
73
  }
74
74
  return false;
75
75
  }
76
- var hasAnchor = (node) => (isScalar3(node) || isCollection(node)) && !!node.anchor;
76
+ var hasAnchor = (node) => (isScalar2(node) || isCollection(node)) && !!node.anchor;
77
77
  exports.ALIAS = ALIAS;
78
78
  exports.DOC = DOC;
79
79
  exports.MAP = MAP;
@@ -85,11 +85,11 @@ var require_identity = __commonJS({
85
85
  exports.isAlias = isAlias;
86
86
  exports.isCollection = isCollection;
87
87
  exports.isDocument = isDocument;
88
- exports.isMap = isMap3;
88
+ exports.isMap = isMap2;
89
89
  exports.isNode = isNode;
90
90
  exports.isPair = isPair;
91
- exports.isScalar = isScalar3;
92
- exports.isSeq = isSeq3;
91
+ exports.isScalar = isScalar2;
92
+ exports.isSeq = isSeq2;
93
93
  }
94
94
  });
95
95
 
@@ -101,7 +101,7 @@ var require_visit = __commonJS({
101
101
  var BREAK = /* @__PURE__ */ Symbol("break visit");
102
102
  var SKIP = /* @__PURE__ */ Symbol("skip children");
103
103
  var REMOVE = /* @__PURE__ */ Symbol("remove node");
104
- function visit3(node, visitor) {
104
+ function visit2(node, visitor) {
105
105
  const visitor_ = initVisitor(visitor);
106
106
  if (identity.isDocument(node)) {
107
107
  const cd = visit_(null, node.contents, visitor_, Object.freeze([node]));
@@ -110,9 +110,9 @@ var require_visit = __commonJS({
110
110
  } else
111
111
  visit_(null, node, visitor_, Object.freeze([]));
112
112
  }
113
- visit3.BREAK = BREAK;
114
- visit3.SKIP = SKIP;
115
- visit3.REMOVE = REMOVE;
113
+ visit2.BREAK = BREAK;
114
+ visit2.SKIP = SKIP;
115
+ visit2.REMOVE = REMOVE;
116
116
  function visit_(key, node, visitor, path) {
117
117
  const ctrl = callVisitor(key, node, visitor, path);
118
118
  if (identity.isNode(ctrl) || identity.isPair(ctrl)) {
@@ -246,7 +246,7 @@ var require_visit = __commonJS({
246
246
  throw new Error(`Cannot replace node with ${pt} parent`);
247
247
  }
248
248
  }
249
- exports.visit = visit3;
249
+ exports.visit = visit2;
250
250
  exports.visitAsync = visitAsync;
251
251
  }
252
252
  });
@@ -256,7 +256,7 @@ var require_directives = __commonJS({
256
256
  "node_modules/yaml/dist/doc/directives.js"(exports) {
257
257
  "use strict";
258
258
  var identity = require_identity();
259
- var visit3 = require_visit();
259
+ var visit2 = require_visit();
260
260
  var escapeChars = {
261
261
  "!": "%21",
262
262
  ",": "%2C",
@@ -400,7 +400,7 @@ var require_directives = __commonJS({
400
400
  let tagNames;
401
401
  if (doc && tagEntries.length > 0 && identity.isNode(doc.contents)) {
402
402
  const tags = {};
403
- visit3.visit(doc.contents, (_key, node) => {
403
+ visit2.visit(doc.contents, (_key, node) => {
404
404
  if (identity.isNode(node) && node.tag)
405
405
  tags[node.tag] = true;
406
406
  });
@@ -427,7 +427,7 @@ var require_anchors = __commonJS({
427
427
  "node_modules/yaml/dist/doc/anchors.js"(exports) {
428
428
  "use strict";
429
429
  var identity = require_identity();
430
- var visit3 = require_visit();
430
+ var visit2 = require_visit();
431
431
  function anchorIsValid(anchor) {
432
432
  if (/[\x00-\x19\s,[\]{}]/.test(anchor)) {
433
433
  const sa = JSON.stringify(anchor);
@@ -438,7 +438,7 @@ var require_anchors = __commonJS({
438
438
  }
439
439
  function anchorNames(root) {
440
440
  const anchors = /* @__PURE__ */ new Set();
441
- visit3.visit(root, {
441
+ visit2.visit(root, {
442
442
  Value(_key, node) {
443
443
  if (node.anchor)
444
444
  anchors.add(node.anchor);
@@ -618,7 +618,7 @@ var require_Alias = __commonJS({
618
618
  "node_modules/yaml/dist/nodes/Alias.js"(exports) {
619
619
  "use strict";
620
620
  var anchors = require_anchors();
621
- var visit3 = require_visit();
621
+ var visit2 = require_visit();
622
622
  var identity = require_identity();
623
623
  var Node = require_Node();
624
624
  var toJS = require_toJS();
@@ -644,7 +644,7 @@ var require_Alias = __commonJS({
644
644
  nodes = ctx.aliasResolveCache;
645
645
  } else {
646
646
  nodes = [];
647
- visit3.visit(doc, {
647
+ visit2.visit(doc, {
648
648
  Node: (_key, node) => {
649
649
  if (identity.isAlias(node) || identity.hasAnchor(node))
650
650
  nodes.push(node);
@@ -4197,9 +4197,9 @@ var require_resolve_flow_collection = __commonJS({
4197
4197
  var blockMsg = "Block collections are not allowed within flow collections";
4198
4198
  var isBlock = (token) => token && (token.type === "block-map" || token.type === "block-seq");
4199
4199
  function resolveFlowCollection({ composeNode, composeEmptyNode }, ctx, fc, onError, tag) {
4200
- const isMap3 = fc.start.source === "{";
4201
- const fcName = isMap3 ? "flow map" : "flow sequence";
4202
- const NodeClass = tag?.nodeClass ?? (isMap3 ? YAMLMap.YAMLMap : YAMLSeq.YAMLSeq);
4200
+ const isMap2 = fc.start.source === "{";
4201
+ const fcName = isMap2 ? "flow map" : "flow sequence";
4202
+ const NodeClass = tag?.nodeClass ?? (isMap2 ? YAMLMap.YAMLMap : YAMLSeq.YAMLSeq);
4203
4203
  const coll = new NodeClass(ctx.schema);
4204
4204
  coll.flow = true;
4205
4205
  const atRoot = ctx.atRoot;
@@ -4235,7 +4235,7 @@ var require_resolve_flow_collection = __commonJS({
4235
4235
  offset = props.end;
4236
4236
  continue;
4237
4237
  }
4238
- if (!isMap3 && ctx.options.strict && utilContainsNewline.containsNewline(key))
4238
+ if (!isMap2 && ctx.options.strict && utilContainsNewline.containsNewline(key))
4239
4239
  onError(
4240
4240
  key,
4241
4241
  // checked by containsNewline()
@@ -4275,7 +4275,7 @@ var require_resolve_flow_collection = __commonJS({
4275
4275
  }
4276
4276
  }
4277
4277
  }
4278
- if (!isMap3 && !sep && !props.found) {
4278
+ if (!isMap2 && !sep && !props.found) {
4279
4279
  const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep, null, props, onError);
4280
4280
  coll.items.push(valueNode);
4281
4281
  offset = valueNode.range[2];
@@ -4298,7 +4298,7 @@ var require_resolve_flow_collection = __commonJS({
4298
4298
  startOnNewline: false
4299
4299
  });
4300
4300
  if (valueProps.found) {
4301
- if (!isMap3 && !props.found && ctx.options.strict) {
4301
+ if (!isMap2 && !props.found && ctx.options.strict) {
4302
4302
  if (sep)
4303
4303
  for (const st of sep) {
4304
4304
  if (st === valueProps.found)
@@ -4330,7 +4330,7 @@ var require_resolve_flow_collection = __commonJS({
4330
4330
  const pair = new Pair.Pair(keyNode, valueNode);
4331
4331
  if (ctx.options.keepSourceTokens)
4332
4332
  pair.srcToken = collItem;
4333
- if (isMap3) {
4333
+ if (isMap2) {
4334
4334
  const map = coll;
4335
4335
  if (utilMapIncludes.mapIncludes(ctx, map.items, keyNode))
4336
4336
  onError(keyStart, "DUPLICATE_KEY", "Map keys must be unique");
@@ -4346,7 +4346,7 @@ var require_resolve_flow_collection = __commonJS({
4346
4346
  offset = valueNode ? valueNode.range[2] : valueProps.end;
4347
4347
  }
4348
4348
  }
4349
- const expectedEnd = isMap3 ? "}" : "]";
4349
+ const expectedEnd = isMap2 ? "}" : "]";
4350
4350
  const [ce, ...ee] = fc.end;
4351
4351
  let cePos = offset;
4352
4352
  if (ce?.source === expectedEnd)
@@ -5566,15 +5566,15 @@ var require_cst_visit = __commonJS({
5566
5566
  var BREAK = /* @__PURE__ */ Symbol("break visit");
5567
5567
  var SKIP = /* @__PURE__ */ Symbol("skip children");
5568
5568
  var REMOVE = /* @__PURE__ */ Symbol("remove item");
5569
- function visit3(cst, visitor) {
5569
+ function visit2(cst, visitor) {
5570
5570
  if ("type" in cst && cst.type === "document")
5571
5571
  cst = { start: cst.start, value: cst.value };
5572
5572
  _visit(Object.freeze([]), cst, visitor);
5573
5573
  }
5574
- visit3.BREAK = BREAK;
5575
- visit3.SKIP = SKIP;
5576
- visit3.REMOVE = REMOVE;
5577
- visit3.itemAtPath = (cst, path) => {
5574
+ visit2.BREAK = BREAK;
5575
+ visit2.SKIP = SKIP;
5576
+ visit2.REMOVE = REMOVE;
5577
+ visit2.itemAtPath = (cst, path) => {
5578
5578
  let item = cst;
5579
5579
  for (const [field, index] of path) {
5580
5580
  const tok = item?.[field];
@@ -5585,8 +5585,8 @@ var require_cst_visit = __commonJS({
5585
5585
  }
5586
5586
  return item;
5587
5587
  };
5588
- visit3.parentCollection = (cst, path) => {
5589
- const parent = visit3.itemAtPath(cst, path.slice(0, -1));
5588
+ visit2.parentCollection = (cst, path) => {
5589
+ const parent = visit2.itemAtPath(cst, path.slice(0, -1));
5590
5590
  const field = path[path.length - 1][0];
5591
5591
  const coll = parent?.[field];
5592
5592
  if (coll && "items" in coll)
@@ -5617,7 +5617,7 @@ var require_cst_visit = __commonJS({
5617
5617
  }
5618
5618
  return typeof ctrl === "function" ? ctrl(item, path) : ctrl;
5619
5619
  }
5620
- exports.visit = visit3;
5620
+ exports.visit = visit2;
5621
5621
  }
5622
5622
  });
5623
5623
 
@@ -5633,7 +5633,7 @@ var require_cst = __commonJS({
5633
5633
  var FLOW_END = "";
5634
5634
  var SCALAR = "";
5635
5635
  var isCollection = (token) => !!token && "items" in token;
5636
- var isScalar3 = (token) => !!token && (token.type === "scalar" || token.type === "single-quoted-scalar" || token.type === "double-quoted-scalar" || token.type === "block-scalar");
5636
+ var isScalar2 = (token) => !!token && (token.type === "scalar" || token.type === "single-quoted-scalar" || token.type === "double-quoted-scalar" || token.type === "block-scalar");
5637
5637
  function prettyToken(token) {
5638
5638
  switch (token) {
5639
5639
  case BOM:
@@ -5717,7 +5717,7 @@ var require_cst = __commonJS({
5717
5717
  exports.FLOW_END = FLOW_END;
5718
5718
  exports.SCALAR = SCALAR;
5719
5719
  exports.isCollection = isCollection;
5720
- exports.isScalar = isScalar3;
5720
+ exports.isScalar = isScalar2;
5721
5721
  exports.prettyToken = prettyToken;
5722
5722
  exports.tokenType = tokenType;
5723
5723
  }
@@ -7247,7 +7247,7 @@ var require_public_api = __commonJS({
7247
7247
  return docs;
7248
7248
  return Object.assign([], { empty: true }, composer$1.streamInfo());
7249
7249
  }
7250
- function parseDocument3(source, options = {}) {
7250
+ function parseDocument2(source, options = {}) {
7251
7251
  const { lineCounter: lineCounter2, prettyErrors } = parseOptions(options);
7252
7252
  const parser$1 = new parser.Parser(lineCounter2?.addNewLine);
7253
7253
  const composer$1 = new composer.Composer(options);
@@ -7273,7 +7273,7 @@ var require_public_api = __commonJS({
7273
7273
  } else if (options === void 0 && reviver && typeof reviver === "object") {
7274
7274
  options = reviver;
7275
7275
  }
7276
- const doc = parseDocument3(src, options);
7276
+ const doc = parseDocument2(src, options);
7277
7277
  if (!doc)
7278
7278
  return null;
7279
7279
  doc.warnings.forEach((warning) => log.warn(doc.options.logLevel, warning));
@@ -7309,7 +7309,7 @@ var require_public_api = __commonJS({
7309
7309
  }
7310
7310
  exports.parse = parse;
7311
7311
  exports.parseAllDocuments = parseAllDocuments;
7312
- exports.parseDocument = parseDocument3;
7312
+ exports.parseDocument = parseDocument2;
7313
7313
  exports.stringify = stringify;
7314
7314
  }
7315
7315
  });
@@ -7333,7 +7333,7 @@ var require_dist = __commonJS({
7333
7333
  var lineCounter = require_line_counter();
7334
7334
  var parser = require_parser();
7335
7335
  var publicApi = require_public_api();
7336
- var visit3 = require_visit();
7336
+ var visit2 = require_visit();
7337
7337
  exports.Composer = composer.Composer;
7338
7338
  exports.Document = Document.Document;
7339
7339
  exports.Schema = Schema.Schema;
@@ -7361,8 +7361,8 @@ var require_dist = __commonJS({
7361
7361
  exports.parseAllDocuments = publicApi.parseAllDocuments;
7362
7362
  exports.parseDocument = publicApi.parseDocument;
7363
7363
  exports.stringify = publicApi.stringify;
7364
- exports.visit = visit3.visit;
7365
- exports.visitAsync = visit3.visitAsync;
7364
+ exports.visit = visit2.visit;
7365
+ exports.visitAsync = visit2.visitAsync;
7366
7366
  }
7367
7367
  });
7368
7368
 
@@ -7434,7 +7434,7 @@ async function dockerAccessGuidance(s) {
7434
7434
  const already = groups.trim().split(/\s+/).includes("docker");
7435
7435
  return [
7436
7436
  "This tool never uses sudo and never escalates privileges itself.",
7437
- "Open firewall / cloud security group ports: 8388/tcp, 8388/udp, 8443/tcp; only for optional ISP outbound: 13839/tcp, 13839/udp, 18444/tcp.",
7437
+ "Open firewall / cloud security group ports: 8388/tcp, 8388/udp, 8443/tcp. ISP routing uses the same listening ports.",
7438
7438
  ...already ? [
7439
7439
  "The Docker daemon may not be running, or your session predates the group change. Log out and back in (or run `newgrp docker`)."
7440
7440
  ] : [
@@ -7717,6 +7717,9 @@ function stripIsp(text) {
7717
7717
  noeol
7718
7718
  };
7719
7719
  }
7720
+ function envWithoutIsp(text) {
7721
+ return stripIsp(text).text;
7722
+ }
7720
7723
  function envWithIsp(text, isp) {
7721
7724
  const base = stripIsp(text);
7722
7725
  return base.text + (base.noeol ? "\n" : "") + "\n" + (base.noeol ? ENV_HEADER_NOEOL : ENV_HEADER) + `
@@ -7855,6 +7858,9 @@ function findUp(name, message) {
7855
7858
  }
7856
7859
  throw new Error(message);
7857
7860
  }
7861
+ function readTemplate(name, projectDir) {
7862
+ return readFileSync2(findTemplate(name, projectDir), "utf8");
7863
+ }
7858
7864
 
7859
7865
  // src/model.ts
7860
7866
  var SS_PORT = 8388;
@@ -7868,8 +7874,6 @@ var VLESS_ISP_TAG = "vless-reality-isp-in";
7868
7874
  var OUT_TAG = "isp-socks";
7869
7875
  var NODE_SS = "shadowsocks";
7870
7876
  var NODE_VLESS = "vless-reality";
7871
- var NODE_SS_ISP = "shadowsocks-isp";
7872
- var NODE_VLESS_ISP = "vless-reality-isp";
7873
7877
  var ModelError = class extends Error {
7874
7878
  name = "ModelError";
7875
7879
  };
@@ -7922,20 +7926,20 @@ function buildModel(env, opts = {}) {
7922
7926
  publicKey,
7923
7927
  shortId: v.REALITY_SHORT_ID
7924
7928
  };
7925
- const node = (name, protocol, tag, port, egress) => ({
7929
+ const node = (name, protocol, tag, port) => ({
7926
7930
  name,
7927
7931
  protocol,
7928
7932
  inbound: { tag, port },
7929
- egress,
7930
7933
  creds: protocol === "shadowsocks" ? ss : reality
7931
7934
  });
7932
7935
  const model = {
7933
7936
  server,
7934
7937
  nodes: [
7935
- node(NODE_SS, "shadowsocks", SS_TAG, SS_PORT, "direct"),
7936
- node(NODE_VLESS, "vless-reality", VLESS_TAG, VLESS_PORT, "direct")
7938
+ node(NODE_SS, "shadowsocks", SS_TAG, SS_PORT),
7939
+ node(NODE_VLESS, "vless-reality", VLESS_TAG, VLESS_PORT)
7937
7940
  ]
7938
7941
  };
7942
+ if (target === "client") return model;
7939
7943
  const {
7940
7944
  ISP_SOCKS_ADDR: rawAddr = "",
7941
7945
  ISP_SOCKS_USER: rawUser = "",
@@ -7958,19 +7962,67 @@ function buildModel(env, opts = {}) {
7958
7962
  pass,
7959
7963
  ...ip ? { egressIp: ip } : {}
7960
7964
  };
7961
- model.nodes.push(
7962
- node(NODE_SS_ISP, "shadowsocks", SS_ISP_TAG, SS_ISP_PORT, "isp-socks"),
7963
- node(
7964
- NODE_VLESS_ISP,
7965
- "vless-reality",
7966
- VLESS_ISP_TAG,
7967
- VLESS_ISP_PORT,
7968
- "isp-socks"
7969
- )
7970
- );
7971
7965
  return model;
7972
7966
  }
7973
7967
 
7968
+ // src/core/routing.ts
7969
+ function isRecord(value) {
7970
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7971
+ }
7972
+ function isIspRule(value) {
7973
+ return isRecord(value) && value.outbound === OUT_TAG;
7974
+ }
7975
+ function isLegacyIspRule(value) {
7976
+ if (!isIspRule(value)) return false;
7977
+ const inbound = typeof value.inbound === "string" ? [value.inbound] : value.inbound;
7978
+ return Array.isArray(inbound) && inbound.length > 0 && inbound.every((tag) => tag === SS_ISP_TAG || tag === VLESS_ISP_TAG) && (value.action === void 0 || value.action === "route") && Object.keys(value).every((key) => ["inbound", "outbound", "action"].includes(key));
7979
+ }
7980
+ function checkNoIspReferences(config) {
7981
+ const referenceKeys = /* @__PURE__ */ new Set(["outbound", "outbounds", "detour", "final", "default"]);
7982
+ const walk = (value, path, key = "") => {
7983
+ if (value === OUT_TAG && referenceKeys.has(key))
7984
+ throw new Error(`Unsupported ISP reference at ${path}. Remove this reference before disabling ISP.`);
7985
+ if (Array.isArray(value))
7986
+ value.forEach((item, i) => walk(item, `${path}[${i}]`, key));
7987
+ else if (isRecord(value))
7988
+ for (const [name, item] of Object.entries(value)) walk(item, `${path}.${name}`, name);
7989
+ };
7990
+ walk(config, "config");
7991
+ }
7992
+ function upsertIspOutbound(config, isp) {
7993
+ const outbounds = config.outbounds ??= [];
7994
+ const matches = outbounds.filter((o) => o.tag === OUT_TAG);
7995
+ if (matches.length > 1) throw new Error(`Duplicate outbound tag: ${OUT_TAG}.`);
7996
+ const fields = {
7997
+ type: "socks",
7998
+ tag: OUT_TAG,
7999
+ server: isp.host,
8000
+ server_port: isp.port,
8001
+ version: "5",
8002
+ username: isp.user,
8003
+ password: isp.pass
8004
+ };
8005
+ if (matches[0]) {
8006
+ if (matches[0].type !== "socks")
8007
+ throw new Error(`Outbound ${OUT_TAG} must have type socks.`);
8008
+ Object.assign(matches[0], fields);
8009
+ } else outbounds.push(fields);
8010
+ }
8011
+ function omitIsp(config) {
8012
+ if (config.outbounds) config.outbounds = config.outbounds.filter((o) => o.tag !== OUT_TAG);
8013
+ if (config.route?.rules) config.route.rules = config.route.rules.filter((r) => !isIspRule(r));
8014
+ checkNoIspReferences(config);
8015
+ }
8016
+ function ensureRoute(config, defaults) {
8017
+ const route = config.route ??= {};
8018
+ const rules = route.rules ??= [];
8019
+ if (!rules.some((r) => isRecord(r) && r.action === "sniff"))
8020
+ rules.unshift({ action: "sniff" });
8021
+ if (!rules.some(isIspRule) && defaults?.rules)
8022
+ rules.push(...structuredClone(defaults.rules.filter(isIspRule)));
8023
+ route.final ??= "direct";
8024
+ }
8025
+
7974
8026
  // src/core/render-config.ts
7975
8027
  function renderSingbox(model, template) {
7976
8028
  const ss = model.nodes.find((n) => n.protocol === "shadowsocks").creds;
@@ -7988,7 +8040,7 @@ function renderSingbox(model, template) {
7988
8040
  const prototypes = /* @__PURE__ */ new Map();
7989
8041
  for (const protocol of ["shadowsocks", "vless-reality"]) {
7990
8042
  const base = model.nodes.find(
7991
- (n) => n.protocol === protocol && n.egress === "direct"
8043
+ (n) => n.protocol === protocol
7992
8044
  );
7993
8045
  const proto = config.inbounds.find(
7994
8046
  (i) => i.type === (protocol === "shadowsocks" ? "shadowsocks" : "vless")
@@ -8002,27 +8054,9 @@ function renderSingbox(model, template) {
8002
8054
  tag: n.inbound.tag,
8003
8055
  listen_port: n.inbound.port
8004
8056
  }));
8005
- const ispNodes = model.nodes.filter((n) => n.egress === "isp-socks");
8006
- if (ispNodes.length) {
8007
- const isp = model.isp;
8008
- config.outbounds = [
8009
- ...config.outbounds ?? [],
8010
- {
8011
- type: "socks",
8012
- tag: OUT_TAG,
8013
- server: isp.host,
8014
- server_port: isp.port,
8015
- version: "5",
8016
- username: isp.user,
8017
- password: isp.pass
8018
- }
8019
- ];
8020
- config.route ??= {};
8021
- config.route.rules = [
8022
- ...config.route.rules ?? [],
8023
- { inbound: ispNodes.map((n) => n.inbound.tag), outbound: OUT_TAG }
8024
- ];
8025
- }
8057
+ ensureRoute(config);
8058
+ if (model.isp) upsertIspOutbound(config, model.isp);
8059
+ else omitIsp(config);
8026
8060
  if (JSON.stringify(config).includes("${"))
8027
8061
  throw new Error("Unresolved placeholder left in config.json.");
8028
8062
  return config;
@@ -8030,6 +8064,9 @@ function renderSingbox(model, template) {
8030
8064
  function serializeSingbox(config) {
8031
8065
  return JSON.stringify(config, null, 2) + "\n";
8032
8066
  }
8067
+ function loadSingboxTemplate(projectDir) {
8068
+ return JSON.parse(readTemplate("config.json.template", projectDir));
8069
+ }
8033
8070
 
8034
8071
  // src/cli/gen-config.ts
8035
8072
  async function genConfig(io) {
@@ -8090,11 +8127,9 @@ function renderClash(model, templateText) {
8090
8127
  }
8091
8128
  proxies.items = model.nodes.map((n) => {
8092
8129
  const proto = prototypes.get(n.protocol);
8093
- if (n.egress === "direct") return proto;
8094
- const clone = proto.clone();
8095
- clone.set("name", n.name);
8096
- clone.set("port", n.inbound.port);
8097
- return clone;
8130
+ proto.set("name", n.name);
8131
+ proto.set("port", n.inbound.port);
8132
+ return proto;
8098
8133
  });
8099
8134
  const group = groups.items.find((g) => (0, import_yaml.isMap)(g) && g.get("name") === "Proxy");
8100
8135
  const list = (0, import_yaml.isMap)(group) ? group.get("proxies", true) : null;
@@ -8143,21 +8178,11 @@ Saved to: ${path}
8143
8178
  }
8144
8179
 
8145
8180
  // src/cli/isp-outbound.ts
8146
- import {
8147
- accessSync,
8148
- constants,
8149
- existsSync as existsSync6,
8150
- mkdirSync as mkdirSync2,
8151
- readFileSync as readFileSync6,
8152
- rmSync as rmSync3,
8153
- statSync as statSync2,
8154
- writeFileSync as writeFileSync3
8155
- } from "node:fs";
8156
- import { join as join4, resolve as resolve4 } from "node:path";
8157
- import { isDeepStrictEqual as isDeepStrictEqual2 } from "node:util";
8181
+ import { accessSync, constants, existsSync as existsSync7, readFileSync as readFileSync8, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
8182
+ import { join as join6, resolve as resolve4 } from "node:path";
8183
+ import { isDeepStrictEqual } from "node:util";
8158
8184
 
8159
8185
  // src/core/isp.ts
8160
- var import_yaml2 = __toESM(require_dist(), 1);
8161
8186
  import {
8162
8187
  chmodSync,
8163
8188
  chownSync,
@@ -8177,131 +8202,65 @@ import {
8177
8202
  } from "node:fs";
8178
8203
  import { join as join3 } from "node:path";
8179
8204
  import { randomBytes as randomBytes3 } from "node:crypto";
8180
- import { isDeepStrictEqual } from "node:util";
8181
8205
  var ISP_BACKUP_DIR = ".isp-backup";
8182
8206
  var ISP_LOCK_FILE = ".isp.lock";
8183
8207
  var ISP_TMP_PREFIX = ".isp-tmp.";
8184
- var GROUP_MAIN = "Proxy";
8185
- var MANAGED_FILES = ["config.json", "clash.yaml", ".env"];
8208
+ var MANAGED_FILES = ["config.json", ".env"];
8186
8209
  var IspError = class extends Error {
8187
8210
  name = "IspError";
8188
8211
  };
8189
8212
  function envGet(text, key) {
8190
8213
  return text.split("\n").find((line) => line.startsWith(`${key}=`))?.slice(key.length + 1).replace(/\r$/, "");
8191
8214
  }
8192
- function envHasIsp(text) {
8193
- return text.split("\n").some((raw) => {
8194
- const line = raw.replace(/\r$/, "");
8195
- return /^ISP_SOCKS_[^=]*=/.test(line) || line === ENV_HEADER || line === ENV_HEADER_NOEOL;
8196
- });
8197
- }
8198
- function isRecord(value) {
8199
- return value !== null && typeof value === "object" && !Array.isArray(value);
8200
- }
8201
8215
  function checkIspConfig(config) {
8202
8216
  if (!isRecord(config)) throw new IspError("config.json is not valid JSON.");
8203
8217
  const inbounds = config.inbounds;
8204
- if (!Array.isArray(inbounds) || ![SS_TAG, VLESS_TAG].every((tag) => inbounds.some((i) => isRecord(i) && i.tag === tag)))
8218
+ if (!Array.isArray(inbounds) || !inbounds.every(isRecord) || ![SS_TAG, VLESS_TAG].every((tag) => inbounds.some((i) => i.tag === tag)))
8205
8219
  throw new IspError("config.json has no inbounds tagged ss-in and vless-reality-in.");
8206
- if (inbounds.some((i) => isRecord(i) && i.tag !== SS_ISP_TAG && i.tag !== VLESS_ISP_TAG && (i.listen_port === SS_ISP_PORT || i.listen_port === VLESS_ISP_PORT)))
8207
- throw new IspError(`Another inbound in config.json already uses port ${SS_ISP_PORT} or ${VLESS_ISP_PORT}.`);
8208
- }
8209
- function configHasIsp(config) {
8210
- return [...config.inbounds ?? [], ...config.outbounds ?? []].some(
8211
- (i) => isRecord(i) && [SS_ISP_TAG, VLESS_ISP_TAG, OUT_TAG].includes(i.tag)
8212
- );
8220
+ if (config.outbounds !== void 0 && (!Array.isArray(config.outbounds) || !config.outbounds.every(isRecord))) throw new IspError("config.json has invalid outbounds.");
8221
+ if (config.route !== void 0 && (!isRecord(config.route) || config.route.rules !== void 0 && !Array.isArray(config.route.rules)))
8222
+ throw new IspError("config.json has invalid route rules.");
8213
8223
  }
8214
- function addIspToConfig(config, isp) {
8215
- checkIspConfig(config);
8224
+ function withoutLegacyListeners(config) {
8216
8225
  const next = structuredClone(config);
8217
- for (const [base, tag, port] of [
8218
- [SS_TAG, SS_ISP_TAG, SS_ISP_PORT],
8219
- [VLESS_TAG, VLESS_ISP_TAG, VLESS_ISP_PORT]
8220
- ]) {
8221
- const existing = next.inbounds.find((i) => i.tag === tag);
8222
- if (existing) existing.listen_port = port;
8223
- else next.inbounds.push({
8224
- ...structuredClone(next.inbounds.find((i) => i.tag === base)),
8225
- tag,
8226
- listen_port: port
8227
- });
8228
- }
8229
- const outbounds = next.outbounds ??= [];
8230
- const outbound = outbounds.find((o) => o.tag === OUT_TAG);
8231
- const fields = {
8232
- type: "socks",
8233
- tag: OUT_TAG,
8234
- server: isp.host,
8235
- server_port: isp.port,
8236
- version: "5",
8237
- username: isp.user,
8238
- password: isp.pass
8226
+ const tags = /* @__PURE__ */ new Set([SS_ISP_TAG, VLESS_ISP_TAG]);
8227
+ next.inbounds = next.inbounds.filter((inbound) => {
8228
+ if (!tags.has(inbound.tag)) return true;
8229
+ const ss = inbound.tag === SS_ISP_TAG;
8230
+ if (inbound.type !== (ss ? "shadowsocks" : "vless") || inbound.listen_port !== (ss ? SS_ISP_PORT : VLESS_ISP_PORT))
8231
+ throw new IspError("Legacy ISP inbound was customized. Migrate its tag and references manually first.");
8232
+ return false;
8233
+ });
8234
+ if (next.route?.rules) next.route.rules = next.route.rules.flatMap((rule) => {
8235
+ if (!isRecord(rule)) return [rule];
8236
+ const inbound = typeof rule.inbound === "string" ? [rule.inbound] : rule.inbound;
8237
+ if (!Array.isArray(inbound) || !inbound.some((tag) => tags.has(tag))) return [rule];
8238
+ const remaining = inbound.filter((tag) => !tags.has(tag));
8239
+ if (remaining.length) return [{ ...rule, inbound: remaining }];
8240
+ if (isLegacyIspRule(rule)) return [];
8241
+ throw new IspError("A custom route depends on legacy ISP inbounds. Migrate it manually first.");
8242
+ });
8243
+ const check = (value, key = "") => {
8244
+ if (typeof value === "string" && tags.has(value) && ["inbound", "detour"].includes(key))
8245
+ throw new IspError("A custom reference depends on legacy ISP inbounds. Migrate it manually first.");
8246
+ if (Array.isArray(value)) value.forEach((item) => check(item, key));
8247
+ else if (isRecord(value)) Object.entries(value).forEach(([name, item]) => check(item, name));
8239
8248
  };
8240
- if (outbound) Object.assign(outbound, fields);
8241
- else outbounds.push(fields);
8242
- const rules = (next.route ??= {}).rules ??= [];
8243
- const rule = rules.find((r) => isRecord(r) && r.outbound === OUT_TAG);
8244
- const inbound = [SS_ISP_TAG, VLESS_ISP_TAG];
8245
- if (isRecord(rule)) {
8246
- if (!isDeepStrictEqual(rule.inbound, inbound)) rule.inbound = inbound;
8247
- } else rules.push({ inbound, outbound: OUT_TAG });
8249
+ check(next);
8248
8250
  return next;
8249
8251
  }
8250
- function clashHasIsp(text) {
8251
- if (/^\s*#.*\bmy-singbox-isp\b/m.test(text)) return true;
8252
- const doc = (0, import_yaml2.parseDocument)(text);
8253
- const proxies = doc.get("proxies", true);
8254
- return (0, import_yaml2.isSeq)(proxies) && proxies.items.some((p) => (0, import_yaml2.isMap)(p) && [NODE_SS_ISP, NODE_VLESS_ISP].includes(p.get("name")));
8252
+ function addIspToConfig(config, isp, routeDefaults) {
8253
+ checkIspConfig(config);
8254
+ const next = withoutLegacyListeners(config);
8255
+ upsertIspOutbound(next, isp);
8256
+ ensureRoute(next, routeDefaults);
8257
+ return next;
8255
8258
  }
8256
- function addIspToClash(text, realityPrivateKey) {
8257
- const doc = (0, import_yaml2.parseDocument)(text);
8258
- if (doc.errors.length) throw new IspError("clash.yaml is not valid YAML.");
8259
- const proxies = doc.get("proxies", true);
8260
- const missing = `clash.yaml has no usable node named "${NODE_SS}" and "${NODE_VLESS}". It was probably generated by an older version (My-sing-box / My-VLESS-Reality). Delete clash.yaml, run my-singbox conf-clash to regenerate it, re-import it in your clients, then re-run my-singbox isp-outbound --add.`;
8261
- if (!(0, import_yaml2.isSeq)(proxies)) throw new IspError(missing);
8262
- let changed = false;
8263
- for (const [base, name, basePort, port] of [
8264
- [NODE_SS, NODE_SS_ISP, SS_PORT, SS_ISP_PORT],
8265
- [NODE_VLESS, NODE_VLESS_ISP, VLESS_PORT, VLESS_ISP_PORT]
8266
- ]) {
8267
- const proto = proxies.items.find((p) => (0, import_yaml2.isMap)(p) && p.get("name") === base);
8268
- if (!(0, import_yaml2.isMap)(proto) || proto.get("port") !== basePort) throw new IspError(missing);
8269
- const existing = proxies.items.find((p) => (0, import_yaml2.isMap)(p) && p.get("name") === name);
8270
- if ((0, import_yaml2.isMap)(existing)) {
8271
- if (existing.get("port") !== port) {
8272
- existing.set("port", port);
8273
- changed = true;
8274
- }
8275
- } else {
8276
- const clone = proto.clone();
8277
- (0, import_yaml2.visit)(clone, (_key, node) => {
8278
- if ((0, import_yaml2.isMap)(node) || (0, import_yaml2.isSeq)(node) || (0, import_yaml2.isScalar)(node)) {
8279
- delete node.comment;
8280
- delete node.commentBefore;
8281
- delete node.spaceBefore;
8282
- }
8283
- });
8284
- clone.set("name", name);
8285
- clone.set("port", port);
8286
- proxies.add(clone);
8287
- changed = true;
8288
- }
8289
- }
8290
- const groups = doc.get("proxy-groups", true);
8291
- const group = (0, import_yaml2.isSeq)(groups) ? groups.items.find((g) => (0, import_yaml2.isMap)(g) && g.get("name") === GROUP_MAIN) : void 0;
8292
- const list = (0, import_yaml2.isMap)(group) ? group.get("proxies", true) : void 0;
8293
- if (!(0, import_yaml2.isSeq)(list))
8294
- throw new IspError(`clash.yaml has no '${GROUP_MAIN}' proxy group with a 'proxies:' list in the expected format (generated by my-singbox conf-clash). Regenerate clash.yaml, re-import it in your clients, then re-run my-singbox isp-outbound --add.`);
8295
- for (const name of [NODE_SS_ISP, NODE_VLESS_ISP]) {
8296
- if (!list.items.some((item) => (0, import_yaml2.isScalar)(item) && item.value === name)) {
8297
- list.add(name);
8298
- changed = true;
8299
- }
8300
- }
8301
- const result2 = changed ? doc.toString({ lineWidth: 0 }) : text;
8302
- if (result2.includes("REALITY_PRIVATE_KEY") || realityPrivateKey && result2.includes(realityPrivateKey))
8303
- throw new IspError("Refusing to write REALITY_PRIVATE_KEY into clash.yaml.");
8304
- return result2;
8259
+ function removeIspFromConfig(config) {
8260
+ checkIspConfig(config);
8261
+ const next = withoutLegacyListeners(config);
8262
+ omitIsp(next);
8263
+ return next;
8305
8264
  }
8306
8265
  function makeTmpPath(projectDir) {
8307
8266
  return join3(projectDir, ISP_TMP_PREFIX + randomBytes3(16).toString("hex"));
@@ -8337,7 +8296,7 @@ function backupDirs(projectDir) {
8337
8296
  return existsSync5(root) ? readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join3(root, entry.name)).sort() : [];
8338
8297
  }
8339
8298
  var lastStamp = 0n;
8340
- function makeBackup(projectDir) {
8299
+ function makeBackup(projectDir, files = MANAGED_FILES) {
8341
8300
  const now = BigInt(Date.now()) * 1000000n;
8342
8301
  lastStamp = now > lastStamp ? now : lastStamp + 1n;
8343
8302
  const d = new Date(Number(lastStamp / 1000000n));
@@ -8345,36 +8304,20 @@ function makeBackup(projectDir) {
8345
8304
  const stamp = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}-${(lastStamp % 1000000000n).toString().padStart(9, "0")}-${process.pid}`;
8346
8305
  const root = join3(projectDir, ISP_BACKUP_DIR), dir = join3(root, stamp);
8347
8306
  mkdirSync(root, { recursive: true, mode: 448 });
8348
- mkdirSync(dir, { mode: 448 });
8349
8307
  chmodSync(root, 448);
8350
- chmodSync(dir, 448);
8351
- for (const file of MANAGED_FILES) {
8308
+ mkdirSync(dir, { mode: 448 });
8309
+ for (const file of files) {
8352
8310
  const src = join3(projectDir, file);
8353
8311
  if (existsSync5(src) && statSync(src).isFile()) copyPreserved(src, join3(dir, file));
8354
8312
  }
8355
8313
  const all = backupDirs(projectDir);
8356
- for (const old of all.slice(1, Math.max(1, all.length - 4)))
8357
- rmSync2(old, { recursive: true });
8314
+ for (const old of all.slice(1, Math.max(1, all.length - 4))) rmSync2(old, { recursive: true });
8358
8315
  return dir;
8359
8316
  }
8360
- function findCleanBackup(projectDir) {
8361
- return backupDirs(projectDir).reverse().find((dir) => MANAGED_FILES.every((file) => {
8362
- const path = join3(dir, file);
8363
- if (!existsSync5(path)) return true;
8364
- const text = readFileSync5(path, "utf8");
8365
- if (file === ".env") return !envHasIsp(text);
8366
- if (file === "clash.yaml") return !clashHasIsp(text);
8367
- try {
8368
- return !configHasIsp(JSON.parse(text));
8369
- } catch {
8370
- return false;
8371
- }
8372
- }));
8373
- }
8374
- function restoreBackup(projectDir, backupDir) {
8375
- for (const file of MANAGED_FILES) {
8376
- const src = join3(backupDir, file);
8377
- if (existsSync5(src) && statSync(src).isFile()) copyPreserved(src, join3(projectDir, file));
8317
+ function restoreBackup(projectDir, backupDir, files = MANAGED_FILES) {
8318
+ for (const file of files) {
8319
+ const src = join3(backupDir, file), dest = join3(projectDir, file);
8320
+ if (existsSync5(src) && statSync(src).isFile()) copyPreserved(src, dest);
8378
8321
  }
8379
8322
  }
8380
8323
  function sweepStaleTmp(projectDir) {
@@ -8432,6 +8375,144 @@ async function withLock(projectDir, fn) {
8432
8375
  // src/core/isp-service.ts
8433
8376
  import { spawn } from "node:child_process";
8434
8377
  import { createConnection } from "node:net";
8378
+ import { readFileSync as readFileSync7 } from "node:fs";
8379
+ import { join as join5 } from "node:path";
8380
+
8381
+ // src/core/start.ts
8382
+ import { execFile as execFile2 } from "node:child_process";
8383
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
8384
+ import { connect } from "node:net";
8385
+ import { join as join4 } from "node:path";
8386
+ function parseListenPorts(configText) {
8387
+ const ports = /* @__PURE__ */ new Set();
8388
+ for (const m of configText.matchAll(/"listen_port"\s*:\s*([0-9]+)/g)) {
8389
+ const port = Number(m[1]);
8390
+ if (port < 1 || port > 65535)
8391
+ throw new Error(`Invalid listen_port in config.json: ${m[1]}`);
8392
+ ports.add(port);
8393
+ }
8394
+ if (!ports.size) throw new Error("config.json has no listen_port.");
8395
+ return [...ports];
8396
+ }
8397
+ function secretValues(envText) {
8398
+ return [...envText.matchAll(/^(?:REALITY_PRIVATE_KEY|SS_PASSWORD|VLESS_UUID|ISP_SOCKS_USER|ISP_SOCKS_PASS)=([^\r\n]*)\r?$/gm)].map((m) => envUnquote(m[1])).filter((value) => value !== null && value !== "");
8399
+ }
8400
+ function redact(text, secrets) {
8401
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length))
8402
+ text = text.replaceAll(secret, "***");
8403
+ return text;
8404
+ }
8405
+ function composeImageTag(text) {
8406
+ const m = /^\s*image:\s*ghcr\.io\/sagernet\/sing-box:([^\s#]+)/m.exec(text);
8407
+ if (!m) throw new Error("sing-box image tag not found in compose.yaml.");
8408
+ return m[1];
8409
+ }
8410
+ function firewallPorts(configText) {
8411
+ const config = JSON.parse(configText);
8412
+ return config.inbounds.flatMap(
8413
+ (i) => i.type === "shadowsocks" ? [`${i.listen_port}/tcp`, `${i.listen_port}/udp`] : [`${i.listen_port}/tcp`]
8414
+ );
8415
+ }
8416
+ function realStartDeps() {
8417
+ return {
8418
+ run: (file, args) => new Promise((resolve7) => {
8419
+ execFile2(file, args, (error, stdout, stderr) => {
8420
+ const code = error ? typeof error.code === "number" ? error.code : error.code === "ENOENT" ? 127 : 1 : 0;
8421
+ resolve7({ code, stdout, stderr });
8422
+ });
8423
+ }),
8424
+ tcpOpen: (port) => new Promise((resolve7) => {
8425
+ const socket = connect(port, "127.0.0.1");
8426
+ const finish = (open) => {
8427
+ socket.destroy();
8428
+ resolve7(open);
8429
+ };
8430
+ socket.setTimeout(500, () => finish(false));
8431
+ socket.once("connect", () => finish(true));
8432
+ socket.once("error", () => finish(false));
8433
+ }),
8434
+ sleep: (ms) => new Promise((resolve7) => setTimeout(resolve7, ms))
8435
+ };
8436
+ }
8437
+ async function startNode(opts) {
8438
+ const { project, deps } = opts;
8439
+ if (!existsSync6(join4(project, "compose.yaml")))
8440
+ throw new Error("compose.yaml does not exist.");
8441
+ if (!existsSync6(join4(project, "config.json")))
8442
+ throw new Error("config.json does not exist. Run my-singbox gen-config first.");
8443
+ const ports = parseListenPorts(readFileSync6(join4(project, "config.json"), "utf8"));
8444
+ if (!await opts.dockerAvailable()) throw new Error("docker is not installed.");
8445
+ if ((await deps.run("docker", ["compose", "version"])).code)
8446
+ throw new Error("docker compose is not available.");
8447
+ if (!await opts.dockerDaemonUp()) throw new Error("Docker daemon is not running.");
8448
+ const envPath = join4(project, ".env");
8449
+ const secrets = secretValues(existsSync6(envPath) ? readFileSync6(envPath, "utf8") : "");
8450
+ const compose = (...args) => deps.run("docker", ["compose", "--project-directory", project, ...args]);
8451
+ const forward = (output) => {
8452
+ opts.stdout(redact(output.stdout, secrets));
8453
+ opts.stderr(redact(output.stderr, secrets));
8454
+ };
8455
+ const fail = async (message) => {
8456
+ const logs = await compose("logs", "--tail", "50", "sing-box");
8457
+ opts.stderr(redact(logs.stdout, secrets));
8458
+ opts.stderr(redact(logs.stderr, secrets));
8459
+ throw new Error(redact(message, secrets));
8460
+ };
8461
+ const status = async () => {
8462
+ const output = await deps.run("docker", ["inspect", "-f", "{{.State.Status}}", "sing-box"]);
8463
+ return output.code ? "missing" : output.stdout.trim();
8464
+ };
8465
+ opts.stdout("Starting sing-box...\n");
8466
+ const up = await compose("up", "-d");
8467
+ forward(up);
8468
+ if (up.code) throw new Error("docker compose up failed.");
8469
+ let state = "missing";
8470
+ for (let i = 0; i < 15; i++) {
8471
+ state = await status();
8472
+ if (state === "running") break;
8473
+ await deps.sleep(1e3);
8474
+ }
8475
+ if (state !== "running") await fail(`sing-box did not stay running (status: ${state}).`);
8476
+ const check = await compose("exec", "-T", "sing-box", "sing-box", "check", "-c", "/etc/sing-box/config.json");
8477
+ forward(check);
8478
+ if (check.code) await fail("sing-box config check failed.");
8479
+ for (const port of ports) {
8480
+ let open = false;
8481
+ for (let i = 0; i < 10; i++) {
8482
+ open = await deps.tcpOpen(port);
8483
+ if (open) break;
8484
+ await deps.sleep(1e3);
8485
+ }
8486
+ if (!open) await fail(`Port ${port} is not accepting connections.`);
8487
+ }
8488
+ if (ports.includes(8388)) {
8489
+ let listening = false;
8490
+ for (let i = 0; i < 10; i++) {
8491
+ const udp = await deps.run("ss", ["-uln"]);
8492
+ if (udp.code === 127) {
8493
+ opts.stderr("Warning: ss is not installed. Skipping UDP listener check for port 8388.\n");
8494
+ listening = true;
8495
+ break;
8496
+ }
8497
+ if (udp.code === 0 && /:8388\s/.test(udp.stdout)) {
8498
+ const owners = await deps.run("ss", ["-ulnp"]);
8499
+ const lines2 = owners.stdout.split("\n").filter((l) => /:8388\s/.test(l)).join("\n");
8500
+ if (lines2.includes("users:") && !lines2.includes("sing-box"))
8501
+ await fail("UDP port 8388 is not owned by sing-box.");
8502
+ listening = true;
8503
+ break;
8504
+ }
8505
+ await deps.sleep(1e3);
8506
+ }
8507
+ if (!listening) await fail("UDP port 8388 is not listening.");
8508
+ }
8509
+ state = await status();
8510
+ if (state !== "running") await fail(`sing-box did not stay running (status: ${state}).`);
8511
+ opts.stdout(`Self-check passed. Ports: ${ports.join(" ")}
8512
+ `);
8513
+ }
8514
+
8515
+ // src/core/isp-service.ts
8435
8516
  var container = "sing-box";
8436
8517
  var containerConfig = "/etc/sing-box/config.json";
8437
8518
  var IspService = class {
@@ -8575,22 +8656,7 @@ var IspService = class {
8575
8656
  }
8576
8657
  return false;
8577
8658
  }
8578
- async portsFree() {
8579
- const probe = await this.command("ss", ["-H", "-ltn"]);
8580
- if (probe.missing) {
8581
- this.warn(`ss is not installed. Skipping the check that ports ${SS_ISP_PORT} and ${VLESS_ISP_PORT} are free.`);
8582
- return;
8583
- }
8584
- for (const [proto, port] of [
8585
- ["tcp", SS_ISP_PORT],
8586
- ["udp", SS_ISP_PORT],
8587
- ["tcp", VLESS_ISP_PORT]
8588
- ]) {
8589
- if (await this.portListening(proto, port))
8590
- throw new IspError(`Port ${port}/${proto} is already in use by another process.`);
8591
- }
8592
- }
8593
- async verifyService(expectIsp) {
8659
+ async verifyService() {
8594
8660
  let status = "missing";
8595
8661
  for (let i = 0; i < 15; i++) {
8596
8662
  status = await this.containerStatus();
@@ -8605,16 +8671,15 @@ var IspService = class {
8605
8671
  this.warn("sing-box config check failed inside the container.");
8606
8672
  return false;
8607
8673
  }
8608
- for (const port of [SS_PORT, VLESS_PORT, ...expectIsp ? [SS_ISP_PORT, VLESS_ISP_PORT] : []]) {
8609
- if (!await this.waitListening("tcp", port)) {
8610
- this.warn(`Port ${port}/tcp is not accepting connections.`);
8674
+ const listeners = firewallPorts(readFileSync7(join5(this.io.projectDir, "config.json"), "utf8"));
8675
+ for (const listener of new Set(listeners)) {
8676
+ const [number, proto] = listener.split("/");
8677
+ const port = Number(number);
8678
+ if (!await this.waitListening(proto, port)) {
8679
+ this.warn(`Port ${port}/${proto} is not listening.`);
8611
8680
  return false;
8612
8681
  }
8613
8682
  }
8614
- if (expectIsp && !await this.waitListening("udp", SS_ISP_PORT)) {
8615
- this.warn(`Port ${SS_ISP_PORT}/udp is not listening.`);
8616
- return false;
8617
- }
8618
8683
  return true;
8619
8684
  }
8620
8685
  async socksEgressIp(isp) {
@@ -8667,11 +8732,11 @@ async function ispOutbound(io, opts) {
8667
8732
  const warn = (message) => io.stderr(`Warning: ${message}
8668
8733
  `);
8669
8734
  const info = (message) => io.stdout(message + "\n");
8670
- const path = (name) => join4(projectDir, name);
8671
- const isFile = (file) => existsSync6(file) && statSync2(file).isFile();
8735
+ const path = (name) => join6(projectDir, name);
8736
+ const isFile = (file) => existsSync7(file) && statSync2(file).isFile();
8672
8737
  const tempFiles = [];
8673
8738
  let backupDir;
8674
- let rollbackArmed = false;
8739
+ let rollbackArmed = false, rollbackNeedsRestart = false;
8675
8740
  let action = opts.action;
8676
8741
  const handlers = ["SIGINT", "SIGTERM", "SIGHUP"].map((signal, i) => {
8677
8742
  const handler = () => {
@@ -8704,7 +8769,7 @@ async function ispOutbound(io, opts) {
8704
8769
  };
8705
8770
  const confirmRestart = async () => {
8706
8771
  if (opts.yes) return;
8707
- info("This restarts sing-box. Connections on 8388 and 8443 drop for a few seconds.");
8772
+ info("This restarts sing-box. Connections on its configured ports drop for a few seconds.");
8708
8773
  if (!["y", "Y"].includes(await ask("Continue? [y/N] ")))
8709
8774
  throw new IspError("Aborted. Nothing was changed.");
8710
8775
  };
@@ -8722,7 +8787,7 @@ async function ispOutbound(io, opts) {
8722
8787
  const preflightConfig = () => {
8723
8788
  if (!isFile(path("config.json")))
8724
8789
  throw new IspError("config.json does not exist. Run my-singbox gen-config first.");
8725
- const text = readFileSync6(path("config.json"), "utf8");
8790
+ const text = readFileSync8(path("config.json"), "utf8");
8726
8791
  let config;
8727
8792
  try {
8728
8793
  config = JSON.parse(text);
@@ -8732,31 +8797,59 @@ async function ispOutbound(io, opts) {
8732
8797
  checkIspConfig(config);
8733
8798
  return { text, config };
8734
8799
  };
8735
- const saveBackup = () => {
8736
- backupDir = makeBackup(projectDir);
8737
- info(`Backup saved in ${backupDir}`);
8738
- };
8739
8800
  const rollback = async () => {
8740
8801
  rollbackArmed = false;
8741
8802
  warn("Rolling back to the previous configuration...");
8742
- restoreBackup(projectDir, backupDir);
8743
- const previous = new IspService(serviceIO);
8744
- if (!await previous.recreate()) {
8745
- await previous.showLogs();
8746
- throw new IspError(`Rollback failed to restart sing-box. Previous files are restored from ${backupDir}; run: docker compose up -d --force-recreate`);
8803
+ restoreBackup(projectDir, backupDir, MANAGED_FILES);
8804
+ if (rollbackNeedsRestart) {
8805
+ const previous = new IspService(serviceIO);
8806
+ if (!await previous.recreate()) {
8807
+ await previous.showLogs();
8808
+ throw new IspError(`Rollback failed to restart sing-box. Previous files are restored from ${backupDir}; run: docker compose up -d --force-recreate`);
8809
+ }
8810
+ if (!await previous.verifyService()) {
8811
+ await previous.showLogs();
8812
+ throw new IspError("Rollback restored the files, but sing-box is not healthy. Check: docker compose logs sing-box");
8813
+ }
8814
+ }
8815
+ io.stderr("Rollback complete. Previous server files are restored.\n");
8816
+ };
8817
+ const apply = async (image, original, config, envText, envCandidate) => {
8818
+ const changed = !isDeepStrictEqual(config, original.config);
8819
+ const envChanged = envCandidate !== envText;
8820
+ const configText = changed ? JSON.stringify(config, null, 2) + "\n" : original.text;
8821
+ if (changed) {
8822
+ await validateCandidate(image, candidate(configText));
8823
+ await confirmRestart();
8747
8824
  }
8748
- if (!await previous.verifyService(false)) {
8749
- await previous.showLogs();
8750
- throw new IspError("Rollback restored the files, but sing-box is not healthy. Check: docker compose logs sing-box");
8825
+ if (!changed && !envChanged) return false;
8826
+ backupDir = makeBackup(projectDir, MANAGED_FILES);
8827
+ info(`Backup saved in ${backupDir}`);
8828
+ rollbackArmed = true;
8829
+ controller.signal.throwIfAborted();
8830
+ if (changed) {
8831
+ rollbackNeedsRestart = true;
8832
+ installFile(path("config.json"), configText, projectDir);
8751
8833
  }
8752
- io.stderr("Rollback complete. sing-box is running the previous configuration.\n");
8834
+ if (envChanged) installFile(path(".env"), envCandidate, projectDir);
8835
+ controller.signal.throwIfAborted();
8836
+ if (changed && !await service.recreate() || !await service.verifyService()) {
8837
+ await service.showLogs();
8838
+ throw new IspError("sing-box did not come up after the ISP change.");
8839
+ }
8840
+ controller.signal.throwIfAborted();
8841
+ rollbackArmed = false;
8842
+ const remainingTags = new Set(config.inbounds.map((inbound) => inbound.tag));
8843
+ const retiredPorts = original.config.inbounds.filter((inbound) => !remainingTags.has(inbound.tag)).map((inbound) => inbound.listen_port);
8844
+ if (retiredPorts.length)
8845
+ info(`Regenerate the client clash.yaml; legacy ISP ports ${retiredPorts.join("/")} can now be closed.`);
8846
+ return true;
8753
8847
  };
8754
8848
  const add = async (image) => {
8755
8849
  const original = preflightConfig();
8756
8850
  if (!isFile(path(".env")))
8757
8851
  throw new IspError(".env does not exist. Run my-singbox gen-keys first.");
8758
- if (!configHasIsp(original.config)) await service.portsFree();
8759
- const envText = readFileSync6(path(".env"), "utf8");
8852
+ const envText = readFileSync8(path(".env"), "utf8");
8760
8853
  const isp = await collectInputs(envText, ask);
8761
8854
  info("Checking the SOCKS5 proxy (TCP) and its egress IP...");
8762
8855
  const got = await service.socksEgressIp(isp);
@@ -8766,103 +8859,38 @@ async function ispOutbound(io, opts) {
8766
8859
  if (got.toLowerCase() !== expected.toLowerCase())
8767
8860
  throw new IspError(`Egress IP through the proxy is ${got}, expected ${expected}. If the ISP's egress differs from its entry IP, enter the egress IP when asked. Nothing was changed.`);
8768
8861
  info(`Egress IP through the SOCKS5 proxy: ${got} (matches).`);
8769
- const envCandidate = envWithIsp(envText, { ...isp, egressIp: isp.egressIp ?? "" });
8770
- candidate(envCandidate);
8771
- const config = addIspToConfig(original.config, isp);
8772
- const configText = isDeepStrictEqual2(config, original.config) ? original.text : JSON.stringify(config, null, 2) + "\n";
8773
- const cfgCandidate = candidate(configText);
8774
- await validateCandidate(image, cfgCandidate);
8775
- let clashText, clashCandidate;
8776
- let clashState;
8777
- if (isFile(path("clash.yaml"))) {
8778
- clashText = readFileSync6(path("clash.yaml"), "utf8");
8779
- clashCandidate = addIspToClash(clashText, envGet(envText, "REALITY_PRIVATE_KEY"));
8780
- candidate(clashCandidate);
8781
- clashState = `clash.yaml updated (nodes ${NODE_SS_ISP} and ${NODE_VLESS_ISP} added and listed in the ${GROUP_MAIN} group).`;
8782
- } else {
8783
- warn("clash.yaml does not exist, so only the server config is changed. After running my-singbox conf-clash, re-run my-singbox isp-outbound --add to add the client nodes.");
8784
- clashState = "clash.yaml not found (skipped).";
8785
- }
8786
- const changed = configText !== original.text;
8787
- const envChanged = envCandidate !== envText;
8788
- const clashChanged = clashCandidate !== clashText;
8789
- if (changed) await confirmRestart();
8790
- if (changed || envChanged || clashChanged) saveBackup();
8791
- if (changed) {
8792
- rollbackArmed = true;
8793
- installFile(path("config.json"), configText, projectDir);
8794
- if (!await service.recreate() || !await service.verifyService(true)) {
8795
- await service.showLogs();
8796
- await rollback();
8797
- throw new IspError("The ISP outbound did not come up. The previous configuration was restored.");
8798
- }
8799
- rollbackArmed = false;
8800
- } else {
8862
+ const hasIspPolicy = original.config.route?.final === OUT_TAG || original.config.route?.rules?.some((rule) => isIspRule(rule) && !isLegacyIspRule(rule));
8863
+ const routeDefaults = hasIspPolicy ? void 0 : loadSingboxTemplate(projectDir).route;
8864
+ const config = addIspToConfig(original.config, isp, routeDefaults);
8865
+ const changed = await apply(
8866
+ image,
8867
+ original,
8868
+ config,
8869
+ envText,
8870
+ envWithIsp(envText, { ...isp, egressIp: isp.egressIp ?? "" })
8871
+ );
8872
+ if (!changed) {
8801
8873
  info("config.json already has this ISP outbound.");
8802
- if (!await service.verifyService(true)) {
8803
- warn("sing-box is not running the ISP configuration. Restarting it once...");
8874
+ if (!await service.verifyService()) {
8875
+ warn("sing-box is not healthy. Restarting it once...");
8804
8876
  await confirmRestart();
8805
- if (!await service.recreate() || !await service.verifyService(true)) {
8877
+ if (!await service.recreate() || !await service.verifyService()) {
8806
8878
  await service.showLogs();
8807
- throw new IspError("sing-box still does not run the ISP outbound after a restart. config.json, .env and clash.yaml were not changed. Check: docker compose logs sing-box");
8879
+ throw new IspError("sing-box is still not healthy after a restart. Server files were not changed.");
8808
8880
  }
8809
8881
  }
8810
8882
  }
8811
- if (envChanged) installFile(path(".env"), envCandidate, projectDir);
8812
- if (clashChanged) installFile(path("clash.yaml"), clashCandidate, projectDir);
8813
- info(`
8814
- ISP outbound is active.
8815
- shadowsocks-isp port ${SS_ISP_PORT} (tcp+udp), tag ${SS_ISP_TAG}
8816
- vless-reality-isp port ${VLESS_ISP_PORT} (tcp), tag ${VLESS_ISP_TAG}
8817
- ${clashState}
8818
- Ports ${SS_PORT} and ${VLESS_PORT} are unchanged and still go out directly.
8819
- Notes:
8820
- - Only TCP was tested. UDP through the ISP (Shadowsocks UDP) works only if the proxy supports SOCKS5 UDP ASSOCIATE.
8821
- - If the ISP proxy stops working, the ISP nodes fail. They never fall back to a direct connection.
8822
- - Re-import clash.yaml in your clients to see the new ${NODE_SS_ISP} and ${NODE_VLESS_ISP} nodes in the ${GROUP_MAIN} group.`);
8823
- portNotice(io, "add");
8883
+ info("\nISP outbound is configured. config.json route rules decide which traffic uses isp-socks.");
8884
+ info(`Server route.final: ${config.route?.final ?? "direct"}. Existing rule order is preserved; check for earlier matching rules when customizing routing.`);
8885
+ info("Only TCP through the SOCKS5 proxy was tested; actual domain routing and UDP require separate verification.");
8824
8886
  };
8825
8887
  const remove = async (image) => {
8826
8888
  const original = preflightConfig();
8827
- const envText = isFile(path(".env")) ? readFileSync6(path(".env"), "utf8") : "";
8828
- const clashText = isFile(path("clash.yaml")) ? readFileSync6(path("clash.yaml"), "utf8") : "";
8829
- if (!configHasIsp(original.config) && !envHasIsp(envText) && !clashHasIsp(clashText)) {
8830
- info("\nNo ISP outbound was configured. Nothing to remove.");
8831
- portNotice(io, "remove");
8832
- return;
8833
- }
8834
- const cleanBackup = findCleanBackup(projectDir);
8835
- if (!cleanBackup)
8836
- throw new IspError("No backup from before the ISP outbound was added was found in .isp-backup. Nothing was changed.");
8837
- const staged = makeTmpPath(projectDir);
8838
- tempFiles.push(staged);
8839
- mkdirSync2(staged, { mode: 448 });
8840
- restoreBackup(staged, cleanBackup);
8841
- const changed = (name) => isFile(join4(staged, name)) && (!isFile(path(name)) || !readFileSync6(join4(staged, name)).equals(readFileSync6(path(name))));
8842
- const cfgChanged = changed("config.json");
8843
- const envChanged = changed(".env");
8844
- const clashChanged = changed("clash.yaml");
8845
- if (cfgChanged) {
8846
- await validateCandidate(image, candidate(readFileSync6(join4(staged, "config.json"))));
8847
- await confirmRestart();
8848
- }
8849
- if (cfgChanged || envChanged || clashChanged) {
8850
- saveBackup();
8851
- rollbackArmed = true;
8852
- restoreBackup(projectDir, staged);
8853
- if (cfgChanged && (!await service.recreate() || !await service.verifyService(false))) {
8854
- await service.showLogs();
8855
- await rollback();
8856
- throw new IspError("sing-box did not come up after removing the ISP outbound. The previous configuration was restored.");
8857
- }
8858
- rollbackArmed = false;
8859
- }
8860
- info("\nISP outbound removed.");
8861
- info(` Restored files from ${cleanBackup}`);
8862
- if (cfgChanged) info(" config.json: ISP inbounds, outbound and route rule removed; sing-box restarted.");
8863
- if (envChanged) info(" .env: ISP_SOCKS_* keys removed.");
8864
- if (clashChanged) info(` clash.yaml: ISP nodes removed from the nodes list and the ${GROUP_MAIN} group. Re-import it in your clients.`);
8865
- portNotice(io, "remove");
8889
+ const envText = isFile(path(".env")) ? readFileSync8(path(".env"), "utf8") : "";
8890
+ const config = removeIspFromConfig(original.config);
8891
+ const changed = await apply(image, original, config, envText, envWithoutIsp(envText));
8892
+ info(changed ? `
8893
+ ISP outbound and its targeted rules removed. Other server settings are preserved; route.final: ${config.route?.final ?? "direct"}.` : "\nNo ISP outbound was configured. Nothing to remove.");
8866
8894
  };
8867
8895
  try {
8868
8896
  if (!await service.composeAvailable()) throw new IspError("docker compose is not available.");
@@ -8884,7 +8912,7 @@ Do one of these, then re-run:
8884
8912
  }
8885
8913
  for (const name of MANAGED_FILES) {
8886
8914
  const file = path(name);
8887
- if (!existsSync6(file)) continue;
8915
+ if (!existsSync7(file)) continue;
8888
8916
  try {
8889
8917
  accessSync(file, constants.R_OK | constants.W_OK);
8890
8918
  } catch {
@@ -8923,8 +8951,7 @@ Do one of these, then re-run:
8923
8951
  }
8924
8952
  });
8925
8953
  } catch (error) {
8926
- if (controller.signal.aborted)
8927
- return controller.signal.reason.code;
8954
+ if (controller.signal.aborted) return controller.signal.reason.code;
8928
8955
  throw error;
8929
8956
  } finally {
8930
8957
  for (const { signal, handler } of handlers) process.removeListener(signal, handler);
@@ -8948,205 +8975,14 @@ async function collectInputs(text, ask) {
8948
8975
  if (!validCred(pass)) throw new IspError("Invalid password: it must be 1-255 bytes with no control characters, and valid UTF-8.");
8949
8976
  let egressIp = (await ask(currentEgress ? `Expected egress IP, if it differs from the entry IP [${currentEgress}; '-' resets to ${address.host}]: ` : `Expected egress IP, if it differs from the entry IP (Enter = the entry IP ${address.host}): `)).trim();
8950
8977
  if (currentEgress) egressIp = egressIp === "-" ? "" : egressIp || currentEgress;
8951
- if (egressIp && !isIpv4(egressIp) && !isIpv6Strict(egressIp))
8952
- throw new IspError("Invalid expected egress IP.");
8978
+ if (egressIp && !isIpv4(egressIp) && !isIpv6Strict(egressIp)) throw new IspError("Invalid expected egress IP.");
8953
8979
  return { ...address, user, pass, egressIp };
8954
8980
  }
8955
- function portNotice(io, action) {
8956
- io.stdout("\n" + (action === "add" ? ADD_PORT_NOTICE : REMOVE_PORT_NOTICE));
8957
- }
8958
- var ADD_PORT_NOTICE = `ACTION REQUIRED: allow these ports yourself. This script does not open any port.
8959
- ${SS_ISP_PORT}/tcp (${SS_ISP_TAG}, Shadowsocks)
8960
- ${SS_ISP_PORT}/udp (${SS_ISP_TAG}, Shadowsocks)
8961
- ${VLESS_ISP_PORT}/tcp (${VLESS_ISP_TAG}, VLESS Reality)
8962
- Allow them in BOTH places, otherwise the new ports are not reachable from outside:
8963
- 1. the local firewall on this server, if you run one (ufw, firewalld or iptables);
8964
- 2. your cloud provider's security group (or network ACL) for this server.
8965
- Ports ${SS_PORT} and ${VLESS_PORT} are not affected.
8966
-
8967
- Example commands for reference (run as root, use the one for your firewall):
8968
- ufw (rules are persistent already):
8969
- ufw allow ${SS_ISP_PORT}/tcp
8970
- ufw allow ${SS_ISP_PORT}/udp
8971
- ufw allow ${VLESS_ISP_PORT}/tcp
8972
- firewalld (use --permanent, then --reload):
8973
- firewall-cmd --permanent --add-port=${SS_ISP_PORT}/tcp
8974
- firewall-cmd --permanent --add-port=${SS_ISP_PORT}/udp
8975
- firewall-cmd --permanent --add-port=${VLESS_ISP_PORT}/tcp
8976
- firewall-cmd --reload
8977
- iptables (lost on reboot unless saved; repeat with ip6tables if you filter IPv6):
8978
- iptables -I INPUT -p tcp --dport ${SS_ISP_PORT} -j ACCEPT
8979
- iptables -I INPUT -p udp --dport ${SS_ISP_PORT} -j ACCEPT
8980
- iptables -I INPUT -p tcp --dport ${VLESS_ISP_PORT} -j ACCEPT
8981
- then save them: netfilter-persistent save
8982
- or: iptables-save > /etc/iptables/rules.v4
8983
- (install iptables-persistent first if it is missing: apt-get install -y iptables-persistent;
8984
- both ways save the whole current iptables ruleset, not only these rules)
8985
- `;
8986
- var REMOVE_PORT_NOTICE = `NOTE: the ISP ports are no longer used, so you can now close them yourself. This script does not close any port.
8987
- ${SS_ISP_PORT}/tcp
8988
- ${SS_ISP_PORT}/udp
8989
- ${VLESS_ISP_PORT}/tcp
8990
- Close them in BOTH places if you opened them:
8991
- 1. the local firewall on this server;
8992
- 2. your cloud provider's security group (or network ACL).
8993
- Closing a rule by port also removes a rule you added yourself for the same port, so check first.
8994
-
8995
- Example commands for reference (run as root, use the one for your firewall):
8996
- ufw:
8997
- ufw delete allow ${SS_ISP_PORT}/tcp
8998
- ufw delete allow ${SS_ISP_PORT}/udp
8999
- ufw delete allow ${VLESS_ISP_PORT}/tcp
9000
- firewalld (use --permanent, then --reload):
9001
- firewall-cmd --permanent --remove-port=${SS_ISP_PORT}/tcp
9002
- firewall-cmd --permanent --remove-port=${SS_ISP_PORT}/udp
9003
- firewall-cmd --permanent --remove-port=${VLESS_ISP_PORT}/tcp
9004
- firewall-cmd --reload
9005
- iptables (then save the ruleset, as above):
9006
- iptables -D INPUT -p tcp --dport ${SS_ISP_PORT} -j ACCEPT
9007
- iptables -D INPUT -p udp --dport ${SS_ISP_PORT} -j ACCEPT
9008
- iptables -D INPUT -p tcp --dport ${VLESS_ISP_PORT} -j ACCEPT
9009
- then: netfilter-persistent save (or: iptables-save > /etc/iptables/rules.v4)
9010
- `;
9011
8981
 
9012
8982
  // src/cli/init.ts
9013
- import { constants as constants2, copyFileSync as copyFileSync2, existsSync as existsSync8, readFileSync as readFileSync8 } from "node:fs";
8983
+ import { constants as constants2, copyFileSync as copyFileSync2, existsSync as existsSync8, readFileSync as readFileSync9 } from "node:fs";
9014
8984
  import { resolve as resolve6 } from "node:path";
9015
8985
 
9016
- // src/core/start.ts
9017
- import { execFile as execFile2 } from "node:child_process";
9018
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
9019
- import { connect } from "node:net";
9020
- import { join as join5 } from "node:path";
9021
- function parseListenPorts(configText) {
9022
- const ports = /* @__PURE__ */ new Set();
9023
- for (const m of configText.matchAll(/"listen_port"\s*:\s*([0-9]+)/g)) {
9024
- const port = Number(m[1]);
9025
- if (port < 1 || port > 65535)
9026
- throw new Error(`Invalid listen_port in config.json: ${m[1]}`);
9027
- ports.add(port);
9028
- }
9029
- if (!ports.size) throw new Error("config.json has no listen_port.");
9030
- return [...ports];
9031
- }
9032
- function secretValues(envText) {
9033
- return [...envText.matchAll(/^(?:REALITY_PRIVATE_KEY|SS_PASSWORD|VLESS_UUID|ISP_SOCKS_USER|ISP_SOCKS_PASS)=([^\r\n]*)\r?$/gm)].map((m) => envUnquote(m[1])).filter((value) => value !== null && value !== "");
9034
- }
9035
- function redact(text, secrets) {
9036
- for (const secret of [...secrets].sort((a, b) => b.length - a.length))
9037
- text = text.replaceAll(secret, "***");
9038
- return text;
9039
- }
9040
- function composeImageTag(text) {
9041
- const m = /^\s*image:\s*ghcr\.io\/sagernet\/sing-box:([^\s#]+)/m.exec(text);
9042
- if (!m) throw new Error("sing-box image tag not found in compose.yaml.");
9043
- return m[1];
9044
- }
9045
- function firewallPorts(configText) {
9046
- const config = JSON.parse(configText);
9047
- return config.inbounds.flatMap(
9048
- (i) => i.type === "shadowsocks" ? [`${i.listen_port}/tcp`, `${i.listen_port}/udp`] : [`${i.listen_port}/tcp`]
9049
- );
9050
- }
9051
- function realStartDeps() {
9052
- return {
9053
- run: (file, args) => new Promise((resolve7) => {
9054
- execFile2(file, args, (error, stdout, stderr) => {
9055
- const code = error ? typeof error.code === "number" ? error.code : error.code === "ENOENT" ? 127 : 1 : 0;
9056
- resolve7({ code, stdout, stderr });
9057
- });
9058
- }),
9059
- tcpOpen: (port) => new Promise((resolve7) => {
9060
- const socket = connect(port, "127.0.0.1");
9061
- const finish = (open) => {
9062
- socket.destroy();
9063
- resolve7(open);
9064
- };
9065
- socket.setTimeout(500, () => finish(false));
9066
- socket.once("connect", () => finish(true));
9067
- socket.once("error", () => finish(false));
9068
- }),
9069
- sleep: (ms) => new Promise((resolve7) => setTimeout(resolve7, ms))
9070
- };
9071
- }
9072
- async function startNode(opts) {
9073
- const { project, deps } = opts;
9074
- if (!existsSync7(join5(project, "compose.yaml")))
9075
- throw new Error("compose.yaml does not exist.");
9076
- if (!existsSync7(join5(project, "config.json")))
9077
- throw new Error("config.json does not exist. Run my-singbox gen-config first.");
9078
- const ports = parseListenPorts(readFileSync7(join5(project, "config.json"), "utf8"));
9079
- if (!await opts.dockerAvailable()) throw new Error("docker is not installed.");
9080
- if ((await deps.run("docker", ["compose", "version"])).code)
9081
- throw new Error("docker compose is not available.");
9082
- if (!await opts.dockerDaemonUp()) throw new Error("Docker daemon is not running.");
9083
- const envPath = join5(project, ".env");
9084
- const secrets = secretValues(existsSync7(envPath) ? readFileSync7(envPath, "utf8") : "");
9085
- const compose = (...args) => deps.run("docker", ["compose", "--project-directory", project, ...args]);
9086
- const forward = (output) => {
9087
- opts.stdout(redact(output.stdout, secrets));
9088
- opts.stderr(redact(output.stderr, secrets));
9089
- };
9090
- const fail = async (message) => {
9091
- const logs = await compose("logs", "--tail", "50", "sing-box");
9092
- opts.stderr(redact(logs.stdout, secrets));
9093
- opts.stderr(redact(logs.stderr, secrets));
9094
- throw new Error(redact(message, secrets));
9095
- };
9096
- const status = async () => {
9097
- const output = await deps.run("docker", ["inspect", "-f", "{{.State.Status}}", "sing-box"]);
9098
- return output.code ? "missing" : output.stdout.trim();
9099
- };
9100
- opts.stdout("Starting sing-box...\n");
9101
- const up = await compose("up", "-d");
9102
- forward(up);
9103
- if (up.code) throw new Error("docker compose up failed.");
9104
- let state = "missing";
9105
- for (let i = 0; i < 15; i++) {
9106
- state = await status();
9107
- if (state === "running") break;
9108
- await deps.sleep(1e3);
9109
- }
9110
- if (state !== "running") await fail(`sing-box did not stay running (status: ${state}).`);
9111
- const check = await compose("exec", "-T", "sing-box", "sing-box", "check", "-c", "/etc/sing-box/config.json");
9112
- forward(check);
9113
- if (check.code) await fail("sing-box config check failed.");
9114
- for (const port of ports) {
9115
- let open = false;
9116
- for (let i = 0; i < 10; i++) {
9117
- open = await deps.tcpOpen(port);
9118
- if (open) break;
9119
- await deps.sleep(1e3);
9120
- }
9121
- if (!open) await fail(`Port ${port} is not accepting connections.`);
9122
- }
9123
- if (ports.includes(8388)) {
9124
- let listening = false;
9125
- for (let i = 0; i < 10; i++) {
9126
- const udp = await deps.run("ss", ["-uln"]);
9127
- if (udp.code === 127) {
9128
- opts.stderr("Warning: ss is not installed. Skipping UDP listener check for port 8388.\n");
9129
- listening = true;
9130
- break;
9131
- }
9132
- if (udp.code === 0 && /:8388\s/.test(udp.stdout)) {
9133
- const owners = await deps.run("ss", ["-ulnp"]);
9134
- const lines2 = owners.stdout.split("\n").filter((l) => /:8388\s/.test(l)).join("\n");
9135
- if (lines2.includes("users:") && !lines2.includes("sing-box"))
9136
- await fail("UDP port 8388 is not owned by sing-box.");
9137
- listening = true;
9138
- break;
9139
- }
9140
- await deps.sleep(1e3);
9141
- }
9142
- if (!listening) await fail("UDP port 8388 is not listening.");
9143
- }
9144
- state = await status();
9145
- if (state !== "running") await fail(`sing-box did not stay running (status: ${state}).`);
9146
- opts.stdout(`Self-check passed. Ports: ${ports.join(" ")}
9147
- `);
9148
- }
9149
-
9150
8986
  // src/cli/start.ts
9151
8987
  import { resolve as resolve5 } from "node:path";
9152
8988
  async function start(io) {
@@ -9173,7 +9009,7 @@ async function init(io) {
9173
9009
  const compose = resolve6(project, "compose.yaml");
9174
9010
  const source = existsSync8(compose) ? compose : findPackageFile("compose.yaml");
9175
9011
  const env = io.env ?? process.env;
9176
- const version = env.SING_BOX_VERSION || composeImageTag(readFileSync8(source, "utf8"));
9012
+ const version = env.SING_BOX_VERSION || composeImageTag(readFileSync9(source, "utf8"));
9177
9013
  if (!existsSync8(compose)) {
9178
9014
  copyFileSync2(source, compose, constants2.COPYFILE_EXCL);
9179
9015
  io.stdout("Wrote compose.yaml\n");
@@ -9194,7 +9030,7 @@ async function init(io) {
9194
9030
  }
9195
9031
  const code = await start(io);
9196
9032
  if (code) return code;
9197
- const ports = firewallPorts(readFileSync8(resolve6(project, "config.json"), "utf8"));
9033
+ const ports = firewallPorts(readFileSync9(resolve6(project, "config.json"), "utf8"));
9198
9034
  io.stdout(`Open these ports in the server firewall and the cloud security group (this tool never changes them):
9199
9035
  ${ports.map((p) => ` ${p}
9200
9036
  `).join("")}`);
@@ -9220,8 +9056,8 @@ Options: --help, -h
9220
9056
  `;
9221
9057
  var ispUsage = `Usage: my-singbox isp-outbound [--add | --remove] [--yes]
9222
9058
 
9223
- --add add or update the ISP outbound (ports 13839 and 18444)
9224
- --remove remove the ISP outbound and everything it added
9059
+ --add add or update the server ISP outbound and route policy
9060
+ --remove remove the ISP outbound and its targeted server rules
9225
9061
  --yes, -y do not ask before restarting sing-box
9226
9062
  --help show this help
9227
9063