From 5b887dfb4247f206184becabae797a5c6d854679 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:45:38 +0200 Subject: [PATCH] Fix shared upload permissions for capability-restricted LTX backend --- README.md | 4 +++- deploy/compose.athena.yaml | 2 ++ docs/ARCHITECTURE.md | 4 ++++ docs/ATHENA_DEPLOYMENT.md | 7 +++++-- docs/VALIDATION.md | 18 ++++++++++++++++++ server/app.mjs | 7 ++++--- server/app.test.mjs | 6 +++++- 7 files changed, 41 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 430a351..39c0c79 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ ffmpeg only. It does not install or restart LTX, Deck, WireGuard or a GPU driver these files. Set file permissions so only the selected WEB_UID can read them. For a backend without authentication, the token file can be empty. 4. Ensure WEB_UID/WEB_GID can write the input directory and read the output - directory. Do not recursively change ownership of existing LTX data. + directory. Use a shared group with the LTX process for inputs: a dedicated + input directory with mode 2750, files 0640. Both containers need this group. + Do not recursively change ownership of existing LTX data. 5. Set PUBLIC_ORIGIN to the browser URL, e.g. `http://127.0.0.1:8118` for an SSH tunnel. 6. Start: `docker compose up -d --build`. 7. Stop: `docker compose down`. Shared media is retained; LTX keeps running. diff --git a/deploy/compose.athena.yaml b/deploy/compose.athena.yaml index cc843f8..9fcd794 100644 --- a/deploy/compose.athena.yaml +++ b/deploy/compose.athena.yaml @@ -3,6 +3,8 @@ services: ltx-deskweb: container_name: ltx-deskweb + # Shared read group with the existing capability-restricted LTX backend. + user: "1000:0" network_mode: host ports: !reset [] extra_hosts: !reset [] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 12bc983..ce39399 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,10 @@ outputs are mounted read-only. Only configured input/output roots are readable; traversal and symlinks escaping those roots are rejected. ffmpeg/ffprobe create thumbnails, dimensions and extracted frames on CPU; they are not inference runtimes. Network protocols are disabled for media inspection. HTTP Range supports seeking. +Uploads, copied assets and thumbnails are mode 0640. Provision the writable input +folder with a shared backend group and mode 2750 (setgid), so new files inherit +that group. Both service identities must be able to traverse/read the shared +folder; a root UID with dropped capabilities cannot bypass ordinary permissions. Projects: initial preview retains the upstream browser-local project storage. Projects are specific to this browser/origin, not multiuser or cross-device synced. diff --git a/docs/ATHENA_DEPLOYMENT.md b/docs/ATHENA_DEPLOYMENT.md index 32cab41..16804d6 100644 --- a/docs/ATHENA_DEPLOYMENT.md +++ b/docs/ATHENA_DEPLOYMENT.md @@ -7,7 +7,8 @@ The additional `deploy/compose.athena.yaml` uses host networking **only for this frontend**, binds its web server to `127.0.0.1:8118`, and connects to Deck at `http://127.0.0.1:8120`. This avoids changing Deck's existing network or relying on its current Docker IP. No Docker socket, GPU devices or inference packages are -mounted into the GUI. CPU and RAM limits: 2 CPUs / 1 GiB. +mounted into the GUI. CPU and RAM limits: 2 CPUs / 1 GiB. GUI identity: UID 1000 / GID 0; +this supplies shared-file read access, not root UID or Linux capabilities. Filtered Deck inventory labels: @@ -17,7 +18,9 @@ Filtered Deck inventory labels: Media mounts: - `/data/video/ltx-desktop/LTXDesktop/remote-inputs/deskweb` → writable inputs. - A new isolated directory owned by UID/GID 1000; no recursive permission changes. + A new isolated directory owned by UID 1000 / GID 0, mode 2750; no changes to other LTX directories. + Files use mode 0640. The existing LTX backend runs with GID 0 and dropped + capabilities, so group-read/traverse permission is required even for its root UID. - `/data/video/ltx-desktop/LTXDesktop/outputs` → read-only outputs. Backend-visible input path: `/data/LTXDesktop/remote-inputs/deskweb`. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 8d03ce7..4094d35 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -33,3 +33,21 @@ synthetic text upload and readback through shared input storage (test file remov Deck's actual Unix-socket helper inventory returns `ltx-deskweb` as `running`. Existing Medium, TTS, WireGuard and Deck remain running; LTX remains stopped. Real video generation was not requested or tested in this deployment. + +## Shared-input permission fix — 2026-09-29 + +A real I2V request exposed a cross-container permission error: private uploads +(0600 in a 0750 UID/GID-1000 directory) were invisible to LTX's UID/GID-0 process +because its container drops all Linux capabilities. Same bind mount/path did not +imply read permission. The API reported this as “Image file not found”. + +Dedicated DeskWEB input directory now uses UID 1000 / GID 0, mode 2750; uploads, +asset copies and thumbnails use 0640. Athena GUI runs as 1000:0 with capabilities +still dropped. Only existing DeskWEB-owned input files had their permissions +corrected; no other media directory, backend container or GPU service was changed. + +Validation: build/typecheck and all 8 tests pass; integration tests now assert +0640 on uploads, copies (including a 0600 source) and thumbnails. On Athena the +reported image's existence/read permission was verified from inside LTX without +opening its content. A fresh synthetic upload was also checked from LTX and then +removed. Only the GUI was recreated; real image generation remains a user retry. diff --git a/server/app.mjs b/server/app.mjs index 4c889ed..33f8660 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,7 +1,7 @@ import http from 'node:http' import https from 'node:https' import { createReadStream, createWriteStream } from 'node:fs' -import { readFile, mkdir, realpath, stat, unlink, copyFile } from 'node:fs/promises' +import { readFile, mkdir, realpath, stat, unlink, copyFile, chmod } from 'node:fs/promises' import path from 'node:path' import { randomBytes, randomUUID, timingSafeEqual, createHash } from 'node:crypto' import { pipeline } from 'node:stream/promises' @@ -119,7 +119,7 @@ export async function createApp(config) { if(Number(req.headers['content-length'])>limit)throw fail(413,'Upload too large') const target=outputName(ext);let size=0 const limiter=new Transform({transform(chunk,_encoding,done){size+=chunk.length;done(size>limit?fail(413,'Upload too large'):null,chunk)}}) - try {await pipeline(req,limiter,createWriteStream(target.local,{flags:'wx',mode:0o600}))} catch(e){await unlink(target.local).catch(()=>{});throw e} + try {await pipeline(req,limiter,createWriteStream(target.local,{flags:'wx',mode:0o640}));await chmod(target.local,0o640)} catch(e){await unlink(target.local).catch(()=>{});throw e} return json(res,201,{success:true,path:target.backend}) } if(url.pathname==='/web/files'&&req.method==='POST') { @@ -131,7 +131,7 @@ export async function createApp(config) { const source=await resolveMedia(p.path) if(p.action==='prepare')return json(res,200,{success:true,path:p.path}) if(p.action==='copy') { - const target=outputName(path.extname(source));await copyFile(source,target.local);return json(res,200,{success:true,path:target.backend}) + const target=outputName(path.extname(source));await copyFile(source,target.local);await chmod(target.local,0o640);return json(res,200,{success:true,path:target.backend}) } if(p.action==='metadata'||p.action==='thumbnail'||p.action==='frame') { if(p.action==='metadata') { @@ -143,6 +143,7 @@ export async function createApp(config) { if(!Number.isFinite(time)||time<0)throw fail(400,'Invalid frame time') const target=outputName('.jpg') await exec('ffmpeg',['-nostdin','-v','error','-ss',String(time),'-protocol_whitelist','file,pipe','-i',source,'-frames:v','1','-vf','scale=960:-2','-y',target.local],{timeout:60000,maxBuffer:1024*1024}) + await chmod(target.local,0o640) return json(res,200,{success:true,path:target.backend,bigThumbnailPath:target.backend,smallThumbnailPath:target.backend}) } throw fail(400,'Unsupported file operation') diff --git a/server/app.test.mjs b/server/app.test.mjs index b688fa0..315a6f2 100644 --- a/server/app.test.mjs +++ b/server/app.test.mjs @@ -1,7 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import http from 'node:http' -import { mkdtemp, mkdir, writeFile, readFile, symlink, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile, readFile, symlink, rm, stat, chmod } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { createApp } from './app.mjs' @@ -54,6 +54,7 @@ test('upload, backend path, range playback and existence check',async t=>{ const {call,dir}=await fixture(t) const upload=await call('/web/upload?name=clip.mp4',{method:'POST',body:'0123456789'}) assert.equal(upload.status,201);const result=await upload.json();assert.ok(result.path.startsWith('/ltx/inputs/')) + assert.equal((await stat(path.join(dir,'input',path.basename(result.path)))).mode & 0o777,0o640) assert.equal(await readFile(path.join(dir,'input',path.basename(result.path)),'utf8'),'0123456789') const media=await call('/web/media?path='+encodeURIComponent(result.path),{headers:{Range:'bytes=2-5'}}) assert.equal(media.status,206);assert.equal(media.headers.get('content-range'),'bytes 2-5/10');assert.equal(await media.text(),'2345') @@ -76,13 +77,16 @@ test('shared media can be inspected, copied and thumbnailed without an inference const {call,dir}=await fixture(t) // Deliberately tiny synthetic PPM pixels; ffprobe detects content, not extension. await writeFile(path.join(dir,'output','test.png'),Buffer.concat([Buffer.from('P6\n2 2\n255\n'),Buffer.from([0,0,255,0,0,255,0,0,255,0,0,255])])) + await chmod(path.join(dir,'output','test.png'),0o600) const invoke=async action=>{ const response=await call('/web/files',{method:'POST',body:JSON.stringify({action,path:'/ltx/outputs/test.png'})}) assert.equal(response.status,200);return response.json() } const metadata=await invoke('metadata');assert.equal(metadata.width,2);assert.equal(metadata.height,2) const copy=await invoke('copy');assert.ok(copy.path.startsWith('/ltx/inputs/')) + assert.equal((await stat(path.join(dir,'input',path.basename(copy.path)))).mode & 0o777,0o640) const thumbnail=await invoke('thumbnail');assert.ok(thumbnail.path.endsWith('.jpg')) + assert.equal((await stat(path.join(dir,'input',path.basename(thumbnail.path)))).mode & 0o777,0o640) const response=await call('/web/media?path='+encodeURIComponent(thumbnail.path));assert.equal(response.status,200) assert.equal(response.headers.get('content-type'),'image/jpeg');assert.ok((await response.arrayBuffer()).byteLength>100) })