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/CHANGELOG.md +22 -0
- package/README.md +9 -3
- package/README_CN.md +9 -3
- package/dist/cli.js +396 -560
- package/dist/core/isp-service.d.ts +1 -2
- package/dist/core/isp.d.ts +7 -7
- package/dist/core/render-config.d.ts +1 -1
- package/dist/core/routing.d.ts +13 -0
- package/dist/index.js +318 -377
- package/dist/model.d.ts +3 -2
- package/package.json +3 -2
- package/templates/config.json.template +20 -1
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
|
|
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
|
|
55
|
-
var
|
|
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) => (
|
|
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 =
|
|
88
|
+
exports.isMap = isMap2;
|
|
89
89
|
exports.isNode = isNode;
|
|
90
90
|
exports.isPair = isPair;
|
|
91
|
-
exports.isScalar =
|
|
92
|
-
exports.isSeq =
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
4201
|
-
const fcName =
|
|
4202
|
-
const NodeClass = tag?.nodeClass ?? (
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
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
|
-
|
|
5589
|
-
const parent =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
7365
|
-
exports.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
|
|
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
|
|
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
|
|
7936
|
-
node(NODE_VLESS, "vless-reality", VLESS_TAG, VLESS_PORT
|
|
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
|
|
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
|
-
|
|
8006
|
-
if (
|
|
8007
|
-
|
|
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
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
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
|
-
|
|
8148
|
-
|
|
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
|
|
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) =>
|
|
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 (
|
|
8207
|
-
|
|
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
|
|
8215
|
-
checkIspConfig(config);
|
|
8224
|
+
function withoutLegacyListeners(config) {
|
|
8216
8225
|
const next = structuredClone(config);
|
|
8217
|
-
|
|
8218
|
-
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
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
|
-
|
|
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
|
|
8251
|
-
|
|
8252
|
-
const
|
|
8253
|
-
|
|
8254
|
-
|
|
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
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
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
|
-
|
|
8351
|
-
for (const file of
|
|
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
|
|
8361
|
-
|
|
8362
|
-
const
|
|
8363
|
-
if (
|
|
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
|
|
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
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
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) =>
|
|
8671
|
-
const isFile = (file) =>
|
|
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
|
|
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 =
|
|
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
|
-
|
|
8744
|
-
|
|
8745
|
-
await previous.
|
|
8746
|
-
|
|
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 (!
|
|
8749
|
-
|
|
8750
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8770
|
-
|
|
8771
|
-
const config = addIspToConfig(original.config, isp);
|
|
8772
|
-
const
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
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(
|
|
8803
|
-
warn("sing-box is not
|
|
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(
|
|
8877
|
+
if (!await service.recreate() || !await service.verifyService()) {
|
|
8806
8878
|
await service.showLogs();
|
|
8807
|
-
throw new IspError("sing-box still
|
|
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
|
-
|
|
8812
|
-
|
|
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")) ?
|
|
8828
|
-
const
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
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 (!
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
9224
|
-
--remove remove the ISP outbound and
|
|
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
|
|