feat(ftp-server): Added support for built-in admin account, FTP server now uses hooks for user auth, and disallows db access when instance is running.

This commit is contained in:
Ben Allfree 2024-06-29 17:24:54 -07:00
parent 478dfa6ddc
commit b43aeb1552
12 changed files with 178 additions and 86 deletions

View File

@ -0,0 +1,5 @@
---
'@pockethost/plugin-ftp-server': minor
---
Added support for built-in admin account, FTP server now uses hooks for user auth, and disallows db access when instance is running.

View File

@ -14,16 +14,19 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@s-libs/micro-dash": "^16.1.0", "@s-libs/micro-dash": "^16.1.0",
"bcryptjs": "^2.4.3",
"commander": "^11.1.0", "commander": "^11.1.0",
"devcert": "^1.2.2", "devcert": "^1.2.2",
"ftp-srv": "github:pockethost/ftp-srv#0fc708bae0d5d7a55ce948767f082d6fcfb2af59", "ftp-srv": "github:pockethost/ftp-srv#0fc708bae0d5d7a55ce948767f082d6fcfb2af59",
"inquirer": "^9.2.15", "inquirer": "^9.2.15",
"nanoid": "^5.0.2",
"pocketbase": "^0.21.3" "pocketbase": "^0.21.3"
}, },
"peerDependencies": { "peerDependencies": {
"pockethost": "workspace:^1.5.0" "pockethost": "workspace:^1.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/inquirer": "^9.0.7", "@types/inquirer": "^9.0.7",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }

View File

@ -1,7 +1,18 @@
# PocketHost FTP Server plugin # PocketHost FTP Server plugin
Prerequisites: FTP Server provides a virtual filesystem atop the physical PocketHost data storage system. This allows users to safely browse and modify their data via FTPS rather than permitting shell access.
- FTP requires instance data to be accessible via the file system. You must run a plugin that implements the `onDataRootFilter`: On first run, FTP will prompt for a fallback admin login and password that will allow FTP access to all instances. You can also manage it with:
- `@pockethost/plugin-launcher-spawn`
- ```bash
pockethost ftp admin set <username> <password>
```
If you want a more multi-user approach, you can also use `@pockethost/plugin-local-auth` and `@pockethost/plugin-mothership`. Both have auth implementations that work with FTP.
If you are not running a multi-node PocketHost cloud, Mothership is overkill. Just use local auth or the built-in fallback auth.
```bash
pockethost plugin install @pockethost/plugin-ftp-server
pockethost plugin install @pockethost/plugin-local-auth
```

View File

@ -1,13 +0,0 @@
import fs from 'fs'
import { promisify } from 'util'
const stat = promisify(fs.stat)
const readdir = promisify(fs.readdir)
const access = promisify(fs.access)
const unlink = promisify(fs.unlink)
const rmdir = promisify(fs.rmdir)
const mkdir = promisify(fs.mkdir)
const rename = promisify(fs.rename)
const chmod = promisify(fs.chmod)
export { access, chmod, mkdir, readdir, rename, rmdir, stat, unlink }

View File

@ -1,8 +0,0 @@
import { info } from '../../log'
import { ftpService } from '../FtpService'
export async function ftp() {
info(`Starting`)
await ftpService({})
}

View File

@ -1,5 +1,5 @@
import { Command } from 'commander' import { Command } from 'commander'
import { ftp } from './ftp' import { ftp } from '../../FtpService/ftp'
type Options = { type Options = {
debug: boolean debug: boolean

View File

@ -1,13 +1,27 @@
import { Command } from 'commander' import { Command } from 'commander'
import { ServeCommand } from './ServeCommand' import { ftp } from '../FtpService/ftp'
import { setFallbackAdmin } from '../plugin'
type Options = {
debug: boolean
}
export const FtpCommand = () => { export const FtpCommand = () => {
const cmd = new Command(`ftp`) const cmd = new Command(`ftp`)
.description(`FTP commands`) .description(`FTP commands`)
.addCommand(ServeCommand()) .addCommand(
new Command(`serve`)
.description(`Run an edge FTP server`)
.action(async (options) => {
await ftp()
}),
)
.addCommand(
new Command(`admin`).description(`Manage the admin login`).addCommand(
new Command(`set`)
.description(`Set the admin login and password`)
.argument(`<username>`, `The username for the admin login`)
.argument(`<password>`, `The password for the admin login`)
.action(async (username, password, options) => {
setFallbackAdmin(username, password)
}),
),
)
return cmd return cmd
} }

View File

@ -1,4 +1,4 @@
import { map } from '@s-libs/micro-dash' import { compact, map } from '@s-libs/micro-dash'
import { import {
Mode, Mode,
constants, constants,
@ -9,8 +9,16 @@ import {
} from 'fs' } from 'fs'
import { FileStat, FileSystem, FtpConnection } from 'ftp-srv' import { FileStat, FileSystem, FtpConnection } from 'ftp-srv'
import { isAbsolute, join, normalize, resolve, sep } from 'path' import { isAbsolute, join, normalize, resolve, sep } from 'path'
import PocketBase from 'pocketbase' import {
import { InstanceFields, Logger, assert } from 'pockethost' Logger,
assert,
doGetAllInstancesByExactCriteriaFilter,
doGetOneInstanceByExactCriteriaFilter,
doIsInstanceRunningFilter,
newId,
} from 'pockethost'
import { DATA_DIR } from 'pockethost/core'
import { UserId } from 'pockethost/src/common/schema/BaseFields'
import { import {
FolderNamesMap, FolderNamesMap,
INSTANCE_ROOT_VIRTUAL_FOLDER_NAMES, INSTANCE_ROOT_VIRTUAL_FOLDER_NAMES,
@ -42,20 +50,19 @@ export class PhFs implements FileSystem {
connection: FtpConnection connection: FtpConnection
cwd: string cwd: string
private _root: string private _root: string
client: PocketBase uid: string | undefined
constructor( constructor(
connection: FtpConnection, connection: FtpConnection,
client: PocketBase, uid: UserId | undefined,
logger: Logger, logger: Logger,
root: string,
) { ) {
const cwd = `/` const cwd = `/`
this.connection = connection this.connection = connection
this.client = client
this.log = logger.create(`PhFs`) this.log = logger.create(`PhFs`)
this.uid = uid
this.cwd = normalize((cwd || '/').replace(WIN_SEP_REGEX, '/')) this.cwd = normalize((cwd || '/').replace(WIN_SEP_REGEX, '/'))
this._root = resolve(root || process.cwd()) this._root = resolve(DATA_DIR() || process.cwd())
} }
get root() { get root() {
@ -94,9 +101,13 @@ export class PhFs implements FileSystem {
// Check if the instance is valid // Check if the instance is valid
const instance = await (async () => { const instance = await (async () => {
if (subdomain) { if (subdomain) {
const instance = await this.client const instance = await doGetOneInstanceByExactCriteriaFilter(
.collection(`instances`) undefined,
.getFirstListItem<InstanceFields>(`subdomain='${subdomain}'`) {
uid: this.uid,
subdomain,
},
)
if (!instance) { if (!instance) {
throw new Error(`${subdomain} not found.`) throw new Error(`${subdomain} not found.`)
} }
@ -112,10 +123,10 @@ export class PhFs implements FileSystem {
}) })
if ( if (
MAINTENANCE_ONLY_FOLDER_NAMES.includes(virtualRootFolderName) && MAINTENANCE_ONLY_FOLDER_NAMES.includes(virtualRootFolderName) &&
!instance.maintenance (await doIsInstanceRunningFilter(false, { instance }))
) { ) {
throw new Error( throw new Error(
`Instance must be in maintenance mode to access ${virtualRootFolderName}`, `Instance must be OFF to access ${virtualRootFolderName}`,
) )
} }
fsPathParts.push(physicalFolderName) fsPathParts.push(physicalFolderName)
@ -195,7 +206,9 @@ export class PhFs implements FileSystem {
If a subdomain is not specified, we are in the user's root. List all subdomains. If a subdomain is not specified, we are in the user's root. List all subdomains.
*/ */
if (subdomain === '') { if (subdomain === '') {
const instances = await this.client.collection(`instances`).getFullList() const instances = await doGetAllInstancesByExactCriteriaFilter([], {
uid: this.uid,
})
return instances.map((i) => { return instances.map((i) => {
return { return {
isDirectory: () => true, isDirectory: () => true,

View File

@ -0,0 +1,8 @@
import { ftpService } from '.'
import { info } from '../log'
export async function ftp() {
info(`Starting`)
ftpService({})
}

View File

@ -1,16 +1,23 @@
import { keys, values } from '@s-libs/micro-dash' import { keys, values } from '@s-libs/micro-dash'
import bcrypt from 'bcryptjs'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { FtpSrv } from 'ftp-srv' import { FtpSrv } from 'ftp-srv'
import { LoggerService, mkSingleton } from 'pockethost/src/common' import {
LoggerService,
doAuthenticateUserFilter,
mkSingleton,
} from 'pockethost/src/common'
import { asyncExitHook } from 'pockethost/src/core' import { asyncExitHook } from 'pockethost/src/core'
import { import {
FALLBACK_PASSWORD,
FALLBACK_USERNAME,
PASV_IP, PASV_IP,
PASV_PORT_MAX, PASV_PORT_MAX,
PASV_PORT_MIN, PASV_PORT_MIN,
PORT, PORT,
SSL_CERT, SSL_CERT,
SSL_KEY, SSL_KEY,
} from '../../constants' } from '../constants'
import { PhFs } from './PhFs' import { PhFs } from './PhFs'
export type FtpConfig = {} export type FtpConfig = {}
@ -81,21 +88,24 @@ export const ftpService = mkSingleton((config: Partial<FtpConfig> = {}) => {
async ({ connection, username, password }, resolve, reject) => { async ({ connection, username, password }, resolve, reject) => {
dbg(`Got a connection`) dbg(`Got a connection`)
dbg(`Finding ${username}`) dbg(`Finding ${username}`)
const client = new PocketBase( const uid = await doAuthenticateUserFilter(undefined, {
await filter<string>( username,
PocketHostFilter.Mothership_MothershipPublicUrl, password,
``, })
), if (!uid) {
) if (
try { username === FALLBACK_USERNAME() &&
await client.collection('users').authWithPassword(username, password) bcrypt.compareSync(password, FALLBACK_PASSWORD())
dbg(`Logged in`) ) {
const fs = new PhFs(connection, client, _ftpServiceLogger) /// Allow authentication to proceed
resolve({ fs }) } else {
} catch (e) { reject(new Error(`Invalid username or password`))
reject(new Error(`Invalid username or password`)) return
return }
} }
dbg(`Logged in`)
const fs = new PhFs(connection, uid, _ftpServiceLogger)
resolve({ fs })
}, },
) )

View File

@ -10,7 +10,7 @@ export const TLS_PFX = `tls`
export const settings = Settings({ export const settings = Settings({
PH_FTP_HOME: mkPath(HOME_DIR, { create: true }), PH_FTP_HOME: mkPath(HOME_DIR, { create: true }),
PH_FTP_PORT: mkNumber(21), PH_FTP_PORT: mkNumber(9021),
PH_FTP_SSL_KEY: mkPath(join(HOME_DIR, `${TLS_PFX}.key`), { PH_FTP_SSL_KEY: mkPath(join(HOME_DIR, `${TLS_PFX}.key`), {
required: false, required: false,
}), }),
@ -20,6 +20,8 @@ export const settings = Settings({
PH_FTP_PASV_IP: mkString(`0.0.0.0`), PH_FTP_PASV_IP: mkString(`0.0.0.0`),
PH_FTP_PASV_PORT_MIN: mkNumber(10000), PH_FTP_PASV_PORT_MIN: mkNumber(10000),
PH_FTP_PASV_PORT_MAX: mkNumber(20000), PH_FTP_PASV_PORT_MAX: mkNumber(20000),
PH_FTP_FALLBACK_USERNAME: mkString(``),
PH_FTP_FALLBACK_PASSWORD: mkString(``),
}) })
export const PORT = () => settings.PH_FTP_PORT export const PORT = () => settings.PH_FTP_PORT
@ -28,3 +30,5 @@ export const SSL_CERT = () => settings.PH_FTP_SSL_CERT
export const PASV_IP = () => settings.PH_FTP_PASV_IP export const PASV_IP = () => settings.PH_FTP_PASV_IP
export const PASV_PORT_MIN = () => settings.PH_FTP_PASV_PORT_MIN export const PASV_PORT_MIN = () => settings.PH_FTP_PASV_PORT_MIN
export const PASV_PORT_MAX = () => settings.PH_FTP_PASV_PORT_MAX export const PASV_PORT_MAX = () => settings.PH_FTP_PASV_PORT_MAX
export const FALLBACK_USERNAME = () => settings.PH_FTP_FALLBACK_USERNAME
export const FALLBACK_PASSWORD = () => settings.PH_FTP_FALLBACK_PASSWORD

View File

@ -1,16 +1,21 @@
import bcrypt from 'bcryptjs'
import devcert from 'devcert' import devcert from 'devcert'
import { existsSync, mkdirSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, writeFileSync } from 'fs'
import inquirer from 'inquirer' import inquirer from 'inquirer'
import { import {
PocketHostPlugin, PocketHostPlugin,
onAfterPluginsLoadedAction,
onCliCommandsFilter, onCliCommandsFilter,
onServeAction, onServeAction,
onServeSlugsFilter, onServeSlugsFilter,
onSettingsFilter, onSettingsFilter,
} from 'pockethost' } from 'pockethost'
import { APEX_DOMAIN, gracefulExit } from 'pockethost/core' import { APEX_DOMAIN, gracefulExit, setConfig } from 'pockethost/core'
import { FtpCommand } from './FtpCommand' import { FtpCommand } from './FtpCommand'
import { ftp } from './FtpService/ftp'
import { import {
FALLBACK_PASSWORD,
FALLBACK_USERNAME,
HOME_DIR, HOME_DIR,
PLUGIN_NAME, PLUGIN_NAME,
SSL_CERT, SSL_CERT,
@ -33,27 +38,67 @@ export const plugin: PocketHostPlugin = async ({}) => {
ftp() ftp()
}) })
onSettingsFilter(async (allSettings) => ({ ...allSettings, ...settings })) /** Check for SSL keys */
if (!existsSync(SSL_KEY())) { onAfterPluginsLoadedAction(async () => {
if (!existsSync(SSL_CERT())) { if (existsSync(SSL_KEY()) && existsSync(SSL_CERT())) return
const answers = await inquirer.prompt<{ ssl: boolean }>([ const answers = await inquirer.prompt<{ ssl: boolean }>([
{ {
type: 'confirm', type: 'confirm',
name: 'ssl', name: 'ssl',
message: `FTP Server requires SSL keys, but none were found in ${SSL_CERT()} and ${SSL_KEY()}. Do you want to generate locally trusted SSL keys?`, message: `FTP Server requires SSL keys, but none were found in ${SSL_CERT()} and ${SSL_KEY()}. Do you want to generate locally trusted SSL keys?`,
}, },
]) ])
if (answers.ssl) { if (answers.ssl) {
info(`Generating SSL keys...`) info(`Generating SSL keys...`)
mkdirSync(HOME_DIR, { recursive: true }) mkdirSync(HOME_DIR, { recursive: true })
const { key, cert } = await devcert.certificateFor(APEX_DOMAIN(), {}) const { key, cert } = await devcert.certificateFor(APEX_DOMAIN(), {})
writeFileSync(SSL_KEY(), key) writeFileSync(SSL_KEY(), key)
writeFileSync(SSL_CERT(), cert) writeFileSync(SSL_CERT(), cert)
info(`SSL keys generated.`) info(`SSL keys generated.`)
} else { } else {
error(`SSL keys are required for FTP server.`) error(`SSL keys are required for FTP server.`)
await gracefulExit() await gracefulExit()
}
} }
} })
/** Check for admin login/pw */
onAfterPluginsLoadedAction(async () => {
if (FALLBACK_USERNAME() && FALLBACK_PASSWORD()) return
info(`FTP needs a default admin user. Choose something secure.`)
const answers = await inquirer.prompt<{
username: string
password: string
}>([
{
type: 'input',
name: 'username',
message: 'Username',
validate: (input) => {
if (!input) return 'Username cannot be empty'
return true
},
},
{
type: 'password',
name: 'password',
message: 'Password',
validate: (input) => {
if (!input) return 'Password cannot be empty'
return true
},
},
])
const { username, password } = answers
setFallbackAdmin(username, password)
})
onSettingsFilter(async (allSettings) => ({ ...allSettings, ...settings }))
}
export const setFallbackAdmin = (username: string, password: string) => {
setConfig('PH_FTP_FALLBACK_USERNAME', username)
const passwordHash = bcrypt.hashSync(password, 10)
setConfig(`PH_FTP_FALLBACK_PASSWORD`, passwordHash)
info(`"${username}" created as admin.`)
} }