Conecta SFTP con navegadores usando WebAssembly y WebSockets
Usa una puerta de enlace de Node.js para que un navegador pueda listar y descargar un pequeño catálogo SFTP autorizado, manteniendo las credenciales SSH en el servidor. Este tutorial incluye un servidor OpenSSH desechable y un emisor local de tokens, para que puedas probar la ruta completa del navegador al servidor SFTP en tu propia máquina.
Elige dónde termina SSH
SFTP normalmente se ejecuta dentro de SSH sobre TCP. Un WebSocket del navegador usa un intercambio inicial HTTP y transporta tramas WebSocket; conectarlo al puerto 22 no lo convierte en un socket SSH. WebAssembly puede ejecutar código SSH, pero no proporciona sockets TCP nativos a una página web estándar.
Con SSH del lado del navegador, un proxy de transporte proporciona conectividad de red mientras el
navegador autentica el host y el usuario SSH. Con una puerta de enlace de API HTTP, SSH termina en la
puerta de enlace, que autoriza cada operación de la aplicación. El siguiente ejemplo usa el segundo
diseño: un catálogo fijo con un único ID público de archivo, sin rutas ni destinos controlados por el
navegador y con el ámbito obligatorio sftp:read.
Crea el proyecto de la puerta de enlace
El ejemplo local se probó en Linux con Node.js 24.22.1
y 26.12.1, Yarn 4.12.0, OpenSSH 9.2 y Chromium 152.
Esta guía está destinada a versiones con mantenimiento de Node 24 o 26; esas versiones de parche
documentan las versiones probadas. El lanzador usa el soporte integrado de Node para TypeScript.
Instala Docker Engine y las herramientas de cliente OpenSSH por separado,
y asegúrate de que node, curl, docker y ssh-keygen estén en tu PATH.
El lanzador usa el socket local de Docker en /var/run/docker.sock; nunca inicia un demonio SSH en el host.
Pega esto en una shell POSIX. Crea un directorio nuevo y usa una caché y un archivo de bloqueo privados
de Yarn, incluso cuando el directorio padre es un proyecto Yarn. La subshell deja tu directorio
actual y tus opciones sin cambios. Se rechaza un directorio sftp-browser-demo existente.
(
command -v node >/dev/null && command -v curl >/dev/null &&
command -v docker >/dev/null && command -v ssh-keygen >/dev/null &&
mkdir sftp-browser-demo && cd sftp-browser-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0","dependencies":{"express":"5.2.1","helmet":"8.3.0","jose":"6.2.12","ssh2-sftp-client":"12.1.1"}}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' 'cacheFolder: .yarn/cache' 'globalFolder: .yarn/global' 'installStatePath: .yarn/install-state.gz' 'enableTelemetry: false' 'injectEnvironmentFiles: []' > .yarnrc.yml &&
touch yarn.lock &&
curl -fsSLo yarn-4.12.0.cjs https://repo.yarnpkg.com/4.12.0/packages/yarnpkg-cli/bin/yarn.js &&
node yarn-4.12.0.cjs install
)
Crea todos los archivos siguientes dentro de sftp-browser-demo. Si la instalación falla,
conserva sus archivos de paquete, corrige el fallo de dependencia o de red indicado y vuelve a
intentar la instalación desde el directorio padre:
(cd sftp-browser-demo &&
curl -fsSLo yarn-4.12.0.cjs https://repo.yarnpkg.com/4.12.0/packages/yarnpkg-cli/bin/yarn.js &&
node yarn-4.12.0.cjs install)
El nuevo intento también vuelve a descargar la CLI si su primera descarga falló. La CLI se guarda
como .cjs para que un proyecto de módulos ES que la contenga no pueda reinterpretarla.
Autoriza y lee el catálogo fijo
Guarda esto como index.mjs. La biblioteca SFTP con versión fijada
proporciona las API de rutas canónicas, consulta de atributos de archivos y flujos. Los archivos
pueden estar vacíos y tienen un límite de 5 MiB. La puerta de enlace permite dos descargas activas
por proceso. Cada descarga dispone de 30 segundos para su conexión SFTP, lectura y entrega HTTP.
El archivo, cuyo tamaño está limitado, se almacena en un búfer antes de enviar una respuesta HTTP
exitosa, por lo que un error SFTP después de un avance parcial sigue siendo una descarga fallida.
import { randomUUID } from 'node:crypto'
import { createConnection } from 'node:net'
import { Readable, Transform, Writable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'node:url'
import express from 'express'
import helmet from 'helmet'
import { importSPKI, jwtVerify } from 'jose'
import SftpClient from 'ssh2-sftp-client'
const {
APP_ORIGIN, JWT_PUBLIC_KEY, JWT_ISSUER, JWT_AUDIENCE,
SFTP_HOST, SFTP_USERNAME, SFTP_PRIVATE_KEY, SFTP_HOST_SHA256,
} = process.env
if (!APP_ORIGIN || !JWT_PUBLIC_KEY || !JWT_ISSUER || !JWT_AUDIENCE ||
!SFTP_HOST || !SFTP_USERNAME || !SFTP_PRIVATE_KEY ||
!/^[a-f0-9]{64}$/.test(SFTP_HOST_SHA256 ?? '')) {
throw new Error('Gateway configuration is incomplete')
}
const sshPort = Number(process.env.SFTP_PORT ?? 22)
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) {
throw new Error('Invalid SFTP port')
}
const key = await importSPKI(JWT_PUBLIC_KEY, 'RS256')
const webOrigin = new URL(APP_ORIGIN)
const localHttp = webOrigin.protocol === 'http:' &&
['localhost', '127.0.0.1', '[::1]'].includes(webOrigin.hostname)
if (webOrigin.origin !== APP_ORIGIN || (!localHttp && webOrigin.protocol !== 'https:')) {
throw new Error('Use an HTTPS origin or loopback HTTP for local development')
}
const catalog = new Map([
['report', { label: 'Sample file', path: '/exports/report.bin' }],
])
const maxBytes = 5 * 1024 * 1024
let active = 0
export const app = express()
app.disable('x-powered-by')
app.use(helmet({
contentSecurityPolicy: {
// Keep local Safari from upgrading this demo's HTTP assets to HTTPS.
directives: { 'upgrade-insecure-requests': localHttp ? null : [] },
},
strictTransportSecurity: localHttp ? false : undefined,
}))
app.use((_req, res, next) => {
res.locals.requestId = randomUUID()
res.set('X-Request-ID', res.locals.requestId)
res.set('Cache-Control', 'no-store')
next()
})
app.use('/files', async (req, res, next) => {
// Same-origin fetches may omit Origin on GET. Require a custom header and disable CORS.
const origin = req.get('origin')
if ((origin && origin !== APP_ORIGIN) || req.get('X-SFTP-Client') !== 'browser' ||
req.get('sec-fetch-site') === 'cross-site' || Object.keys(req.query).length !== 0) {
return res.status(403).json({ error: 'Request not allowed' })
}
try {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.get('authorization') ?? '')
if (!match) return res.status(401).json({ error: 'Authentication required' })
const { payload } = await jwtVerify(match[1], key, {
algorithms: ['RS256'], issuer: JWT_ISSUER, audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'], maxTokenAge: '15m',
})
if (typeof payload.scope !== 'string' || !payload.scope.split(' ').includes('sftp:read')) {
return res.status(403).json({ error: 'Permission denied' })
}
} catch {
return res.status(401).json({ error: 'Invalid access token' })
}
next()
})
app.get('/files', (_req, res) => {
res.json(Array.from(catalog, ([id, item]) => ({ id, label: item.label })))
})
app.get('/files/:id', async (req, res) => {
const item = catalog.get(req.params.id)
if (!item) return res.status(404).json({ error: 'File not found' })
if (active >= 2) return res.status(503).json({ error: 'Gateway busy' })
active += 1
const abort = new AbortController()
const sftp = new SftpClient('gateway', {
error: () => abort.abort(),
end: () => abort.abort(),
close: () => abort.abort(),
})
const timer = setTimeout(() => abort.abort(), 30_000)
const disconnect = () => { if (!res.writableFinished) abort.abort() }
res.once('close', disconnect)
const socket = createConnection({ host: SFTP_HOST, port: sshPort })
socket.on('error', () => {})
const cancelSocket = () => socket.destroy()
abort.signal.addEventListener('abort', cancelSocket)
try {
await sftp.connect({
sock: socket, username: SFTP_USERNAME, privateKey: SFTP_PRIVATE_KEY,
hostHash: 'sha256', hostVerifier: (hash) => hash === SFTP_HOST_SHA256,
readyTimeout: 10_000,
})
if (await sftp.realPath(item.path) !== item.path) throw new Error('Unexpected path')
const info = await sftp.lstat(item.path)
if (!info.isFile || info.isSymbolicLink || !Number.isSafeInteger(info.size) ||
info.size < 0 || info.size > maxBytes) throw new Error('Invalid file')
abort.signal.throwIfAborted()
let received = 0
const limit = new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length
callback(received > info.size ? new Error('Byte limit exceeded') : null, chunk)
},
flush(callback) {
callback(received !== info.size ? new Error('Incomplete file') : null)
},
})
// With files this small, finish SFTP before committing an HTTP success response.
const chunks = []
const sink = new Writable({
write(chunk, _encoding, callback) { chunks.push(chunk); callback() },
})
await pipeline(sftp.createReadStream(item.path), limit, sink, { signal: abort.signal })
abort.signal.throwIfAborted()
res.set('Content-Type', 'application/octet-stream')
res.set('Content-Disposition', 'attachment; filename="' + req.params.id + '.bin"')
res.set('Content-Length', String(received))
await pipeline(Readable.from([Buffer.concat(chunks, received)]), res, { signal: abort.signal })
} catch {
console.error(JSON.stringify({ event: 'download_failed', requestId: res.locals.requestId }))
if (!res.headersSent && !res.destroyed) {
res.removeHeader('Content-Length')
res.removeHeader('Content-Disposition')
res.status(502).json({ error: 'Download failed', requestId: res.locals.requestId })
} else {
res.destroy()
}
} finally {
socket.destroy()
await sftp.end().catch(() => {})
clearTimeout(timer)
res.off('close', disconnect)
abort.signal.removeEventListener('abort', cancelSocket)
active -= 1
}
})
app.use(express.static(fileURLToPath(new URL('./public/', import.meta.url))))
app.use((_error, _req, res, _next) => {
if (!res.headersSent) res.status(500).json({ error: 'Request failed' })
})
const port = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid HTTP port')
const server = app.listen(port, '127.0.0.1', (error) => {
if (error) {
console.error('Gateway could not listen; check PORT and whether it is in use')
process.exitCode = 1
return
}
console.log('Open ' + APP_ORIGIN)
})
server.requestTimeout = 35_000
server.headersTimeout = 10_000
Conecta el navegador
Guarda esto como public/index.html, creando primero el directorio public.
El campo del token es para este ejercicio local; la página no incluye ninguna credencial SSH compartida.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>SFTP file browser</title>
</head>
<body>
<h1>SFTP files</h1>
<label>Your access token <input id="token" type="password" autocomplete="off" /></label>
<button id="list" type="button">List available files</button>
<ul id="files"></ul>
<p id="status" role="status"></p>
<script src="/files.js" defer></script>
</body>
</html>
Guarda esto como public/files.js. La página desactiva sus campos durante cada solicitud
y conserva como máximo una URL de objeto de descarga. Una solicitud de catálogo fallida borra la lista anterior.
const token = document.getElementById('token')
const list = document.getElementById('list')
const files = document.getElementById('files')
const status = document.getElementById('status')
let busy = false
let downloadUrl
function setBusy(value) {
busy = value
token.disabled = value
for (const button of document.querySelectorAll('button')) button.disabled = value
}
window.addEventListener('pagehide', () => {
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
})
async function api(path) {
const response = await fetch(path, {
headers: {
Authorization: 'Bearer ' + token.value,
'X-SFTP-Client': 'browser',
},
signal: AbortSignal.timeout(35_000),
})
if (!response.ok) throw new Error('Request rejected')
return response
}
list.addEventListener('click', async () => {
if (busy) return
setBusy(true)
status.textContent = 'Loading files…'
files.replaceChildren()
try {
const response = await api('/files')
const catalog = await response.json()
for (const item of catalog) {
const li = document.createElement('li')
const button = document.createElement('button')
button.type = 'button'
button.textContent = 'Download ' + item.label
button.addEventListener('click', async () => {
if (busy) return
setBusy(true)
status.textContent = 'Downloading…'
try {
const result = await api('/files/' + encodeURIComponent(item.id))
const blob = await result.blob()
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
downloadUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = item.id + '.bin'
link.click()
status.textContent = 'Download received.'
} catch {
status.textContent = 'Download failed.'
} finally {
setBusy(false)
}
})
li.append(button)
files.append(li)
}
status.textContent = 'Available files loaded.'
} catch {
status.textContent = 'Could not load files.'
} finally {
setBusy(false)
}
})
Proporciona un servidor SFTP local y un token
Guarda esto como sftp-server.Dockerfile. La instalación de paquetes ocurre dentro de una
imagen de contenedor propia, y el lanzador copia solo este Dockerfile y la configuración SSH en su
contexto de compilación. Las claves y los tokens se generan después y nunca entran en la imagen.
FROM node:26.12.1-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && \
rm -rf /var/lib/apt/lists/* /etc/ssh/ssh_host_* && \
useradd --uid 10001 --home-dir /exports --shell /usr/sbin/nologin fixture && \
passwd -d fixture && mkdir -p /srv/sftp/exports && chmod 755 /srv/sftp
COPY sshd_config /etc/ssh/demo_config
CMD ["sh", "-c", "mkdir -p /run/sshd && cp /fixture/host-key /run/host-key && chmod 600 /run/host-key && cp /fixture/client-key.pub /run/authorized_keys && chmod 644 /run/authorized_keys && timeout --signal=TERM --kill-after=5 600 /usr/sbin/sshd -D -e -f /etc/ssh/demo_config"]
Guarda esto como sshd_config. Esto reemplaza por completo la configuración SSH
del contenedor de demostración. No incluye los archivos de configuración de la distribución.
La cuenta está confinada mediante chroot, y
internal-sftp -R rechaza las escrituras.
Port 22
ListenAddress 0.0.0.0
HostKey /run/host-key
PidFile /run/sshd.pid
AuthorizedKeysFile /run/authorized_keys
StrictModes yes
AllowUsers fixture
PermitRootLogin no
PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
X11Forwarding no
PermitTTY no
PermitUserRC no
Subsystem sftp internal-sftp
Match User fixture
ChrootDirectory /srv/sftp
ForceCommand internal-sftp -R -d /exports
Guarda esto como demo.ts. Crea claves SSH nuevas, calcula la huella fijada
de la clave de host local de confianza, emite un token RS256 de cinco minutos e inicia ambos servicios
en puertos disponibles de la interfaz de bucle local. El token permanece en un archivo privado.
Dos tokens adicionales permiten probar un ámbito incorrecto y la caducidad.
Este emisor demuestra el contrato JWT; no verifica la integración con un proveedor de identidad alojado.
import { type ChildProcess, execFile, spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { createConnection, createServer } from 'node:net'
import { join } from 'node:path'
import { setTimeout as pause } from 'node:timers/promises'
import { promisify } from 'node:util'
import { exportSPKI, generateKeyPair, SignJWT } from 'jose'
const root = import.meta.dirname
const state = join(root, '.demo')
const owner = randomUUID()
const name = 'sftp-browser-' + owner
const exec = promisify(execFile)
await mkdir(state, { mode: 0o700 }) // Refuse an existing directory; never clear someone else's state.
const environment = { PATH: process.env.PATH, DOCKER_HOST: 'unix:///var/run/docker.sock',
DOCKER_CONFIG: join(state, 'docker') }
async function command(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return exec(file, args, { cwd: root, env: environment, timeout: 240_000, maxBuffer: 8 * 1024 * 1024 })
}
const docker = (args: string[]) => command('docker', args)
let gateway: ChildProcess | undefined
let imageBuilt = false
let stopped = false
const stop = () => { stopped = true; gateway?.kill('SIGTERM') }
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const lifetime = setTimeout(stop, 10 * 60_000)
try {
await writeFile(join(state, 'resources.json'), JSON.stringify({ owner, name }) + '\n', { mode: 0o600 })
for (const dir of ['docker', 'build', 'exports']) await mkdir(join(state, dir))
await copyFile(join(root, 'sftp-server.Dockerfile'), join(state, 'build', 'Dockerfile'))
await copyFile(join(root, 'sshd_config'), join(state, 'build', 'sshd_config'))
await docker(['build', '--tag', name, join(state, 'build')])
imageBuilt = true
if (stopped) throw new Error('Demo was stopped during startup')
for (const key of ['host-key', 'client-key']) {
await command('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(state, key)])
}
if (stopped) throw new Error('Demo was stopped during startup')
// A deterministic binary sample, including NUL bytes and content beyond the first read.
const sample = Buffer.from(Array.from({ length: 65_568 }, (_, i) => i % 251))
await writeFile(join(state, 'exports', 'report.bin'), sample, { mode: 0o644 })
await chmod(join(state, 'exports'), 0o755)
const publicHost = (await readFile(join(state, 'host-key.pub'), 'utf8')).split(' ')[1]
if (!publicHost) throw new Error('Missing host public key')
const pin = createHash('sha256').update(Buffer.from(publicHost, 'base64')).digest('hex')
await docker(['run', '--detach', '--rm', '--name', name, '--label', 'sftp.demo.owner=' + owner,
'--read-only', '--tmpfs', '/run', '-p', '127.0.0.1::22',
'--mount', `type=bind,src=${state},dst=/fixture,readonly`,
'--mount', `type=bind,src=${join(state, 'exports')},dst=/srv/sftp/exports,readonly`, name])
const sshPort = Number((await docker(['port', name, '22/tcp'])).stdout.trim().split(':').at(-1))
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) throw new Error('Invalid published SSH port')
let ready = false
for (let attempt = 0; attempt < 40 && !stopped; attempt++) {
ready = await new Promise<boolean>((resolve) => {
const socket = createConnection({ host: '127.0.0.1', port: sshPort })
socket.setTimeout(500)
const done = (ok: boolean) => { socket.destroy(); resolve(ok) }
socket.once('data', (data) => done(data.toString().startsWith('SSH-2.0-OpenSSH_')))
socket.once('error', () => done(false))
socket.once('timeout', () => done(false))
})
if (ready) break
await pause(100)
}
if (!ready || stopped) throw new Error('Demo SSH server did not become ready')
const probe = createServer()
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve))
const address = probe.address()
if (!address || typeof address === 'string') throw new Error('Missing HTTP port')
const port = address.port
await new Promise<void>((resolve) => probe.close(() => resolve()))
const { publicKey, privateKey } = await generateKeyPair('RS256')
const now = Math.floor(Date.now() / 1000)
for (const [file, scope, expiry] of [
['token.txt', 'sftp:read', now + 300],
['wrong-scope.txt', 'sftp:upload', now + 300],
['expired.txt', 'sftp:read', now - 60],
] satisfies [string, string, number][]) {
const access = await new SignJWT({ scope }).setProtectedHeader({ alg: 'RS256' })
.setSubject('local-reader').setIssuer('urn:sftp-browser-demo').setAudience('sftp-browser')
.setIssuedAt(now).setExpirationTime(expiry).sign(privateKey)
await writeFile(join(state, file), access + '\n', { mode: 0o600 })
}
const jwtPublicKey = await exportSPKI(publicKey)
await writeFile(join(state, 'jwt-public.pem'), jwtPublicKey, { mode: 0o600 })
console.log('Copy the token from ' + join(state, 'token.txt'))
console.log('Sample SHA-256: ' + createHash('sha256').update(sample).digest('hex'))
if (stopped) throw new Error('Demo was stopped before gateway startup')
gateway = spawn(process.execPath, [join(root, 'index.mjs')], { stdio: 'inherit', env: {
PATH: process.env.PATH, APP_ORIGIN: 'http://127.0.0.1:' + port, PORT: String(port),
JWT_PUBLIC_KEY: jwtPublicKey, JWT_ISSUER: 'urn:sftp-browser-demo',
JWT_AUDIENCE: 'sftp-browser', SFTP_HOST: '127.0.0.1', SFTP_PORT: String(sshPort),
SFTP_USERNAME: 'fixture', SFTP_PRIVATE_KEY: await readFile(join(state, 'client-key'), 'utf8'),
SFTP_HOST_SHA256: pin,
} })
const child = gateway
const exit = await new Promise<number | null>((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code) => resolve(code))
})
if (!stopped && exit !== 0) throw new Error('Gateway exited unsuccessfully')
} finally {
clearTimeout(lifetime)
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
gateway?.kill('SIGTERM')
// The UUID and label restrict cleanup to the container this invocation created.
const existing = await docker(['inspect', '--format', '{{ index .Config.Labels "sftp.demo.owner" }}', name]).catch(() => null)
if (existing?.stdout.trim() === owner) await docker(['rm', '--force', name])
if (imageBuilt) await docker(['image', 'rm', name])
await rm(state, { recursive: true })
}
Desde el directorio padre, ejecuta el lanzador en primer plano:
node sftp-browser-demo/demo.ts
Abre la URL que muestra la puerta de enlace. Copia el token de sftp-browser-demo/.demo/token.txt
en Your access token y luego haz clic en
List available files y
Download Sample file.
Download received. significa que el navegador recibió
todo el cuerpo HTTP e inició su descarga. Revisa las descargas de tu navegador para encontrar el
archivo guardado; la página no puede confirmar que lo conservaste en disco.
La muestra contiene 65.568 bytes binarios. Compara el SHA-256 del archivo report.bin
guardado con el Sample SHA-256: que muestra el lanzador.
Guarda esto como verify-download.ts dentro del proyecto:
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
const path = process.argv[2]
const expected = process.argv[3]
if (!path || !/^[a-f0-9]{64}$/.test(expected ?? '')) throw new Error('Supply a file and SHA-256')
const actual = createHash('sha256').update(await readFile(path)).digest('hex')
if (actual !== expected) throw new Error('Downloaded bytes do not match')
console.log('Downloaded bytes match')
Pasa la ruta real de tu descarga y el resumen criptográfico mostrado como argumentos:
node sftp-browser-demo/verify-download.ts "$HOME/Downloads/report.bin" YOUR_PRINTED_SHA256
Un 401 significa que se rechazó la autenticación, incluso por un token caducado;
403 significa que se rechazó el origen de la solicitud, el encabezado del cliente,
la consulta o el ámbito. Un 502 significa que la puerta de enlace no pudo confirmar
la lectura SFTP, por ejemplo, debido a una clave de host que no coincide, un enlace simbólico, un
archivo demasiado grande o una conexión perdida. Prueba los tokens de .demo/wrong-scope.txt
y .demo/expired.txt: ambas solicitudes de catálogo deben fallar.
Cuando caduque el token, detén y reinicia la demostración para obtener uno nuevo.
Presiona Ctrl+C en la terminal del lanzador para detener la puerta de enlace y eliminar su contenedor,
la etiqueta de imagen, las claves y el token. La demostración también se detiene después de diez
minutos. El proyecto y tu descarga guardada permanecen. Durante el inicio, la limpieza espera a que
termine la compilación o el comando de Docker en curso; cada comando tiene un tiempo límite de cuatro
minutos. Detén la demostración después del inicio para seguir la ruta normal de cierre inmediato.
Una limpieza interrumpida puede dejar .demo. Inspecciona .demo/resources.json
y verifica la etiqueta sftp.demo.owner del contenedor antes de eliminar manualmente ese
contenedor y su etiqueta de imagen. Luego elimina .demo; el lanzador se niega
a sobrescribirlo en la siguiente ejecución.
Adapta la puerta de enlace a tu cuenta SFTP
Proporciona las mismas variables de entorno exclusivas del servidor que usa demo.ts,
con tu emisor, audiencia y clave pública verificados. Establece SFTP_HOST_SHA256 en el
SHA-256 de la clave pública SSH del host en su formato sin procesar, verificado por el administrador
y expresado con 64 caracteres hexadecimales en minúsculas, como exige el
verificador de host de ssh2.
Una huella SHA256:base64 de OpenSSH usa una codificación diferente y no se puede pegar en este campo.
Solo un responsable de publicación de confianza puede modificar los archivos del catálogo; publica
archivos inmutables de forma atómica. Las comprobaciones de rutas canónicas y lstat()
no pueden impedir que un actor malicioso con permisos de escritura provoque una condición de carrera
con la apertura posterior, y un conteo de bytes no puede detectar la sustitución del contenido por
otro de igual longitud. Cada token sftp:read válido puede leer todo el catálogo
configurado. Si hay múltiples inquilinos, necesitan catálogos y cuentas SSH separados, derivados de
registros de autorización verificados del lado del servidor.
Usa HTTPS en el punto de conexión del navegador y mantén la verificación de la clave de host SSH. El navegador solicita una API del mismo origen con un encabezado personalizado, y la puerta de enlace no habilita ningún acceso CORS. Las comprobaciones de origen complementan la autorización por token; no autentican a clientes que no sean navegadores. El límite de dos descargas se aplica a un proceso Node, y el uso de búferes requiere memoria para esos archivos, además de las copias y la sobrecarga del protocolo. Las cuotas para todo el despliegue y el análisis de seguridad del contenido son decisiones independientes.
Elige una alternativa con SSH del lado del navegador
Si los usuarios necesitan su propia sesión SSH, hullarb/ssheasy
compila un cliente Go/WebAssembly junto con un proxy de WebSocket a TCP y una interfaz.
c2FmZQ/sshterm es una aplicación Go/WASM completa con subidas
y descargas SFTP, gestión de claves SSH y un endpoint WebSocket tlsproxy.
Sigue el contrato de despliegue y verificación de host de cada aplicación. Son referencias de
arquitectura, no bibliotecas instaladas o probadas en este ejemplo. Aquí no hay pruebas comparativas:
mide el costo de inicio y la latencia de transferencia para tu propia carga de trabajo.
El proyecto histórico sftp-ws transporta SFTP v3 sobre WebSockets en lugar de SSH, y su README advierte que no está suficientemente probado. No es un proxy de transporte que pueda sustituir directamente al de un servidor SSH existente. La puerta de enlace HTTP autenticada anterior es la ruta funcional de este tutorial.
Para importar por SFTP a Assemblies de Transloadit, consulta la documentación de 🤖 /sftp/import. Para subidas desde el navegador, consulta Uppy.
