mirror of
https://github.com/pockethost/pockethost.git
synced 2025-06-02 04:06:40 +00:00
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:
parent
478dfa6ddc
commit
b43aeb1552
5
.changeset/1719707094209.md
Normal file
5
.changeset/1719707094209.md
Normal 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.
|
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
```
|
||||
|
@ -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 }
|
@ -1,8 +0,0 @@
|
||||
import { info } from '../../log'
|
||||
import { ftpService } from '../FtpService'
|
||||
|
||||
export async function ftp() {
|
||||
info(`Starting`)
|
||||
|
||||
await ftpService({})
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { Command } from 'commander'
|
||||
import { ftp } from './ftp'
|
||||
import { ftp } from '../../FtpService/ftp'
|
||||
|
||||
type Options = {
|
||||
debug: boolean
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
8
packages/plugin-ftp-server/src/FtpService/ftp.ts
Normal file
8
packages/plugin-ftp-server/src/FtpService/ftp.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ftpService } from '.'
|
||||
import { info } from '../log'
|
||||
|
||||
export async function ftp() {
|
||||
info(`Starting`)
|
||||
|
||||
ftpService({})
|
||||
}
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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.`)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user