diff --git a/.changeset/1719707094209.md b/.changeset/1719707094209.md new file mode 100644 index 00000000..c722eaec --- /dev/null +++ b/.changeset/1719707094209.md @@ -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. \ No newline at end of file diff --git a/packages/plugin-ftp-server/package.json b/packages/plugin-ftp-server/package.json index 6e40c1cb..9102f994 100644 --- a/packages/plugin-ftp-server/package.json +++ b/packages/plugin-ftp-server/package.json @@ -14,16 +14,19 @@ "license": "ISC", "dependencies": { "@s-libs/micro-dash": "^16.1.0", + "bcryptjs": "^2.4.3", "commander": "^11.1.0", "devcert": "^1.2.2", "ftp-srv": "github:pockethost/ftp-srv#0fc708bae0d5d7a55ce948767f082d6fcfb2af59", "inquirer": "^9.2.15", + "nanoid": "^5.0.2", "pocketbase": "^0.21.3" }, "peerDependencies": { "pockethost": "workspace:^1.5.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/inquirer": "^9.0.7", "typescript": "^5.4.5" } diff --git a/packages/plugin-ftp-server/readme.md b/packages/plugin-ftp-server/readme.md index 992d2620..285a9518 100644 --- a/packages/plugin-ftp-server/readme.md +++ b/packages/plugin-ftp-server/readme.md @@ -1,7 +1,18 @@ # 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`: - - `@pockethost/plugin-launcher-spawn` -- +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: + +```bash +pockethost ftp admin set +``` + +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 +``` diff --git a/packages/plugin-ftp-server/src/FtpCommand/FtpService/fs-async.ts b/packages/plugin-ftp-server/src/FtpCommand/FtpService/fs-async.ts deleted file mode 100644 index 20eebe80..00000000 --- a/packages/plugin-ftp-server/src/FtpCommand/FtpService/fs-async.ts +++ /dev/null @@ -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 } diff --git a/packages/plugin-ftp-server/src/FtpCommand/ServeCommand/ftp.ts b/packages/plugin-ftp-server/src/FtpCommand/ServeCommand/ftp.ts deleted file mode 100644 index 0172070d..00000000 --- a/packages/plugin-ftp-server/src/FtpCommand/ServeCommand/ftp.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { info } from '../../log' -import { ftpService } from '../FtpService' - -export async function ftp() { - info(`Starting`) - - await ftpService({}) -} diff --git a/packages/plugin-ftp-server/src/FtpCommand/ServeCommand/index.ts b/packages/plugin-ftp-server/src/FtpCommand/ServeCommand/index.ts index 56f188a6..fbc81781 100644 --- a/packages/plugin-ftp-server/src/FtpCommand/ServeCommand/index.ts +++ b/packages/plugin-ftp-server/src/FtpCommand/ServeCommand/index.ts @@ -1,5 +1,5 @@ import { Command } from 'commander' -import { ftp } from './ftp' +import { ftp } from '../../FtpService/ftp' type Options = { debug: boolean diff --git a/packages/plugin-ftp-server/src/FtpCommand/index.ts b/packages/plugin-ftp-server/src/FtpCommand/index.ts index 3de921c8..61e65d80 100644 --- a/packages/plugin-ftp-server/src/FtpCommand/index.ts +++ b/packages/plugin-ftp-server/src/FtpCommand/index.ts @@ -1,13 +1,27 @@ import { Command } from 'commander' -import { ServeCommand } from './ServeCommand' - -type Options = { - debug: boolean -} +import { ftp } from '../FtpService/ftp' +import { setFallbackAdmin } from '../plugin' export const FtpCommand = () => { const cmd = new Command(`ftp`) .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(``, `The username for the admin login`) + .argument(``, `The password for the admin login`) + .action(async (username, password, options) => { + setFallbackAdmin(username, password) + }), + ), + ) return cmd } diff --git a/packages/plugin-ftp-server/src/FtpCommand/FtpService/PhFs.ts b/packages/plugin-ftp-server/src/FtpService/PhFs.ts similarity index 93% rename from packages/plugin-ftp-server/src/FtpCommand/FtpService/PhFs.ts rename to packages/plugin-ftp-server/src/FtpService/PhFs.ts index 4648c510..f86e1c32 100644 --- a/packages/plugin-ftp-server/src/FtpCommand/FtpService/PhFs.ts +++ b/packages/plugin-ftp-server/src/FtpService/PhFs.ts @@ -1,4 +1,4 @@ -import { map } from '@s-libs/micro-dash' +import { compact, map } from '@s-libs/micro-dash' import { Mode, constants, @@ -9,8 +9,16 @@ import { } from 'fs' import { FileStat, FileSystem, FtpConnection } from 'ftp-srv' import { isAbsolute, join, normalize, resolve, sep } from 'path' -import PocketBase from 'pocketbase' -import { InstanceFields, Logger, assert } from 'pockethost' +import { + Logger, + assert, + doGetAllInstancesByExactCriteriaFilter, + doGetOneInstanceByExactCriteriaFilter, + doIsInstanceRunningFilter, + newId, +} from 'pockethost' +import { DATA_DIR } from 'pockethost/core' +import { UserId } from 'pockethost/src/common/schema/BaseFields' import { FolderNamesMap, INSTANCE_ROOT_VIRTUAL_FOLDER_NAMES, @@ -42,20 +50,19 @@ export class PhFs implements FileSystem { connection: FtpConnection cwd: string private _root: string - client: PocketBase + uid: string | undefined constructor( connection: FtpConnection, - client: PocketBase, + uid: UserId | undefined, logger: Logger, - root: string, ) { const cwd = `/` this.connection = connection - this.client = client this.log = logger.create(`PhFs`) + this.uid = uid this.cwd = normalize((cwd || '/').replace(WIN_SEP_REGEX, '/')) - this._root = resolve(root || process.cwd()) + this._root = resolve(DATA_DIR() || process.cwd()) } get root() { @@ -94,9 +101,13 @@ export class PhFs implements FileSystem { // Check if the instance is valid const instance = await (async () => { if (subdomain) { - const instance = await this.client - .collection(`instances`) - .getFirstListItem(`subdomain='${subdomain}'`) + const instance = await doGetOneInstanceByExactCriteriaFilter( + undefined, + { + uid: this.uid, + subdomain, + }, + ) if (!instance) { throw new Error(`${subdomain} not found.`) } @@ -112,10 +123,10 @@ export class PhFs implements FileSystem { }) if ( MAINTENANCE_ONLY_FOLDER_NAMES.includes(virtualRootFolderName) && - !instance.maintenance + (await doIsInstanceRunningFilter(false, { instance })) ) { throw new Error( - `Instance must be in maintenance mode to access ${virtualRootFolderName}`, + `Instance must be OFF to access ${virtualRootFolderName}`, ) } 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 (subdomain === '') { - const instances = await this.client.collection(`instances`).getFullList() + const instances = await doGetAllInstancesByExactCriteriaFilter([], { + uid: this.uid, + }) return instances.map((i) => { return { isDirectory: () => true, diff --git a/packages/plugin-ftp-server/src/FtpService/ftp.ts b/packages/plugin-ftp-server/src/FtpService/ftp.ts new file mode 100644 index 00000000..0edbbb30 --- /dev/null +++ b/packages/plugin-ftp-server/src/FtpService/ftp.ts @@ -0,0 +1,8 @@ +import { ftpService } from '.' +import { info } from '../log' + +export async function ftp() { + info(`Starting`) + + ftpService({}) +} diff --git a/packages/plugin-ftp-server/src/FtpCommand/FtpService/index.ts b/packages/plugin-ftp-server/src/FtpService/index.ts similarity index 77% rename from packages/plugin-ftp-server/src/FtpCommand/FtpService/index.ts rename to packages/plugin-ftp-server/src/FtpService/index.ts index 8b558292..b46081cc 100644 --- a/packages/plugin-ftp-server/src/FtpCommand/FtpService/index.ts +++ b/packages/plugin-ftp-server/src/FtpService/index.ts @@ -1,16 +1,23 @@ import { keys, values } from '@s-libs/micro-dash' +import bcrypt from 'bcryptjs' import { readFileSync } from 'fs' 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 { + FALLBACK_PASSWORD, + FALLBACK_USERNAME, PASV_IP, PASV_PORT_MAX, PASV_PORT_MIN, PORT, SSL_CERT, SSL_KEY, -} from '../../constants' +} from '../constants' import { PhFs } from './PhFs' export type FtpConfig = {} @@ -81,21 +88,24 @@ export const ftpService = mkSingleton((config: Partial = {}) => { async ({ connection, username, password }, resolve, reject) => { dbg(`Got a connection`) dbg(`Finding ${username}`) - const client = new PocketBase( - await filter( - PocketHostFilter.Mothership_MothershipPublicUrl, - ``, - ), - ) - try { - await client.collection('users').authWithPassword(username, password) - dbg(`Logged in`) - const fs = new PhFs(connection, client, _ftpServiceLogger) - resolve({ fs }) - } catch (e) { - reject(new Error(`Invalid username or password`)) - return + const uid = await doAuthenticateUserFilter(undefined, { + username, + password, + }) + if (!uid) { + if ( + username === FALLBACK_USERNAME() && + bcrypt.compareSync(password, FALLBACK_PASSWORD()) + ) { + /// Allow authentication to proceed + } else { + reject(new Error(`Invalid username or password`)) + return + } } + dbg(`Logged in`) + const fs = new PhFs(connection, uid, _ftpServiceLogger) + resolve({ fs }) }, ) diff --git a/packages/plugin-ftp-server/src/constants.ts b/packages/plugin-ftp-server/src/constants.ts index ee296eb7..44dcb44e 100644 --- a/packages/plugin-ftp-server/src/constants.ts +++ b/packages/plugin-ftp-server/src/constants.ts @@ -10,7 +10,7 @@ export const TLS_PFX = `tls` export const settings = Settings({ 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`), { required: false, }), @@ -20,6 +20,8 @@ export const settings = Settings({ PH_FTP_PASV_IP: mkString(`0.0.0.0`), PH_FTP_PASV_PORT_MIN: mkNumber(10000), PH_FTP_PASV_PORT_MAX: mkNumber(20000), + PH_FTP_FALLBACK_USERNAME: mkString(``), + PH_FTP_FALLBACK_PASSWORD: mkString(``), }) 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_PORT_MIN = () => settings.PH_FTP_PASV_PORT_MIN 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 diff --git a/packages/plugin-ftp-server/src/plugin.ts b/packages/plugin-ftp-server/src/plugin.ts index cc1de179..8286420a 100644 --- a/packages/plugin-ftp-server/src/plugin.ts +++ b/packages/plugin-ftp-server/src/plugin.ts @@ -1,16 +1,21 @@ +import bcrypt from 'bcryptjs' import devcert from 'devcert' import { existsSync, mkdirSync, writeFileSync } from 'fs' import inquirer from 'inquirer' import { PocketHostPlugin, + onAfterPluginsLoadedAction, onCliCommandsFilter, onServeAction, onServeSlugsFilter, onSettingsFilter, } from 'pockethost' -import { APEX_DOMAIN, gracefulExit } from 'pockethost/core' +import { APEX_DOMAIN, gracefulExit, setConfig } from 'pockethost/core' import { FtpCommand } from './FtpCommand' +import { ftp } from './FtpService/ftp' import { + FALLBACK_PASSWORD, + FALLBACK_USERNAME, HOME_DIR, PLUGIN_NAME, SSL_CERT, @@ -33,27 +38,67 @@ export const plugin: PocketHostPlugin = async ({}) => { ftp() }) - onSettingsFilter(async (allSettings) => ({ ...allSettings, ...settings })) - if (!existsSync(SSL_KEY())) { - if (!existsSync(SSL_CERT())) { - const answers = await inquirer.prompt<{ ssl: boolean }>([ - { - type: 'confirm', - 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?`, - }, - ]) - if (answers.ssl) { - info(`Generating SSL keys...`) - mkdirSync(HOME_DIR, { recursive: true }) - const { key, cert } = await devcert.certificateFor(APEX_DOMAIN(), {}) - writeFileSync(SSL_KEY(), key) - writeFileSync(SSL_CERT(), cert) - info(`SSL keys generated.`) - } else { - error(`SSL keys are required for FTP server.`) - await gracefulExit() - } + /** Check for SSL keys */ + onAfterPluginsLoadedAction(async () => { + if (existsSync(SSL_KEY()) && existsSync(SSL_CERT())) return + const answers = await inquirer.prompt<{ ssl: boolean }>([ + { + type: 'confirm', + 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?`, + }, + ]) + if (answers.ssl) { + info(`Generating SSL keys...`) + mkdirSync(HOME_DIR, { recursive: true }) + const { key, cert } = await devcert.certificateFor(APEX_DOMAIN(), {}) + writeFileSync(SSL_KEY(), key) + writeFileSync(SSL_CERT(), cert) + info(`SSL keys generated.`) + } else { + error(`SSL keys are required for FTP server.`) + 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.`) }