mirror of
https://github.com/pockethost/pockethost.git
synced 2025-06-03 20:56: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",
|
"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"
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
```
|
||||||
|
@ -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 { Command } from 'commander'
|
||||||
import { ftp } from './ftp'
|
import { ftp } from '../../FtpService/ftp'
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
debug: boolean
|
debug: boolean
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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,
|
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 { 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 })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -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
|
||||||
|
@ -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.`)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user