@synopackageland/cli 0.7.0 → 0.7.1
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 +234 -1
- package/dist/add.d.ts +4 -1
- package/dist/add.js +14 -9
- package/dist/build-spk/dsm-platforms.json +4 -1
- package/dist/build-spk/gateway.d.ts +1 -1
- package/dist/build-spk/gateway.js +8 -4
- package/dist/build-spk/go-toolchain.d.ts +20 -0
- package/dist/build-spk/go-toolchain.js +44 -0
- package/dist/cli.js +13 -7
- package/dist/deploy/native-install-values.d.ts +17 -0
- package/dist/deploy/native-install-values.js +32 -0
- package/dist/deploy/plan.d.ts +6 -2
- package/dist/deploy/plan.js +57 -6
- package/dist/error-registry.js +12 -6
- package/dist/host/dsm-client.js +53 -17
- package/dist/host/dsm-http.js +24 -2
- package/dist/host/types.d.ts +1 -0
- package/dist/info.js +28 -9
- package/dist/init.js +1 -1
- package/dist/runtime/dsm-version.d.ts +6 -0
- package/dist/runtime/dsm-version.js +8 -0
- package/dist/runtime/executor-build.d.ts +1 -1
- package/dist/runtime/executor-build.js +8 -5
- package/dist/standalone/daemon.d.ts +2 -1
- package/dist/standalone/daemon.js +10 -6
- package/dist/standalone/dsm-window.js +6 -34
- package/dist/standalone/inspect-spk.js +5 -1
- package/dist/standalone/native-authorization-window.d.ts +1 -1
- package/dist/standalone/native-authorization-window.js +7 -1
- package/dist/standalone/native-root-window.d.ts +4 -2
- package/dist/standalone/native-root-window.js +33 -18
- package/dist/standalone/root-window.d.ts +4 -1
- package/dist/standalone/root-window.js +143 -33
- package/dist/templates/_daemon/go.mod +1 -1
- package/dist/templates/_daemon/main.go +6 -3
- package/dist/templates/_daemon/main_test.go +21 -1
- package/dist/templates/_daemon/testdata/child.go +3 -0
- package/dist/templates/_executor/README.md +4 -1
- package/dist/templates/_executor/anchor.go +10 -0
- package/dist/templates/_executor/anchor_test.go +40 -5
- package/dist/templates/_executor/digest_review_test.go +2 -2
- package/dist/templates/_executor/dsm.go +40 -3
- package/dist/templates/_executor/go.mod +2 -0
- package/dist/templates/_executor/main.go +11 -5
- package/dist/templates/_executor/managed.go +3 -3
- package/dist/templates/_executor/managed_test.go +3 -3
- package/dist/templates/_executor/path_test.go +18 -0
- package/dist/templates/_executor/pid.go +37 -0
- package/dist/templates/_executor/pid_test.go +113 -0
- package/dist/templates/_executor/platform_other.go +8 -0
- package/dist/templates/_executor/purge_log.go +18 -0
- package/dist/templates/_executor/tasks.go +45 -7
- package/dist/templates/_executor/tasks_dsm71_test.go +114 -0
- package/dist/templates/_executor/tasks_test.go +58 -1
- package/dist/templates/_executor/upgrade_watch_test.go +13 -5
- package/dist/templates/_gateway/go.mod +2 -0
- package/dist/templates/native-go/backend/go.mod +1 -1
- package/dist/templates/native-go/backend/internal/filestation/client.go +39 -3
- package/dist/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
- package/dist/templates/native-go/backend/internal/identity/README.md +5 -1
- package/dist/templates/native-go/backend/internal/identity/dsm.go +24 -5
- package/dist/templates/native-go/backend/internal/identity/http.go +1 -1
- package/dist/templates/native-go/backend/internal/identity/identity_test.go +48 -1
- package/dist/templates/native-go/backend/internal/identity/service.go +1 -1
- package/dist/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
- package/dist/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
- package/dist/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
- package/dist/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
- package/package.json +1 -1
- package/templates/_daemon/go.mod +1 -1
- package/templates/_daemon/main.go +6 -3
- package/templates/_daemon/main_test.go +21 -1
- package/templates/_daemon/testdata/child.go +3 -0
- package/templates/_executor/README.md +4 -1
- package/templates/_executor/anchor.go +10 -0
- package/templates/_executor/anchor_test.go +40 -5
- package/templates/_executor/digest_review_test.go +2 -2
- package/templates/_executor/dsm.go +40 -3
- package/templates/_executor/go.mod +2 -0
- package/templates/_executor/main.go +11 -5
- package/templates/_executor/managed.go +3 -3
- package/templates/_executor/managed_test.go +3 -3
- package/templates/_executor/path_test.go +18 -0
- package/templates/_executor/pid.go +37 -0
- package/templates/_executor/pid_test.go +113 -0
- package/templates/_executor/platform_other.go +8 -0
- package/templates/_executor/purge_log.go +18 -0
- package/templates/_executor/tasks.go +45 -7
- package/templates/_executor/tasks_dsm71_test.go +114 -0
- package/templates/_executor/tasks_test.go +58 -1
- package/templates/_executor/upgrade_watch_test.go +13 -5
- package/templates/_gateway/go.mod +2 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/internal/filestation/client.go +39 -3
- package/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
- package/templates/native-go/backend/internal/identity/README.md +5 -1
- package/templates/native-go/backend/internal/identity/dsm.go +24 -5
- package/templates/native-go/backend/internal/identity/http.go +1 -1
- package/templates/native-go/backend/internal/identity/identity_test.go +48 -1
- package/templates/native-go/backend/internal/identity/service.go +1 -1
- package/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
- package/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
- package/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
- package/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
- package/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
- package/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Shared host flow. CGI plans are never accepted as completed authorization. */
|
|
2
|
-
export declare const AUTHORIZATION_RUNTIME = "function createAuthorizationAccess(options) {\n var busy = null;\n function fail(code) { var error = new Error(code); error.code = code; return error; }\n function status() { return options.call(\"authorization.status\").then(function (d) {\n if (!d || d.command !== \"authorization\" || d.schema_version !== 1 || [\"ready\",\"authorization_required\",\"not_admin\",\"session_expired\",\"unavailable\"].indexOf(d.state) < 0) throw fail(\"authorization_bad_response\");\n // upgrade_reapproval is a reason-only tombstone: it can only explain a pending request.\n if (d.reason === \"upgrade_reapproval\" && d.state !== \"authorization_required\") throw fail(\"authorization_bad_response\");\n return d;\n }); }\n function plan(method) { return options.call(method).then(function (p) {\n if (!p || p.command !== \"authorization\" || p.schema_version !== 1 || p.phase !== \"plan\" || !Array.isArray(p.operations) || p.operations.length === 0 || p.operations.length > options.tasks.length) throw fail(\"authorization_bad_response\");\n var seen = {};\n p.operations.forEach(function (op) { if (seen[op] || !options.tasks.some(function (t) { return t.op === op; })) throw fail(\"authorization_bad_response\"); seen[op] = true; });\n return p;\n }); }\n function exclusive(fn) { if (busy) return busy; busy = Promise.resolve().then(fn).then(function (s) { busy = null; return s; },function (e) { busy = null; throw e; });return busy; }\n function authorize(ops,run) { return ops.reduce(function (previous,op) { return previous.then(function () {\n return options.root.authorize(op).then(function (state) { if (state !== \"ready\") throw fail(\"root_cancelled\"); return run ? options.root.run(op) : null; });\n }); },Promise.resolve()); }\n return {canManage:function () {return !!options.user && options.user().admin === true;},status:status,request:function () { return exclusive(function () {\n return plan(\"authorization.request\").then(function (p) { return authorize(p.operations,p.run === true); }).then(status,function (e) { if (e.code === \"root_cancelled\") return status();throw e; });\n }); },revoke:function () { return exclusive(function () {\n return plan(\"authorization.revoke\").then(function (p) {\n if (p.withdraw === true && typeof options.withdraw !== \"function\") throw fail(\"authorization_bad_response\");\n var disabled = p.withdraw === true ? options.withdraw() : p.disable === true ? authorize([\"helper_disable\"],true) : Promise.resolve();\n return disabled.then(function () { return p.purge === true ? authorize([\"helper_purge\"],true) : null; }).then(function () { return p.operations.reduce(function (previous,op) {return previous.then(function () {return options.root.remove(op);});},Promise.resolve()); });\n }).then(status,function (e) { if (e.code === \"root_cancelled\") return status();throw e; });\n }); }};\n}";
|
|
2
|
+
export declare const AUTHORIZATION_RUNTIME = "function createAuthorizationAccess(options) {\n var busy = null;\n function fail(code) { var error = new Error(code); error.code = code; return error; }\n function status() { return options.call(\"authorization.status\").then(function (d) {\n if (!d || d.command !== \"authorization\" || d.schema_version !== 1 || [\"ready\",\"authorization_required\",\"not_admin\",\"session_expired\",\"unavailable\"].indexOf(d.state) < 0) throw fail(\"authorization_bad_response\");\n // upgrade_reapproval is a reason-only tombstone: it can only explain a pending request.\n if (d.reason === \"upgrade_reapproval\" && d.state !== \"authorization_required\") throw fail(\"authorization_bad_response\");\n return d;\n }); }\n function plan(method) { return options.call(method).then(function (p) {\n if (!p || p.command !== \"authorization\" || p.schema_version !== 1 || p.phase !== \"plan\" || !Array.isArray(p.operations) || p.operations.length === 0 || p.operations.length > options.tasks.length) throw fail(\"authorization_bad_response\");\n var seen = {};\n p.operations.forEach(function (op) { if (seen[op] || !options.tasks.some(function (t) { return t.op === op; })) throw fail(\"authorization_bad_response\"); seen[op] = true; });\n return p;\n }); }\n // The backend's grant decides: a task still waiting at the window's limit, or\n // one signalled after it wrote the grant, has still authorized the package.\n function settled(e) {\n if (!e || (e.code !== \"root_timeout\" && e.code !== \"root_task_failed\")) throw e;\n return status().then(function (d) { if (d.state !== \"ready\") throw e; });\n }\n function exclusive(fn) { if (busy) return busy; busy = Promise.resolve().then(fn).then(function (s) { busy = null; return s; },function (e) { busy = null; throw e; });return busy; }\n function authorize(ops,run) { return ops.reduce(function (previous,op) { return previous.then(function () {\n return options.root.authorize(op).then(function (state) { if (state !== \"ready\") throw fail(\"root_cancelled\"); return run ? options.root.run(op).catch(settled) : null; });\n }); },Promise.resolve()); }\n return {canManage:function () {return !!options.user && options.user().admin === true;},status:status,request:function () { return exclusive(function () {\n return plan(\"authorization.request\").then(function (p) { return authorize(p.operations,p.run === true); }).then(status,function (e) { if (e.code === \"root_cancelled\") return status();throw e; });\n }); },revoke:function () { return exclusive(function () {\n return plan(\"authorization.revoke\").then(function (p) {\n if (p.withdraw === true && typeof options.withdraw !== \"function\") throw fail(\"authorization_bad_response\");\n var disabled = p.withdraw === true ? options.withdraw() : p.disable === true ? authorize([\"helper_disable\"],true) : Promise.resolve();\n return disabled.then(function () { return p.purge === true ? authorize([\"helper_purge\"],true) : null; }).then(function () { return p.operations.reduce(function (previous,op) {return previous.then(function () {return options.root.remove(op);});},Promise.resolve()); });\n }).then(status,function (e) { if (e.code === \"root_cancelled\") return status();throw e; });\n }); }};\n}";
|
|
@@ -14,9 +14,15 @@ export const AUTHORIZATION_RUNTIME = `function createAuthorizationAccess(options
|
|
|
14
14
|
p.operations.forEach(function (op) { if (seen[op] || !options.tasks.some(function (t) { return t.op === op; })) throw fail("authorization_bad_response"); seen[op] = true; });
|
|
15
15
|
return p;
|
|
16
16
|
}); }
|
|
17
|
+
// The backend's grant decides: a task still waiting at the window's limit, or
|
|
18
|
+
// one signalled after it wrote the grant, has still authorized the package.
|
|
19
|
+
function settled(e) {
|
|
20
|
+
if (!e || (e.code !== "root_timeout" && e.code !== "root_task_failed")) throw e;
|
|
21
|
+
return status().then(function (d) { if (d.state !== "ready") throw e; });
|
|
22
|
+
}
|
|
17
23
|
function exclusive(fn) { if (busy) return busy; busy = Promise.resolve().then(fn).then(function (s) { busy = null; return s; },function (e) { busy = null; throw e; });return busy; }
|
|
18
24
|
function authorize(ops,run) { return ops.reduce(function (previous,op) { return previous.then(function () {
|
|
19
|
-
return options.root.authorize(op).then(function (state) { if (state !== "ready") throw fail("root_cancelled"); return run ? options.root.run(op) : null; });
|
|
25
|
+
return options.root.authorize(op).then(function (state) { if (state !== "ready") throw fail("root_cancelled"); return run ? options.root.run(op).catch(settled) : null; });
|
|
20
26
|
}); },Promise.resolve()); }
|
|
21
27
|
return {canManage:function () {return !!options.user && options.user().admin === true;},status:status,request:function () { return exclusive(function () {
|
|
22
28
|
return plan("authorization.request").then(function (p) { return authorize(p.operations,p.run === true); }).then(status,function (e) { if (e.code === "root_cancelled") return status();throw e; });
|
|
@@ -17,10 +17,12 @@ export interface RootTask {
|
|
|
17
17
|
* with exactly the embedded script. The one-use token goes into that single
|
|
18
18
|
* create call and is never kept.
|
|
19
19
|
* - run: re-reads the task and runs it only if it is still exactly ours, then
|
|
20
|
-
* waits for its history entry and asks the backend for the output.
|
|
20
|
+
* waits for its history entry and asks the backend for the output. A queued
|
|
21
|
+
* or running task keeps the wait going, up to RUN_LIMIT_MS (loaded NAS took
|
|
22
|
+
* 4.5 minutes), and `options.progress({op, state, elapsedMs})` hears each poll. Repair
|
|
21
23
|
* additionally requires fresh PasswordConfirm and another exact task check.
|
|
22
24
|
* - an older version of our task is "outdated" and never runs; authorizing
|
|
23
25
|
* again replaces it. A same-named task that is not ours is a "conflict" and
|
|
24
26
|
* is never run, replaced or deleted.
|
|
25
27
|
*/
|
|
26
|
-
export declare const ROOT_RUNTIME = "function createRootAccess(options) {\n var API = \"SYNO.Core.TaskScheduler\";\n var POLL_MS = 500, POLLS = 60;\n var byOp = {}, running = {};\n var legacy = null, probe = null;\n options.tasks.forEach(function (task) { byOp[task.op] = task; });\n\n function fail(code) {\n var error = new Error(code);\n error.code = code;\n return error;\n }\n function begin(op) {\n if (!Object.prototype.hasOwnProperty.call(byOp, op)) return Promise.reject(fail(\"root_unknown_op\"));\n if (options.user().admin !== true) return Promise.reject(fail(\"admin_required\"));\n return Promise.resolve(byOp[op]);\n }\n function readOutput(task) {\n return task.output ? options.call(\"root.output\", { op: task.op }) : Promise.resolve(null);\n }\n function isRootTask(summary) {\n return summary.owner === \"root\" && summary.real_owner === \"root\" && summary.enable === false && summary.type === \"script\";\n }\n function listTasks() {\n if (legacy === false) return options.send(API, \"list\", 3, { start: 0, limit: 500 });\n if (legacy === true) return legacyList();\n if (!probe) {\n probe = options.send(API, \"list\", 3, { start: 0, limit: 500 }).then(function (data) {\n legacy = false;\n return data;\n }, function (error) {\n if (!error || (error.code !== \"dsm_103\" && error.code !== 103)) {\n probe = null;\n throw error;\n }\n legacy = true;\n return legacyList();\n });\n }\n return probe;\n }\n function legacyList() {\n return options.send(API, \"list\", 2, { offset: 0, limit: 500 }).then(function (data) {\n // Refuse a truncated snapshot: a same-named task could lie outside it.\n if (!data || !Array.isArray(data.tasks) || data.tasks.length > 500\n || !(typeof data.total === \"number\" && isFinite(data.total) && Math.floor(data.total) === data.total) || data.total !== data.tasks.length) throw fail(\"root_bad_response\");\n return data;\n });\n }\n function readTask(ref) {\n return options.send(API, \"get\", legacy ? 3 : 4, ref);\n }\n function createTask(params) {\n return options.send(legacy ? API : API + \".Root\", \"create\", legacy ? 3 : 4, params);\n }\n function deleteTask(ref) {\n return options.send(API, \"delete\", 2, { tasks: [ref] });\n }\n function runTask(ref) {\n return options.send(API, \"run\", 2, { tasks: [ref] });\n }\n function find(task) {\n return listTasks().then(function (data) {\n if (!data || !Array.isArray(data.tasks)) throw fail(\"root_bad_response\");\n var named = data.tasks.filter(function (summary) { return summary && summary.name === task.name; });\n if (named.length === 0) return { state: \"missing\" };\n if (named.length > 1 || !isRootTask(named[0])) return { state: \"conflict\" };\n var ref = { id: named[0].id, real_owner: named[0].real_owner };\n return readTask(ref).then(function (detail) {\n var script = detail && detail.extra && detail.extra.script;\n if (!detail || String(detail.id) !== String(ref.id) || detail.name !== task.name\n || detail.owner !== \"root\" || detail.real_owner !== ref.real_owner || detail.enable !== false || detail.type !== \"script\" || typeof script !== \"string\") {\n return { state: \"conflict\" };\n }\n if (script === task.script) return { state: \"ready\", ref: ref };\n if (script.indexOf(task.header) === 0) return { state: \"outdated\", ref: ref };\n return { state: \"conflict\" };\n });\n });\n }\n function definition(task, user) {\n var hours = [];\n for (var h = 1; h <= 23; h++) hours.push(h);\n return {\n name: task.name,\n real_owner: user,\n owner: \"root\",\n enable: false,\n type: \"script\",\n schedule: {\n date_type: 0, week_day: \"0,1,2,3,4,5,6\", repeat_date: 1001, monthly_week: [], hour: 0, minute: 0,\n repeat_hour: 0, repeat_min: 0, last_work_hour: 0,\n repeat_min_store_config: [1, 5, 10, 15, 20, 30], repeat_hour_store_config: hours\n },\n extra: { notify_enable: false, script: task.script, notify_mail: \"\", notify_if_error: false }\n };\n }\n function newest(history) {\n if (!Array.isArray(history)) throw fail(\"root_bad_response\");\n return history.reduce(function (latest, entry) {\n return entry && (!latest || Number(entry.timestamp) > Number(latest.timestamp)) ? entry : latest;\n }, null);\n }\n function history(ref) {\n return options.send(API, \"get_history_status_list\", 1, { id: ref.id }).then(newest);\n }\n function waitForRun(ref, before, polls) {\n return history(ref).then(function (latest) {\n var finished = latest && (!before || latest.timestamp !== before.timestamp)\n && latest.exit_type !== \"running\" && latest.stop_time;\n if (finished) {\n if (latest.exit_code !== 0) throw fail(\"root_task_failed\");\n return;\n }\n if (polls <= 1) throw fail(\"root_timeout\");\n return options.wait(POLL_MS).then(function () { return waitForRun(ref, before, polls - 1); });\n });\n }\n\n function validTime(value) {\n if (typeof value !== \"string\" || !/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$/.test(value)) return false;\n var normalized = value.replace(\" \", \"T\") + \"Z\";\n var date = new Date(normalized);\n return !isNaN(date.getTime()) && date.toISOString().slice(0, 19) === normalized.slice(0, 19);\n }\n function bounded(call, deadline) {\n // A poll count alone cannot bound a stalled DSM callback. Discard late replies.\n return new Promise(function (resolve, reject) {\n var remaining = deadline - Date.now();\n if (remaining <= 0) { reject(fail(\"root_timeout\")); return; }\n var timer = setTimeout(function () { reject(fail(\"root_timeout\")); }, remaining);\n Promise.resolve().then(call).then(function (value) {\n clearTimeout(timer);\n if (Date.now() >= deadline) reject(fail(\"root_timeout\"));\n else resolve(value);\n }, function (error) { clearTimeout(timer); reject(error); });\n });\n }\n function legacyHistory(ref, deadline) {\n return bounded(function () { return options.send(API, \"view\", 1, { id: ref.id }); }, deadline).then(function (records) {\n // DSM 7.1 omits data entirely when a task has never run.\n if (records === undefined) records = [];\n if (!Array.isArray(records) || records.length > 500) throw fail(\"root_bad_response\");\n var seen = {};\n records.forEach(function (record) {\n if (!record || !validTime(record.start_time) || typeof record.stop_time !== \"string\"\n || (record.stop_time && (!validTime(record.stop_time) || record.stop_time < record.start_time))\n || typeof record.exit_type !== \"string\" || !(typeof record.exit_code === \"number\" && isFinite(record.exit_code) && Math.floor(record.exit_code) === record.exit_code)\n || seen[record.start_time]) throw fail(\"root_bad_response\");\n seen[record.start_time] = true;\n });\n return records;\n });\n }\n function waitForLegacyRun(ref, before, deadline, polls) {\n return legacyHistory(ref, deadline).then(function (records) {\n var previous = before.map(function (record) { return record.start_time; });\n var newestBefore = previous.slice().sort().pop();\n var fresh = records.filter(function (record) { return previous.indexOf(record.start_time) === -1; });\n if (fresh.length > 1) throw fail(\"root_bad_response\");\n var latest = fresh[0];\n if (latest && (!newestBefore || latest.start_time > newestBefore) && latest.stop_time) {\n if (latest.exit_code !== 0 || latest.exit_type !== \"normal\") throw fail(\"root_task_failed\");\n return;\n }\n if (polls <= 1 || Date.now() >= deadline) throw fail(\"root_timeout\");\n return options.wait(Math.min(POLL_MS, deadline - Date.now())).then(function () {\n return waitForLegacyRun(ref, before, deadline, polls - 1);\n });\n });\n }\n function execute(task, verified) {\n if (!legacy) {\n return history(verified.ref).then(function (before) {\n return runTask(verified.ref).then(function () { return waitForRun(verified.ref, before, POLLS); });\n });\n }\n var deadline = Date.now() + POLL_MS * POLLS;\n return legacyHistory(verified.ref, deadline).then(function (before) {\n if (before.some(function (record) { return !record.stop_time || record.exit_type === \"running\"; })) throw fail(\"root_not_ready\");\n // Mutating calls are sent exactly once, never retried with another profile.\n return bounded(function () { return runTask(verified.ref); }, deadline).then(function () {\n return waitForLegacyRun(verified.ref, before, deadline, POLLS);\n });\n }).then(function () {\n return bounded(function () { return find(task); }, deadline).then(function (after) {\n if (after.state !== \"ready\" || String(after.ref.id) !== String(verified.ref.id)) throw fail(\"root_verify_failed\");\n });\n });\n }\n function performRun(op) {\n return begin(op).then(function (task) {\n return find(task).then(function (found) {\n if (found.state !== \"ready\") throw fail(\"root_not_ready\");\n var consent = op === \"helper_repair\" ? options.confirmPassword() : Promise.resolve({ confirmed: true });\n return consent.then(function (confirmation) {\n if (!confirmation || confirmation.confirmed !== true) throw fail(\"root_cancelled\");\n return op === \"helper_repair\" ? begin(op).then(find) : found;\n }).then(function (verified) {\n if (verified.state !== \"ready\") throw fail(\"root_not_ready\");\n return execute(task, verified);\n });\n }).then(function () { return readOutput(task); });\n });\n }\n\n return {\n state: function (op) {\n return begin(op).then(find).then(function (found) { return found.state; });\n },\n authorize: function (op) {\n return begin(op).then(function (task) {\n return find(task).then(function (found) {\n if (found.state === \"ready\") return \"ready\";\n if (found.state === \"conflict\") throw fail(\"root_conflict\");\n return options.confirmPassword().then(function (confirmation) {\n if (!confirmation || confirmation.confirmed !== true) return \"cancelled\";\n if (!legacy && (typeof confirmation.token !== \"string\" || !confirmation.token)) throw fail(\"root_verify_failed\");\n var params = definition(task, options.user().name);\n if (!legacy) params.SynoConfirmPWToken = confirmation.token;\n // Re-read after the legacy client-side dialog before replacing a task.\n var checked = legacy ? begin(op).then(find) : Promise.resolve(found);\n return checked.then(function (current) {\n if (current.state === \"conflict\") throw fail(\"root_conflict\");\n if (current.state === \"ready\") return;\n var replaced = current.state === \"outdated\" ? deleteTask(current.ref) : Promise.resolve();\n return replaced.then(function () { return createTask(params); });\n }).then(function () {\n return find(task);\n }).then(function (after) {\n if (after.state !== \"ready\") throw fail(\"root_verify_failed\");\n return \"ready\";\n });\n });\n });\n });\n },\n run: function (op) {\n if (!Object.prototype.hasOwnProperty.call(byOp, op)) return Promise.reject(fail(\"root_unknown_op\"));\n if (options.user().admin !== true) return Promise.reject(fail(\"admin_required\"));\n if (running[op]) return running[op];\n var pending = performRun(op).then(function (value) {\n delete running[op];\n return value;\n }, function (error) { delete running[op]; throw error; });\n running[op] = pending;\n return pending;\n },\n remove: function (op) {\n return begin(op).then(function (task) {\n return find(task).then(function (found) {\n if (found.state === \"missing\") return \"missing\";\n if (found.state === \"conflict\") throw fail(\"root_conflict\");\n return deleteTask(found.ref).then(function () {\n return find(task);\n }).then(function (after) {\n if (after.state !== \"missing\") throw fail(\"root_verify_failed\");\n return \"missing\";\n });\n });\n });\n },\n output: function (op) {\n return begin(op).then(readOutput);\n }\n };\n}";
|
|
28
|
+
export declare const ROOT_RUNTIME: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { versionUnsupported } from "../runtime/dsm-version.js";
|
|
1
2
|
/**
|
|
2
3
|
* `createRootAccess(options)` → `{ state, authorize, run, remove }`, plain
|
|
3
4
|
* ES5 for DSM's desktop. Every DSM call goes through `options.send` (the
|
|
@@ -7,7 +8,9 @@
|
|
|
7
8
|
* with exactly the embedded script. The one-use token goes into that single
|
|
8
9
|
* create call and is never kept.
|
|
9
10
|
* - run: re-reads the task and runs it only if it is still exactly ours, then
|
|
10
|
-
* waits for its history entry and asks the backend for the output.
|
|
11
|
+
* waits for its history entry and asks the backend for the output. A queued
|
|
12
|
+
* or running task keeps the wait going, up to RUN_LIMIT_MS (loaded NAS took
|
|
13
|
+
* 4.5 minutes), and `options.progress({op, state, elapsedMs})` hears each poll. Repair
|
|
11
14
|
* additionally requires fresh PasswordConfirm and another exact task check.
|
|
12
15
|
* - an older version of our task is "outdated" and never runs; authorizing
|
|
13
16
|
* again replaces it. A same-named task that is not ours is a "conflict" and
|
|
@@ -15,11 +18,20 @@
|
|
|
15
18
|
*/
|
|
16
19
|
export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
17
20
|
var API = "SYNO.Core.TaskScheduler";
|
|
18
|
-
var POLL_MS = 500,
|
|
21
|
+
var POLL_MS = 500, MAX_POLL_MS = 2000, RUN_LIMIT_MS = 10 * 60 * 1000;
|
|
19
22
|
var byOp = {}, running = {};
|
|
20
23
|
var legacy = null, probe = null;
|
|
24
|
+
var versionUnsupported = ${versionUnsupported.toString()};
|
|
21
25
|
options.tasks.forEach(function (task) { byOp[task.op] = task; });
|
|
22
26
|
|
|
27
|
+
function now() { return options.now ? options.now() : Date.now(); }
|
|
28
|
+
function report(task, state, started) {
|
|
29
|
+
if (typeof options.progress !== "function") return;
|
|
30
|
+
try { options.progress({ op: task.op, state: state, elapsedMs: now() - started }); } catch (error) {}
|
|
31
|
+
}
|
|
32
|
+
function pause(interval, deadline) {
|
|
33
|
+
return options.wait(Math.max(0, Math.min(interval, deadline - now())));
|
|
34
|
+
}
|
|
23
35
|
function fail(code) {
|
|
24
36
|
var error = new Error(code);
|
|
25
37
|
error.code = code;
|
|
@@ -44,7 +56,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
44
56
|
legacy = false;
|
|
45
57
|
return data;
|
|
46
58
|
}, function (error) {
|
|
47
|
-
if (!error || (error.code
|
|
59
|
+
if (!error || !versionUnsupported(error.code)) {
|
|
48
60
|
probe = null;
|
|
49
61
|
throw error;
|
|
50
62
|
}
|
|
@@ -119,16 +131,18 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
119
131
|
function history(ref) {
|
|
120
132
|
return options.send(API, "get_history_status_list", 1, { id: ref.id }).then(newest);
|
|
121
133
|
}
|
|
122
|
-
function waitForRun(ref, before,
|
|
134
|
+
function waitForRun(task, ref, before, started, deadline, interval) {
|
|
123
135
|
return history(ref).then(function (latest) {
|
|
124
|
-
var
|
|
125
|
-
|
|
126
|
-
if (finished) {
|
|
136
|
+
var fresh = latest && (!before || latest.timestamp !== before.timestamp);
|
|
137
|
+
if (fresh && latest.exit_type !== "running" && latest.stop_time) {
|
|
127
138
|
if (latest.exit_code !== 0) throw fail("root_task_failed");
|
|
128
139
|
return;
|
|
129
140
|
}
|
|
130
|
-
|
|
131
|
-
|
|
141
|
+
report(task, fresh ? "running" : "queued", started);
|
|
142
|
+
if (now() >= deadline) throw fail("root_timeout");
|
|
143
|
+
return pause(interval, deadline).then(function () {
|
|
144
|
+
return waitForRun(task, ref, before, started, deadline, Math.min(interval * 2, MAX_POLL_MS));
|
|
145
|
+
});
|
|
132
146
|
});
|
|
133
147
|
}
|
|
134
148
|
|
|
@@ -141,12 +155,12 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
141
155
|
function bounded(call, deadline) {
|
|
142
156
|
// A poll count alone cannot bound a stalled DSM callback. Discard late replies.
|
|
143
157
|
return new Promise(function (resolve, reject) {
|
|
144
|
-
var remaining = deadline -
|
|
158
|
+
var remaining = deadline - now();
|
|
145
159
|
if (remaining <= 0) { reject(fail("root_timeout")); return; }
|
|
146
160
|
var timer = setTimeout(function () { reject(fail("root_timeout")); }, remaining);
|
|
147
161
|
Promise.resolve().then(call).then(function (value) {
|
|
148
162
|
clearTimeout(timer);
|
|
149
|
-
if (
|
|
163
|
+
if (now() >= deadline) reject(fail("root_timeout"));
|
|
150
164
|
else resolve(value);
|
|
151
165
|
}, function (error) { clearTimeout(timer); reject(error); });
|
|
152
166
|
});
|
|
@@ -167,7 +181,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
167
181
|
return records;
|
|
168
182
|
});
|
|
169
183
|
}
|
|
170
|
-
function waitForLegacyRun(ref, before, deadline,
|
|
184
|
+
function waitForLegacyRun(task, ref, before, started, deadline, interval) {
|
|
171
185
|
return legacyHistory(ref, deadline).then(function (records) {
|
|
172
186
|
var previous = before.map(function (record) { return record.start_time; });
|
|
173
187
|
var newestBefore = previous.slice().sort().pop();
|
|
@@ -178,24 +192,25 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
178
192
|
if (latest.exit_code !== 0 || latest.exit_type !== "normal") throw fail("root_task_failed");
|
|
179
193
|
return;
|
|
180
194
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
report(task, latest ? "running" : "queued", started);
|
|
196
|
+
if (now() >= deadline) throw fail("root_timeout");
|
|
197
|
+
return pause(interval, deadline).then(function () {
|
|
198
|
+
return waitForLegacyRun(task, ref, before, started, deadline, Math.min(interval * 2, MAX_POLL_MS));
|
|
184
199
|
});
|
|
185
200
|
});
|
|
186
201
|
}
|
|
187
202
|
function execute(task, verified) {
|
|
203
|
+
var started = now(), deadline = started + RUN_LIMIT_MS;
|
|
188
204
|
if (!legacy) {
|
|
189
205
|
return history(verified.ref).then(function (before) {
|
|
190
|
-
return runTask(verified.ref).then(function () { return waitForRun(verified.ref, before,
|
|
206
|
+
return runTask(verified.ref).then(function () { return waitForRun(task, verified.ref, before, started, deadline, POLL_MS); });
|
|
191
207
|
});
|
|
192
208
|
}
|
|
193
|
-
var deadline = Date.now() + POLL_MS * POLLS;
|
|
194
209
|
return legacyHistory(verified.ref, deadline).then(function (before) {
|
|
195
210
|
if (before.some(function (record) { return !record.stop_time || record.exit_type === "running"; })) throw fail("root_not_ready");
|
|
196
211
|
// Mutating calls are sent exactly once, never retried with another profile.
|
|
197
212
|
return bounded(function () { return runTask(verified.ref); }, deadline).then(function () {
|
|
198
|
-
return waitForLegacyRun(verified.ref, before, deadline,
|
|
213
|
+
return waitForLegacyRun(task, verified.ref, before, started, deadline, POLL_MS);
|
|
199
214
|
});
|
|
200
215
|
}).then(function () {
|
|
201
216
|
return bounded(function () { return find(task); }, deadline).then(function (after) {
|
|
@@ -2,4 +2,7 @@ export { executorTasks as rootTasks, type ExecutorTask as RootTask } from '../ru
|
|
|
2
2
|
/** Exact readback is authoritative. PasswordConfirm tokens are reused immediately,
|
|
3
3
|
* across both Root APIs, and never persisted (DSM validity is about 300 seconds). */
|
|
4
4
|
export declare const ROOT_RUNTIME: string;
|
|
5
|
-
export declare const ROOT_PANEL = "function mountRootPanel(host, access, i18n, tasks, admin) {\n if (!admin) return;\n var status = document.createElement(\"p\"), approve = document.createElement(\"button\"), retry = document.createElement(\"button\");\n approve.textContent = \"Approve background access\"; retry.textContent = \"Check status\";\n host.appendChild(status); host.appendChild(approve); host.appendChild(retry);\n function show(result) { status.textContent = result.state + (result.reason ? \": \" + result.reason : \"\"); approve.hidden = result.state !== \"needs-admin-approval\"; }\n function run(fn) { approve.disabled = retry.disabled = true; return fn().then(show, function(e) { show({state: \"blocked\", reason: e.message || e.code}); }).then(function() { approve.disabled = retry.disabled = false; }); }\n approve.onclick = function() { return run(function() { return access.authorize(); }); };\n retry.onclick = function() { return run(function() { return access.status(); }); };\n run(function() { return access.status().then(function(result) { return result.tasks && result.tasks.boot && result.tasks.control && result.tasks.boot.state === \"ready\" && result.tasks.control.state === \"ready\" ? access.control(\"start\") : result; }); });\n}";
|
|
5
|
+
export declare const ROOT_PANEL = "function mountRootPanel(host, access, i18n, tasks, admin) {\n if (!admin) return;\n var status = document.createElement(\"p\"), approve = document.createElement(\"button\"), retry = document.createElement(\"button\");\n approve.textContent = \"Approve background access\"; retry.textContent = \"Check status\";\n host.appendChild(status); host.appendChild(approve); host.appendChild(retry);\n function show(result) { status.textContent = result.state + (result.reason ? \": \" + result.reason : \"\"); approve.hidden = result.state !== \"needs-admin-approval\" && result.retry !== true; }\n function run(fn) { approve.disabled = retry.disabled = true; return fn().then(show, function(e) { show({state: \"blocked\", reason: e.message || e.code}); }).then(function() { approve.disabled = retry.disabled = false; }); }\n access.subscribe(show);\n approve.onclick = function() { return run(function() { return access.authorize(); }); };\n retry.onclick = function() { return run(function() { return access.status(); }); };\n run(function() { return access.status().then(function(result) { return result.tasks && result.tasks.boot && result.tasks.control && result.tasks.boot.state === \"ready\" && result.tasks.control.state === \"ready\" ? access.control(\"start\") : result; }); });\n}";
|
|
6
|
+
/** Administrator password confirmation in the window's own session: a supplied
|
|
7
|
+
* password, or a masked form when none is given. */
|
|
8
|
+
export declare const PASSWORD_CONFIRM: string;
|
|
@@ -1,23 +1,47 @@
|
|
|
1
1
|
import { bootDefinition, controlDefinition } from '../runtime/executor-tasks.js';
|
|
2
2
|
import { executorScopeTask } from '../runtime/executor-scope-task.js';
|
|
3
|
+
import { versionUnsupported } from '../runtime/dsm-version.js';
|
|
3
4
|
export { executorTasks as rootTasks } from '../runtime/executor-tasks.js';
|
|
4
5
|
/** Exact readback is authoritative. PasswordConfirm tokens are reused immediately,
|
|
5
6
|
* across both Root APIs, and never persisted (DSM validity is about 300 seconds). */
|
|
6
7
|
export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
7
8
|
var eventAPI = "SYNO.Core.EventScheduler", taskAPI = "SYNO.Core.TaskScheduler";
|
|
8
|
-
var busy = null;
|
|
9
|
-
|
|
9
|
+
var busy = null, legacy = false;
|
|
10
|
+
var versionUnsupported = ${versionUnsupported.toString()};
|
|
11
|
+
function fail(code, detail) { var e = new Error(code); e.code = code; if (detail) e.detail = detail; return e; }
|
|
10
12
|
function reason(e) {
|
|
11
13
|
var code = String(e && e.code || "executor_bad_response");
|
|
14
|
+
if (e && e.passwordConfirm) return code + (code === "dsm_3121" ? ": DSM rejected the administrator password." : ": administrator password confirmation failed.") + " Nothing was created.";
|
|
15
|
+
if (e && e.detail) return code + ": " + e.detail;
|
|
12
16
|
return code + (code === "105" || code === "dsm_105" ? ": DSM denied administrator scheduler access. Check the DSM session and scheduler; a NAS restart resolved this on the measured DSM. No automatic retry was made." : ": scheduler readback or operation failed.");
|
|
13
17
|
}
|
|
14
18
|
// AppWindow.sendWebAPI encodes JSON parameters. Preserve their native types;
|
|
15
19
|
// pre-encoding turns objects/booleans into strings and DSM rejects create.
|
|
16
20
|
function send(api, method, version, params) { return options.send(api, method, version, params || {}); }
|
|
21
|
+
// DSM 7.1 has no Root APIs: TaskScheduler stops at list v2 (offset) and get v3.
|
|
22
|
+
// Only an unsupported method version on a read selects that profile, which
|
|
23
|
+
// then creates through the plain scheduler APIs. Writes never fall back.
|
|
24
|
+
function taskRead(method, version, params, legacyVersion, legacyParams) {
|
|
25
|
+
if (legacy) return send(taskAPI, method, legacyVersion, legacyParams);
|
|
26
|
+
return send(taskAPI, method, version, params).catch(function (e) {
|
|
27
|
+
if (!versionUnsupported(e && e.code)) throw e;
|
|
28
|
+
legacy = true;
|
|
29
|
+
return send(taskAPI, method, legacyVersion, legacyParams);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function create(boot, params, confirmation) {
|
|
33
|
+
if (legacy) return send(boot ? eventAPI : taskAPI, "create", boot ? 1 : 3, params);
|
|
34
|
+
params.SynoConfirmPWToken = confirmation.token;
|
|
35
|
+
return send(boot ? eventAPI + ".Root" : taskAPI + ".Root", "create", boot ? 1 : 4, params);
|
|
36
|
+
}
|
|
37
|
+
function remove(task, ref) {
|
|
38
|
+
var boot = task.kind === "boot";
|
|
39
|
+
return send(boot ? eventAPI : taskAPI, "delete", boot ? 1 : 2, boot ? ref : {tasks: [ref]});
|
|
40
|
+
}
|
|
17
41
|
function admin() { if (!options.user() || options.user().admin !== true) throw fail("admin_required"); }
|
|
18
42
|
function find(task) {
|
|
19
43
|
var boot = task.kind === "boot";
|
|
20
|
-
return
|
|
44
|
+
return (boot ? send(eventAPI, "list", 1, {}) : taskRead("list", 3, {start: 0, limit: 500}, 2, {offset: 0, limit: 500})).then(function (data) {
|
|
21
45
|
var records = boot ? data : data && data.tasks;
|
|
22
46
|
if (!Array.isArray(records) || records.length > 500 || (!boot && (!Number.isInteger(data.total) || data.total !== records.length))) throw fail("executor_bad_response");
|
|
23
47
|
var named = records.filter(function (r) { return r && (boot ? r.task_name : r.name) === task.name; });
|
|
@@ -26,7 +50,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
26
50
|
var summary = named[0];
|
|
27
51
|
if (!boot && !/^[0-9]+$/.test(String(summary.id))) throw fail("executor_bad_response");
|
|
28
52
|
var ref = boot ? {task_name: task.name} : {id: summary.id, real_owner: "root"};
|
|
29
|
-
var detail =
|
|
53
|
+
var detail = boot ? send(eventAPI, "get", 1, ref) : taskRead("get", 4, ref, 3, ref);
|
|
30
54
|
return detail.then(function (r) {
|
|
31
55
|
var owner = r && r.owner;
|
|
32
56
|
var root = owner === "root" || (boot && owner && owner["0"] === "root");
|
|
@@ -49,6 +73,16 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
49
73
|
return result;
|
|
50
74
|
});
|
|
51
75
|
}
|
|
76
|
+
// Removes, newest first, the tasks one approval created and still reads as
|
|
77
|
+
// exactly ours. Resolves with the last refusal's reason, if any.
|
|
78
|
+
function withdraw(created) {
|
|
79
|
+
var failed;
|
|
80
|
+
return created.reduceRight(function (withdrawn, task) {
|
|
81
|
+
return withdrawn.then(function () { return find(task); }).then(function (current) {
|
|
82
|
+
if (current.state === "ready") return remove(task, current.ref);
|
|
83
|
+
}).catch(function (e) { failed = reason(e); });
|
|
84
|
+
}, Promise.resolve()).then(function () { return failed; });
|
|
85
|
+
}
|
|
52
86
|
function definition(task) { return task.kind === "boot" ? (${bootDefinition.toString()})(task) : (${controlDefinition.toString()})(task); }
|
|
53
87
|
function approve(password, selectedScope) {
|
|
54
88
|
var selectionTask;
|
|
@@ -56,10 +90,13 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
56
90
|
return read().then(function (before) {
|
|
57
91
|
if (before.state === "blocked" || before.state === "unsupported") return before;
|
|
58
92
|
if (before.state === "ready" && !selectionTask) return start(before);
|
|
59
|
-
return options.confirmPassword(password).
|
|
93
|
+
return options.confirmPassword(password).catch(function (e) {
|
|
94
|
+
throw {code: e && e.code, passwordConfirm: true};
|
|
95
|
+
}).then(function (confirmation) {
|
|
60
96
|
if (!confirmation || confirmation.confirmed !== true) return {state: "cancelled", tasks: before.tasks};
|
|
61
|
-
|
|
62
|
-
|
|
97
|
+
// DSM 7.1 confirms the password server-side but issues no create token.
|
|
98
|
+
if (!legacy && (typeof confirmation.token !== "string" || !confirmation.token)) throw fail("executor_confirmation_missing");
|
|
99
|
+
var errors = {}, created = [], withdrawFailed;
|
|
63
100
|
// Delete only a verified, same-header outdated task, then create the pinned
|
|
64
101
|
// replacement. set has no measured wire contract. Re-read before each write.
|
|
65
102
|
var chain = Promise.resolve();
|
|
@@ -68,22 +105,35 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
68
105
|
return find(task).then(function (current) {
|
|
69
106
|
if (current.state === "ready") return;
|
|
70
107
|
if (current.state === "blocked") throw fail(current.reason);
|
|
71
|
-
var
|
|
72
|
-
|
|
73
|
-
return removed.then(function () {
|
|
74
|
-
var params = definition(task); params.SynoConfirmPWToken = confirmation.token;
|
|
75
|
-
return send(boot ? eventAPI + ".Root" : taskAPI + ".Root", "create", boot ? 1 : 4, params);
|
|
76
|
-
});
|
|
108
|
+
var removed = current.ref ? remove(task, current.ref) : Promise.resolve();
|
|
109
|
+
return removed.then(function () { return create(task.kind === "boot", definition(task), confirmation); }).then(function () { created.push(task); });
|
|
77
110
|
}).catch(function (e) { errors[task.kind] = reason(e); });
|
|
78
111
|
});
|
|
79
112
|
});
|
|
80
|
-
|
|
113
|
+
// A partial approval must not leave an enabled root bootup task that no
|
|
114
|
+
// control task or uninstall inventory owns: withdraw what it created.
|
|
115
|
+
return chain.then(function () {
|
|
116
|
+
if (Object.keys(errors).length) return withdraw(created).then(function (failed) { withdrawFailed = failed; });
|
|
117
|
+
}).then(read).then(function (after) {
|
|
81
118
|
Object.keys(errors).forEach(function (kind) {
|
|
82
119
|
if (after.tasks[kind].state !== "ready") { after.tasks[kind].state = "blocked"; after.tasks[kind].reason = errors[kind]; after.state = "blocked"; after.reason = errors[kind]; }
|
|
83
120
|
});
|
|
121
|
+
if (withdrawFailed) { after.state = "blocked"; after.reason = (after.reason ? after.reason + " " : "") + "Withdrawing the tasks this approval created failed: " + withdrawFailed; }
|
|
84
122
|
// A scope change must not restart a running executor: the boot task
|
|
85
123
|
// stops it, which interrupts in-flight copies instead of canceling them.
|
|
86
124
|
return after.state === "ready" ? (selectionTask ? running(after) : Promise.resolve(null)).then(function (live) { return live || start(after); }).then(function (started) {
|
|
125
|
+
// Without a running executor there is no task inventory, so uninstall
|
|
126
|
+
// cannot find these tasks: withdraw what this approval created.
|
|
127
|
+
if (started.state === "ready" || !created.length) return started;
|
|
128
|
+
return withdraw(created).then(function (failed) {
|
|
129
|
+
return read().then(function (current) {
|
|
130
|
+
current.state = "blocked";
|
|
131
|
+
current.reason = started.reason + (failed ? " Withdrawing the tasks this approval created failed: " + failed : " The tasks this approval created were withdrawn.");
|
|
132
|
+
current.retry = !failed;
|
|
133
|
+
return current;
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}).then(function (started) {
|
|
87
137
|
if (!selectionTask || started.state !== "ready") return started;
|
|
88
138
|
var generation = started.executor.selection ? started.executor.selection.generation : 0;
|
|
89
139
|
if (started.executor.selection && (!Number.isSafeInteger(generation) || generation <= 0)) throw fail("executor_selection_invalid");
|
|
@@ -102,23 +152,20 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
102
152
|
function selectScope(task, confirmation, verified, selectedScope) {
|
|
103
153
|
function removeExact() {
|
|
104
154
|
return find(task).then(function (current) {
|
|
105
|
-
if (current.state === "ready") return
|
|
155
|
+
if (current.state === "ready") return remove(task, current.ref);
|
|
106
156
|
if (current.ref) throw fail("executor_selection_cleanup_conflict");
|
|
107
157
|
});
|
|
108
158
|
}
|
|
109
159
|
return find(task).then(function (current) {
|
|
110
160
|
if (current.state === "blocked" || current.state === "unsupported") throw fail("executor_selection_conflict");
|
|
111
|
-
var removed = current.ref ?
|
|
112
|
-
return removed.then(function () {
|
|
113
|
-
var params = definition(task); params.SynoConfirmPWToken = confirmation.token;
|
|
114
|
-
return send(taskAPI + ".Root", "create", 4, params);
|
|
115
|
-
});
|
|
161
|
+
var removed = current.ref ? remove(task, current.ref) : Promise.resolve();
|
|
162
|
+
return removed.then(function () { return create(false, definition(task), confirmation); });
|
|
116
163
|
}).then(function () { return find(task); }).then(function (current) {
|
|
117
164
|
if (current.state !== "ready") throw fail("executor_selection_readback_mismatch");
|
|
118
165
|
return send(taskAPI, "run", 2, {tasks: [current.ref]});
|
|
119
166
|
}).then(function () {
|
|
120
167
|
function readSelection(attempts) {
|
|
121
|
-
return health(verified,
|
|
168
|
+
return health(verified, 0).then(function (current) {
|
|
122
169
|
if (current.state === "ready" && current.executor.selection && current.executor.selection.scope === selectedScope && Number.isSafeInteger(current.executor.selection.generation) && current.executor.selection.generation > 0) return current;
|
|
123
170
|
if (attempts > 1 && options.wait) return options.wait(500).then(function () { verified.state = "ready"; return readSelection(attempts - 1); });
|
|
124
171
|
throw fail("executor_selection_unconfirmed");
|
|
@@ -129,21 +176,35 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
129
176
|
return removeExact().then(function () { throw error; });
|
|
130
177
|
});
|
|
131
178
|
}
|
|
132
|
-
|
|
179
|
+
// Why a status reply is not the approved, running executor; null when it is.
|
|
180
|
+
function mismatch(status) {
|
|
181
|
+
if (!status || status.ready !== true) return "the approved executor is not answering" + (status && (status.reason || status.state) ? " (" + (status.reason || status.state) + ")" : "") + ".";
|
|
182
|
+
if (!options.pins || status.executor_digest !== options.pins.executorDigest || status.policy_digest !== options.pins.policyDigest) return "the running executor reports other digests than this package pins.";
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
function pinned(status) { return mismatch(status) === null; }
|
|
133
186
|
function running(verified) {
|
|
134
187
|
return options.call("executor.status", {}).then(function (status) {
|
|
135
188
|
if (!pinned(status)) return null;
|
|
136
189
|
verified.executor = status; return verified;
|
|
137
190
|
}, function () { return null; });
|
|
138
191
|
}
|
|
139
|
-
|
|
192
|
+
// budget: how long to keep asking after a start (ms). The wait doubles from
|
|
193
|
+
// 500 ms to 2 s so an open window does not poll the executor twice a second.
|
|
194
|
+
function health(verified, budget, delay) {
|
|
140
195
|
if (verified.state !== "ready") return Promise.resolve(verified);
|
|
196
|
+
delay = Math.min(delay || 500, 2000);
|
|
141
197
|
return options.call("executor.status", {}).then(function (status) {
|
|
142
|
-
|
|
198
|
+
var why = mismatch(status);
|
|
199
|
+
if (why) throw fail("executor_status_mismatch", why);
|
|
143
200
|
verified.executor = status; return verified;
|
|
201
|
+
}, function (e) {
|
|
202
|
+
throw fail(e && e.code, "the executor status request failed.");
|
|
144
203
|
}).catch(function (e) {
|
|
145
|
-
if (
|
|
146
|
-
|
|
204
|
+
if (budget >= delay && options.wait) return options.wait(delay).then(function () { return health(verified, budget - delay, delay * 2); });
|
|
205
|
+
// The tasks are approved: approving again runs start, which restarts the
|
|
206
|
+
// executor without a password, so the panel offers it.
|
|
207
|
+
verified.state = "blocked"; verified.reason = reason(e); verified.retry = true; return verified;
|
|
147
208
|
});
|
|
148
209
|
}
|
|
149
210
|
function start(verified) {
|
|
@@ -151,13 +212,17 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
151
212
|
return send(eventAPI, "run", 1, verified.tasks.boot.ref);
|
|
152
213
|
}).then(function () {
|
|
153
214
|
// run can succeed for a nonexistent task. Read both definitions again.
|
|
154
|
-
return read().then(function (after) { return health(after,
|
|
215
|
+
return read().then(function (after) { return health(after, 10000); });
|
|
155
216
|
}).catch(function (e) { verified.state = "blocked"; verified.reason = reason(e); return verified; });
|
|
156
217
|
}
|
|
218
|
+
// The window panel and the app's own settings share this access; each
|
|
219
|
+
// result reaches every view so neither shows a stale state.
|
|
220
|
+
var listeners = [];
|
|
221
|
+
function publish(result) { listeners.forEach(function (listener) { listener(result); }); return result; }
|
|
157
222
|
return {
|
|
158
|
-
status: function () { return read().then(function (verified) { return health(verified,
|
|
223
|
+
status: function () { return read().then(function (verified) { return health(verified, 0); }).then(publish); },
|
|
159
224
|
authorize: function (password, selectedScope) {
|
|
160
|
-
if (!busy) busy = Promise.resolve().then(function () { return approve(password, selectedScope); }).catch(function (e) { return {state: "blocked", reason: reason(e), tasks: {}}; }).then(function (r) { busy = null; return r; });
|
|
225
|
+
if (!busy) busy = Promise.resolve().then(function () { return approve(password, selectedScope); }).catch(function (e) { return {state: "blocked", reason: reason(e), tasks: {}, retry: !!(e && e.passwordConfirm)}; }).then(function (r) { busy = null; return publish(r); });
|
|
161
226
|
return busy;
|
|
162
227
|
},
|
|
163
228
|
control: function (action) {
|
|
@@ -166,9 +231,10 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
166
231
|
if (verified.state !== "ready") throw fail("executor_not_ready");
|
|
167
232
|
return options.call("executor.control", {action: action}).then(function () {
|
|
168
233
|
return send(taskAPI, "run", 2, {tasks: [verified.tasks.control.ref]});
|
|
169
|
-
}).then(read).then(function (after) { return action === "start" ? health(after,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
234
|
+
}).then(read).then(function (after) { return action === "start" ? health(after, 10000) : after; });
|
|
235
|
+
}).then(publish);
|
|
236
|
+
},
|
|
237
|
+
subscribe: function (listener) { listeners.push(listener); }
|
|
172
238
|
};
|
|
173
239
|
}`;
|
|
174
240
|
export const ROOT_PANEL = `function mountRootPanel(host, access, i18n, tasks, admin) {
|
|
@@ -176,9 +242,53 @@ export const ROOT_PANEL = `function mountRootPanel(host, access, i18n, tasks, ad
|
|
|
176
242
|
var status = document.createElement("p"), approve = document.createElement("button"), retry = document.createElement("button");
|
|
177
243
|
approve.textContent = "Approve background access"; retry.textContent = "Check status";
|
|
178
244
|
host.appendChild(status); host.appendChild(approve); host.appendChild(retry);
|
|
179
|
-
function show(result) { status.textContent = result.state + (result.reason ? ": " + result.reason : ""); approve.hidden = result.state !== "needs-admin-approval"; }
|
|
245
|
+
function show(result) { status.textContent = result.state + (result.reason ? ": " + result.reason : ""); approve.hidden = result.state !== "needs-admin-approval" && result.retry !== true; }
|
|
180
246
|
function run(fn) { approve.disabled = retry.disabled = true; return fn().then(show, function(e) { show({state: "blocked", reason: e.message || e.code}); }).then(function() { approve.disabled = retry.disabled = false; }); }
|
|
247
|
+
access.subscribe(show);
|
|
181
248
|
approve.onclick = function() { return run(function() { return access.authorize(); }); };
|
|
182
249
|
retry.onclick = function() { return run(function() { return access.status(); }); };
|
|
183
250
|
run(function() { return access.status().then(function(result) { return result.tasks && result.tasks.boot && result.tasks.control && result.tasks.boot.state === "ready" && result.tasks.control.state === "ready" ? access.control("start") : result; }); });
|
|
184
251
|
}`;
|
|
252
|
+
/** Administrator password confirmation in the window's own session: a supplied
|
|
253
|
+
* password, or a masked form when none is given. */
|
|
254
|
+
export const PASSWORD_CONFIRM = `function createPasswordConfirm(win) {
|
|
255
|
+
var versionUnsupported = ${versionUnsupported.toString()};
|
|
256
|
+
function send(password, version) {
|
|
257
|
+
return new Promise(function (resolve, reject) {
|
|
258
|
+
win.sendWebAPI({api: "SYNO.Core.User.PasswordConfirm", method: "auth", version: version, params: {password: password}, scope: win,
|
|
259
|
+
callback: function (success, response) {
|
|
260
|
+
if (!success) reject({code: "dsm_" + ((response && response.code) || "error")});
|
|
261
|
+
else resolve({confirmed: true, token: response && response.SynoConfirmPWToken});
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// DSM 7.1 has PasswordConfirm v1 only, which checks the password but issues no
|
|
267
|
+
// token. Only an unsupported version is retried; a wrong password is not.
|
|
268
|
+
function auth(password) {
|
|
269
|
+
return send(password, 2).catch(function (error) {
|
|
270
|
+
if (!versionUnsupported(error.code)) throw error;
|
|
271
|
+
return send(password, 1);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return function (suppliedPassword) {
|
|
275
|
+
if (typeof suppliedPassword === "string" && suppliedPassword) return auth(suppliedPassword);
|
|
276
|
+
return new Promise(function (resolve, reject) {
|
|
277
|
+
var form = document.createElement("form"), label = document.createElement("label"), input = document.createElement("input");
|
|
278
|
+
var submit = document.createElement("button"), cancel = document.createElement("button");
|
|
279
|
+
label.textContent = "Confirm your DSM administrator password";
|
|
280
|
+
input.type = "password"; input.autocomplete = "current-password"; input.required = true;
|
|
281
|
+
label.appendChild(input); form.appendChild(label);
|
|
282
|
+
submit.type = "submit"; submit.textContent = "Approve"; form.appendChild(submit);
|
|
283
|
+
cancel.type = "button"; cancel.textContent = "Cancel"; form.appendChild(cancel);
|
|
284
|
+
win.body.dom.appendChild(form); input.focus();
|
|
285
|
+
function close() { input.value = ""; if (form.parentNode) form.parentNode.removeChild(form); }
|
|
286
|
+
cancel.onclick = function () { close(); resolve({confirmed: false}); };
|
|
287
|
+
form.onsubmit = function (event) {
|
|
288
|
+
event.preventDefault(); submit.disabled = cancel.disabled = true;
|
|
289
|
+
var password = input.value; input.value = "";
|
|
290
|
+
auth(password).then(function (result) { close(); resolve(result); }, function (error) { close(); reject(error); });
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
}`;
|
|
@@ -28,6 +28,9 @@ type deadlines struct {
|
|
|
28
28
|
Start int `json:"start"`
|
|
29
29
|
Stop int `json:"stop"`
|
|
30
30
|
Status int `json:"status"`
|
|
31
|
+
// Uninstall bounds uninstall_args separately from Stop: a revoke on a
|
|
32
|
+
// loaded NAS can take longer than stopping the daemon.
|
|
33
|
+
Uninstall int `json:"uninstall"`
|
|
31
34
|
}
|
|
32
35
|
type config struct {
|
|
33
36
|
Package string `json:"package"`
|
|
@@ -88,7 +91,7 @@ func load(dest, data string) (*controller, error) {
|
|
|
88
91
|
if cfg.Exec == "" || filepath.IsAbs(cfg.Exec) || filepath.Clean(cfg.Exec) != cfg.Exec || strings.HasPrefix(cfg.Exec, "../") {
|
|
89
92
|
return nil, errors.New("invalid fixed executable")
|
|
90
93
|
}
|
|
91
|
-
if cfg.Timeouts.Start < 2 || cfg.Timeouts.Start > 120 || cfg.Timeouts.Stop < 2 || cfg.Timeouts.Stop > 120 || cfg.Timeouts.Status < 1 || cfg.Timeouts.Status > 10 {
|
|
94
|
+
if cfg.Timeouts.Start < 2 || cfg.Timeouts.Start > 120 || cfg.Timeouts.Stop < 2 || cfg.Timeouts.Stop > 120 || cfg.Timeouts.Status < 1 || cfg.Timeouts.Status > 10 || cfg.Timeouts.Uninstall < 2 || cfg.Timeouts.Uninstall > 120 {
|
|
92
95
|
return nil, errors.New("invalid daemon deadlines")
|
|
93
96
|
}
|
|
94
97
|
hash := sha256.Sum256(raw)
|
|
@@ -162,7 +165,7 @@ func (c *controller) run(action string) (int, error) {
|
|
|
162
165
|
budget += c.Timeouts.Start
|
|
163
166
|
}
|
|
164
167
|
if action == "uninstall" {
|
|
165
|
-
budget += c.Timeouts.
|
|
168
|
+
budget += c.Timeouts.Uninstall + 2
|
|
166
169
|
}
|
|
167
170
|
if action != "start" && action != "stop" && action != "status" && action != "restart" && action != "uninstall" {
|
|
168
171
|
return 1, errors.New("unsupported daemon action")
|
|
@@ -414,7 +417,7 @@ func (c *controller) uninstall() error {
|
|
|
414
417
|
select {
|
|
415
418
|
case err := <-done:
|
|
416
419
|
return err
|
|
417
|
-
case <-time.After(time.Duration(c.Timeouts.
|
|
420
|
+
case <-time.After(time.Duration(c.Timeouts.Uninstall) * time.Second):
|
|
418
421
|
cmd.Process.Signal(syscall.SIGTERM)
|
|
419
422
|
}
|
|
420
423
|
select {
|
|
@@ -50,7 +50,7 @@ func fixture(t *testing.T, mode string) (string, string, string) {
|
|
|
50
50
|
if raw, err := exec.Command(app, "cleanup").CombinedOutput(); err != nil {
|
|
51
51
|
t.Fatalf("payload warm-up: %v %s", err, raw)
|
|
52
52
|
}
|
|
53
|
-
cfg := config{Package: "DaemonFixture", Exec: "app", Args: []string{"${SYNOPKG_PKGVAR}/a space", "$(touch BAD)'", "${SYNOPKG_PKGDEST}"}, Env: map[string]string{"APP_MODE": mode}, Timeouts: deadlines{Start: 2, Stop: 2, Status: 1}}
|
|
53
|
+
cfg := config{Package: "DaemonFixture", Exec: "app", Args: []string{"${SYNOPKG_PKGVAR}/a space", "$(touch BAD)'", "${SYNOPKG_PKGDEST}"}, Env: map[string]string{"APP_MODE": mode}, Timeouts: deadlines{Start: 2, Stop: 2, Status: 1, Uninstall: 2}}
|
|
54
54
|
if os.Geteuid() == 0 {
|
|
55
55
|
cfg.Package = "nobody"
|
|
56
56
|
} else {
|
|
@@ -250,6 +250,26 @@ func TestUninstallCleanup(t *testing.T) {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// A revoke on uninstall can outlast the stop deadline on a loaded NAS; it has
|
|
254
|
+
// its own budget instead of Stop-1.
|
|
255
|
+
func TestUninstallCommandHasItsOwnBudget(t *testing.T) {
|
|
256
|
+
control, dest, data := fixture(t, "normal")
|
|
257
|
+
path := filepath.Join(dest, "daemon/config.json")
|
|
258
|
+
raw, _ := os.ReadFile(path)
|
|
259
|
+
var cfg config
|
|
260
|
+
json.Unmarshal(raw, &cfg)
|
|
261
|
+
cfg.UninstallArgs = []string{"cleanup-slow"}
|
|
262
|
+
cfg.Timeouts.Uninstall = 5
|
|
263
|
+
raw, _ = json.Marshal(cfg)
|
|
264
|
+
os.WriteFile(path, raw, 0644)
|
|
265
|
+
if rc, out := runControl(control, "start", dest, data); rc != 0 {
|
|
266
|
+
t.Fatalf("start: %d %s", rc, out)
|
|
267
|
+
}
|
|
268
|
+
if rc, out := runControl(control, "uninstall", dest, data); rc != 0 {
|
|
269
|
+
t.Fatalf("slow uninstall within its budget failed: %d %s", rc, out)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
253
273
|
func TestPackageAccountIdentity(t *testing.T) {
|
|
254
274
|
control, dest, data := fixture(t, "normal")
|
|
255
275
|
path := filepath.Join(dest, "daemon/config.json")
|