Fix shared upload permissions for capability-restricted LTX backend

This commit is contained in:
Mikei386 committed 2026-09-29 19:45:38 +02:00
1 parent 64573e01a3
commit 5b887dfb42
7 files changed
+41 -7

No files matched your search

+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.
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.
+2
View File
@@ -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 []
+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
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.
+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
`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`.
+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`.
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.
+4 -3
View File
@@ -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')
+5 -1
View File
@@ -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)
})