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",
"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"
}

View File

@ -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 <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 { ftp } from './ftp'
import { ftp } from '../../FtpService/ftp'
type Options = {
debug: boolean

View File

@ -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(`<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
}

View File

@ -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<InstanceFields>(`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,

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 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<FtpConfig> = {}) => {
async ({ connection, username, password }, resolve, reject) => {
dbg(`Got a connection`)
dbg(`Finding ${username}`)
const client = new PocketBase(
await filter<string>(
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 })
},
)

View File

@ -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

View File

@ -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.`)
}