Fix shared upload permissions for capability-restricted LTX backend

This commit is contained in:
Mikei386
2026-09-29 19:45:38 +02:00
parent 64573e01a3
commit 5b887dfb42
7 changed files with 41 additions and 7 deletions
+3 -1
View File
@@ -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. 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. 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 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. 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`. 6. Start: `docker compose up -d --build`.
7. Stop: `docker compose down`. Shared media is retained; LTX keeps running. 7. Stop: `docker compose down`. Shared media is retained; LTX keeps running.
+2
View File
@@ -3,6 +3,8 @@
services: services:
ltx-deskweb: ltx-deskweb:
container_name: ltx-deskweb container_name: ltx-deskweb
# Shared read group with the existing capability-restricted LTX backend.
user: "1000:0"
network_mode: host network_mode: host
ports: !reset [] ports: !reset []
extra_hosts: !reset [] extra_hosts: !reset []
+4
View File
@@ -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 traversal and symlinks escaping those roots are rejected. ffmpeg/ffprobe create
thumbnails, dimensions and extracted frames on CPU; they are not inference runtimes. thumbnails, dimensions and extracted frames on CPU; they are not inference runtimes.
Network protocols are disabled for media inspection. HTTP Range supports seeking. 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: initial preview retains the upstream browser-local project storage.
Projects are specific to this browser/origin, not multiuser or cross-device synced. Projects are specific to this browser/origin, not multiuser or cross-device synced.
+5 -2
View File
@@ -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 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 `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 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: Filtered Deck inventory labels:
@@ -17,7 +18,9 @@ Filtered Deck inventory labels:
Media mounts: Media mounts:
- `/data/video/ltx-desktop/LTXDesktop/remote-inputs/deskweb` → writable inputs. - `/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. - `/data/video/ltx-desktop/LTXDesktop/outputs` → read-only outputs.
Backend-visible input path: `/data/LTXDesktop/remote-inputs/deskweb`. Backend-visible input path: `/data/LTXDesktop/remote-inputs/deskweb`.
+18
View File
@@ -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`. Deck's actual Unix-socket helper inventory returns `ltx-deskweb` as `running`.
Existing Medium, TTS, WireGuard and Deck remain running; LTX remains stopped. Existing Medium, TTS, WireGuard and Deck remain running; LTX remains stopped.
Real video generation was not requested or tested in this deployment. 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.
+4 -3
View File
@@ -1,7 +1,7 @@
import http from 'node:http' import http from 'node:http'
import https from 'node:https' import https from 'node:https'
import { createReadStream, createWriteStream } from 'node:fs' 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 path from 'node:path'
import { randomBytes, randomUUID, timingSafeEqual, createHash } from 'node:crypto' import { randomBytes, randomUUID, timingSafeEqual, createHash } from 'node:crypto'
import { pipeline } from 'node:stream/promises' 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') if(Number(req.headers['content-length'])>limit)throw fail(413,'Upload too large')
const target=outputName(ext);let size=0 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)}}) 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}) return json(res,201,{success:true,path:target.backend})
} }
if(url.pathname==='/web/files'&&req.method==='POST') { if(url.pathname==='/web/files'&&req.method==='POST') {
@@ -131,7 +131,7 @@ export async function createApp(config) {
const source=await resolveMedia(p.path) const source=await resolveMedia(p.path)
if(p.action==='prepare')return json(res,200,{success:true,path:p.path}) if(p.action==='prepare')return json(res,200,{success:true,path:p.path})
if(p.action==='copy') { 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'||p.action==='thumbnail'||p.action==='frame') {
if(p.action==='metadata') { 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') if(!Number.isFinite(time)||time<0)throw fail(400,'Invalid frame time')
const target=outputName('.jpg') 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 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}) return json(res,200,{success:true,path:target.backend,bigThumbnailPath:target.backend,smallThumbnailPath:target.backend})
} }
throw fail(400,'Unsupported file operation') throw fail(400,'Unsupported file operation')
+5 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import http from 'node:http' 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 os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { createApp } from './app.mjs' 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 {call,dir}=await fixture(t)
const upload=await call('/web/upload?name=clip.mp4',{method:'POST',body:'0123456789'}) 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(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') 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'}}) 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') 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) const {call,dir}=await fixture(t)
// Deliberately tiny synthetic PPM pixels; ffprobe detects content, not extension. // 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 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 invoke=async action=>{
const response=await call('/web/files',{method:'POST',body:JSON.stringify({action,path:'/ltx/outputs/test.png'})}) 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() assert.equal(response.status,200);return response.json()
} }
const metadata=await invoke('metadata');assert.equal(metadata.width,2);assert.equal(metadata.height,2) 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/')) 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')) 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) 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) assert.equal(response.headers.get('content-type'),'image/jpeg');assert.ok((await response.arrayBuffer()).byteLength>100)
}) })