diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e2d462c4..b95634da 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -119,6 +119,8 @@ tags: description: Transcode defaults and ffmpeg setup. - name: Admin - DLNA description: DLNA server configuration. + - name: Admin - Remote Access + description: UPnP / NAT-PMP port mapping for exposing mStream to the public internet. - name: Admin - SSL description: SSL certificate management. - name: Admin - Logs @@ -301,6 +303,47 @@ components: type: string enum: [musicbrainz, itunes, deezer] + RemoteAccessStatus: + type: object + description: > + Live + configured state of the UPnP / NAT-PMP hole-punch utility. + `enabled` reflects whether a router mapping is currently live; + `configured` reflects the persisted intent from the config file. + properties: + enabled: + type: boolean + description: True when an active port mapping is currently held on the router. + protocol: + type: string + nullable: true + enum: [upnp, nat-pmp] + description: Protocol mode last used to establish the mapping. + publicIp: + type: string + nullable: true + description: Public IP address of the mStream host (from NAT-PMP or the configured public-IP check URL). + publicPort: + type: integer + nullable: true + description: The externally reachable port. + leaseExpiresAt: + type: integer + nullable: true + description: Unix epoch ms at which the current lease is due to expire; null if the mapping is permanent or inactive. + lastError: + type: string + nullable: true + description: Message from the most recent failure, if any. + configured: + type: object + properties: + enabled: { type: boolean } + protocol: + type: string + enum: [upnp, nat-pmp] + publicPort: { type: integer } + leaseSeconds: { type: integer } + responses: EmptySuccess: description: Operation succeeded. @@ -3963,6 +4006,76 @@ paths: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } + # ── Admin - Remote Access ──────────────────────────────────────────────── + + /api/v1/admin/remote-access: + get: + tags: [Admin - Remote Access] + summary: Current hole-punch / port-mapping status + description: > + Returns the live state of the UPnP / NAT-PMP port mapping (if enabled) + along with the configured values. `enabled=true` in the top level means + the mapping is actively established on the router; `configured.enabled` + reflects the persisted intent. + responses: + "200": + description: Remote access status. + content: + application/json: + schema: { $ref: "#/components/schemas/RemoteAccessStatus" } + "405": { $ref: "#/components/responses/AdminLocked" } + + /api/v1/admin/remote-access/toggle: + post: + tags: [Admin - Remote Access] + summary: Enable, disable, or reconfigure remote access + description: > + Single endpoint that branches on the `enabled` flag in the body. + When `enabled=true`, persists the provided config, tears down any + existing mapping, and establishes a new one. When `enabled=false`, + tears down the mapping and persists the disabled state. Returns the + fresh status so the caller doesn't need a follow-up GET. + Serialized: a 409 is returned if another toggle is already in flight. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: { type: boolean } + protocol: + type: string + enum: [upnp, nat-pmp] + description: > + `upnp` (default) uses UPnP only. `nat-pmp` opts into + NAT-PMP as a fallback after UPnP — experimental, + underlying library has known stability bugs on some + networks. + publicPort: + type: integer + minimum: 1 + maximum: 65535 + description: Defaults to the local mStream port. + leaseSeconds: + type: integer + minimum: 0 + description: TTL in seconds; 0 = permanent where supported by the router. Default 7200. + responses: + "200": + description: Toggle applied; returns current status. + content: + application/json: + schema: { $ref: "#/components/schemas/RemoteAccessStatus" } + "403": { $ref: "#/components/responses/Forbidden" } + "405": { $ref: "#/components/responses/AdminLocked" } + "409": + description: Another remote-access toggle is already in flight. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + # ── Admin - SSL ────────────────────────────────────────────────────────── /api/v1/admin/ssl: diff --git a/package-lock.json b/package-lock.json index 5a7a49f6..aaadee13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "mime-types": "^3.0.2", "music-metadata": "^11.11.1", "nanoid": "^5.0.9", + "nat-api": "^0.3.1", "tree-kill": "^1.2.2", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", @@ -2357,7 +2358,6 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -2573,13 +2573,20 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.8" } @@ -2636,6 +2643,21 @@ "node": ">=6.0.0" } }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, "node_modules/axios": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", @@ -2705,6 +2727,15 @@ ], "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/better-ajv-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/better-ajv-errors/-/better-ajv-errors-1.2.0.tgz", @@ -3031,6 +3062,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3538,6 +3575,18 @@ "dev": true, "license": "MIT" }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3597,6 +3646,18 @@ "dev": true, "license": "MIT" }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -3845,6 +3906,16 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -4336,6 +4407,47 @@ "bare-events": "^2.7.0" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/exif-parser": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", @@ -4400,22 +4512,25 @@ "node": ">=6.6.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "dev": true, "engines": [ "node >=0.6.0" ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -4428,7 +4543,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -4708,6 +4822,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -4869,6 +4992,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/gifwrap": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", @@ -5069,6 +5201,29 @@ "uglify-js": "^3.1.4" } }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5200,6 +5355,21 @@ "node": ">= 14" } }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/http2-client": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", @@ -5235,6 +5405,15 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -5418,6 +5597,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -5460,6 +5645,12 @@ "node": ">=18" } }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -5594,6 +5785,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5611,6 +5808,12 @@ "foreach": "^2.0.4" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-to-ts": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-2.7.2.tgz", @@ -5630,7 +5833,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5644,9 +5846,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", @@ -5715,6 +5915,35 @@ "npm": ">=6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/jsprim/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -6109,6 +6338,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -6151,7 +6386,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6526,6 +6760,34 @@ "node": "^18 || >=20" } }, + "node_modules/nat-api": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/nat-api/-/nat-api-0.3.1.tgz", + "integrity": "sha512-5cyLugEkXnKSKSvVjKjxxPMLDnkwY3boZLbATWwiGJ4T/3UvIpiQmzb2RqtxxEFcVo/7PwsHPGN0MosopONO8Q==", + "license": "MIT", + "dependencies": { + "async": "^3.2.0", + "debug": "^4.2.0", + "default-gateway": "^6.0.2", + "request": "^2.88.2", + "unordered-array-remove": "^1.0.2", + "xml2js": "^0.1.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/nat-api/node_modules/xml2js": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.1.14.tgz", + "integrity": "sha512-pbdws4PPPNc1HPluSUKamY4GWMk592K7qwcj6BExbVOhhubub8+pMda/ql68b6L3luZs/OGjGSB5goV7SnmgnA==", + "dependencies": { + "sax": ">=0.1.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6687,6 +6949,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/oas-kit-common": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", @@ -6762,6 +7036,15 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6844,7 +7127,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -7156,6 +7438,12 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7474,6 +7762,18 @@ "node": ">=10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -7489,7 +7789,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7886,6 +8185,82 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -8604,6 +8979,31 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -8787,6 +9187,15 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strnum": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", @@ -9085,6 +9494,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -9134,6 +9556,24 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9263,6 +9703,12 @@ "node": ">= 10.0.0" } }, + "node_modules/unordered-array-remove": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unordered-array-remove/-/unordered-array-remove-1.0.2.tgz", + "integrity": "sha512-45YsfD6svkgaCBNyvD+dFHm4qFX9g3wRSIVgWVPtm2OCnphvPxzJoe20ATsiNpNJrmzHifnxm+BN5F7gFT/4gw==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -9276,7 +9722,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -9328,6 +9773,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 659c0c81..ded55db3 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "mime-types": "^3.0.2", "music-metadata": "^11.11.1", "nanoid": "^5.0.9", + "nat-api": "^0.3.1", "tree-kill": "^1.2.2", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", diff --git a/src/api/remote-access.js b/src/api/remote-access.js new file mode 100644 index 00000000..5279033b --- /dev/null +++ b/src/api/remote-access.js @@ -0,0 +1,60 @@ +import Joi from 'joi'; +import * as config from '../state/config.js'; +import * as remoteAccess from '../state/remote-access.js'; +import * as adminUtil from '../util/admin.js'; +import { joiValidate } from '../util/validation.js'; +import WebError from '../util/web-error.js'; + +const toggleSchema = Joi.object({ + enabled: Joi.boolean().required(), + protocol: Joi.string().valid('upnp', 'nat-pmp').optional(), + publicPort: Joi.number().integer().min(1).max(65535).optional(), + leaseSeconds: Joi.number().integer().min(0).optional(), +}); + +async function persistRemoteAccess(partial) { + const loadConfig = await adminUtil.loadFile(config.configFile); + if (!loadConfig.remoteAccess) { loadConfig.remoteAccess = {}; } + Object.assign(loadConfig.remoteAccess, partial); + await adminUtil.saveFile(loadConfig, config.configFile); + Object.assign(config.program.remoteAccess, partial); +} + +let toggleInFlight = false; + +export function setup(mstream) { + mstream.get('/api/v1/admin/remote-access', (req, res) => { + res.json(remoteAccess.getStatus()); + }); + + mstream.post('/api/v1/admin/remote-access/toggle', async (req, res) => { + if (toggleInFlight === true) { + throw new WebError('Another remote access toggle is in progress', 409); + } + + const { value } = joiValidate(toggleSchema, req.body); + + const patch = { enabled: value.enabled }; + if (value.protocol !== undefined) { patch.protocol = value.protocol; } + if (value.publicPort !== undefined) { patch.publicPort = value.publicPort; } + if (value.leaseSeconds !== undefined) { patch.leaseSeconds = value.leaseSeconds; } + + toggleInFlight = true; + try { + await persistRemoteAccess(patch); + + if (value.enabled === true) { + // Tear down any existing mapping first so a protocol/port change + // doesn't leave a stale mapping on the router. + await remoteAccess.teardown(); + await remoteAccess.setup(); + } else { + await remoteAccess.teardown(); + } + + res.json(remoteAccess.getStatus()); + } finally { + toggleInFlight = false; + } + }); +} diff --git a/src/server.js b/src/server.js index abc0fa81..9e2e0f56 100644 --- a/src/server.js +++ b/src/server.js @@ -23,6 +23,8 @@ import * as logger from './logger.js'; import * as transcode from './api/transcode.js'; import * as dbManager from './db/manager.js'; import * as syncthing from './state/syncthing.js'; +import * as remoteAccess from './state/remote-access.js'; +import * as remoteAccessApi from './api/remote-access.js'; import * as federationApi from './api/federation.js'; // scanner.js removed — parser now writes directly to SQLite import * as ytdlApi from './api/ytdl.js'; @@ -189,6 +191,7 @@ export async function serveIt(configFile) { authApi.setup(mstream); adminApi.setup(mstream); + remoteAccessApi.setup(mstream); dbApi.setup(mstream); playlistApi.setup(mstream); downloadApi.setup(mstream); @@ -281,6 +284,11 @@ export async function serveIt(configFile) { // Auto-boot the Rust server audio player if configured serverPlaybackApi.bootRustPlayer(); + + // Remote Access (UPnP / NAT-PMP) — best-effort, non-fatal. + remoteAccess.setup().catch(err => { + winston.warn(`Remote Access: boot setup failed: ${err.message}`); + }); }); } @@ -299,9 +307,18 @@ export function reboot() { dlnaServer.stop(); serverPlaybackApi.killRustPlayer(); - // Close the server - server.close(() => { - serveIt(config.configFile); + // Release the UPnP/NAT-PMP mapping so the router doesn't hold it across + // reboots under a port/protocol change. Best-effort; errors are logged. + const teardownPromise = remoteAccess.teardown().catch(err => { + winston.warn(`Remote Access: teardown during reboot failed: ${err.message}`); + }); + + // Close the server after teardown completes (or fails). serveIt() will + // re-establish the mapping if enabled in the refreshed config. + teardownPromise.finally(() => { + server.close(() => { + serveIt(config.configFile); + }); }); } catch (err) { winston.error('Reboot Failed', { stack: err }); diff --git a/src/state/config.js b/src/state/config.js index 7ff20687..0d137b13 100644 --- a/src/state/config.js +++ b/src/state/config.js @@ -49,6 +49,14 @@ const rpnOptions = Joi.object({ url: Joi.string().optional() }); +const remoteAccessOptions = Joi.object({ + enabled: Joi.boolean().default(false), + protocol: Joi.string().valid('upnp', 'nat-pmp').default('upnp'), + publicPort: Joi.number().integer().min(1).max(65535).optional(), + leaseSeconds: Joi.number().integer().min(0).default(7200), + publicIpCheckUrl: Joi.string().uri({ scheme: ['http', 'https'] }).default('https://api.ipify.org'), +}); + const lastFMOptions = Joi.object({ apiKey: Joi.string().default('25627de528b6603d6471cd331ac819e0'), apiSecret: Joi.string().default('a9df934fc504174d4cb68853d9feb143') @@ -97,6 +105,7 @@ const schema = Joi.object({ ui: Joi.string().valid('default', 'velvet').default('default'), webAppDirectory: Joi.string().default(path.join(__dirname, '../../webapp')), rpn: rpnOptions.default(rpnOptions.validate({}).value), + remoteAccess: remoteAccessOptions.default(remoteAccessOptions.validate({}).value), transcode: transcodeOptions.default(transcodeOptions.validate({}).value), secret: Joi.string().optional(), maxRequestSize: Joi.string().pattern(/[0-9]+(KB|MB)/i).default('1MB'), diff --git a/src/state/remote-access.js b/src/state/remote-access.js new file mode 100644 index 00000000..db0eab75 --- /dev/null +++ b/src/state/remote-access.js @@ -0,0 +1,260 @@ +import axios from 'axios'; +import winston from 'winston'; +import NatAPI from 'nat-api'; +import * as killQueue from './kill-list.js'; +import * as config from './config.js'; + +let client; +let renewTimer; +let killHookRegistered = false; +let crashGuardInstalled = false; +// Reject fn of whichever nat-api call is currently pending. The crash guard +// uses this to forcibly fail the awaiter when the library throws mid-op. +let pendingReject; + +const OP_TIMEOUT_MS = 15_000; + +const state = { + enabled: false, + protocol: null, + publicIp: null, + publicPort: null, + leaseExpiresAt: null, + lastError: null, +}; + +export function getStatus() { + const opts = config.program.remoteAccess; + return { + enabled: state.enabled, + protocol: state.protocol, + publicIp: state.publicIp, + publicPort: state.publicPort, + leaseExpiresAt: state.leaseExpiresAt, + lastError: state.lastError, + configured: { + enabled: opts.enabled, + protocol: opts.protocol, + publicPort: opts.publicPort || config.program.port, + leaseSeconds: opts.leaseSeconds, + }, + }; +} + +function clearRenewTimer() { + if (renewTimer) { + clearTimeout(renewTimer); + renewTimer = undefined; + } +} + +function destroyClient() { + if (!client) { return; } + try { + client.destroy(); + } catch (err) { + winston.warn('Remote Access: failed to destroy NAT client cleanly', { stack: err }); + } + client = undefined; +} + +function mapPort(privatePort, publicPort, ttl) { + return new Promise((resolve, reject) => { + let settled = false; + const done = (err) => { + if (settled) { return; } + settled = true; + pendingReject = undefined; + clearTimeout(timer); + if (err) { return reject(err); } + resolve(); + }; + const timer = setTimeout(() => done(new Error(`map timeout after ${OP_TIMEOUT_MS}ms`)), OP_TIMEOUT_MS); + pendingReject = done; + // protocol: 'TCP' — HTTP only needs TCP; omitting would also map UDP. + client.map({ publicPort, privatePort, ttl, protocol: 'TCP' }, done); + }); +} + +function unmapPort(publicPort) { + return new Promise((resolve, reject) => { + if (!client) { return resolve(); } + let settled = false; + const done = (err) => { + if (settled) { return; } + settled = true; + pendingReject = undefined; + clearTimeout(timer); + if (err) { + winston.warn(`Remote Access: unmap reported error: ${err.message}`); + return reject(err); + } + resolve(); + }; + const timer = setTimeout(() => done(new Error(`unmap timeout after ${OP_TIMEOUT_MS}ms`)), OP_TIMEOUT_MS); + pendingReject = done; + client.unmap(publicPort, done); + }); +} + +function getExternalIpFromClient() { + return new Promise((resolve) => { + if (!client || typeof client.externalIp !== 'function') { return resolve(null); } + client.externalIp((err, ip) => { + if (err || !ip) { return resolve(null); } + resolve(ip); + }); + }); +} + +async function getExternalIpFallback() { + try { + const res = await axios.get(config.program.remoteAccess.publicIpCheckUrl, { timeout: 5000 }); + const ip = typeof res.data === 'string' ? res.data.trim() : null; + return ip || null; + } catch (err) { + winston.warn(`Remote Access: public IP check failed: ${err.message}`); + return null; + } +} + +async function establishMapping() { + const opts = config.program.remoteAccess; + const privatePort = config.program.port; + const publicPort = opts.publicPort || privatePort; + const ttl = opts.leaseSeconds; + + // nat-api always tries UPnP; `enablePMP` opts into NAT-PMP as a fallback. + // PMP has a known crash bug in this library (malformed datagrams throw + // inside a UDP message handler), so we gate it behind an explicit opt-in: + // only `protocol === 'nat-pmp'` enables PMP. 'auto' and 'upnp' stay on + // UPnP only, which is the safe default for consumer routers. + if (!client) { + const ctorOpts = { + autoUpdate: false, + enablePMP: opts.protocol === 'nat-pmp', + description: 'mStream Remote Access', + }; + client = new NatAPI(ctorOpts); + } + + await mapPort(privatePort, publicPort, ttl); + const externalIp = await getExternalIpFromClient() || await getExternalIpFallback(); + + state.enabled = true; + state.protocol = opts.protocol; + state.publicIp = externalIp; + state.publicPort = publicPort; + state.leaseExpiresAt = ttl > 0 ? Date.now() + (ttl * 1000) : null; + state.lastError = null; + + winston.info(`Remote Access: mapped ${privatePort} \u2192 ${publicPort}${externalIp ? ` (public IP ${externalIp})` : ''}`); +} + +function scheduleRenewal() { + clearRenewTimer(); + const ttl = config.program.remoteAccess.leaseSeconds; + if (!ttl || ttl === 0) { return; } + const renewMs = Math.max(30_000, Math.floor(ttl * 1000 * 0.75)); + renewTimer = setTimeout(() => { + refresh().catch(err => { + winston.warn(`Remote Access: scheduled refresh failed: ${err.message}`); + }); + }, renewMs); + if (typeof renewTimer.unref === 'function') { renewTimer.unref(); } +} + +function registerKillHook() { + if (killHookRegistered) { return; } + killQueue.addToKillQueue(() => { + clearRenewTimer(); + if (client && state.publicPort) { + try { client.unmap(state.publicPort, () => {}); } catch (_err) { /* best-effort */ } + } + destroyClient(); + }); + killHookRegistered = true; +} + +// nat-api has a known bug where malformed NAT-PMP datagrams trigger an +// unhandled TypeError inside its UDP 'message' handler, which would otherwise +// crash the whole mStream process. We install a narrow process-level guard +// that swallows only errors originating from nat-api's internal paths and +// lets every other uncaught exception fall through to Node's default handler. +function installCrashGuard() { + if (crashGuardInstalled) { return; } + process.on('uncaughtException', (err) => { + const stack = (err && err.stack) ? String(err.stack) : ''; + if (stack.includes('nat-api')) { + winston.warn(`Remote Access: nat-api threw an unhandled error, suppressed to keep server alive: ${err.message}`); + state.enabled = false; + state.lastError = err.message || String(err); + clearRenewTimer(); + // Fail any pending map/unmap so awaiters don't hang forever. + if (pendingReject) { + try { pendingReject(err); } catch (_e) { /* ignore */ } + } + // Try a best-effort unmap before destroying, so the router doesn't + // hold an orphaned mapping if UPnP succeeded before the PMP crash. + // Ignore errors — the client may already be in a bad state. + if (client && state.publicPort) { + try { client.unmap(state.publicPort, () => {}); } catch (_e) { /* ignore */ } + } + destroyClient(); + return; + } + // Re-raise: preserve Node's default crash behavior for unrelated errors. + winston.error('Uncaught exception', { stack: err }); + process.exit(1); + }); + crashGuardInstalled = true; +} + +export async function setup() { + if (!config.program.remoteAccess || config.program.remoteAccess.enabled !== true) { + return; + } + + installCrashGuard(); + registerKillHook(); + + try { + await establishMapping(); + scheduleRenewal(); + } catch (err) { + state.enabled = false; + state.lastError = err.message || String(err); + winston.warn(`Remote Access: failed to establish mapping: ${state.lastError}`); + destroyClient(); + } +} + +export async function refresh() { + if (!config.program.remoteAccess || config.program.remoteAccess.enabled !== true) { + return; + } + try { + await establishMapping(); + scheduleRenewal(); + } catch (err) { + state.lastError = err.message || String(err); + winston.warn(`Remote Access: refresh failed: ${state.lastError}`); + } +} + +export async function teardown() { + clearRenewTimer(); + const mappedPort = state.publicPort; + if (client && mappedPort) { + try { await unmapPort(mappedPort); } catch (err) { + winston.warn(`Remote Access: unmap failed: ${err.message}`); + } + } + destroyClient(); + state.enabled = false; + state.protocol = null; + state.publicIp = null; + state.publicPort = null; + state.leaseExpiresAt = null; + state.lastError = null; +} diff --git a/webapp/admin/index.html b/webapp/admin/index.html index 1e178922..d0caf548 100644 --- a/webapp/admin/index.html +++ b/webapp/admin/index.html @@ -102,6 +102,10 @@ Federation +