refactor: split renderer ipc and unraid domains
This commit is contained in:
1 parent
5d3731a853
commit
7b05c953b6
36 files changed
+9316
-8330
No files matched your search
@@ -0,0 +1,447 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto }) {
|
||||
class UnraidAccessMethods {
|
||||
serverGitRemote(repository, profile) {
|
||||
const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean);
|
||||
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
|
||||
if (!value) {
|
||||
const error = new Error("Server pull requires the repository SSH clone URL from Gitea.");
|
||||
error.code = "SERVER_GIT_SSH_URL_REQUIRED";
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
serverGitHost(repository, profile) {
|
||||
const remote = this.serverGitRemote(repository, profile);
|
||||
if (/^ssh:\/\//i.test(remote)) {
|
||||
const parsed = new URL(remote);
|
||||
return { host: parsed.hostname, port: Number(parsed.port || 22) };
|
||||
}
|
||||
const match = remote.match(/^[^@\s]+@([^:\s]+):/);
|
||||
if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL.");
|
||||
return { host: match[1], port: 22 };
|
||||
}
|
||||
|
||||
serverGitCredentialPaths(repository, server) {
|
||||
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
|
||||
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId);
|
||||
return {
|
||||
directory,
|
||||
privateKey: path.join(directory, "deploy-key"),
|
||||
publicKey: path.join(directory, "deploy-key.pub"),
|
||||
knownHosts: path.join(directory, "known_hosts"),
|
||||
};
|
||||
}
|
||||
|
||||
serverGitEnvironment(repository, profile, server) {
|
||||
const credentials = this.serverGitCredentialPaths(repository, server);
|
||||
return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`;
|
||||
}
|
||||
|
||||
async configureServerGitAccess({ repository, profileId }) {
|
||||
const { profile, server } = this.resolve(repository, profileId);
|
||||
const remote = this.serverGitRemote(repository, profile);
|
||||
const { host, port } = this.serverGitHost(repository, profile);
|
||||
const credentials = this.serverGitCredentialPaths(repository, server);
|
||||
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
|
||||
const marker = "__FORGEFLOW_DEPLOY_KEY__";
|
||||
const setupScript = `
|
||||
command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; }
|
||||
command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; }
|
||||
command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; }
|
||||
credential_dir=${shellQuote(credentials.directory)}
|
||||
private_key=${shellQuote(credentials.privateKey)}
|
||||
public_key=${shellQuote(credentials.publicKey)}
|
||||
known_hosts=${shellQuote(credentials.knownHosts)}
|
||||
expected_host_fingerprint=${shellQuote(trustedHostFingerprint)}
|
||||
mkdir -p "$credential_dir"
|
||||
chmod 700 "$credential_dir"
|
||||
if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then
|
||||
rm -f "$private_key" "$public_key"
|
||||
ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key"
|
||||
fi
|
||||
chmod 600 "$private_key"
|
||||
chmod 644 "$public_key"
|
||||
scan_tmp="$known_hosts.$$.tmp"
|
||||
scan_ok=false
|
||||
for attempt in 1 2 3; do
|
||||
ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true
|
||||
if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
[ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; }
|
||||
scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)"
|
||||
if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then
|
||||
rm -f "$scan_tmp"
|
||||
echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2
|
||||
exit 46
|
||||
fi
|
||||
mv "$scan_tmp" "$known_hosts"
|
||||
chmod 600 "$known_hosts"
|
||||
printf '%s\n' ${shellQuote(marker)}
|
||||
printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')"
|
||||
printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')"
|
||||
printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint"
|
||||
`;
|
||||
const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 });
|
||||
const output = String(setup.stdout || "");
|
||||
const markerIndex = output.lastIndexOf(marker);
|
||||
if (markerIndex < 0) throw new Error("The server did not return the generated deploy key.");
|
||||
const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
|
||||
}));
|
||||
if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) {
|
||||
const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust.");
|
||||
error.code = "GITEA_SSH_HOST_KEY_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim();
|
||||
const [owner, repo] = String(repository.fullName || "").split("/");
|
||||
if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull.");
|
||||
const deployKey = await this.gitea.ensureReadOnlyDeployKey({
|
||||
owner,
|
||||
repo,
|
||||
title: `ForgeFlow · ${server.name} · read-only`,
|
||||
publicKey,
|
||||
});
|
||||
const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`;
|
||||
const probe = await this.ssh.exec(
|
||||
server.id,
|
||||
bash(`probe_error=''
|
||||
for attempt in 1 2 3; do
|
||||
if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi
|
||||
probe_error=$probe_output
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
printf '%s\n' "$probe_error" >&2
|
||||
exit 45`),
|
||||
{ timeout: 45_000, maxOutput: 256 * 1024 },
|
||||
);
|
||||
const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null;
|
||||
const updated = await this.store.saveDeploymentProfile(repository.fullName, {
|
||||
...profile,
|
||||
deploymentMode: "server-git",
|
||||
cloneUrl: remote,
|
||||
serverGitAccess: {
|
||||
configured: true,
|
||||
deployKeyId: deployKey.id || null,
|
||||
keyFingerprint: fields.fingerprint || null,
|
||||
hostFingerprint: fields.hostFingerprint || null,
|
||||
configuredAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
profile: updated,
|
||||
created: deployKey.created === true,
|
||||
remoteSha,
|
||||
keyFingerprint: fields.fingerprint || null,
|
||||
hostFingerprint: fields.hostFingerprint || null,
|
||||
};
|
||||
}
|
||||
|
||||
async probeServerGitAccess({ repository, profile, server }) {
|
||||
try {
|
||||
const remote = this.serverGitRemote(repository, profile);
|
||||
const credentials = this.serverGitCredentialPaths(repository, server);
|
||||
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
|
||||
const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim();
|
||||
const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`;
|
||||
const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 });
|
||||
const output = String(result.stdout || "");
|
||||
const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__");
|
||||
if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence.");
|
||||
const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
|
||||
}));
|
||||
return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null };
|
||||
} catch (error) {
|
||||
return { ready: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async verifyServerGitProfile({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const checks = [];
|
||||
const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence });
|
||||
if (profile.deploymentMode === "monitor-only") {
|
||||
add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy.");
|
||||
return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
|
||||
}
|
||||
if (profile.deploymentMode !== "server-git") {
|
||||
add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles.");
|
||||
return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
|
||||
}
|
||||
let branchSha = null;
|
||||
try {
|
||||
const [owner, repo] = String(repository.fullName || "").split("/");
|
||||
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
||||
branchSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha });
|
||||
const keys = await this.gitea.listDeployKeys(owner, repo);
|
||||
const keyId = Number(profile.serverGitAccess?.deployKeyId);
|
||||
const key = keys.find((item) => Number(item.id) === keyId);
|
||||
add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true });
|
||||
} catch (error) {
|
||||
add("gitea-access", "Gitea verification", "fail", error.message);
|
||||
}
|
||||
const access = await this.probeServerGitAccess({ repository, profile, server });
|
||||
add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access);
|
||||
let inspection = null;
|
||||
try {
|
||||
inspection = await this.inspect({ repository, profileId });
|
||||
const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile);
|
||||
const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file));
|
||||
add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists });
|
||||
add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose });
|
||||
add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] });
|
||||
add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] });
|
||||
} catch (error) {
|
||||
add("server-inspection", "Server inspection", "fail", error.message);
|
||||
}
|
||||
const state = this.store.getDeploymentState(profile.id) || {};
|
||||
const liveSha = state.liveSha || inspection?.head || null;
|
||||
const running = state.containerRunning;
|
||||
const healthy = state.healthy;
|
||||
add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { liveSha });
|
||||
add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha });
|
||||
add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified.");
|
||||
add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available.");
|
||||
const failed = checks.some((item) => item.status === "fail");
|
||||
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
|
||||
const readiness = failed ? (checks.some((item) => item.id.includes("access") || item.id.includes("key")) ? "Access failed" : checks.some((item) => item.id === "runtime" || item.id === "health") ? "Runtime unhealthy" : "Configuration required") : incomplete ? (branchSha && liveSha && branchSha !== liveSha ? "Commit mismatch" : "Verification incomplete") : "Ready";
|
||||
return { readiness, ready: readiness === "Ready" || readiness === "Commit mismatch", checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
|
||||
}
|
||||
|
||||
permissionTargets(profile, server, remotePath) {
|
||||
const targets = [
|
||||
{
|
||||
id: "server-base",
|
||||
label: "Configured deployment base",
|
||||
path: server.basePath,
|
||||
kind: "directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "project-root",
|
||||
label: "Project folder",
|
||||
path: remotePath,
|
||||
kind: "directory",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "forgeflow-state",
|
||||
label: "ForgeFlow upload and rollback storage",
|
||||
path: path.join(remotePath, ".forgeflow"),
|
||||
kind: "directory",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "forgeflow-incoming",
|
||||
label: "ForgeFlow incoming upload folder",
|
||||
path: path.join(remotePath, ".forgeflow", "incoming"),
|
||||
kind: "directory",
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
if (!profile.generatedCompose) {
|
||||
for (const file of this.deploymentComposeFiles(profile)) {
|
||||
targets.push({
|
||||
id: `compose:${file}`,
|
||||
label: `Compose file ${file}`,
|
||||
path: path.join(remotePath, file),
|
||||
kind: "file",
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const unique = new Map();
|
||||
for (const target of targets) unique.set(`${target.kind}:${target.path}`, target);
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
permissionInspectionScript(profile, server, remotePath) {
|
||||
const targetCalls = this.permissionTargets(profile, server, remotePath)
|
||||
.map(
|
||||
(target) =>
|
||||
`probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`,
|
||||
)
|
||||
.join("\n");
|
||||
return `
|
||||
encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; }
|
||||
can_elevate=false
|
||||
[ "$(id -u)" = 0 ] && can_elevate=true
|
||||
if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi
|
||||
has_acl=false
|
||||
command -v setfacl >/dev/null 2>&1 && has_acl=true
|
||||
printf '__FORGEFLOW_PERMISSIONS__\\n'
|
||||
printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
|
||||
"$(encode "$(id -un 2>/dev/null || echo unknown)")" \
|
||||
"$(id -u 2>/dev/null || echo -1)" \
|
||||
"$(id -g 2>/dev/null || echo -1)" \
|
||||
"$(encode "$(id -Gn 2>/dev/null || true)")" \
|
||||
"$has_acl" "$can_elevate"
|
||||
probe() {
|
||||
target_id=$1
|
||||
label=$2
|
||||
target=$3
|
||||
kind=$4
|
||||
required=$5
|
||||
exists=false; readable=false; writable=false; parent_writable=false; effective=false
|
||||
owner=''; group=''; mode=''; detail=''; nearest=''
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then
|
||||
exists=true
|
||||
[ -r "$target" ] && readable=true
|
||||
[ -w "$target" ] && writable=true
|
||||
owner=$(stat -c '%U' "$target" 2>/dev/null || true)
|
||||
group=$(stat -c '%G' "$target" 2>/dev/null || true)
|
||||
mode=$(stat -c '%a' "$target" 2>/dev/null || true)
|
||||
fi
|
||||
parent=$(dirname "$target")
|
||||
ancestor=$parent
|
||||
while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done
|
||||
nearest=$ancestor
|
||||
marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}"
|
||||
if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then
|
||||
rm -f -- "$marker" >/dev/null 2>&1 || true
|
||||
parent_writable=true
|
||||
fi
|
||||
if [ "$kind" = directory ]; then
|
||||
if [ -d "$target" ]; then
|
||||
marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}"
|
||||
if (umask 077; : > "$marker") 2>/dev/null; then
|
||||
rm -f -- "$marker" >/dev/null 2>&1 || true
|
||||
effective=true
|
||||
fi
|
||||
elif [ "$parent_writable" = true ]; then
|
||||
effective=true
|
||||
fi
|
||||
else
|
||||
if [ "$exists" = true ] && [ ! -f "$target" ]; then
|
||||
detail='Path exists but is not a regular file.'
|
||||
elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then
|
||||
effective=true
|
||||
elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then
|
||||
effective=true
|
||||
detail='File is absent but can be created by the deployment user.'
|
||||
fi
|
||||
fi
|
||||
if [ -z "$detail" ]; then
|
||||
if [ "$effective" = true ]; then detail='Read/write probe passed.'
|
||||
else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi
|
||||
fi
|
||||
printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
|
||||
"$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \
|
||||
"$exists" "$readable" "$writable" "$parent_writable" "$effective" \
|
||||
"$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")"
|
||||
}
|
||||
${targetCalls}
|
||||
`;
|
||||
}
|
||||
|
||||
async inspectWriteAccess({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const result = await this.ssh.exec(
|
||||
server.id,
|
||||
bash(this.permissionInspectionScript(profile, server, remotePath)),
|
||||
{ timeout: 45_000, maxOutput: 2 * 1024 * 1024 },
|
||||
);
|
||||
const report = parsePermissionInspection(result.stdout);
|
||||
report.serverId = server.id;
|
||||
report.remotePath = remotePath;
|
||||
return report;
|
||||
}
|
||||
|
||||
permissionRepairScript(profile, server, remotePath) {
|
||||
const preserve = [
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
...(profile.preservePaths || []),
|
||||
]
|
||||
.map((value) => safeRelativeRemoteFile(value))
|
||||
.filter(Boolean);
|
||||
const pruneExpression = preserve.length
|
||||
? preserve
|
||||
.map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`)
|
||||
.join(" -o ")
|
||||
: "-false";
|
||||
const composePaths = this.deploymentComposeFiles(profile)
|
||||
.map((file) => shellQuote(path.join(remotePath, file)))
|
||||
.join(" ");
|
||||
return `
|
||||
root=${shellQuote(remotePath)}
|
||||
base=${shellQuote(server.basePath)}
|
||||
case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac
|
||||
run_privileged() {
|
||||
if [ "$(id -u)" = 0 ]; then "$@";
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@";
|
||||
else "$@";
|
||||
fi
|
||||
}
|
||||
mkdir_cmd=mkdir
|
||||
if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then
|
||||
run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
|
||||
fi
|
||||
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
|
||||
if command -v setfacl >/dev/null 2>&1; then
|
||||
run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
||||
run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
||||
fi
|
||||
run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
||||
run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
|
||||
if [ -d "$root" ]; then
|
||||
while IFS= read -r -d '' entry; do
|
||||
case "$entry" in
|
||||
"$root/.forgeflow"|"$root/.forgeflow"/*) continue ;;
|
||||
esac
|
||||
run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true
|
||||
if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi
|
||||
done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0)
|
||||
fi
|
||||
for compose_file in ${composePaths || ""}; do
|
||||
[ -e "$compose_file" ] || continue
|
||||
run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true
|
||||
run_privileged chmod u+rw,g+rw "$compose_file"
|
||||
done
|
||||
echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths."
|
||||
`;
|
||||
}
|
||||
|
||||
async repairWriteAccess({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const before = await this.inspectWriteAccess({ repository, profileId });
|
||||
await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), {
|
||||
timeout: 5 * 60_000,
|
||||
maxOutput: 4 * 1024 * 1024,
|
||||
});
|
||||
const after = await this.inspectWriteAccess({ repository, profileId });
|
||||
if (!after.ready) {
|
||||
const error = new Error(
|
||||
`Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`,
|
||||
);
|
||||
error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE";
|
||||
error.permissionReport = after;
|
||||
throw error;
|
||||
}
|
||||
await this.diagnostics?.info("unraid.write-access.repaired", {
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
user: after.identity.user,
|
||||
});
|
||||
return { changed: true, normalized: true, before, after };
|
||||
}
|
||||
}
|
||||
return UnraidAccessMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidAccessMethods };
|
||||
Reference in new issue
Block a user