mirror of
https://github.com/pockethost/pockethost.git
synced 2025-03-30 15:08:30 +00:00
feat: CLI tool and npm package
This commit is contained in:
parent
6fb93ab5de
commit
c491da0b9c
5
.npmignore
Normal file
5
.npmignore
Normal file
@ -0,0 +1,5 @@
|
||||
*
|
||||
!dist/**
|
||||
!package.json
|
||||
!readme.md
|
||||
!LICENSE.md
|
56
buildtool/index.ts
Normal file
56
buildtool/index.ts
Normal 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
16
cli-todo.md
Normal 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
|
||||
-
|
@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
51
package.json
51
package.json
@ -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
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
BIN
pockethost-1.0.0-rc.2.tgz
Normal file
Binary file not shown.
172
readme.md
172
readme.md
@ -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).
|
||||
|
13
src/cli/commands/DownloadCommand/download.ts
Normal file
13
src/cli/commands/DownloadCommand/download.ts
Normal 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)
|
||||
}
|
15
src/cli/commands/DownloadCommand/index.ts
Normal file
15
src/cli/commands/DownloadCommand/index.ts
Normal 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
|
||||
}
|
@ -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`)
|
||||
})()
|
||||
}
|
@ -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
|
||||
}
|
13
src/cli/commands/EdgeCommand/DaemonCommand/index.ts
Normal file
13
src/cli/commands/EdgeCommand/DaemonCommand/index.ts
Normal 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
|
||||
}
|
16
src/cli/commands/EdgeCommand/FtpCommand/ServeCommand/ftp.ts
Normal file
16
src/cli/commands/EdgeCommand/FtpCommand/ServeCommand/ftp.ts
Normal 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(),
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
13
src/cli/commands/EdgeCommand/FtpCommand/index.ts
Normal file
13
src/cli/commands/EdgeCommand/FtpCommand/index.ts
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
13
src/cli/commands/EdgeCommand/SyslogCommand/index.ts
Normal file
13
src/cli/commands/EdgeCommand/SyslogCommand/index.ts
Normal 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
|
||||
}
|
18
src/cli/commands/EdgeCommand/index.ts
Normal file
18
src/cli/commands/EdgeCommand/index.ts
Normal 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
|
||||
}
|
@ -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')
|
||||
})
|
||||
}
|
15
src/cli/commands/FirewallCommand/ServeCommand/index.ts
Normal file
15
src/cli/commands/FirewallCommand/ServeCommand/index.ts
Normal 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
|
||||
}
|
13
src/cli/commands/FirewallCommand/index.ts
Normal file
13
src/cli/commands/FirewallCommand/index.ts
Normal 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
|
||||
}
|
238
src/cli/commands/HealthCommand/checkHealth.ts
Normal file
238
src/cli/commands/HealthCommand/checkHealth.ts
Normal 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}`)
|
||||
}
|
||||
}
|
20
src/cli/commands/HealthCommand/index.ts
Normal file
20
src/cli/commands/HealthCommand/index.ts
Normal 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
|
||||
}
|
15
src/cli/commands/MothershipCommand/ServeCommand/index.ts
Normal file
15
src/cli/commands/MothershipCommand/ServeCommand/index.ts
Normal 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
|
||||
}
|
@ -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)
|
||||
})
|
||||
})()
|
||||
}
|
13
src/cli/commands/MothershipCommand/index.ts
Normal file
13
src/cli/commands/MothershipCommand/index.ts
Normal 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
|
||||
}
|
20
src/cli/commands/SendMailCommand/index.ts
Normal file
20
src/cli/commands/SendMailCommand/index.ts
Normal 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
|
||||
}
|
127
src/cli/commands/SendMailCommand/sendmail.ts
Normal file
127
src/cli/commands/SendMailCommand/sendmail.ts
Normal 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()
|
||||
}
|
26
src/cli/commands/ServeCommand/index.ts
Normal file
26
src/cli/commands/ServeCommand/index.ts
Normal 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
|
||||
}
|
@ -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)
|
@ -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(),
|
||||
})
|
||||
})()
|
@ -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}`)
|
||||
}
|
@ -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
41
src/cli/index.ts
Normal 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()
|
@ -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')
|
||||
})
|
@ -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()
|
@ -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,
|
||||
}),
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -24,5 +24,5 @@
|
||||
"$shared": ["src/shared"]
|
||||
}
|
||||
},
|
||||
"include": ["./src", "plopfile.mjs"]
|
||||
"include": ["./src", "./buildtool", "plopfile.mjs"]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user