Read-only SFTP server

Serves sample files over SFTP and rejects write operations.

Port 30023Auth: none

Connect

Browse

sftp -P 30023 demo@modernssh-examples.manaf.ch

Download

sftp -P 30023 demo@modernssh-examples.manaf.ch:/hello.txt ./hello.txt
server.ts
import { Server, SessionChannel, SFTPOpenFlags, SFTPStatusCode } from "@bunkerch/modernssh"

const hostKey = process.env.SSH_HOST_KEY_PRIVATE_KEY
if (!hostKey) throw new Error("SSH_HOST_KEY_PRIVATE_KEY is required")

const contents = Buffer.from("Hello from modernssh!\n")
const fileHandle = Buffer.from("hello.txt")
const directoryHandle = Buffer.from("root")
const fileAttributes = { size: BigInt(contents.length), permissions: 0o100444 }
const directoryAttributes = { size: 0n, permissions: 0o040555 }
const server = new Server({ hostKeys: [hostKey] })

server.hooker.hook("noneAuthentication", (_hook, _context, decision) => {
  decision.allowLogin = true
})
server.hooker.hook("channelOpenRequest", (_hook, channel, decision) => {
  decision.allowOpen = channel instanceof SessionChannel
})

server.on("connection", (connection) => {
  connection.on("channel", (channel) => {
    if (!(channel instanceof SessionChannel)) return
    channel.hooker.hook("subsystemRequest", (_hook, context, decision) => {
      decision.success = context.subsystem === "sftp"
    })

    channel.events.on("sftp", (sftp) => {
      let directoryWasRead = false

      sftp.hooker.hook("REALPATH", async (_hook, request) => {
        await sftp.name(request.requestId, {
          filename: Buffer.from("/"), longname: Buffer.from("/"), attributes: directoryAttributes,
        })
      })
      sftp.hooker.hook("STAT", async (_hook, request) => {
        const path = request.path.toString()
        await sftp.attributes(request.requestId, path === "/hello.txt" ? fileAttributes : directoryAttributes)
      })
      sftp.hooker.hook("LSTAT", async (_hook, request) => {
        const path = request.path.toString()
        await sftp.attributes(request.requestId, path === "/hello.txt" ? fileAttributes : directoryAttributes)
      })
      sftp.hooker.hook("OPENDIR", async (_hook, request) => {
        directoryWasRead = false
        await sftp.handle(request.requestId, directoryHandle)
      })
      sftp.hooker.hook("READDIR", async (_hook, request) => {
        if (directoryWasRead) {
          await sftp.status(request.requestId, SFTPStatusCode.EOF)
          return
        }
        directoryWasRead = true
        await sftp.name(request.requestId, {
          filename: Buffer.from("hello.txt"),
          longname: Buffer.from("hello.txt"),
          attributes: fileAttributes,
        })
      })
      sftp.hooker.hook("OPEN", async (_hook, request) => {
        if (request.filename.toString() !== "/hello.txt" || request.flags !== SFTPOpenFlags.Read) {
          await sftp.status(request.requestId, SFTPStatusCode.PermissionDenied)
          return
        }
        await sftp.handle(request.requestId, fileHandle)
      })
      sftp.hooker.hook("READ", async (_hook, request) => {
        const start = Number(request.offset)
        if (start >= contents.length) {
          await sftp.status(request.requestId, SFTPStatusCode.EOF)
          return
        }
        await sftp.data(request.requestId, contents.subarray(start, start + request.length))
      })
      sftp.hooker.hook("CLOSE", async (_hook, request) => {
        await sftp.status(request.requestId, SFTPStatusCode.Ok)
      })
      sftp.hooker.hook("request", async (_hook, request) => {
        await sftp.status(request.requestId, SFTPStatusCode.PermissionDenied, "Read-only server")
      })
    })
  })
})

server.listen({ host: "0.0.0.0", port: Number(process.env.PORT ?? 2223) })