import { execFileSync } from 'node:child_process'; import { randomBytes, randomInt, randomUUID } from 'node:crypto'; import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; const suffix = randomUUID().slice(0, 8), name = `hop-check-${suffix}`, volume = `hop-check-db-${suffix}`, uploads = `hop-check-files-${suffix}`; const port = String(randomInt(20000, 60000)); // Isolated container only; no host port is published. const bindUploads = process.env.HOP_CHECK_BIND_UPLOADS === '1'; const folder = bindUploads ? mkdtempSync(resolve(tmpdir(), 'hop-bind-files-')) : null; const runtimeUser = bindUploads ? '12001:12001' : '1000:1000'; const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const node = (code) => docker('exec', name, 'node', '--input-type=module', '-e', code); try { docker('volume', 'create', volume); if (bindUploads) { docker( 'run', '--rm', '--network', 'none', '--user', '0:0', '-v', `${volume}:/database`, '--mount', `type=bind,source=${folder},target=/uploads`, process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local', 'sh', '-eu', '-c', `chown 12001:12001 /database /uploads; chmod 700 /database /uploads`, ); } else docker('volume', 'create', uploads); docker( 'run', '-d', '--name', name, '--network', 'none', '--user', runtimeUser, '-e', 'NODE_ENV=production', '-e', `PORT=${port}`, '-e', 'APP_URL=https://hop.test', '-e', 'PAIR_REQUESTS_PER_MINUTE=4', '-e', `SESSION_SECRET=${randomBytes(32).toString('hex')}`, '-e', `DISCOVERY_SECRET=${randomBytes(32).toString('hex')}`, ...(bindUploads ? ['--mount', `type=volume,source=${volume},target=/data/database,volume-nocopy`] : ['-v', `${volume}:/data/database`]), ...(bindUploads ? ['--mount', `type=bind,source=${folder},target=/data/uploads`] : ['-v', `${uploads}:/data/uploads`]), process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local', ); const wait = () => node( `for(let i=0;i<60;i++){try{const r=await fetch('http://127.0.0.1:${port}/api/health');if(r.ok){console.log('ready');break}}catch{}if(i===59)throw Error('not ready');await new Promise(r=>setTimeout(r,250))}`, ); wait(); const state = JSON.parse( node( `const base='http://127.0.0.1:${port}';const r=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test','Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error('session failed');const session=await r.json(),cookie=r.headers.get('set-cookie').split(';')[0];if(!r.headers.get('set-cookie').includes('Secure'))throw Error('insecure cookie');const renamed=await fetch(base+'/api/device/me',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({name:'Docker 저장 검증'})});if(!renamed.ok)throw Error('rename failed');console.log(JSON.stringify({cookie,id:session.device.id}));`, ), ); let verifiedAccount; if (process.env.HOP_CHECK_AUTH === '1') { verifiedAccount = JSON.parse( node(` import Database from 'better-sqlite3'; import {drizzle} from 'drizzle-orm/better-sqlite3'; import argon2 from 'argon2'; import {randomUUID} from 'node:crypto'; import {users, accountTokens} from './apps/server/dist/db/schema.js'; import {hash, randomToken} from './apps/server/dist/security.js'; const base='http://127.0.0.1:${port}', guestCookie=${JSON.stringify(state.cookie)}; const passwordHash=await argon2.hash('12345678'), token=randomToken(), now=Date.now(); const database=new Database('/data/database/hop.sqlite'), db=drizzle(database); const oldId=randomUUID(), nextId=randomUUID(); db.insert(users).values({id:oldId,email:'previous@docker.hop.test',displayName:'previous',passwordHash,status:'active',verifiedAt:now,createdAt:now}).run(); db.insert(users).values({id:nextId,email:'hello@noname.company',displayName:'verified',passwordHash,status:'pending',createdAt:now}).run(); db.insert(accountTokens).values({id:randomUUID(),userId:nextId,purpose:'verify',tokenHash:hash(token),createdAt:now,expiresAt:now+60000}).run(); database.close(); const sessionResponse=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:guestCookie,'Content-Type':'application/json'},body:'{}'}); const session=await sessionResponse.json(); const headers={Origin:'https://hop.test',Cookie:guestCookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'}; const login=await fetch(base+'/api/auth/login',{method:'POST',headers,body:JSON.stringify({email:'previous@docker.hop.test',password:'12345678'})}); if(!login.ok)throw Error(await login.text()); const oldCookie=guestCookie+'; '+login.headers.get('set-cookie').split(';')[0]; const verification=await fetch(base+'/api/auth/verify-email',{method:'POST',headers:{...headers,Cookie:oldCookie},body:JSON.stringify({token})}); if(!verification.ok)throw Error(await verification.text()); const verified=await verification.json(), cookie=verification.headers.get('set-cookie'); if(verified.account.id!==nextId||!verified.account.verified||verified.token)throw Error('wrong verified account'); if(!cookie.includes('Secure')||!cookie.includes('HttpOnly')||!cookie.includes('SameSite=Lax'))throw Error('unprotected verification cookie'); const old=await fetch(base+'/api/auth/me',{headers:{Cookie:oldCookie}}); if((await old.json()).account!==null)throw Error('previous session still valid'); const replay=await fetch(base+'/api/auth/verify-email',{method:'POST',headers:{...headers,Cookie:guestCookie+'; '+cookie.split(';')[0]},body:JSON.stringify({token})}); if(replay.status!==400||replay.headers.get('set-cookie'))throw Error('token replay allowed'); console.log(JSON.stringify({cookie:guestCookie+'; '+cookie.split(';')[0],userId:nextId})); `), ); } if (verifiedAccount) { const saved = JSON.parse( node( `const base='http://127.0.0.1:${port}', cookie=${JSON.stringify(verifiedAccount.cookie)};const session=await(await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:cookie,'Content-Type':'application/json'},body:'{}'})).json();const r=await fetch(base+'/api/admin/settings',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({revision:0,maxConcurrentUploads:3,accountBytes:3e9,deviceBytes:4e9,devicesTotalBytes:12e9,perFileBytes:1e9,minDiskFreeBytes:0,blockedCountries:['CN'],allowedIps:['127.0.0.1'],autoBlockEnabled:true})});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify(await r.json()));`, ), ); assert.equal(saved.maxConcurrentUploads, 3); assert.equal(saved.accountBytes, 3e9); node( `import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');db.prepare('INSERT INTO automatic_blocks (ip,countryCode,reason,createdAt,expiresAt) VALUES (?,?,?,?,?)').run('8.8.8.8','KR','login-failures',Date.now(),Date.now()+1800000);db.close();`, ); } const fileState = JSON.parse( node( `import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`, ), ); if (verifiedAccount && process.env.HOP_CHECK_FILE_POLICY === '1') { node( `const base='http://127.0.0.1:${port}',cookie=${JSON.stringify(verifiedAccount.cookie)};const headers={Origin:'https://hop.test',Cookie:cookie,'Content-Type':'application/json'};const session=await(await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'})).json();const summary=await(await fetch(base+'/api/admin/summary',{headers})).json();const paused=await fetch(base+'/api/admin/settings',{method:'PATCH',headers:{...headers,'X-CSRF-Token':session.csrfToken},body:JSON.stringify({revision:summary.settings.revision,uploadsPaused:true})});if(!paused.ok)throw Error(await paused.text());console.log('file policy paused');`, ); } docker('restart', name); wait(); const result = JSON.parse( node( `const r=await fetch('http://127.0.0.1:${port}/api/device/me',{headers:{Cookie:${JSON.stringify(state.cookie)}}});console.log(JSON.stringify(await r.json()));`, ), ); assert.equal(result.device.name, 'Docker 저장 검증'); assert.equal(result.device.id, state.id); if (verifiedAccount) { const restored = JSON.parse( node( `const r=await fetch('http://127.0.0.1:${port}/api/auth/me',{headers:{Cookie:${JSON.stringify(verifiedAccount.cookie)}}});console.log(JSON.stringify(await r.json()));`, ), ); assert.equal(restored.passwordMinLength, 8); assert.equal(restored.account.id, verifiedAccount.userId); assert.equal(restored.account.verified, true); assert.equal(restored.account.administrator, true); const settings = JSON.parse( node( `const r=await fetch('http://127.0.0.1:${port}/api/admin/summary',{headers:{Cookie:${JSON.stringify(verifiedAccount.cookie)}}});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify((await r.json()).settings));`, ), ); assert.equal(settings.maxConcurrentUploads, 3); for (const [key, value] of Object.entries({ accountBytes: 3e9, deviceBytes: 4e9, devicesTotalBytes: 12e9, perFileBytes: 1e9, minDiskFreeBytes: 0, })) assert.equal(settings[key], value, key); assert.deepEqual(settings.blockedCountries, ['CN']); assert.deepEqual(settings.allowedIps, ['127.0.0.1']); assert.equal(settings.autoBlockEnabled, true); if (process.env.HOP_CHECK_FILE_POLICY === '1') { assert.equal(settings.uploadsPaused, true); const policy = JSON.parse( node( `const base='http://127.0.0.1:${port}',cookie=${JSON.stringify(fileState.cookie)};const response=await fetch(base+'/api/device/snapshot',{headers:{Cookie:cookie}});if(!response.ok)throw Error(await response.text());console.log(JSON.stringify((await response.json()).servicePolicy));`, ), ); assert.equal(policy.fileTransfersAllowed, false); assert.equal(policy.revision, settings.revision); } const adminState = JSON.parse( node( `const headers={Cookie:${JSON.stringify(verifiedAccount.cookie)}};const base='http://127.0.0.1:${port}';const blocks=await(await fetch(base+'/api/admin/automatic-blocks',{headers})).json();const users=await(await fetch(base+'/api/admin/users',{headers})).json();const audit=await(await fetch(base+'/api/admin/audit',{headers})).json();console.log(JSON.stringify({blocks,users,audit}));`, ), ); assert.equal(adminState.blocks.items[0].ip, '8.8.8.8'); assert.ok( adminState.users.users.find((user) => user.id === verifiedAccount.userId)?.lastSeenAt, ); assert.ok( adminState.audit.events.some((event) => event.details.changes?.some((change) => change.field === 'maxConcurrentUploads'), ), ); const contexts = JSON.parse( node( `import Database from 'better-sqlite3'; const db=new Database('/data/database/hop.sqlite');console.log(JSON.stringify(db.prepare('SELECT count(*) AS count FROM file_upload_contexts').get()));db.close();`, ), ); assert.ok(contexts.count > 0, 'per-file upload location snapshots survive restart'); } const preservedText = JSON.parse( node( `const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('text list failed');const item=(await r.json()).items.find(i=>i.accessId===${JSON.stringify(fileState.text.accessId)});if(!item)throw Error('text not preserved');console.log(JSON.stringify(item.text));`, ), ); assert.equal(preservedText, fileState.text.value); const restored = node( `const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('download failed');console.log(await r.text());`, ); assert.equal(restored, 'Docker file persistence'); const resumed = node( `import {createHash} from 'node:crypto';const state=${JSON.stringify(fileState.pending)},base='http://127.0.0.1:${port}',path='/api/uploads/resumable/'+state.uploadId;const headers={Origin:'https://hop.test',Cookie:state.cookie,'X-CSRF-Token':state.csrf};const r=await fetch(base+path,{headers});const progress=await r.json();if(!r.ok||progress.offset!==8*1024*1024)throw Error('partial offset not preserved');const last=await fetch(base+path,{method:'PUT',headers:{...headers,'Content-Type':'application/octet-stream','Upload-Offset':String(progress.offset)},body:Buffer.alloc(7,120)});if(!last.ok)throw Error(await last.text());const complete=await fetch(base+path+'/complete',{method:'POST',headers:{...headers,'Content-Type':'application/json'},body:'{}'});if(!complete.ok)throw Error(await complete.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const item=(await list.json()).items.find(i=>i.file?.name==='resume.bin');const downloaded=await fetch(base+'/api/access/'+item.accessId+'/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const content=Buffer.from(await downloaded.arrayBuffer());if(!downloaded.ok||content.length!==8*1024*1024+7||!content.equals(Buffer.alloc(content.length,120)))throw Error('resumed content mismatch');console.log('resumed');`, ); assert.equal(resumed, 'resumed'); // Per-uploader physical paths and the original download URL survive another restart. docker('restart', name); wait(); assert.equal( node( `const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('owner folder download failed after restart');console.log(await r.text());`, ), 'Docker file persistence', ); const integrity = node( `import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(db.pragma('integrity_check',{simple:true}));db.close();`, ); assert.equal(integrity, 'ok'); if (bindUploads) { assert.equal(docker('inspect', '--format', '{{.Config.User}}', name), runtimeUser); const mode = JSON.parse( node( `import {statSync} from 'node:fs';const s=statSync('/data/uploads/originals');console.log(JSON.stringify({uid:s.uid,gid:s.gid,mode:s.mode&511}));`, ), ); assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 }); const originals = resolve(folder, 'originals'); const stored = readdirSync(originals) .filter((key) => /^[a-f0-9-]{36}$/.test(key)) .flatMap((owner) => readdirSync(resolve(originals, owner)).map((key) => ({ owner, key, path: resolve(originals, owner, key), })), ) .find(({ path }) => readFileSync(path).toString() === 'Docker file persistence'); assert(stored, 'uploaded file is visible under its guest owner in the host project'); assert.match(stored.owner, /^[a-f0-9-]{36}$/); assert.match(stored.key, /^[a-f0-9-]{36}\.txt$/); assert.equal(existsSync(resolve(folder, 'completed')), false); unlinkSync(stored.path); const missing = JSON.parse( node( `const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify({status:r.status,body:await r.json()}));`, ), ); assert.equal(missing.status, 404); assert.equal(missing.body.code, 'FILE_NOT_FOUND'); const visible = JSON.parse( node( `const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify((await r.json()).items));`, ), ); assert.equal( visible.some((item) => item.accessId === fileState.accessId), false, ); assert(visible.some((item) => item.file?.name === 'resume.bin')); } assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}'); if (process.env.HOP_CHECK_RESET === '1') { const protectedState = `import Database from 'better-sqlite3';import {createHash} from 'node:crypto';const db=new Database('/data/database/hop.sqlite');const tables=['users','account_sessions','account_tokens','user_rules','admin_settings','user_activity'];const rows=tables.map(t=>db.prepare('SELECT * FROM '+t).all());console.log(createHash('sha256').update(JSON.stringify(rows)).digest('hex'));db.close();`; const beforeReset = node(protectedState); docker('stop', name); const reset = docker( 'run', '--rm', '--network', 'none', '--volumes-from', name, '--user', runtimeUser, '--mount', `type=bind,source=${resolve('scripts/reset-transfer-records.mjs')},target=/app/scripts/reset-transfer-records.mjs,readonly`, '--env', 'DATABASE_PATH=/data/database/hop.sqlite', '--entrypoint', 'node', process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local', '/app/scripts/reset-transfer-records.mjs', '--confirm-reset-transfer-records', ); assert.match(reset, /초기화 완료/); docker('start', name); wait(); assert.equal( node(protectedState), beforeReset, 'offline reset preserves user/auth/admin data exactly', ); const emptied = JSON.parse( node( `import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(JSON.stringify({files:db.prepare('SELECT count(*) AS n FROM files').get().n,requests:db.prepare('SELECT count(*) AS n FROM transfer_requests').get().n,guests:db.prepare('SELECT count(*) AS n FROM guest_sessions').get().n,integrity:db.pragma('integrity_check',{simple:true})}));db.close();`, ), ); assert.deepEqual(emptied, { files: 0, requests: 0, guests: 0, integrity: 'ok' }); } console.log( `PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${process.env.HOP_CHECK_FILE_POLICY === '1' ? ' File ban survives restart while previously accepted chunks and existing downloads complete.' : ''}${process.env.HOP_CHECK_RESET === '1' ? ' Offline transfer reset preserves accounts and admin settings.' : ''}${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token, admin role and persisted admin controls after restart.' : ''}`, ); } catch (error) { console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' })); console.error(docker('inspect', '--format', '{{json .State}}', name)); throw error; } finally { try { docker('rm', '-f', name); } catch {} for (const v of [volume, ...(bindUploads ? [] : [uploads])]) try { docker('volume', 'rm', v); } catch {} if (folder) rmSync(folder, { recursive: true, force: true }); }