feat: CLI tool and npm package

This commit is contained in:
Ben Allfree 2024-02-29 11:50:33 -08:00
parent 6fb93ab5de
commit c491da0b9c
44 changed files with 1457 additions and 882 deletions

5
.npmignore Normal file
View File

@ -0,0 +1,5 @@
*
!dist/**
!package.json
!readme.md
!LICENSE.md

56
buildtool/index.ts Normal file
View File

@ -0,0 +1,56 @@
import { Command, program } from 'commander'
import { BuildOptions, build, context } from 'esbuild'
import { nodeExternalsPlugin } from 'esbuild-node-externals'
import ncp from 'ncp'
export const main = async () => {
program.name('buildtool').description('CLI build and watch ')
const args: BuildOptions = {
entryPoints: ['src/cli/index.ts'],
bundle: true,
format: 'esm',
platform: 'node',
outfile: 'dist/index.mjs',
plugins: [nodeExternalsPlugin()],
}
program.addCommand(
new Command(`build`).description(`Build CLI`).action(async () => {
console.log(`Building CLI`)
await build(args)
console.log(`Building mothership app`)
await new Promise<void>((resolve) => {
ncp(`src/mothership-app`, './dist/mothership-app', (e) => {
if (e) {
console.error(e)
}
resolve()
})
})
console.log(`Building instance app`)
await new Promise<void>((resolve) => {
ncp(`src/instance-app`, './dist/instance-app', (e) => {
if (e) {
console.error(e)
}
resolve()
})
})
}),
)
program.addCommand(
new Command(`watch`).description(`Watch CLI`).action(async () => {
console.log(`Watching`)
const ctx = await context(args)
await ctx.watch()
}),
)
await program.parseAsync()
}
main()

16
cli-todo.md Normal file
View File

@ -0,0 +1,16 @@
v1.0
- publish docker container
- homestead command
next
- linux amd64 support
- cname
- auto ssl greenlock
- eject ecosystem
- eject .env
- remove docker dependency
- s3fs file storage
- s3fs backup storage
-

View File

@ -1,34 +1,34 @@
module.exports = {
apps: [
{
name: `proxy`,
script: 'pnpm prod:proxy',
name: `firewall`,
script: 'pnpm dev:cli firewall serve',
},
{
name: `edge-daemon`,
script: 'pnpm prod:edge:daemon',
script: 'pnpm dev:cli edge daemon serve',
},
{
name: `edge-ftp`,
script: 'pnpm prod:edge:ftp',
script: 'pnpm dev:cli edge ftp serve',
},
{
name: `edge-syslog`,
script: 'pnpm prod:edge:syslog',
script: 'pnpm dev:cli edge syslog serve',
},
{
name: `mothership`,
script: 'pnpm prod:mothership',
script: 'pnpm dev:cli mothership serve',
},
{
name: `downloader`,
restart_delay: 60 * 60 * 1000, // 1 hour
script: 'pnpm download-versions',
script: 'pnpm dev:cli download',
},
{
name: `edge-health`,
name: `health`,
restart_delay: 60 * 1000, // 1 minute
script: 'pnpm prod:edge:health',
script: 'pnpm dev:cli health',
},
],
}

View File

@ -1,39 +1,33 @@
{
"name": "pockethost",
"version": "0.11.0",
"author": "Ben Allfree <ben@benallfree.com>",
"author": {
"name": "Ben Allfree",
"url": "https://github.com/benallfree"
},
"license": "MIT",
"main": "dist/index.mjs",
"bin": {
"pockethost": "dist/index.mjs"
},
"scripts": {
"check:types": "concurrently 'pnpm:check:types:*'",
"check:types:dashboard": "cd frontends/dashboard && pnpm check:types",
"check:types:pockethost": "tsc --noEmit --skipLibCheck",
"lint": "prettier -c \"./**/*.{ts,js,cjs,svelte,json}\"",
"lint:fix": "prettier -w \"./**/*.{ts,js,cjs,svelte,json}\"",
"download-versions": "tsx ./src/cli/download.ts",
"build": "concurrently 'pnpm:build:*'",
"buildtool": "tsx buildtool/index.ts",
"build-frontends": "concurrently 'pnpm:build:frontend:*'",
"build:frontend:dashboard": "cd frontends/dashboard && pnpm build",
"build:docker": "cd src/services/PocketBaseService && docker build . -t pockethost-instance",
"build:docker": "docker build . -t benallfree/pockethost-instance:1.0.0 -t benallfree/pockethost-instance:latest",
"build:frontend:lander": "cd frontends/lander && pnpm build",
"build:frontend:superadmin": "cd frontends/superadmin && pnpm build",
"dev": "NODE_ENV=development concurrently 'pnpm:dev:*'",
"dev-daemon": "concurrently 'pnpm:dev:daemon:*'",
"dev:proxy": " dotenv tsx watch ./src/cli/proxy/server.ts",
"dev:cli": "NODE_ENV=development tsx src/cli/index.ts",
"dev:lander": "cd frontends/lander && pnpm start",
"dev:dashboard": "cd frontends/dashboard && pnpm dev",
"dev:superadmin": "cd frontends/superadmin && pnpm dev",
"dev:edge:daemon": "tsx watch src/cli/edge-daemon.ts",
"dev:edge:ftp": "tsx watch src/cli/edge-ftp.ts",
"dev:edge:syslogd": "tsx watch src/cli/edge-syslogd.ts",
"dev:downloader": "pnpm download-versions",
"dev:mothership:maildev": "npx -y maildev",
"dev:mothership:pocketbase": "nodemon --signal SIGTERM --watch src --exec tsx ./src/cli/mothership.ts",
"prod:proxy": "dotenv tsx ./src/cli/proxy/server.ts",
"prod:edge:daemon": "tsx src/cli/edge-daemon.ts",
"prod:edge:ftp": "tsx src/cli/edge-ftp.ts",
"prod:edge:health": "tsx src/cli/edge-health.ts",
"prod:edge:syslog": "tsx src/cli/edge-syslogd.ts",
"prod:mothership": "tsx src/cli/mothership.ts",
"prod:cli": "tsx ./src/cli/index.ts",
"plop": "plop --no-progress",
"nofile": "cat /proc/sys/fs/file-nr",
"mail": "tsx ./src/cli/sendmail.ts"
@ -57,8 +51,9 @@
"ajv": "^8.12.0",
"boolean": "^3.2.0",
"bottleneck": "^2.19.5",
"chalk": "^5.3.0",
"commander": "^11.1.0",
"date-fns": "^2.30.0",
"cors": "^2.8.5",
"decompress": "^4.2.1",
"decompress-unzip": "https://github.com/pockethost/decompress-unzip.git#6ef397b9a2df11d39c7b26ce779e123833844751",
"devcert": "^1.2.2",
@ -67,6 +62,7 @@
"event-source-polyfill": "^1.0.31",
"eventsource": "^2.0.2",
"exit-hook": "^4.0.0",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-sslify": "^1.2.0",
"find-up": "^6.3.0",
@ -74,13 +70,14 @@
"get-port": "^6.1.2",
"glob": "^10.3.10",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^2.0.6",
"ip-cidr": "^3.1.0",
"json-stringify-safe": "^5.0.1",
"knex": "^2.5.1",
"memorystream": "^0.3.1",
"nanoid": "^5.0.2",
"node-fetch": "^3.3.2",
"node-os-utils": "^1.3.7",
"ora": "^7.0.1",
"pocketbase": "^0.20.1",
"semver": "^7.5.4",
"sqlite3": "^5.1.6",
@ -88,6 +85,7 @@
"tail": "^2.2.6",
"tmp": "^0.2.1",
"url-pattern": "^1.0.3",
"vhost": "^3.0.2",
"winston": "^3.11.0",
"winston-syslog": "^2.7.0"
},
@ -95,6 +93,7 @@
"@swc/cli": "^0.1.62",
"@swc/core": "^1.3.95",
"@types/bootstrap": "^5.2.8",
"@types/copyfiles": "^2.4.4",
"@types/cors": "^2.8.17",
"@types/d3-scale": "^4.0.6",
"@types/d3-scale-chromatic": "^3.0.1",
@ -110,23 +109,28 @@
"@types/json-stringify-safe": "^5.0.2",
"@types/marked": "^4.3.2",
"@types/memorystream": "^0.3.3",
"@types/ncp": "^2.0.8",
"@types/node": "^20.8.10",
"@types/semver": "^7.5.4",
"@types/tail": "^2.2.2",
"@types/tmp": "^0.2.5",
"@types/unzipper": "^0.10.8",
"@types/vhost": "^3.0.9",
"chalk": "^5.3.0",
"chokidar-cli": "^3.0.0",
"concurrently": "^8.2.2",
"copyfiles": "^2.4.1",
"cors": "^2.8.5",
"date-fns": "^2.30.0",
"dotenv-cli": "^7.3.0",
"esbuild": "^0.20.0",
"esbuild-node-externals": "^1.13.0",
"express": "^4.18.2",
"http-proxy-middleware": "^2.0.6",
"inquirer": "^9.2.15",
"ip-cidr": "^3.1.0",
"js-yaml": "^4.1.0",
"ncp": "^2.0.0",
"nodemon": "^3.0.3",
"ora": "^7.0.1",
"plop": "^4.0.0",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.0.3",
@ -138,8 +142,7 @@
"tslib": "^2.6.2",
"tsx": "^3.14.0",
"type-fest": "^4.6.0",
"typescript": "^5.2.2",
"vhost": "^3.0.2"
"typescript": "^5.2.2"
},
"packageManager": "pnpm@8.10.2"
}

537
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
pockethost-1.0.0-rc.2.tgz Normal file

Binary file not shown.

172
readme.md
View File

@ -1,168 +1,44 @@
# pockethost.io
# pockethost
---
### Sponsored by https://pockethost.io. Instantly host your PocketBase projects.
---
## Introduction
PocketHost is the open source cloud hosting platform for PocketBase. https://pockethost.io is the flagship service running PocketHost, where you can host your PocketBase projects with zero setup. Create a project like you would in Firebase and Supabase and let PocketHost do the rest.
PocketHost is the multi-user, multi-tenant PocketBase server. Run hundreds, even thousands, of PocketBase instances at the same time on a single server or a global network.
Features:
- Create unlimited PocketBase instances
- Each instance runs on a subdomain of `pockethost.io`
- Run your instance in an ultra-beefy shared environment
- Each instance runs on its own subdomain
- Secure by default - Docker + automatic SSL
- Custom domain (CNAME) support
**Focus on your app**
## Quickstart
Get a live PocketBase instance in 10 seconds with no backend setup:
`npx pockethost serve`
1. Create an account at pockethost.io
2. Provision your first PocketBase instance
3. Connect from anywhere
## Scaling Up
```ts
const client = new PocketBase(`https://harvest.pockethost.io`)
```
### `pockethost firewall`
**Batteries Included**
### `pockethost mothership`
Here's all the Linux/devops stuff that PocketHost does for you:
### `pockethost edge:daemon`
- Email and DKIM+SPF and more
- DNS jargon: MX, TXT, CNAME
- SSL cert provisioning and management
- Storage
- Volume mounts
- Cloud computing or VPS deployment
- CDN and static asset hosting
- Amazon AWS
- Lots more - scaling, firewalls, DDoS defense, user security, log rotation, patches, updates, build tools, CPU architectures, multitenancy, on and on
### `pockethost edge:syslog`
PocketHost is free and open source project licensed under the MIT License.
### `pockethost edge:ftp`
This monorepo contains the entire open source stack that powers pockethost.io. You can use it to run your own private or public multitenant platform.
## Other commands
**Questions?**
### `pockethost download`
Head over to Discord and ask away.
### `pockethost health`
## Frontend only
## Support
If you just want to work on the UI/frontend client apps, they are configured to connect to pockethost.io out of the box. This is the most convenient way to make UI modifications to the PocketHost project.
```bash
git clone git@github.com:benallfree/pockethost.git
cd pockethost
pnpm
pnpm dev:lander # Marketing/blog area
pnpm dev:dashboard # Dashboard/control panel
```
## All our base
**Running in dev mode**
Note for OS X users: if the `pocketbase` binaries do not run, it's probably your [security settings](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac).
**Prerequisites**
Local PocketHost development relies on Caddy to create reverse proxies into the various services powering the PocketHost backend. We use `lvh.me` to work with subdomains that resolve to localhost.
```bash
brew install caddy
```
Then:
```bash
git clone git@github.com:benallfree/pockethost.git
cd pockethost
pnpm
cp .env-template .env
```
`.env-template` is preconfigured to make all of PocketHost run locally using `lvh.me` as follows:
```bash
pnpm dev
# marketing/blog
open https://pockethost.lvh.me
# dashboard
open https://app.pockethost.lvh.me
# mothership
open https://pockethost-central.pockethost.lvh.me
# sample edge server (ph1)
open https://instance1.ph1.edge.pockethost.lvh.me
# second edge server (ph2)
open https://instance2.ph2.edge.pockethost.lvh.me
```
Additionally, the setup creates the following users and instances:
**Admin**
https://pockethost-central.pockethost.lvh.me
login: `admin@pockethost.lvh.me`
password: `password`
**User**
https://app.pockethost.lvh.me
login: `user@pockethost.lvh.me`
password: `password`
The user `user@pockethost.lvh.me` owns `instance1` and `instance2`, each which is running in its own region on separate edge servers.
That's it! You can control all this and much more from the `.env-template` file. It's fully documented, but here it is again just in case.
## Public Variables (available to frontend and backend)
| Name | Default | Discussion |
| ----------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PUBLIC_DEBUG | `true` | The global debugging variable, used by both frontends and backend services. |
| PUBLIC_HTTP_PROTOCOL | `https` | You can use `http` if you like, but there is really no reason to. Caddy automatically provisions SSL certificates even for local development, and Cloudflare (recommended for DNS and CDN) does too. |
| PUBLIC_APEX_DOMAIN | `pockethost.lvh.me` | The apex domain is the domain on which all other subdomains live. If you set up your own PocketHost-based service, you'll want to change this. If you're just focusing on contributing to the project (please do!), then you can leave this as-is. |
| PUBLIC_APP_DOMAIN | `app.<apex>` | The fully qualified domain on which the dashboard/control panel lives (app). Probably no reason to alter this, as it defaults to `app` on the apex domain defined earlier. |
| PUBLIC_BLOG_DOMAIN | `<apex>` | The marketing/blog site lives on `<apex>` itself. |
| PUBLIC_EDGE_APEX_DOMAIN | `<id>.edge.<apex>` | Edge servers are the work horses of PocketHost. They receive incoming requests and route them to PocketBase instances. Each edge server needs a unique `<id>`, which are generally named after geographic regions such as `sfo-1`, `nyc-2`, etc. |
| PUBLIC_MOTHERSHIP_URL | `pockethost-central.<apex>` | The mothership URL is configurable because there may be cases where you want to deploy the mothership to its own central region. It does not live on an edge node. |
## Mothership Variables (backend only)
The Mothership is a set of backend services, including a central `pocketbase` instance, that is the source of truth for the state of PocketHost.
| Name | Default | Discussion |
| ------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MOTHERSHIP_MIGRATIONS_DIR | `<root>/src/mothership-app/migrations` | The directory where the Mothership migrations live. This is typically kept in revision control, so the default location is within the project directory structure. |
| MOTHERSHIP_HOOKS_DIR | `<root>/src/mothership-app/pb_hooks` | The directory where the Mothership `pb_hooks` live. This is typically kept in revision control, so the default location is within the project directory structure. |
| MOTHERSHIP_ADMIN_USERNAME | `admin@pockethost.lvh.me` | This admin login is created the first time PocketHost runs. |
| MOTHERSHIP_ADMIN_PASSWORD | `password` | |
| DEMO_USER_USERNAME | `user@pockethost.lvh.me` | This login is created the first time PocketHost runs |
| DEMO_USER_PASSWORD | `password` | |
| MOTHERSHIP_PORT | `8091` | The port the Mothership service will listen on. |
| MOTHERSHIP_SEMVER | `(blank)` | The semver used to lock the Mothership to a specific `pocketbase` version range. The Mothership will never launch with a `pocketbase` binary version outside this range. |
| MOTHERSHIP_PRIVATE_URL | `http://mothership.pockdthost.lvh.me` | This should be set to an intranet IP address in production settings. |
## Edge Variables (backend only)
An Edge node is the workhorse of PocketHost. Edge nodes manage `pocketbase` instances, spin them up, shut them down when idle, implement security, and afford regional access to instances. They communicate with the Mothership through `MOTHERSHIP_PRIVATE_URL`.
| Name | Default | Discussion |
| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| EDGE_DATA_ROOT | `<root>/.data` | Where the edge node should store its pocketbase instance data |
| EDGE_INSTANCE_MIN_IDLE_TTL | `5000` | The minimum amount of time an instance must remain idle (no connections) before an edge node is allowed to shut it down to free up resources |
| EDGE_INSTANCE_POOL_SIZE | `20` | The maximum number of simultaneous instances the edge node will allow to run concurrently. If this limit is exceeded, connections to instances not yet running in the pool will be denied. |
| \*\*IP_CIDR | `(blank)` | A comma-separated list of upstream proxy IP addresses or ranges PocketHost will respond to. |
\* Note: `PUBLIC_*` environment variables are visible to the frontend as well.
\*\* Note: [Cloudflare IP addresses](https://www.cloudflare.com/ips/) are currently: `173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22`. [Learn more about IP CIDR](https://developers.cloudflare.com/fundamentals/setup/allow-cloudflare-ip-addresses/).
## Security Variables (backend only)
| Name | Default | Discussion |
| ------- | --------- | ------------------------------------------------------------------------------------------- |
| IP_CIDR | `(blank)` | A comma-separated list of upstream proxy IP addresses or ranges PocketHost will respond to. |
Note: [Cloudflare IP addresses](https://www.cloudflare.com/ips/) are currently: `173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22`. [Learn more about IP CIDR](https://developers.cloudflare.com/fundamentals/setup/allow-cloudflare-ip-addresses/).
PocketHost has a thriving [Discord community](https://discord.gg/nVTxCMEcGT).

View File

@ -0,0 +1,13 @@
import { PocketbaseReleaseDownloadService } from '$services'
import { LoggerService } from '$shared'
import { discordAlert } from '$util'
export const download = async () => {
const logger = LoggerService().create(`download.ts`)
const { dbg, error, info, warn } = logger
info(`Starting`)
const { check } = PocketbaseReleaseDownloadService({})
await check().catch(discordAlert)
}

View File

@ -0,0 +1,15 @@
import { Command } from 'commander'
import { download } from './download'
type Options = {
debug: boolean
}
export const DownloadCommand = () => {
const cmd = new Command(`download`)
.description(`Download PocketBase versions`)
.action(async () => {
await download()
})
return cmd
}

View File

@ -1,15 +1,10 @@
import {
DEBUG,
DefaultSettingsService,
MOTHERSHIP_ADMIN_PASSWORD,
MOTHERSHIP_ADMIN_USERNAME,
MOTHERSHIP_INTERNAL_URL,
PH_BIN_CACHE,
SETTINGS,
} from '$constants'
import {
MothershipAdminClientService,
PocketbaseReleaseVersionService,
PocketbaseService,
PortService,
SqliteService,
@ -17,41 +12,20 @@ import {
proxyService,
realtimeLog,
} from '$services'
import { LogLevelName, LoggerService } from '$shared'
import { LoggerService } from '$shared'
import { discordAlert, tryFetch } from '$util'
import EventSource from 'eventsource'
import { ErrorRequestHandler } from 'express'
const [major, minor, patch] = process.versions.node.split('.').map(Number)
if ((major || 0) < 18) {
throw new Error(`Node 18 or higher required.`)
}
DefaultSettingsService(SETTINGS)
LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
})
// npm install eventsource --save
// @ts-ignore
global.EventSource = EventSource
;(async () => {
const logger = LoggerService().create(`server.ts`)
export async function daemon() {
const logger = LoggerService().create(`EdgeDaemonCommand`)
const { dbg, error, info, warn } = logger
info(`Starting`)
tryFetch(`${MOTHERSHIP_INTERNAL_URL(`/api/health`)}`, {})
const udService = await PocketbaseReleaseVersionService({
cachePath: PH_BIN_CACHE(),
checkIntervalMs: 1000 * 5 * 60,
})
await PortService({})
await PocketbaseService({})
await tryFetch(`${MOTHERSHIP_INTERNAL_URL(`/api/health`)}`, {})
info(`Serving`)
/** Launch services */
@ -77,5 +51,4 @@ global.EventSource = EventSource
res.status(500).send(err.toString())
}
;(await proxyService()).use(errorHandler)
info(`Hooking into process exit event`)
})()
}

View File

@ -0,0 +1,17 @@
import { PocketbaseReleaseVersionService } from '$services'
import { Command } from 'commander'
import { daemon } from './daemon'
type Options = {
debug: boolean
}
export const ServeCommand = () => {
const cmd = new Command(`serve`)
.description(`Run an edge daemon server`)
.action(async (options: Options) => {
await PocketbaseReleaseVersionService({})
await daemon()
})
return cmd
}

View File

@ -0,0 +1,13 @@
import { Command } from 'commander'
import { ServeCommand } from './ServeCommand'
type Options = {
debug: boolean
}
export const DaemonCommand = () => {
const cmd = new Command(`daemon`)
.description(`Daemon commands`)
.addCommand(ServeCommand())
return cmd
}

View File

@ -0,0 +1,16 @@
import { MOTHERSHIP_INTERNAL_URL } from '$constants'
import { ftpService } from '$services'
import { LoggerService } from '$shared'
import { tryFetch } from '$util'
export async function ftp() {
const logger = LoggerService().create(`EdgeFtpCommand`)
const { dbg, error, info, warn } = logger
info(`Starting`)
await tryFetch(`${MOTHERSHIP_INTERNAL_URL(`/api/health`)}`, {})
await ftpService({
mothershipUrl: MOTHERSHIP_INTERNAL_URL(),
})
}

View File

@ -0,0 +1,15 @@
import { Command } from 'commander'
import { ftp } from './ftp'
type Options = {
debug: boolean
}
export const ServeCommand = () => {
const cmd = new Command(`serve`)
.description(`Run an edge FTP server`)
.action(async (options: Options) => {
await ftp()
})
return cmd
}

View File

@ -0,0 +1,13 @@
import { Command } from 'commander'
import { ServeCommand } from './ServeCommand'
type Options = {
debug: boolean
}
export const FtpCommand = () => {
const cmd = new Command(`ftp`)
.description(`FTP commands`)
.addCommand(ServeCommand())
return cmd
}

View File

@ -0,0 +1,15 @@
import { Command } from 'commander'
import { syslog } from './syslog'
type Options = {
debug: boolean
}
export const ServeCommand = () => {
const cmd = new Command(`serve`)
.description(`Run an edge syslog server`)
.action(async (options: Options) => {
await syslog()
})
return cmd
}

View File

@ -0,0 +1,49 @@
import { SYSLOGD_PORT } from '$constants'
import { InstanceLogger } from '$services'
import { LoggerService } from '$shared'
import * as dgram from 'dgram'
import parse from 'syslog-parse'
export function syslog() {
return new Promise<void>((resolve) => {
const logger = LoggerService().create(`EdgeSyslogCommand`)
const { dbg, error, info, warn } = logger
info(`Starting`)
const PORT = SYSLOGD_PORT()
const HOST = '0.0.0.0'
const server = dgram.createSocket('udp4')
server.on('error', (err) => {
console.log(`Server error:\n${err.stack}`)
server.close()
})
server.on('message', (msg, rinfo) => {
const raw = msg.toString()
const parsed = parse(raw)
if (!parsed) {
return
}
dbg(parsed)
const { process: instanceId, severity, message } = parsed
const logger = InstanceLogger(instanceId, `exec`, { ttl: 5000 })
if (severity === 'info') {
logger.info(message)
} else {
logger.error(message)
}
})
server.on('listening', () => {
const address = server.address()
info(`Server listening ${address.address}:${address.port}`)
resolve()
})
server.bind(PORT, HOST)
})
}

View File

@ -0,0 +1,13 @@
import { Command } from 'commander'
import { ServeCommand } from './ServeCommand'
type Options = {
debug: boolean
}
export const SyslogCommand = () => {
const cmd = new Command(`syslog`)
.description(`Syslog commands`)
.addCommand(ServeCommand())
return cmd
}

View File

@ -0,0 +1,18 @@
import { Command } from 'commander'
import { DaemonCommand } from './DaemonCommand'
import { FtpCommand } from './FtpCommand'
import { SyslogCommand } from './SyslogCommand'
type Options = {
debug: boolean
}
export const EdgeCommand = () => {
const cmd = new Command(`edge`).description(`Edge commands`)
cmd
.addCommand(DaemonCommand())
.addCommand(FtpCommand())
.addCommand(SyslogCommand())
return cmd
}

View File

@ -0,0 +1,92 @@
import {
APEX_DOMAIN,
APP_NAME,
DAEMON_PORT,
IPCIDR_LIST,
IS_DEV,
MOTHERSHIP_NAME,
MOTHERSHIP_PORT,
SSL_CERT,
SSL_KEY,
} from '$constants'
import { LoggerService } from '$src/shared'
import { discordAlert } from '$util'
import { forEach } from '@s-libs/micro-dash'
import cors from 'cors'
import express, { ErrorRequestHandler } from 'express'
import 'express-async-errors'
import enforce from 'express-sslify'
import fs from 'fs'
import http from 'http'
import { createProxyMiddleware } from 'http-proxy-middleware'
import https from 'https'
import { createIpWhitelistMiddleware } from './cidr'
import { createVhostProxyMiddleware } from './createVhostProxyMiddleware'
export const firewall = async () => {
const { debug } = LoggerService().create(`proxy`)
const PROD_ROUTES = {
[`${MOTHERSHIP_NAME()}.${APEX_DOMAIN()}`]: `http://localhost:${MOTHERSHIP_PORT()}`,
}
const DEV_ROUTES = {
[`mail.${APEX_DOMAIN()}`]: `http://localhost:${1080}`,
[`${MOTHERSHIP_NAME()}.${APEX_DOMAIN()}`]: `http://localhost:${MOTHERSHIP_PORT()}`,
[`${APP_NAME()}.${APEX_DOMAIN()}`]: `http://localhost:${5174}`,
[`superadmin.${APEX_DOMAIN()}`]: `http://localhost:${5175}`,
[`${APEX_DOMAIN()}`]: `http://localhost:${8080}`,
}
const hostnameRoutes = IS_DEV() ? DEV_ROUTES : PROD_ROUTES
// Create Express app
const app = express()
app.use(cors())
app.use(enforce.HTTPS())
// Use the IP blocker middleware
app.use(createIpWhitelistMiddleware(IPCIDR_LIST()))
forEach(hostnameRoutes, (target, host) => {
app.use(createVhostProxyMiddleware(host, target, IS_DEV()))
})
app.get(`/_api/health`, (req, res, next) => {
res.json({ status: 'ok' })
res.end()
})
// Fall-through
const handler = createProxyMiddleware({
target: `http://localhost:${DAEMON_PORT()}`,
})
app.all(`*`, (req, res, next) => {
const method = req.method
const fullUrl = req.protocol + '://' + req.get('host') + req.originalUrl
debug(`${method} ${fullUrl} -> ${`http://localhost:${DAEMON_PORT()}`}`)
handler(req, res, next)
})
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
discordAlert(err.toString())
res.status(500).send(err.toString())
}
app.use(errorHandler)
http.createServer(app).listen(80, () => {
console.log('SSL redirect server listening on 80')
})
// HTTPS server options
const httpsOptions = {
key: fs.readFileSync(SSL_KEY()),
cert: fs.readFileSync(SSL_CERT()),
}
// Create HTTPS server
https.createServer(httpsOptions, app).listen(443, () => {
console.log('HTTPS server running on port 443')
})
}

View File

@ -0,0 +1,15 @@
import { Command } from 'commander'
import { firewall } from './firewall/server'
type Options = {
debug: boolean
}
export const ServeCommand = () => {
const cmd = new Command(`serve`)
.description(`Serve the root firewall`)
.action(async (options: Options) => {
await firewall()
})
return cmd
}

View File

@ -0,0 +1,13 @@
import { Command } from 'commander'
import { ServeCommand } from './ServeCommand'
type Options = {
debug: boolean
}
export const FirewallCommand = () => {
const cmd = new Command(`firewall`)
.description(`Root firewall commands`)
.addCommand(ServeCommand())
return cmd
}

View File

@ -0,0 +1,238 @@
import {
DAEMON_PORT,
DISCORD_HEALTH_CHANNEL_URL,
MOTHERSHIP_PORT,
} from '$constants'
import { LoggerService } from '$shared'
import { discordAlert } from '$util'
import Bottleneck from 'bottleneck'
import { execSync } from 'child_process'
import fetch from 'node-fetch'
import { default as osu } from 'node-os-utils'
import { freemem } from 'os'
export const checkHealth = async () => {
const { cpu, drive, openfiles, proc } = osu
const DISCORD_URL = DISCORD_HEALTH_CHANNEL_URL()
const { dbg, error, info, warn } = LoggerService().create('edge-health.ts')
info(`Starting`)
try {
const _exec = (cmd: string) =>
execSync(cmd, { shell: '/bin/bash', maxBuffer: 1024 * 1024 * 10 })
.toString()
.split(`\n`)
const openFiles = _exec(`lsof -n | awk '$4 ~ /^[0-9]/ {print}'`)
const [freeSpace] = _exec(`df -h / | awk 'NR==2{print $4}'`)
type DockerPs = {
Command: string
CreatedAt: string
ID: string
Image: string
Labels: string
LocalVolumes: string
Mounts: string
Names: string
Networks: string
Ports: string
RunningFor: string
Size: string
State: string
Status: string
}
const SAMPLE: DockerPs = {
Command: '"docker-entrypoint.s…"',
CreatedAt: '2024-01-23 04:36:09 +0000 UTC',
ID: '6e0921e84391',
Image: 'pockethost-instance',
Labels: '',
LocalVolumes: '0',
Mounts:
'/home/pocketho…,/home/pocketho…,/home/pocketho…,/home/pocketho…,/home/pocketho…',
Names: 'kekbase-1705984569777',
Networks: 'bridge',
Ports: '0.0.0.0:44447-\u003e8090/tcp, :::44447-\u003e8090/tcp',
RunningFor: '7 hours ago',
Size: '0B (virtual 146MB)',
State: 'running',
Status: 'Up 7 hours',
}
type Check = {
name: string
priority: number
emoji: string
isHealthy: boolean
url: string
// Instance
port?: number
ago?: string
mem?: string
created?: Date
}
const containers = _exec(`docker ps --format '{{json .}}'`)
.filter((line) => line.trim())
.map((line) => {
dbg(line)
return line
})
.map((line) => JSON.parse(line) as DockerPs)
.map<Check>((rec) => {
const name = rec.Names.replace(/-\d+/, '')
const port = parseInt(rec.Ports.match(/:(\d+)/)?.[1] || '0', 10)
const mem = rec.Size.match(/(\d+MB)/)?.[1] || '0MB'
const created = new Date(rec.CreatedAt)
return {
name,
priority: 0,
emoji: ':octopus:',
port,
isHealthy: false,
url: `http://localhost:${port}/api/health`,
ago: rec.RunningFor,
mem,
created,
}
})
function getFreeMemoryInGB(): string {
const freeMemoryBytes: number = freemem()
const freeMemoryGB: number = freeMemoryBytes / Math.pow(1024, 3)
return freeMemoryGB.toFixed(2) // Rounds to 2 decimal places
}
function splitIntoChunks(
lines: string[],
maxChars: number = 2000,
): string[] {
const chunks: string[] = []
let currentChunk: string = ''
lines.forEach((line) => {
// Check if adding the next line exceeds the maxChars limit
if (currentChunk.length + line.length + 1 > maxChars) {
chunks.push(currentChunk)
currentChunk = ''
}
currentChunk += line + '\n' // Add the line and a newline character
})
// Add the last chunk if it's not empty
if (currentChunk) {
chunks.push(currentChunk)
}
return chunks
}
const limiter = new Bottleneck({ maxConcurrent: 1 })
const send = (lines: string[]) =>
Promise.all(
splitIntoChunks(lines).map((content) =>
limiter.schedule(() =>
fetch(DISCORD_URL, {
method: 'POST',
body: JSON.stringify({
content,
}),
headers: { 'content-type': 'application/json' },
}),
),
),
)
const driveInfo = await drive.info(`/`)
await send([
`===================`,
`Server: SFO-1`,
`${new Date()}`,
`CPUs: ${cpu.count()}`,
`CPU Usage: ${await cpu.usage()}%`,
`Free RAM: ${getFreeMemoryInGB()}GB`,
`Free disk: ${driveInfo.freeGb}GB`,
`Open files: ${openFiles.length}`,
`Containers: ${containers.length}`,
])
const checks: Check[] = [
{
name: `edge proxy`,
priority: 10,
emoji: `:park:`,
isHealthy: false,
url: `https://proxy.pockethost.io/_api/health`,
},
{
name: `edge daemon`,
priority: 8,
emoji: `:imp:`,
isHealthy: false,
url: `http://localhost:${DAEMON_PORT()}/_api/health`,
},
{
name: `mothership`,
priority: 9,
emoji: `:flying_saucer:`,
isHealthy: false,
url: `http://localhost:${MOTHERSHIP_PORT()}/api/health`,
},
...containers,
]
await Promise.all(
checks.map(async (check) => {
const { url } = check
dbg({ container: check })
try {
const res = await fetch(url)
dbg({ url, status: res.status })
check.isHealthy = res.status === 200
return true
} catch (e) {
dbg(`${url}: ${e}`)
check.isHealthy = false
}
}),
)
dbg({ checks })
await send([
`---health checks---`,
...checks
.sort((a, b) => {
if (a.priority > b.priority) return -1
if (a.priority < b.priority) return 1
const now = new Date()
const res = +(b.created || now) - +(a.created || now)
if (res) return res
return a.name.localeCompare(b.name)
})
.map(({ name, isHealthy, emoji, mem, ago }) => {
const isInstance = !!mem
if (isInstance) {
return `${
isHealthy ? ':white_check_mark:' : ':face_vomiting: '
} \`${name.padStart(30)} ${(mem || '').padStart(10)} ${(
ago || ''
).padStart(20)}\``
} else {
return `${
isHealthy ? ':white_check_mark:' : ':face_vomiting: '
} ${name}`
}
}),
])
} catch (e) {
discordAlert(`${e}`)
}
}

View File

@ -0,0 +1,20 @@
import { LoggerService } from '$shared'
import { Command } from 'commander'
import { checkHealth } from './checkHealth'
type Options = {
debug: boolean
}
export const HealthCommand = () => {
const cmd = new Command(`health`)
.description(`Perform a health check on the PocketHost system`)
.action(async (options: Options) => {
const logger = LoggerService().create(`HealthCommand`)
const { dbg, error, info, warn } = logger
info(`Starting`)
await checkHealth()
})
return cmd
}

View File

@ -0,0 +1,15 @@
import { Command } from 'commander'
import { mothership } from './mothership'
type Options = {
debug: boolean
}
export const ServeCommand = () => {
const cmd = new Command(`serve`)
.description(`Run the PocketHost mothership`)
.action(async (options: Options) => {
await mothership()
})
return cmd
}

View File

@ -1,7 +1,6 @@
import {
DATA_ROOT,
DEBUG,
DefaultSettingsService,
LS_WEBHOOK_SECRET,
mkContainerHomePath,
MOTHERSHIP_APP_DIR,
@ -10,50 +9,26 @@ import {
MOTHERSHIP_NAME,
MOTHERSHIP_PORT,
MOTHERSHIP_SEMVER,
PH_BIN_CACHE,
PH_VERSIONS,
SETTINGS,
} from '$constants'
import {
PocketbaseReleaseVersionService,
PocketbaseService,
PortService,
} from '$services'
import { LoggerService, LogLevelName } from '$shared'
import { LoggerService } from '$shared'
import { gracefulExit } from '$util'
import EventSource from 'eventsource'
// gen:import
const [major, minor, patch] = process.versions.node.split('.').map(Number)
if ((major || 0) < 18) {
throw new Error(`Node 18 or higher required.`)
}
DefaultSettingsService(SETTINGS)
LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
})
// npm install eventsource --save
// @ts-ignore
global.EventSource = EventSource
;(async () => {
const logger = LoggerService().create(`mothership.ts`)
export async function mothership() {
const logger = LoggerService().create(`Mothership`)
const { dbg, error, info, warn } = logger
info(`Starting`)
const udService = await PocketbaseReleaseVersionService({
cachePath: PH_BIN_CACHE(),
checkIntervalMs: 1000 * 5 * 60,
})
await PortService({})
await PocketbaseReleaseVersionService({})
const pbService = await PocketbaseService({})
/** Launch central database */
info(`Serving`)
const { url, exitCode } = await pbService.spawn({
version: MOTHERSHIP_SEMVER(),
@ -77,4 +52,4 @@ global.EventSource = EventSource
exitCode.then((c) => {
gracefulExit(c)
})
})()
}

View File

@ -0,0 +1,13 @@
import { Command } from 'commander'
import { ServeCommand } from './ServeCommand'
type Options = {
debug: boolean
}
export const MothershipCommand = () => {
const cmd = new Command(`mothership`)
.description(`Mothership commands`)
.addCommand(ServeCommand())
return cmd
}

View File

@ -0,0 +1,20 @@
import { LoggerService } from '$shared'
import { Command } from 'commander'
import { sendMail } from './sendmail'
type Options = {
debug: boolean
}
export const SendMailCommand = () => {
const cmd = new Command(`mail:send`)
.description(`Send a PocketHost bulk mail`)
.action(async (options: Options) => {
const logger = LoggerService().create(`SendMailCommand`)
const { dbg, error, info, warn } = logger
info(`Starting`)
await sendMail()
})
return cmd
}

View File

@ -0,0 +1,127 @@
import {
MOTHERSHIP_ADMIN_PASSWORD,
MOTHERSHIP_ADMIN_USERNAME,
MOTHERSHIP_DATA_DB,
MOTHERSHIP_INTERNAL_URL,
MOTHERSHIP_URL,
TEST_EMAIL,
} from '$constants'
import { SqliteService } from '$services'
import { LoggerService, UserFields } from '$src/shared'
import { map } from '@s-libs/micro-dash'
import Bottleneck from 'bottleneck'
import { InvalidArgumentError, program } from 'commander'
import PocketBase from 'pocketbase'
const TBL_SENT_MESSAGES = `sent_messages`
export const sendMail = async () => {
const { dbg, info } = LoggerService().create(`mail.ts`)
function myParseInt(value: string) {
// parseInt takes a string and a radix
const parsedValue = parseInt(value, 10)
if (isNaN(parsedValue)) {
throw new InvalidArgumentError('Not a number.')
}
return parsedValue
}
function interpolateString(
template: string,
dict: { [key: string]: string },
): string {
return template.replace(/\{\$(\w+)\}/g, (match, key) => {
dbg({ match, key })
const lowerKey = key.toLowerCase()
return dict.hasOwnProperty(lowerKey) ? dict[lowerKey]! : match
})
}
program
.argument(`<messageId>`, `ID of the message to send`)
.option('--limit <number>', `Max messages to send`, myParseInt, 1)
.option('--confirm', `Really send messages`, false)
.action(async (messageId, { limit, confirm }) => {
dbg({ messageId, confirm, limit })
const { getDatabase } = SqliteService({})
const db = await getDatabase(MOTHERSHIP_DATA_DB())
info(MOTHERSHIP_URL())
const client = new PocketBase(MOTHERSHIP_INTERNAL_URL())
await client.admins.authWithPassword(
MOTHERSHIP_ADMIN_USERNAME(),
MOTHERSHIP_ADMIN_PASSWORD(),
)
const message = await client
.collection(`campaign_messages`)
.getOne(messageId, { expand: 'campaign' })
const { campaign } = message.expand || {}
dbg({ messageId, limit, message, campaign })
const vars: { [_: string]: string } = {
messageId,
}
await Promise.all(
map(campaign.vars, async (sql, k) => {
const res = await db.raw(sql)
const [{ value }] = res
vars[k.toLocaleLowerCase()] = value
}),
)
dbg({ vars })
const subject = interpolateString(message.subject, vars)
const body = interpolateString(message.body, vars)
const sql = `SELECT u.*
FROM (${campaign.usersQuery}) u
LEFT JOIN sent_messages sm ON u.id = sm.user AND sm.campaign_message = '${messageId}'
WHERE sm.id IS NULL;
`
dbg(sql)
const users = (await db.raw<UserFields[]>(sql)).slice(0, limit)
// dbg({ users })
const limiter = new Bottleneck({ maxConcurrent: 1, minTime: 100 })
await Promise.all(
users.map((user) => {
return limiter.schedule(async () => {
if (!confirm) {
const old = user.email
user.email = TEST_EMAIL()
info(`Sending to ${user.email} masking ${old}`)
} else {
info(`Sending to ${user.email}`)
}
await client.send(`/api/mail`, {
method: 'POST',
body: {
to: user.email,
subject,
body: `${body}<hr/>I only send PocketHost annoucements. But I get it. <a href="https://pockethost-central.pockethost.io/api/unsubscribe?e=${user.id}">[[unsub]]</a>`,
},
})
info(`Sent`)
if (confirm) {
await client.collection(TBL_SENT_MESSAGES).create({
user: user.id,
message: messageId,
campaign_message: messageId,
})
}
})
}),
)
SqliteService().shutdown()
})
program.parseAsync()
}

View File

@ -0,0 +1,26 @@
import { LoggerService } from '$shared'
import { Command } from 'commander'
import { daemon } from '../EdgeCommand/DaemonCommand/ServeCommand/daemon'
import { syslog } from '../EdgeCommand/SyslogCommand/ServeCommand/syslog'
import { firewall } from '../FirewallCommand/ServeCommand/firewall/server'
import { mothership } from '../MothershipCommand/ServeCommand/mothership'
type Options = {
debug: boolean
}
export const ServeCommand = () => {
const cmd = new Command(`serve`)
.description(`Run the entire PocketHost stack`)
.action(async (options: Options) => {
const logger = LoggerService().create(`ServeComand`)
const { dbg, error, info, warn } = logger
info(`Starting`)
await syslog()
await mothership()
await daemon()
await firewall()
})
return cmd
}

View File

@ -1,21 +0,0 @@
import { DEBUG, DefaultSettingsService, SETTINGS } from '$constants'
import { PocketbaseReleaseDownloadService } from '$services'
import { LogLevelName, LoggerService } from '$shared'
import { discordAlert } from '$util'
const check = async () => {
DefaultSettingsService(SETTINGS)
LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
})
const logger = LoggerService().create(`download.ts`)
const { dbg, error, info, warn } = logger
info(`Starting`)
const { check } = PocketbaseReleaseDownloadService({})
await check()
}
check().catch(discordAlert)

View File

@ -1,38 +0,0 @@
import {
DEBUG,
DefaultSettingsService,
MOTHERSHIP_INTERNAL_URL,
SETTINGS,
} from '$constants'
import { ftpService } from '$services'
import { LogLevelName, LoggerService } from '$shared'
import { tryFetch } from '$util'
import EventSource from 'eventsource'
// gen:import
const [major, minor, patch] = process.versions.node.split('.').map(Number)
if ((major || 0) < 18) {
throw new Error(`Node 18 or higher required.`)
}
DefaultSettingsService(SETTINGS)
LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
})
// npm install eventsource --save
// @ts-ignore
global.EventSource = EventSource
;(async () => {
const logger = LoggerService().create(`edge-ftp.ts`)
const { dbg, error, info, warn } = logger
info(`Starting`)
tryFetch(`${MOTHERSHIP_INTERNAL_URL(`/api/health`)}`, {})
await ftpService({
mothershipUrl: MOTHERSHIP_INTERNAL_URL(),
})
})()

View File

@ -1,240 +0,0 @@
import {
DAEMON_PORT,
DEBUG,
DefaultSettingsService,
DISCORD_HEALTH_CHANNEL_URL,
MOTHERSHIP_PORT,
SETTINGS,
} from '$constants'
import { LoggerService, LogLevelName } from '$shared'
import { discordAlert } from '$util'
import Bottleneck from 'bottleneck'
import { execSync } from 'child_process'
import fetch from 'node-fetch'
import { default as osu } from 'node-os-utils'
import { freemem } from 'os'
const { cpu, drive, openfiles, proc } = osu
DefaultSettingsService(SETTINGS)
const DISCORD_URL = DISCORD_HEALTH_CHANNEL_URL()
const { dbg, error, info, warn } = LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
}).create('edge-health.ts')
info(`Starting`)
try {
const _exec = (cmd: string) =>
execSync(cmd, { shell: '/bin/bash', maxBuffer: 1024 * 1024 * 10 })
.toString()
.split(`\n`)
const openFiles = _exec(`lsof -n | awk '$4 ~ /^[0-9]/ {print}'`)
const [freeSpace] = _exec(`df -h / | awk 'NR==2{print $4}'`)
type DockerPs = {
Command: string
CreatedAt: string
ID: string
Image: string
Labels: string
LocalVolumes: string
Mounts: string
Names: string
Networks: string
Ports: string
RunningFor: string
Size: string
State: string
Status: string
}
const SAMPLE: DockerPs = {
Command: '"docker-entrypoint.s…"',
CreatedAt: '2024-01-23 04:36:09 +0000 UTC',
ID: '6e0921e84391',
Image: 'pockethost-instance',
Labels: '',
LocalVolumes: '0',
Mounts:
'/home/pocketho…,/home/pocketho…,/home/pocketho…,/home/pocketho…,/home/pocketho…',
Names: 'kekbase-1705984569777',
Networks: 'bridge',
Ports: '0.0.0.0:44447-\u003e8090/tcp, :::44447-\u003e8090/tcp',
RunningFor: '7 hours ago',
Size: '0B (virtual 146MB)',
State: 'running',
Status: 'Up 7 hours',
}
type Check = {
name: string
priority: number
emoji: string
isHealthy: boolean
url: string
// Instance
port?: number
ago?: string
mem?: string
created?: Date
}
const containers = _exec(`docker ps --format '{{json .}}'`)
.filter((line) => line.trim())
.map((line) => {
dbg(line)
return line
})
.map((line) => JSON.parse(line) as DockerPs)
.map<Check>((rec) => {
const name = rec.Names.replace(/-\d+/, '')
const port = parseInt(rec.Ports.match(/:(\d+)/)?.[1] || '0', 10)
const mem = rec.Size.match(/(\d+MB)/)?.[1] || '0MB'
const created = new Date(rec.CreatedAt)
return {
name,
priority: 0,
emoji: ':octopus:',
port,
isHealthy: false,
url: `http://localhost:${port}/api/health`,
ago: rec.RunningFor,
mem,
created,
}
})
function getFreeMemoryInGB(): string {
const freeMemoryBytes: number = freemem()
const freeMemoryGB: number = freeMemoryBytes / Math.pow(1024, 3)
return freeMemoryGB.toFixed(2) // Rounds to 2 decimal places
}
function splitIntoChunks(lines: string[], maxChars: number = 2000): string[] {
const chunks: string[] = []
let currentChunk: string = ''
lines.forEach((line) => {
// Check if adding the next line exceeds the maxChars limit
if (currentChunk.length + line.length + 1 > maxChars) {
chunks.push(currentChunk)
currentChunk = ''
}
currentChunk += line + '\n' // Add the line and a newline character
})
// Add the last chunk if it's not empty
if (currentChunk) {
chunks.push(currentChunk)
}
return chunks
}
const limiter = new Bottleneck({ maxConcurrent: 1 })
const send = (lines: string[]) =>
Promise.all(
splitIntoChunks(lines).map((content) =>
limiter.schedule(() =>
fetch(DISCORD_URL, {
method: 'POST',
body: JSON.stringify({
content,
}),
headers: { 'content-type': 'application/json' },
}),
),
),
)
const driveInfo = await drive.info(`/`)
await send([
`===================`,
`Server: SFO-1`,
`${new Date()}`,
`CPUs: ${cpu.count()}`,
`CPU Usage: ${await cpu.usage()}%`,
`Free RAM: ${getFreeMemoryInGB()}GB`,
`Free disk: ${driveInfo.freeGb}GB`,
`Open files: ${openFiles.length}`,
`Containers: ${containers.length}`,
])
const checks: Check[] = [
{
name: `edge proxy`,
priority: 10,
emoji: `:park:`,
isHealthy: false,
url: `https://proxy.pockethost.io/_api/health`,
},
{
name: `edge daemon`,
priority: 8,
emoji: `:imp:`,
isHealthy: false,
url: `http://localhost:${DAEMON_PORT()}/_api/health`,
},
{
name: `mothership`,
priority: 9,
emoji: `:flying_saucer:`,
isHealthy: false,
url: `http://localhost:${MOTHERSHIP_PORT()}/api/health`,
},
...containers,
]
await Promise.all(
checks.map(async (check) => {
const { url } = check
dbg({ container: check })
try {
const res = await fetch(url)
dbg({ url, status: res.status })
check.isHealthy = res.status === 200
return true
} catch (e) {
dbg(`${url}: ${e}`)
check.isHealthy = false
}
}),
)
dbg({ checks })
await send([
`---health checks---`,
...checks
.sort((a, b) => {
if (a.priority > b.priority) return -1
if (a.priority < b.priority) return 1
const now = new Date()
const res = +(b.created || now) - +(a.created || now)
if (res) return res
return a.name.localeCompare(b.name)
})
.map(({ name, isHealthy, emoji, mem, ago }) => {
const isInstance = !!mem
if (isInstance) {
return `${
isHealthy ? ':white_check_mark:' : ':face_vomiting: '
} \`${name.padStart(30)} ${(mem || '').padStart(10)} ${(
ago || ''
).padStart(20)}\``
} else {
return `${
isHealthy ? ':white_check_mark:' : ':face_vomiting: '
} ${name}`
}
}),
])
} catch (e) {
discordAlert(`${e}`)
}

View File

@ -1,51 +0,0 @@
import {
DEBUG,
DefaultSettingsService,
SETTINGS,
SYSLOGD_PORT,
} from '$constants'
import { InstanceLogger } from '$services'
import { LogLevelName, LoggerService } from '$src/shared'
import * as dgram from 'dgram'
import parse from 'syslog-parse'
const server = dgram.createSocket('udp4')
DefaultSettingsService(SETTINGS)
const PORT = SYSLOGD_PORT()
const HOST = '0.0.0.0'
const { dbg, info, error } = LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
}).create(`edge-syslogd`)
server.on('error', (err) => {
console.log(`Server error:\n${err.stack}`)
server.close()
})
server.on('message', (msg, rinfo) => {
const raw = msg.toString()
const parsed = parse(raw)
if (!parsed) {
return
}
dbg(parsed)
const { process: instanceId, severity, message } = parsed
const logger = InstanceLogger(instanceId, `exec`, { ttl: 5000 })
if (severity === 'info') {
logger.info(message)
} else {
logger.error(message)
}
})
server.on('listening', () => {
const address = server.address()
console.log(`Server listening ${address.address}:${address.port}`)
})
server.bind(PORT, HOST)

41
src/cli/index.ts Normal file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env node
import { DefaultSettingsService, SETTINGS } from '$constants'
import { LogLevelName, LoggerService } from '$shared'
import { program } from 'commander'
import EventSource from 'eventsource'
import { DownloadCommand } from './commands/DownloadCommand'
import { EdgeCommand } from './commands/EdgeCommand'
import { FirewallCommand } from './commands/FirewallCommand'
import { HealthCommand } from './commands/HealthCommand'
import { MothershipCommand } from './commands/MothershipCommand'
import { SendMailCommand } from './commands/SendMailCommand'
import { ServeCommand } from './commands/ServeCommand'
export type GlobalOptions = {
logLevel?: LogLevelName
debug: boolean
}
DefaultSettingsService(SETTINGS)
LoggerService({})
//@ts-ignore
global.EventSource = EventSource
export const main = async () => {
program.name('pockethost').description('Multitenant PocketBase hosting')
program
.addCommand(MothershipCommand())
.addCommand(EdgeCommand())
.addCommand(HealthCommand())
.addCommand(FirewallCommand())
.addCommand(SendMailCommand())
.addCommand(ServeCommand())
.addCommand(DownloadCommand())
await program.parseAsync()
}
main()

View File

@ -1,89 +0,0 @@
import {
DAEMON_PORT,
DefaultSettingsService,
IPCIDR_LIST,
IS_DEV,
MOTHERSHIP_PORT,
SETTINGS,
SSL_CERT,
SSL_KEY,
} from '$constants'
import { forEach } from '@s-libs/micro-dash'
import cors from 'cors'
import express, { ErrorRequestHandler } from 'express'
import 'express-async-errors'
import enforce from 'express-sslify'
import fs from 'fs'
import http from 'http'
import https from 'https'
import { discordAlert } from '$util'
import { createProxyMiddleware } from 'http-proxy-middleware'
import { createIpWhitelistMiddleware } from './cidr'
import { createVhostProxyMiddleware } from './createVhostProxyMiddleware'
DefaultSettingsService(SETTINGS)
const PROD_ROUTES = {
'pockethost-central.pockethost.io': `http://localhost:${MOTHERSHIP_PORT()}`,
}
const DEV_ROUTES = {
'mail.pockethost.lvh.me': `http://localhost:${1080}`,
'pockethost-central.pockethost.lvh.me': `http://localhost:${MOTHERSHIP_PORT()}`,
'app.pockethost.lvh.me': `http://localhost:${5174}`,
'superadmin.pockethost.lvh.me': `http://localhost:${5175}`,
'pockethost.lvh.me': `http://localhost:${8080}`,
}
const hostnameRoutes = IS_DEV() ? DEV_ROUTES : PROD_ROUTES
// Create Express app
const app = express()
app.use(cors())
app.use(enforce.HTTPS())
// Use the IP blocker middleware
app.use(createIpWhitelistMiddleware(IPCIDR_LIST()))
forEach(hostnameRoutes, (target, host) => {
app.use(createVhostProxyMiddleware(host, target, IS_DEV()))
})
app.get(`/_api/health`, (req, res, next) => {
res.json({ status: 'ok' })
res.end()
})
// Fall-through
const handler = createProxyMiddleware({
target: `http://localhost:${DAEMON_PORT()}`,
})
app.all(`*`, (req, res, next) => {
const method = req.method
const fullUrl = req.protocol + '://' + req.get('host') + req.originalUrl
console.log(`${method} ${fullUrl} -> ${`http://localhost:${DAEMON_PORT()}`}`)
handler(req, res, next)
})
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
discordAlert(err.toString())
res.status(500).send(err.toString())
}
app.use(errorHandler)
http.createServer(app).listen(80, () => {
console.log('SSL redirect server listening on 80')
})
// HTTPS server options
const httpsOptions = {
key: fs.readFileSync(SSL_KEY()),
cert: fs.readFileSync(SSL_CERT()),
}
// Create HTTPS server
https.createServer(httpsOptions, app).listen(443, () => {
console.log('HTTPS server running on port 443')
})

View File

@ -1,131 +0,0 @@
import {
DEBUG,
DefaultSettingsService,
MOTHERSHIP_ADMIN_PASSWORD,
MOTHERSHIP_ADMIN_USERNAME,
MOTHERSHIP_DATA_DB,
MOTHERSHIP_INTERNAL_URL,
MOTHERSHIP_URL,
SETTINGS,
TEST_EMAIL,
} from '$constants'
import { SqliteService } from '$services'
import { LogLevelName, LoggerService, UserFields } from '$src/shared'
import { map } from '@s-libs/micro-dash'
import Bottleneck from 'bottleneck'
import { InvalidArgumentError, program } from 'commander'
import PocketBase from 'pocketbase'
const TBL_SENT_MESSAGES = `sent_messages`
DefaultSettingsService(SETTINGS)
const { dbg, info } = LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
}).create(`mail.ts`)
function myParseInt(value: string) {
// parseInt takes a string and a radix
const parsedValue = parseInt(value, 10)
if (isNaN(parsedValue)) {
throw new InvalidArgumentError('Not a number.')
}
return parsedValue
}
function interpolateString(
template: string,
dict: { [key: string]: string },
): string {
return template.replace(/\{\$(\w+)\}/g, (match, key) => {
dbg({ match, key })
const lowerKey = key.toLowerCase()
return dict.hasOwnProperty(lowerKey) ? dict[lowerKey]! : match
})
}
program
.argument(`<messageId>`, `ID of the message to send`)
.option('--limit <number>', `Max messages to send`, myParseInt, 1)
.option('--confirm', `Really send messages`, false)
.action(async (messageId, { limit, confirm }) => {
dbg({ messageId, confirm, limit })
const { getDatabase } = SqliteService({})
const db = await getDatabase(MOTHERSHIP_DATA_DB())
info(MOTHERSHIP_URL())
const client = new PocketBase(MOTHERSHIP_INTERNAL_URL())
await client.admins.authWithPassword(
MOTHERSHIP_ADMIN_USERNAME(),
MOTHERSHIP_ADMIN_PASSWORD(),
)
const message = await client
.collection(`campaign_messages`)
.getOne(messageId, { expand: 'campaign' })
const { campaign } = message.expand || {}
dbg({ messageId, limit, message, campaign })
const vars: { [_: string]: string } = {
messageId,
}
await Promise.all(
map(campaign.vars, async (sql, k) => {
const res = await db.raw(sql)
const [{ value }] = res
vars[k.toLocaleLowerCase()] = value
}),
)
dbg({ vars })
const subject = interpolateString(message.subject, vars)
const body = interpolateString(message.body, vars)
const sql = `SELECT u.*
FROM (${campaign.usersQuery}) u
LEFT JOIN sent_messages sm ON u.id = sm.user AND sm.campaign_message = '${messageId}'
WHERE sm.id IS NULL;
`
dbg(sql)
const users = (await db.raw<UserFields[]>(sql)).slice(0, limit)
// dbg({ users })
const limiter = new Bottleneck({ maxConcurrent: 1, minTime: 100 })
await Promise.all(
users.map((user) => {
return limiter.schedule(async () => {
if (!confirm) {
const old = user.email
user.email = TEST_EMAIL()
info(`Sending to ${user.email} masking ${old}`)
} else {
info(`Sending to ${user.email}`)
}
await client.send(`/api/mail`, {
method: 'POST',
body: {
to: user.email,
subject,
body: `${body}<hr/>I only send PocketHost annoucements. But I get it. <a href="https://pockethost-central.pockethost.io/api/unsubscribe?e=${user.id}">[[unsub]]</a>`,
},
})
info(`Sent`)
if (confirm) {
await client.collection(TBL_SENT_MESSAGES).create({
user: user.id,
message: messageId,
campaign_message: messageId,
})
}
})
}),
)
SqliteService().shutdown()
})
program.parseAsync()

View File

@ -42,10 +42,17 @@ export const _MOTHERSHIP_NAME =
process.env.MOTHERSHIP_NAME || 'pockethost-central'
export const _MOTHERSHIP_APP_ROOT = (...paths: string[]) =>
join(_PH_PROJECT_ROOT, 'src', 'mothership-app', ...paths)
join(
process.env.PH_MOTHERSHIP_APP_ROOT ||
join(_PH_PROJECT_ROOT, 'mothership-app'),
...paths,
)
export const _INSTANCE_APP_ROOT = (...paths: string[]) =>
join(_PH_PROJECT_ROOT, 'src', 'instance-app', ...paths)
join(
process.env.PH_INSTANCE_APP_ROOT || join(_PH_PROJECT_ROOT, 'instance-app'),
...paths,
)
const TLS_PFX = `tls`
@ -82,6 +89,7 @@ export const SETTINGS = {
MOTHERSHIP_INTERNAL_HOST: mkString(`localhost`),
MOTHERSHIP_ADMIN_USERNAME: mkString(),
MOTHERSHIP_ADMIN_PASSWORD: mkString(),
PH_MOTHERSHIP_APP_ROOT: mkString(_MOTHERSHIP_APP_ROOT()),
MOTHERSHIP_MIGRATIONS_DIR: mkPath(_MOTHERSHIP_APP_ROOT(`migrations`)),
MOTHERSHIP_HOOKS_DIR: mkPath(_MOTHERSHIP_APP_ROOT(`pb_hooks`, `src`)),
MOTHERSHIP_APP_DIR: mkPath(_MOTHERSHIP_APP_ROOT(`ph_app`), {
@ -107,6 +115,7 @@ export const SETTINGS = {
EDGE_APEX_DOMAIN: mkString(_APEX_DOMAIN),
EDGE_MAX_ACTIVE_INSTANCES: mkNumber(20),
PH_INSTANCE_APP_ROOT: mkString(_INSTANCE_APP_ROOT()),
INSTANCE_APP_HOOKS_DIR: mkPath(_INSTANCE_APP_ROOT(`pb_hooks`), {
create: true,
}),

View File

@ -11,6 +11,7 @@ export function mkSingleton<TConfig, TInstance>(
if (instance && config) {
console.warn('Attempted to initialize service twice.')
console.warn(new Error().stack)
return instance
}
if (!instance && !config) {

View File

@ -3,7 +3,12 @@ import { discordAlert } from './discordAlert'
import { gracefulExit } from './exit'
;['unhandledRejection', 'uncaughtException'].forEach((type) => {
process.on(type, (e) => {
discordAlert(e)
console.error(e)
try {
discordAlert(e)
} catch (e) {
console.error(e)
}
const debug = (() => {
try {
return ioc.service('settings').DEBUG

View File

@ -24,5 +24,5 @@
"$shared": ["src/shared"]
}
},
"include": ["./src", "plopfile.mjs"]
"include": ["./src", "./buildtool", "plopfile.mjs"]
}