├── README.md ├── types ├── bytes │ └── index.d.ts ├── cfonts │ └── index.d.ts ├── subcommand │ └── index.d.ts ├── @hyperswarm__dht │ └── index.d.ts ├── random-access-memory │ └── index.d.ts ├── @atek-cloud__node-fetch-cookies │ └── index.d.ts ├── pauls-sliding-window-rate-limiter │ └── index.d.ts └── hyperspace │ └── index.d.ts ├── .gitignore ├── .npmignore ├── logo.png ├── static ├── login │ ├── logo.png │ ├── index.html │ ├── index.js │ └── index.css ├── views │ └── no-main-service.ejs └── js │ └── rpc.js ├── test ├── util │ ├── simple-app │ │ ├── atek.json │ │ └── index.js │ ├── auth-app-1 │ │ ├── atek.json │ │ └── index.js │ └── auth-app-2 │ │ ├── atek.json │ │ └── index.js ├── host.ts ├── users.ts └── services.ts ├── src ├── lib │ ├── functions.ts │ ├── crypto.ts │ ├── rpc.ts │ ├── npm.ts │ ├── strings.ts │ ├── errors.ts │ ├── git.ts │ ├── lock.ts │ └── cli.ts ├── rpcapi │ ├── inspect.ts │ ├── index.ts │ ├── user-sessions.ts │ ├── users.ts │ └── services.ts ├── schemas │ └── util.ts ├── serverdb │ └── index.ts ├── broker │ ├── types.ts │ └── index.ts ├── services │ ├── util.ts │ ├── instance.ts │ └── index.ts ├── httpapi │ ├── gateway.ts │ └── session-middleware.ts ├── test.ts ├── setup-flow.ts ├── config.ts ├── users │ └── index.ts ├── bin.ts └── index.ts ├── Dockerfile ├── tsconfig.json └── package.json /README.md: -------------------------------------------------------------------------------- 1 | ![logo.png](logo.png) -------------------------------------------------------------------------------- /types/bytes/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bytes'; -------------------------------------------------------------------------------- /types/cfonts/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cfonts'; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | src 3 | frontend 4 | .git 5 | -------------------------------------------------------------------------------- /types/subcommand/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'subcommand'; -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atek-cloud/atek/HEAD/logo.png -------------------------------------------------------------------------------- /types/@hyperswarm__dht/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@hyperswarm/dht'; -------------------------------------------------------------------------------- /types/random-access-memory/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'random-access-memory'; -------------------------------------------------------------------------------- /static/login/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atek-cloud/atek/HEAD/static/login/logo.png -------------------------------------------------------------------------------- /types/@atek-cloud__node-fetch-cookies/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@atek-cloud/node-fetch-cookies'; -------------------------------------------------------------------------------- /test/util/simple-app/atek.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NAME", 3 | "description": "DESCRIPTION", 4 | "author": "AUTHOR", 5 | "license": "LICENSE" 6 | } -------------------------------------------------------------------------------- /test/util/auth-app-1/atek.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NAME", 3 | "description": "DESCRIPTION", 4 | "author": "AUTHOR", 5 | "license": "LICENSE", 6 | "exports": [ 7 | {"api": "auth-app-one.com/api", "path": "/_api"} 8 | ] 9 | } -------------------------------------------------------------------------------- /test/util/auth-app-2/atek.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NAME", 3 | "description": "DESCRIPTION", 4 | "author": "AUTHOR", 5 | "license": "LICENSE", 6 | "exports": [ 7 | {"api": "auth-app-two.com/api", "path": "/_api"} 8 | ] 9 | } -------------------------------------------------------------------------------- /src/lib/functions.ts: -------------------------------------------------------------------------------- 1 | export function removeUndefinedsAtEndOfArray (arr: any[]) { 2 | let len = arr.length 3 | for (let i = len - 1; i >= 0; i--) { 4 | if (typeof arr[i] === 'undefined') len-- 5 | else break 6 | } 7 | return arr.slice(0, len) 8 | } -------------------------------------------------------------------------------- /test/util/simple-app/index.js: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | 3 | const SOCKETFILE = process.env.ATEK_ASSIGNED_SOCKET_FILE 4 | http.createServer((req, res) => { 5 | res.writeHead(200).end('Hello, world!') 6 | }).listen(SOCKETFILE, e => { 7 | console.log(`simple-app HTTP webserver running at`, SOCKETFILE) 8 | }) -------------------------------------------------------------------------------- /types/pauls-sliding-window-rate-limiter/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pauls-sliding-window-rate-limiter' { 2 | interface RateLimiterConfig { 3 | window: number 4 | limit: number 5 | } 6 | class RateLimiter { 7 | constructor (config?: RateLimiterConfig) 8 | getCurrentWindow(): [number, number] 9 | hit (id: string): boolean 10 | } 11 | } -------------------------------------------------------------------------------- /src/rpcapi/inspect.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from '@atek-cloud/inspect-api' 2 | import { Config } from '../config.js' 3 | 4 | export function setup () { 5 | return createServer({ 6 | isReady () { 7 | this.session.assertIsAdminAuthed() 8 | return true 9 | }, 10 | getConfig () { 11 | this.session.assertIsAdminAuthed() 12 | return Config.getActiveConfig().values 13 | } 14 | }) 15 | } -------------------------------------------------------------------------------- /static/login/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Login - Atek 5 | 6 | 7 | 8 | 9 |
10 |
11 |
12 | 13 |
14 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM debian:jessie 3 | 4 | RUN apt-get update 5 | RUN apt-get install curl python make automake libtool g++ -y 6 | RUN curl -fs https://raw.githubusercontent.com/mafintosh/node-install/master/install | sh 7 | RUN node-install 16.8.0 8 | 9 | #FROM node:16-alpine 10 | #RUN apk update 11 | #RUN apk add libtool automake gcc 12 | #RUN apk add --no-cache libtool autoconf automake g++ make 13 | WORKDIR /app 14 | COPY . . 15 | RUN npm i 16 | CMD ["node", "dist/bin.js"] 17 | 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "typeRoots": [ "./types", "./node_modules/@types"], 7 | "lib": ["es2020"], 8 | "module": "es2020", 9 | "moduleResolution": "node", 10 | "target": "es2020", 11 | "declaration": true, 12 | 13 | "checkJs": true, 14 | "strict": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true 18 | } 19 | } -------------------------------------------------------------------------------- /src/schemas/util.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import addFormats from 'ajv-formats' 3 | import { ValidationError } from '../lib/errors.js' 4 | 5 | export const ajv = new Ajv({strictTuples: false}) 6 | addFormats(ajv) 7 | 8 | export interface Validator { 9 | assert: (v: any) => void 10 | } 11 | 12 | export function createValidator (schema: object): Validator { 13 | const validate = ajv.compile(schema) 14 | return { 15 | assert: (value: any) => { 16 | const valid = validate(value) 17 | if (!valid) { 18 | throw new ValidationError(`${validate.errors?.[0].propertyName} ${validate.errors?.[0].message}`) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/util/auth-app-2/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { createRpcServer } from '@atek-cloud/node-rpc' 3 | 4 | const api = createRpcServer({ 5 | getAuthHeaders () { 6 | return { 7 | user: this.req.headers['atek-auth-user'], 8 | service: this.req.headers['atek-auth-service'] 9 | } 10 | } 11 | }) 12 | 13 | const SOCKETFILE = process.env.ATEK_ASSIGNED_SOCKET_FILE 14 | const app = express() 15 | app.use(express.json()) 16 | app.get('/', (req, res) => res.status(200).end('Hello!')) 17 | app.post('/_api', (req, res) => api.handle(req, res, req.body)) 18 | app.listen(SOCKETFILE, e => { 19 | console.log(`auth-app-2 HTTP webserver running at`, SOCKETFILE) 20 | }) -------------------------------------------------------------------------------- /test/util/auth-app-1/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { createRpcServer, rpc } from '@atek-cloud/node-rpc' 3 | 4 | const authApp2Api = rpc('auth-app-two.com/api') 5 | 6 | const api = createRpcServer({ 7 | getAuthHeaders () { 8 | return { 9 | user: this.req.headers['atek-auth-user'], 10 | service: this.req.headers['atek-auth-service'] 11 | } 12 | }, 13 | getAuthHeadersFromApp2 () { 14 | return authApp2Api.getAuthHeaders() 15 | } 16 | }) 17 | 18 | const SOCKETFILE = process.env.ATEK_ASSIGNED_SOCKET_FILE 19 | const app = express() 20 | app.use(express.json()) 21 | app.get('/', (req, res) => res.status(200).end('Hello!')) 22 | app.post('/_api', (req, res) => api.handle(req, res, req.body)) 23 | app.listen(SOCKETFILE, e => { 24 | console.log(`auth-app-1 HTTP webserver running at`, SOCKETFILE) 25 | }) -------------------------------------------------------------------------------- /src/serverdb/index.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../config.js' 2 | import adb, { AdbDatabase } from '@atek-cloud/adb-api' 3 | import * as tables from '@atek-cloud/adb-tables' 4 | 5 | // globals 6 | // = 7 | 8 | export let serverdb: AdbDatabase|undefined = undefined 9 | 10 | // exported api 11 | // = 12 | 13 | export async function setup () { 14 | const cfg = Config.getActiveConfig() 15 | if (!cfg.serverDbId) throw new Error('Unable to setup host database: no server DB configured') 16 | serverdb = adb.db(cfg.serverDbId) 17 | await Promise.all([ 18 | tables.databases(serverdb).isReady, 19 | tables.services(serverdb).isReady, 20 | tables.users(serverdb).isReady, 21 | tables.userSessions(serverdb).isReady 22 | ]) 23 | } 24 | 25 | export function get (): AdbDatabase { 26 | if (!serverdb) throw new Error('Server Database not yet available') 27 | return serverdb 28 | } -------------------------------------------------------------------------------- /src/lib/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | export function hashPassword (password: string): Promise { 4 | return new Promise((resolve, reject) => { 5 | const salt = crypto.randomBytes(8).toString('hex') 6 | crypto.scrypt(password, salt, 64, (err, derivedKey) => { 7 | if (err) reject(err) 8 | resolve(salt + ':' + derivedKey.toString('hex')) 9 | }) 10 | }) 11 | } 12 | 13 | export function verifyPassword (password: string, hash: string): Promise { 14 | return new Promise((resolve, reject) => { 15 | const [salt, key] = hash.split(':') 16 | crypto.scrypt(password, salt, 64, (err, derivedKey) => { 17 | if (err) reject(err) 18 | // TODO constant time compare 19 | resolve(key == derivedKey.toString('hex')) 20 | }) 21 | }) 22 | } 23 | 24 | export function generateBearerToken (): string { 25 | return crypto.randomBytes(32).toString('hex') 26 | } 27 | -------------------------------------------------------------------------------- /src/rpcapi/index.ts: -------------------------------------------------------------------------------- 1 | import * as inspect from './inspect.js' 2 | import * as services from './services.js' 3 | import * as users from './users.js' 4 | import * as userSessions from './user-sessions.js' 5 | import * as apiBroker from '../broker/index.js' 6 | 7 | const APIs = [ 8 | {id: 'atek.cloud/inspect-api', api: inspect.setup()}, 9 | {id: 'atek.cloud/services-api', api: services.setup()}, 10 | {id: 'atek.cloud/users-api', api: users.setup()}, 11 | {id: 'atek.cloud/user-sessions-api', api: userSessions.setup()} 12 | ] 13 | 14 | export function setup () { 15 | const systemApiProvider = { 16 | id: 'system', 17 | handleRpc (callDesc: apiBroker.CallDescription, methodName: string, params: any[], ctx: apiBroker.CallContext): Promise { 18 | const item = APIs.find(item => item.id === callDesc.api) 19 | if (item) { 20 | return item.api.handlers[methodName](ctx, params) 21 | } 22 | throw new Error('API not found') 23 | } 24 | } 25 | for (const api of APIs) { 26 | apiBroker.registerProvider(systemApiProvider, apiBroker.TransportEnum.RPC, api.id) 27 | } 28 | } -------------------------------------------------------------------------------- /src/broker/types.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws' 2 | import { Session } from '../httpapi/session-middleware.js' 3 | 4 | export enum TransportEnum { 5 | PROXY = 'proxy', 6 | RPC = 'rpc' 7 | } 8 | 9 | export interface ApiProvider { 10 | id: string 11 | handleRpc? (callDesc: CallDescription, methodName: string, params: any[], ctx: CallContext): Promise 12 | handleProxy? (callDesc: CallDescription, socket: WebSocket, ctx: CallContext): any 13 | } 14 | 15 | export interface CallDescription { 16 | transport: TransportEnum 17 | service?: string 18 | api?: string 19 | } 20 | 21 | export interface CallContext { 22 | session?: Session 23 | } 24 | 25 | export class CustomError extends Error { 26 | name: string; 27 | code: number; 28 | data: any; 29 | 30 | constructor(code: number, message: string, data?: any) { 31 | super(message); 32 | this.name = this.constructor.name; 33 | this.code = code; 34 | this.data = data; 35 | } 36 | } 37 | 38 | export class ServiceNotFound extends CustomError { 39 | static CODE = -32601; // we're using JSON-RPC's code for this 40 | constructor (msg: string, data?: any) { 41 | super(ServiceNotFound.CODE, msg, data) 42 | } 43 | } -------------------------------------------------------------------------------- /test/host.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import * as atek from '../dist/index.js' 3 | 4 | let inst: any 5 | let activeCfg: any 6 | test.after(async () => { 7 | await inst.close() 8 | }) 9 | 10 | test.serial('Correctly loads core services (hyper, adb) and creates server db', async t => { 11 | const cfg = new atek.test.Config() 12 | inst = await atek.test.startAtek(cfg) 13 | 14 | activeCfg = await inst.api('atek.cloud/inspect-api').call('getConfig') 15 | t.truthy(activeCfg.serverDbId, 'Server DB ID was created') 16 | t.is(activeCfg.coreServices.length, 2, 'Core services config match what we passed') 17 | t.is(activeCfg.coreServices[0].sourceUrl, cfg.coreServices[0].sourceUrl, 'Core services config match what we passed') 18 | t.deepEqual(activeCfg.coreServices[0].config, cfg.coreServices[0].config, 'Core services config match what we passed') 19 | t.is(activeCfg.coreServices[1].sourceUrl, cfg.coreServices[1].sourceUrl, 'Core services config match what we passed') 20 | t.truthy(activeCfg.coreServices[0].id, 'Core services are active') 21 | t.truthy(activeCfg.coreServices[1].id, 'Core services are active') 22 | }) 23 | 24 | test.serial('Routes calls to the server db', async t => { 25 | const desc = await inst.api('atek.cloud/adb-api').call('dbDescribe', [activeCfg.serverDbId]) 26 | t.truthy(desc, 'Returns a description object') 27 | t.is(desc.dbId, activeCfg.serverDbId, 'Gave the correct database\'s description') 28 | }) 29 | -------------------------------------------------------------------------------- /types/hyperspace/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'hyperspace' { 2 | declare class Client { 3 | constructor (opts?: {host?: string}) 4 | async ready () 5 | async status () 6 | async close () 7 | corestore (str?: string): RemoteCorestore 8 | network: RemoteNetworker 9 | } 10 | declare class Server { 11 | constructor (opts?: {}) 12 | async open () 13 | async ready () 14 | async close () 15 | } 16 | declare interface ServerOpts { 17 | host?: string 18 | storage?: any 19 | network?: { 20 | bootstrap?: string[] 21 | preferredPort?: numbe 22 | } 23 | noMigrate?: boolean 24 | } 25 | declare interface RemoteCorestore { 26 | get (key: Buffer | undefined): RemoteHypercore 27 | } 28 | declare interface RemoteNetworker { 29 | async configure (core: Buffer | RemoteHypercore, opts: object) 30 | } 31 | declare interface RemoteHypercore { 32 | key: Buffer 33 | discoveryKey: Buffer 34 | writable: boolean 35 | length: number 36 | byteLength: number 37 | ready (): Promise 38 | get (index: number, opts?: object): Promise 39 | has (index: number): Promise 40 | cancel (p: Promise) 41 | download (start: number, end: number): Promise 42 | undownload (p: Promise) 43 | downloaded (start: number, end: number): Promise 44 | update (opts?: object): Promise 45 | seek (byteOffset: number): Promise<{index: number, relativeOffset: number}> 46 | append (data: Uint8Array|Uint8Array[]): Promise 47 | } 48 | } -------------------------------------------------------------------------------- /src/rpcapi/user-sessions.ts: -------------------------------------------------------------------------------- 1 | import { AtekRpcServer } from '@atek-cloud/node-rpc' 2 | import { createServer, UserSession, UserCreds } from '@atek-cloud/user-sessions-api' 3 | import { users } from '@atek-cloud/adb-tables' 4 | import * as serverdb from '../serverdb/index.js' 5 | import { verifyPassword } from '../lib/crypto.js' 6 | 7 | export function setup (): AtekRpcServer { 8 | return createServer({ 9 | // Get the current session 10 | whoami (): UserSession { 11 | if (this.session.isUserAuthed()) { 12 | return { 13 | isActive: true, 14 | username: this.session.auth.username 15 | } 16 | } 17 | return {isActive: false} 18 | }, 19 | 20 | // Create a new session 21 | async login (creds: UserCreds): Promise { 22 | const {records} = await users(serverdb.get()).list() 23 | const record = records.find((record: any) => record.value.username === creds.username) 24 | if (!record) throw new Error('Username or password was incorrect') 25 | if (!(await verifyPassword(creds.password, record.value.hashedPassword))) { 26 | throw new Error('Username or password was incorrect') 27 | } 28 | await this.session.create({ 29 | userKey: record.key, 30 | username: record.value.username 31 | }) 32 | return { 33 | isActive: true, 34 | username: record.value.username 35 | } 36 | }, 37 | 38 | // End the current session 39 | async logout (): Promise { 40 | await this.session.destroy() 41 | } 42 | }) 43 | } -------------------------------------------------------------------------------- /src/lib/rpc.ts: -------------------------------------------------------------------------------- 1 | import { URLSearchParams } from 'url' 2 | import {fetch, CookieJar} from '@atek-cloud/node-fetch-cookies' 3 | import jsonrpc from 'jsonrpc-lite' 4 | import { removeUndefinedsAtEndOfArray } from './functions.js' 5 | 6 | let _id = 1 7 | export function createApi (origin: string, apiDesc: string|NodeJS.Dict, authToken?: string) { 8 | const qp = new URLSearchParams(typeof apiDesc === 'string' ? {api: apiDesc} : apiDesc) 9 | const url = `${origin}/_atek/gateway?${qp.toString()}` 10 | const cookieJar = new CookieJar() 11 | 12 | return { 13 | cookieJar, 14 | copyCookiesTo (jar2: any) { 15 | for (const domain of cookieJar.domains()) { 16 | for (const cookie of cookieJar.cookiesDomain(domain)) { 17 | jar2.addCookie(cookie) 18 | } 19 | } 20 | }, 21 | call: async (methodName: string, params: any[] = []): Promise => { 22 | const responseBody = await (await fetch(cookieJar, url, { 23 | method: 'POST', 24 | headers: {'Content-Type': 'application/json', 'Authorization': authToken ? `Bearer ${authToken}` : undefined}, 25 | body: JSON.stringify(jsonrpc.request(_id++, methodName, removeUndefinedsAtEndOfArray(params))) 26 | })).json() 27 | const parsed = jsonrpc.parseObject(responseBody) 28 | if (parsed.type === 'error') { 29 | throw parsed.payload.error 30 | } else if (parsed.type === 'invalid') { 31 | throw parsed.payload.data 32 | } else if (parsed.type === 'success') { 33 | return parsed.payload.result 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/lib/npm.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsp } from 'fs' 2 | import { join } from 'path' 3 | import { exec } from 'child_process' 4 | import { Config } from '../config.js' 5 | 6 | interface PackageJson { 7 | scripts?: { 8 | build?: string 9 | } 10 | } 11 | 12 | // exported api 13 | // = 14 | 15 | export async function setupPackage (id: string, dir: string) { 16 | const packageJson = await readPackageJson(dir) 17 | if (!packageJson) return console.log("no package.json") 18 | console.log('Installing dependencies for', id, 'in', dir) 19 | await runInstall(dir) 20 | if (packageJson.scripts?.build) { 21 | console.log('Building', id, 'in', dir) 22 | await runBuild(dir) 23 | } 24 | } 25 | 26 | // internal methods 27 | // = 28 | 29 | function runInstall (dir: string): Promise { 30 | return new Promise((resolve, reject) => { 31 | exec(`npm install`, {cwd: dir}, (err, stdout, stderr) => { 32 | if (stdout) console.log(stdout) 33 | if (stderr) console.log(stderr) 34 | if (err) reject(err) 35 | else resolve() 36 | }) 37 | }) 38 | } 39 | 40 | function runBuild (dir: string): Promise { 41 | return new Promise((resolve, reject) => { 42 | exec(`npm run build`, {cwd: dir}, (err, stdout, stderr) => { 43 | if (stdout) console.log(stdout) 44 | if (stderr) console.log(stderr) 45 | if (err) reject(err) 46 | else resolve() 47 | }) 48 | }) 49 | } 50 | 51 | async function readPackageJson (dir: string): Promise { 52 | try { 53 | return JSON.parse(await fsp.readFile(join(dir, 'package.json'), 'utf8')) 54 | } catch (e) { 55 | return undefined 56 | } 57 | } -------------------------------------------------------------------------------- /static/login/index.js: -------------------------------------------------------------------------------- 1 | import { create } from '/_atek/js/rpc.js' 2 | 3 | const api = create('/_atek/gateway?api=atek.cloud%2Fuser-sessions-api') 4 | 5 | class CtznLogin extends HTMLElement { 6 | $ (sel) { 7 | return this.querySelector(sel) 8 | } 9 | 10 | connectedCallback () { 11 | this.innerHTML = ` 12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 |
23 | 24 |
25 |
26 | ` 27 | this.$('input').focus() 28 | this.$('form').addEventListener('submit', this.onSubmit.bind(this)) 29 | } 30 | 31 | // events 32 | // = 33 | 34 | async onSubmit (e) { 35 | e.preventDefault() 36 | if (this.$('.error')) { 37 | this.$('.error').textContent = '' 38 | } 39 | let creds = { 40 | username: e.target.username.value, 41 | password: e.target.password.value 42 | } 43 | try { 44 | await api.login(creds) 45 | var searchParams = new URLSearchParams(window.location.search) 46 | window.location = searchParams.get('redirect') || '/' 47 | } catch (e) { 48 | console.log(e) 49 | this.$('.error').textContent = e.data || e.message || e.toString() 50 | } 51 | } 52 | 53 | } 54 | 55 | customElements.define('app-login', CtznLogin) 56 | -------------------------------------------------------------------------------- /src/lib/strings.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url' 2 | 3 | export const HYPER_KEY = /([0-9a-f]{64})/i 4 | export const HYPER_KEY_ONLY = /^([0-9a-f]{64})$/i 5 | 6 | export function hyperUrlToKey (str: string): Buffer | undefined { 7 | let matches = HYPER_KEY.exec(str) 8 | return matches ? Buffer.from(matches[1], 'hex') : undefined 9 | } 10 | 11 | export function hyperUrlToKeyStr (str: string): string | undefined { 12 | let matches = HYPER_KEY.exec(str) 13 | return matches ? matches[1] : undefined 14 | } 15 | 16 | export function isUrl (str: string): boolean { 17 | return /^https?:\/\//.test(str) 18 | } 19 | 20 | export function isHyperUrl (str: string): boolean { 21 | return /^hyper:\/\//.test(str) 22 | } 23 | 24 | export function isHyperKey (str: string): boolean { 25 | return HYPER_KEY_ONLY.test(String(str)) 26 | } 27 | 28 | export function toOrigin (url: string): string { 29 | const urlp = new URL(url) 30 | return `${urlp.protocol}//${urlp.hostname}/` 31 | } 32 | 33 | export function constructEntryPath (schemaId: string, key: string): string { 34 | return '/' + joinPath(schemaId, encodeURIComponent(key)) 35 | } 36 | 37 | export function constructEntryUrl (origin: string, schemaId: string, key: string): string { 38 | return joinPath(origin, constructEntryPath(schemaId, key)) 39 | } 40 | 41 | type ParsedEntryUrl = { 42 | origin: string 43 | dbId: string 44 | schemaId: string 45 | key: string 46 | } 47 | 48 | export function parseEntryUrl (url: string): ParsedEntryUrl { 49 | const urlp = new URL(url) 50 | const pathParts = urlp.pathname.split('/') 51 | return { 52 | origin: `hyper://${urlp.hostname}/`, 53 | dbId: urlp.hostname, 54 | schemaId: pathParts.slice(1, 3).join('/'), 55 | key: pathParts.slice(3).map(decodeURIComponent).join('/') 56 | } 57 | } 58 | 59 | export function joinPath (...args: string[]): string { 60 | var str = args[0] 61 | for (let v of args.slice(1)) { 62 | v = v && typeof v === 'string' ? v : '' 63 | let left = str.endsWith('/') 64 | let right = v.startsWith('/') 65 | if (left !== right) str += v 66 | else if (left) str += v.slice(1) 67 | else str += '/' + v 68 | } 69 | return str 70 | } -------------------------------------------------------------------------------- /src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | export class ExtendableError extends Error { 2 | code: string 3 | rpcCode: number 4 | 5 | constructor (msg: string) { 6 | super(msg) 7 | this.name = this.constructor.name 8 | this.message = msg 9 | this.code = 'error' 10 | this.rpcCode = -32000 11 | if (typeof Error.captureStackTrace === 'function') { 12 | Error.captureStackTrace(this, this.constructor) 13 | } else { 14 | this.stack = (new Error(msg)).stack 15 | } 16 | } 17 | } 18 | 19 | export class SessionError extends ExtendableError { 20 | constructor (msg: string) { 21 | super(msg || 'Must be logged in') 22 | this.code = 'session-does-not-exist' 23 | this.rpcCode = -32001 24 | } 25 | } 26 | 27 | export class ValidationError extends ExtendableError { 28 | constructor (msg: string) { 29 | super(msg) 30 | this.code = 'validation-failed' 31 | this.rpcCode = -32002 32 | } 33 | } 34 | 35 | export class NotFoundError extends ExtendableError { 36 | constructor (msg: string) { 37 | super(msg || 'Not found') 38 | this.code = 'not-found' 39 | this.rpcCode = -32003 40 | } 41 | } 42 | 43 | export class PermissionsError extends ExtendableError { 44 | constructor (msg: string) { 45 | super(msg || 'Not authorized to complete this action') 46 | this.code = 'not-authorized' 47 | this.rpcCode = -32004 48 | } 49 | } 50 | 51 | export class InvalidCredentialsError extends ExtendableError { 52 | constructor (msg: string) { 53 | super(msg || 'Invalid username or password') 54 | this.code = 'invalid-credentials' 55 | this.rpcCode = -32005 56 | } 57 | } 58 | 59 | export class ConfigurationError extends ExtendableError { 60 | constructor (msg: string) { 61 | super(msg || 'Server or network configuration error') 62 | this.code = 'configuration-error' 63 | this.rpcCode = -32006 64 | } 65 | } 66 | 67 | export class RateLimitError extends ExtendableError { 68 | constructor (msg: string) { 69 | super(msg || 'Rate limit exceeded') 70 | this.code = 'rate-limit-exceeded' 71 | this.rpcCode = -32007 72 | } 73 | } 74 | 75 | export class InvalidIdError extends ExtendableError { 76 | constructor (msg: string) { 77 | super(msg || 'Invalid id') 78 | this.code = 'invalid-id' 79 | this.rpcCode = -32008 80 | } 81 | } -------------------------------------------------------------------------------- /src/broker/index.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws' 2 | import { TransportEnum, ApiProvider, CallDescription, CallContext, ServiceNotFound } from './types.js' 3 | export * from './types.js' 4 | 5 | class Registration { 6 | transport: TransportEnum 7 | api: string 8 | provider: ApiProvider 9 | 10 | constructor (transport: TransportEnum, api: string, provider: ApiProvider) { 11 | this.transport = transport 12 | this.api = api 13 | this.provider = provider 14 | } 15 | } 16 | 17 | // globals 18 | // = 19 | 20 | const registry: Registration[] = [] 21 | 22 | // exported api 23 | // = 24 | 25 | export function registerProvider (provider: ApiProvider, transport: TransportEnum, api: string): void { 26 | registry.push(new Registration(transport, api, provider)) 27 | } 28 | 29 | export function unregisterProvider (provider: ApiProvider, transport: TransportEnum, api: string): void { 30 | const i = registry.findIndex(p => p.provider === provider && p.api === api && p.transport === transport) 31 | if (i !== -1) registry.splice(i, 1) 32 | } 33 | 34 | export function unregisterProviderAll (provider: ApiProvider): void { 35 | let i 36 | do { 37 | i = registry.findIndex(p => p.provider === provider) 38 | if (i !== -1) registry.splice(i, 1) 39 | } while (i !== -1) 40 | } 41 | 42 | export function findProvider (callDesc: CallDescription): Registration|undefined { 43 | return registry.find(p => { 44 | if (p.transport !== callDesc.transport) return false 45 | if (callDesc.service && p.provider.id !== callDesc.service) return false 46 | if (callDesc.api && p.api !== callDesc.api) return false 47 | return true 48 | }) 49 | } 50 | 51 | export function routeRpc (callDesc: CallDescription, methodName: string, params: any[], ctx: CallContext): Promise { 52 | const reg = findProvider(callDesc) 53 | if (!reg?.provider?.handleRpc) throw new ServiceNotFound(`No service available which matches ${JSON.stringify(callDesc)}`) 54 | return reg.provider.handleRpc(callDesc, methodName, params, ctx) 55 | } 56 | 57 | export function routeProxy (callDesc: CallDescription, socket: WebSocket, ctx: CallContext): void { 58 | const reg = findProvider(callDesc) 59 | if (!reg?.provider?.handleProxy) throw new ServiceNotFound(`No service available which matches ${JSON.stringify(callDesc)}`) 60 | reg.provider.handleProxy(callDesc, socket, ctx) 61 | } 62 | -------------------------------------------------------------------------------- /src/rpcapi/users.ts: -------------------------------------------------------------------------------- 1 | import { AtekRpcServer } from '@atek-cloud/node-rpc' 2 | import { createServer, User, NewUser, UserUpdate, UserSettings, UserSettingsUpdate } from '@atek-cloud/users-api' 3 | import { users } from '@atek-cloud/adb-tables' 4 | import * as serverdb from '../serverdb/index.js' 5 | import { getUser, createUser, updateUser, deleteUser, getUserSettings, updateUserSettings } from '../users/index.js' 6 | 7 | export function setup (): AtekRpcServer { 8 | return createServer({ 9 | // List current users 10 | async list (): Promise<{users: User[]}> { 11 | this.session.assertIsAdminAuthed() 12 | const {records} = await users(serverdb.get()).list() 13 | return { 14 | users: records.map((record: any) => ({ 15 | key: record.key, 16 | username: record.value.username, 17 | role: record.value.role 18 | })) 19 | } 20 | }, 21 | 22 | // Get a user 23 | get (userKey: string): Promise { 24 | if (!this.session.isAdminAuthed()) { 25 | if (this.session.auth?.userKey !== userKey) { 26 | throw new Error('Not authorized') 27 | } 28 | } 29 | return getUser(userKey) 30 | }, 31 | 32 | // Create a user 33 | create (user: NewUser): Promise { 34 | this.session.assertIsAdminAuthed() 35 | return createUser(user) 36 | }, 37 | 38 | // Update a user 39 | update (userKey: string, user: UserUpdate): Promise { 40 | this.session.assertIsAdminAuthed() 41 | return updateUser(userKey, user) 42 | }, 43 | 44 | // Delete a user 45 | delete (userKey: string): Promise { 46 | this.session.assertIsAdminAuthed() 47 | return deleteUser(userKey) 48 | }, 49 | 50 | // Get a user's settings 51 | getSettings (userKey: string): Promise { 52 | if (!this.session.isAdminAuthed()) { 53 | if (this.session.auth?.userKey !== userKey) { 54 | throw new Error('Not authorized') 55 | } 56 | } 57 | return getUserSettings(userKey) 58 | }, 59 | 60 | // Get a user's settings 61 | updateSettings (userKey: string, settings: UserSettingsUpdate): Promise { 62 | if (!this.session.isAdminAuthed()) { 63 | if (this.session.auth?.userKey !== userKey) { 64 | throw new Error('Not authorized') 65 | } 66 | } 67 | return updateUserSettings(userKey, settings) 68 | } 69 | }) 70 | } -------------------------------------------------------------------------------- /static/login/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | background-color: rgba(37, 99, 235); 10 | color: #fff; 11 | width: 100vw; 12 | height: 100vh; 13 | font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; 14 | margin: 0; 15 | } 16 | 17 | .container { 18 | width: 24rem; 19 | margin-bottom: 20vh; 20 | } 21 | 22 | .animated-ring-container { 23 | width: 12rem; 24 | height: 12rem; 25 | position: relative; 26 | margin: 0 auto 2.5rem; 27 | } 28 | 29 | .animated-ring { 30 | position: absolute; 31 | width: 12rem; 32 | height: 12rem; 33 | border-radius: 9999px; 34 | border: 1px solid #fff; 35 | animation: animated-ring-anim 3s infinite; 36 | } 37 | 38 | @keyframes animated-ring-anim { 39 | 0% { 40 | transform: scale(1); 41 | opacity: 1; 42 | } 43 | 100% { 44 | transform: scale(1.25); 45 | opacity: 0; 46 | } 47 | } 48 | 49 | .logo { 50 | display: flex; 51 | justify-content: center; 52 | align-items: center; 53 | width: 12rem; 54 | height: 12rem; 55 | border-radius: 9999px; 56 | border: 8px solid #fff; 57 | font-size: 2.25rem; 58 | line-height: 2.5rem; 59 | } 60 | 61 | .logo img { 62 | position: relative; 63 | top: -0.5rem; 64 | } 65 | 66 | app-login { 67 | display: block; 68 | padding: 1.5rem 2rem; 69 | } 70 | 71 | app-login form > div { 72 | margin-bottom: 1.5rem; 73 | } 74 | 75 | app-login form > div:last-child { 76 | margin-bottom: 0; 77 | } 78 | 79 | app-login form label { 80 | display: block; 81 | margin-bottom: 0.25rem; 82 | } 83 | 84 | app-login form input { 85 | display: block; 86 | margin-bottom: 0.25rem; 87 | background-color: transparent; 88 | padding: 1rem; 89 | width: 100%; 90 | color: #fff; 91 | border: 1px solid #fffa; 92 | border-radius: 0.25rem; 93 | font-size: 1rem; 94 | line-height: 1rem; 95 | } 96 | 97 | app-login form input::placeholder { 98 | color: #fff6; 99 | } 100 | 101 | app-login form button { 102 | display: block; 103 | width: 100%; 104 | font-size: 1.1rem; 105 | color: #fff; 106 | background-color: #1d53eb; 107 | border: 1px solid #2463eb; 108 | border-bottom-color: #0f41ab; 109 | border-top-color: #4a7be7; 110 | border-radius: 0.25rem; 111 | padding: 0.7rem; 112 | } 113 | 114 | app-login form button:hover { 115 | cursor: pointer; 116 | box-shadow: 0 1px 2px #0004; 117 | } -------------------------------------------------------------------------------- /src/lib/git.ts: -------------------------------------------------------------------------------- 1 | import git from 'isomorphic-git' 2 | import http from 'isomorphic-git/http/node/index.js' 3 | import * as fs from 'fs' 4 | import { Config } from '../config.js' 5 | import semver from 'semver' 6 | 7 | export interface PackageVersion { 8 | version: string 9 | tag: string 10 | } 11 | 12 | export async function clone (id: string, url: string): Promise { 13 | const dir = Config.getActiveConfig().packageInstallPath(id) 14 | await fs.promises.mkdir(dir, {recursive: true}) 15 | try { 16 | console.log('git clone', url, dir) 17 | await git.clone({fs, http, dir, url}) 18 | } catch (e: any) { 19 | if (!url.endsWith('.git') && e.toString().includes('404')) { 20 | return clone(id, url + '.git') 21 | } 22 | throw e 23 | } 24 | return dir 25 | } 26 | 27 | export async function fetch (id: string): Promise { 28 | const dir = Config.getActiveConfig().packageInstallPath(id) 29 | console.log('git fetch', dir) 30 | await git.fetch({fs, http, dir, tags: true}) 31 | } 32 | 33 | export async function checkout (id: string, version: string): Promise { 34 | const dir = Config.getActiveConfig().packageInstallPath(id) 35 | await git.checkout({fs, dir, ref: version, force: true}) 36 | } 37 | 38 | export async function listVersions (id: string): Promise { 39 | const dir = Config.getActiveConfig().packageInstallPath(id) 40 | const tags = await git.listTags({fs, dir}) 41 | return tags 42 | .map((tag: string) => ({tag, version: (semver.valid(semver.coerce(tag)) as string)})) 43 | .filter(v => typeof v.version === 'string') 44 | } 45 | 46 | export async function getCurrentVersion (id: string): Promise { 47 | const dir = Config.getActiveConfig().packageInstallPath(id) 48 | const head = await git.resolveRef({fs, dir, ref: 'HEAD'}) 49 | const tags = await git.listTags({fs, dir}) 50 | let currentTag 51 | for (let tag of tags) { 52 | if (await git.resolveRef({fs, dir, ref: tag}) === head) { 53 | currentTag = tag 54 | break 55 | } 56 | } 57 | return currentTag && semver.valid(semver.coerce(currentTag)) ? currentTag : undefined 58 | } 59 | 60 | export async function getLatestVersion (id: string, spec: string): Promise { 61 | let versions = await listVersions(id) 62 | if (versions.length === 0) { 63 | throw new Error(`No release (git tag) has been set for ${id}`) 64 | } 65 | if (spec && spec !== 'latest') { 66 | versions = versions.filter(tag => semver.satisfies(tag.version, spec)) 67 | if (versions.length === 0) { 68 | throw new Error(`No release (git tag) available for ${id} which matches the desired version of ${spec}`) 69 | } 70 | } 71 | versions = versions.sort((a, b) => semver.rcompare(a.version, b.version)) 72 | return versions[0]?.tag 73 | } -------------------------------------------------------------------------------- /src/lib/lock.ts: -------------------------------------------------------------------------------- 1 | /* 2 | await-lock 3 | NOTE copied into here because it struggles with compilation and it's too small to bother with anything else 4 | 5 | The MIT License (MIT) 6 | 7 | Copyright (c) 2015-present James Ide 8 | */ 9 | 10 | /** 11 | * A mutex lock for coordination across async functions 12 | */ 13 | class AwaitLock { 14 | _acquired: boolean 15 | _waitingResolvers: ((value: unknown) => void)[] 16 | 17 | constructor() { 18 | this._acquired = false; 19 | this._waitingResolvers = []; 20 | } 21 | /** 22 | * Whether the lock is currently acquired or not. Accessing this property does not affect the 23 | * status of the lock. 24 | */ 25 | get acquired() { 26 | return this._acquired; 27 | } 28 | /** 29 | * Acquires the lock, waiting if necessary for it to become free if it is already locked. The 30 | * returned promise is fulfilled once the lock is acquired. 31 | * 32 | * After acquiring the lock, you **must** call `release` when you are done with it. 33 | */ 34 | acquireAsync() { 35 | if (!this._acquired) { 36 | this._acquired = true; 37 | return Promise.resolve(); 38 | } 39 | return new Promise((resolve) => { 40 | this._waitingResolvers.push(resolve); 41 | }); 42 | } 43 | /** 44 | * Acquires the lock if it is free and otherwise returns immediately without waiting. Returns 45 | * `true` if the lock was free and is now acquired, and `false` otherwise, 46 | */ 47 | tryAcquire() { 48 | if (!this._acquired) { 49 | this._acquired = true; 50 | return true; 51 | } 52 | return false; 53 | } 54 | /** 55 | * Releases the lock and gives it to the next waiting acquirer, if there is one. Each acquirer 56 | * must release the lock exactly once. 57 | */ 58 | release() { 59 | if (!this._acquired) { 60 | throw new Error(`Cannot release an unacquired lock`); 61 | } 62 | if (this._waitingResolvers.length > 0) { 63 | const resolve = this._waitingResolvers.shift(); 64 | if (resolve) { 65 | resolve(undefined); 66 | } 67 | } 68 | else { 69 | this._acquired = false; 70 | } 71 | } 72 | } 73 | 74 | // wraps await-lock in a simpler interface, with many possible locks 75 | interface LocksMap { 76 | [key: string]: AwaitLock 77 | } 78 | var locks: LocksMap = {} 79 | 80 | /** 81 | * Create a new lock 82 | * @example 83 | * var lock = require('./lock') 84 | * async function foo () { 85 | * var release = await lock('bar') 86 | * // ... 87 | * release() 88 | * } 89 | */ 90 | export default async function (key: string): Promise<() => void> { 91 | if (!(key in locks)) locks[key] = new AwaitLock() 92 | 93 | var lock = locks[key] 94 | await lock.acquireAsync() 95 | return lock.release.bind(lock) 96 | }; -------------------------------------------------------------------------------- /src/services/util.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { URL } from 'url' 3 | import * as serverdb from '../serverdb/index.js' 4 | import lock from '../lib/lock.js' 5 | import { Record } from '@atek-cloud/adb-api' 6 | import { services, Service } from '@atek-cloud/adb-tables' 7 | import { Session } from '../httpapi/session-middleware.js' 8 | import { getByKey } from './index.js' 9 | import { User } from '@atek-cloud/adb-tables' 10 | 11 | export function sourceUrlToId (sourceUrl: string) { 12 | const urlp = new URL(sourceUrl) 13 | const pathname = urlp.pathname 14 | return path.basename(pathname) || 'service' 15 | } 16 | 17 | export async function getServiceRecordById (id: string): Promise> { 18 | // TODO this should be a value that's automatically indexed by adb -prf 19 | const srvRecords = (await services(serverdb.get()).list()).records 20 | const srvRecord = srvRecords.find((r: Record) => r.value?.id === id) 21 | if (!srvRecord) throw new Error(`Service not found with id=${id}`) 22 | return srvRecord 23 | } 24 | 25 | export async function getAvailableId (sourceUrl: string): Promise { 26 | const release = await lock('service:get-available-id') 27 | try { 28 | const srvRecords = await services(serverdb.get()).list() 29 | const basename = sourceUrlToId(sourceUrl) 30 | for (let i = 1; i < 1e9; i++) { 31 | const id = ((i === 1) ? basename : `${basename}-${i}`) 32 | if (!srvRecords.records.find((r: Record) => r.key == id)) { 33 | return id 34 | } 35 | } 36 | // yikes if this happens 37 | throw new Error('Unable to find an available ID for the app') 38 | } finally { 39 | release() 40 | } 41 | } 42 | 43 | interface Headers { 44 | [key: string]: string 45 | } 46 | export function getAuthHeaders (session?: Session, targetServicesKey?: string): Headers { 47 | const authHeaders: Headers = {} 48 | if (session?.isAuthed()) { 49 | const auth = session?.auth 50 | if (auth?.userKey && auth?.serviceKey) { 51 | authHeaders['Atek-Auth-User'] = auth.userKey 52 | authHeaders['Atek-Auth-Service'] = auth.serviceKey 53 | } else if (auth?.serviceKey) { 54 | authHeaders['Atek-Auth-Service'] = auth.serviceKey 55 | const service = getByKey(auth.serviceKey) 56 | if (service) { 57 | authHeaders['Atek-Auth-User'] = service.owningUserKey 58 | } 59 | } else if (auth?.userKey) { 60 | authHeaders['Atek-Auth-Service'] = targetServicesKey || '' // user cookie-based session means this is a request on the target service's origin 61 | authHeaders['Atek-Auth-User'] = auth.userKey 62 | } else { 63 | delete authHeaders['Atek-Auth-Service'] 64 | delete authHeaders['Atek-Auth-User'] 65 | } 66 | } else { 67 | delete authHeaders['Atek-Auth-Service'] 68 | delete authHeaders['Atek-Auth-User'] 69 | } 70 | return authHeaders 71 | } -------------------------------------------------------------------------------- /src/httpapi/gateway.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | import { RequestWithSession, Session } from './session-middleware.js' 3 | import * as apiBroker from '../broker/index.js' 4 | import jsonrpc from 'jsonrpc-lite' 5 | import { ParsedQs } from 'qs' 6 | import { IncomingMessage } from 'http' 7 | import WebSocket from 'ws' 8 | import { URL } from 'url' 9 | 10 | export function setup (app: express.Application) { 11 | console.log('Enabling /_atek/gateway endpoints') 12 | 13 | app.use('/_atek/gateway', (req: RequestWithSession, res: express.Response, next: express.NextFunction) => { 14 | if (!req.session?.isAuthed()) { 15 | if (queryParamToString(req.query.api) === 'atek.cloud/user-sessions-api' && (req.body.method === 'login' || req.body.method === 'whoami')) { 16 | return next() // allow this call only 17 | } 18 | res.status(401).json({error: true, message: 'Not authorized'}) 19 | return 20 | } 21 | next() 22 | }) 23 | 24 | app.post('/_atek/gateway', async (req: RequestWithSession, res: express.Response) => { 25 | const callDesc = { 26 | transport: apiBroker.TransportEnum.RPC, 27 | api: queryParamToString(req.query.api) 28 | } 29 | const parsed = jsonrpc.parseObject(req.body) 30 | if (parsed.type === 'error') { 31 | return res.status(200).json(parsed.payload) 32 | } else if (parsed.type === 'request') { 33 | try { 34 | const params = Array.isArray(parsed.payload.params) ? parsed.payload.params : [] 35 | let apiRes = await apiBroker.routeRpc(callDesc, parsed.payload.method, params, {session: req.session}) 36 | if (typeof apiRes === 'undefined') apiRes = 0 37 | return res.status(200).json(jsonrpc.success(parsed.payload.id, apiRes)) 38 | } catch (e: any) { 39 | const rpcErr = e instanceof jsonrpc.JsonRpcError ? e : new jsonrpc.JsonRpcError(e.message || e.toString(), e.code || -32000, e.data) 40 | return res.status(200).json(jsonrpc.error(parsed.payload.id, rpcErr)) 41 | } 42 | } 43 | return res.status(200).json({}) 44 | }) 45 | } 46 | 47 | export function handleWebSocket (ws: WebSocket, req: IncomingMessage, session: Session) { 48 | const urlp = new URL(req.url || '/', 'http://localhost/') // the domain isn't important, we just need to parse the query params 49 | const callDesc = { 50 | transport: apiBroker.TransportEnum.PROXY, 51 | api: urlp.searchParams.get('api') || '' 52 | } 53 | 54 | try { 55 | apiBroker.routeProxy(callDesc, ws, {session}) 56 | } catch (e) { 57 | console.error('Failed to route call', callDesc) 58 | console.error(e) 59 | ws.close() 60 | } 61 | } 62 | 63 | 64 | function queryParamToString (v: undefined | string | string[] | ParsedQs | ParsedQs[]): string | undefined { 65 | if (!v) return undefined 66 | if (Array.isArray(v)) return queryParamToString(v[0]) 67 | if (typeof v === 'string') return v 68 | return undefined 69 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@atek-cloud/atek", 3 | "version": "0.0.20", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "bin": "dist/bin.js", 7 | "type": "module", 8 | "scripts": { 9 | "start": "node dist/bin.js", 10 | "build": "tsc --project tsconfig.json", 11 | "test": "ava -s test/*.ts" 12 | }, 13 | "ava": { 14 | "extensions": { 15 | "ts": "module" 16 | }, 17 | "nonSemVerExperiments": { 18 | "configurableModuleFormat": true 19 | }, 20 | "nodeArguments": [ 21 | "--loader=ts-node/esm" 22 | ] 23 | }, 24 | "repository": "github:atek-cloud/atek", 25 | "bugs": { 26 | "url": "https://github.com/atek-cloud/atek/issues" 27 | }, 28 | "homepage": "https://github.com/atek-cloud/atek#readme", 29 | "author": "Paul Frazee ", 30 | "license": "MIT", 31 | "devDependencies": { 32 | "@types/cookie-parser": "^1.4.2", 33 | "@types/cors": "^2.8.12", 34 | "@types/express": "^4.17.13", 35 | "@types/http-proxy": "^1.17.7", 36 | "@types/inquirer": "^8.1.1", 37 | "@types/minimist": "^1.2.2", 38 | "@types/node": "^16.6.0", 39 | "@types/node-fetch": "^2.5.12", 40 | "@types/semver": "^7.3.8", 41 | "@types/tmp": "^0.2.1", 42 | "@types/uuid": "^8.3.1", 43 | "ava": "^3.15.0", 44 | "ts-node": "^10.2.1", 45 | "typescript": "^4.3.5" 46 | }, 47 | "dependencies": { 48 | "@atek-cloud/adb-api": "^2.0.0", 49 | "@atek-cloud/adb-tables": "^2.0.0", 50 | "@atek-cloud/inspect-api": "^1.0.0", 51 | "@atek-cloud/node-fetch-cookies": "^2.0.4", 52 | "@atek-cloud/node-rpc": "^4.2.0", 53 | "@atek-cloud/services-api": "^1.2.1", 54 | "@atek-cloud/user-sessions-api": "^1.0.0", 55 | "@atek-cloud/users-api": "^1.1.0", 56 | "@hyperspace/rpc": "^1.15.1", 57 | "@hyperswarm/dht": "^4.0.1", 58 | "@mishguru/selfupdate": "^4.8.0", 59 | "@tinyhttp/cookie": "^2.0.1", 60 | "@types/ws": "^7.4.7", 61 | "ajv": "^8.6.2", 62 | "ajv-formats": "^2.1.0", 63 | "ansi-escapes": "^5.0.0", 64 | "await-lock": "^2.1.0", 65 | "boxen": "^5.1.1", 66 | "bytes": "^3.1.0", 67 | "cfonts": "^2.9.3", 68 | "chalk": "^4.1.2", 69 | "cli-spinners": "^2.6.0", 70 | "cookie-parser": "^1.4.5", 71 | "cors": "^2.8.5", 72 | "ejs": "^3.1.6", 73 | "express": "^4.17.1", 74 | "figures": "^3.2.0", 75 | "http-proxy": "^1.18.1", 76 | "hyperspace": "^3.19.0", 77 | "inquirer": "^8.1.3", 78 | "is-installed-globally": "^0.4.0", 79 | "is-interactive": "^1.0.0", 80 | "isomorphic-git": "^1.9.2", 81 | "json-ptr": "^2.2.0", 82 | "jsonrpc-lite": "^2.2.0", 83 | "minimist": "^1.2.5", 84 | "monotonic-lexicographic-timestamp": "^1.0.0", 85 | "node-fetch": "^2.6.1", 86 | "pauls-sliding-window-rate-limiter": "^1.0.0", 87 | "quick-lru": "^6.0.1", 88 | "random-access-memory": "^3.1.2", 89 | "semver": "^7.3.5", 90 | "subcommand": "^2.1.1", 91 | "term-size": "^3.0.0", 92 | "terminal-link": "^3.0.0", 93 | "tmp": "^0.2.1", 94 | "uuid": "^8.3.2", 95 | "ws": "^8.1.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/lib/cli.ts: -------------------------------------------------------------------------------- 1 | import terminalSize from 'term-size' 2 | import ansiEscapes from 'ansi-escapes' 3 | import cliSpinners from 'cli-spinners' 4 | import figures from 'figures' 5 | import chalk from 'chalk' 6 | import isInteractive from 'is-interactive' 7 | 8 | const STATUS_WRITE_INTERVAl = cliSpinners.dots.interval 9 | 10 | let consoleLog = console.log 11 | let consoleError = console.error 12 | let isOutputtingStatus = false 13 | let currentStatus: string|undefined 14 | let outInterval: NodeJS.Timer|undefined 15 | let frameCounter = 0 16 | 17 | export function status (...args: any[]) { 18 | currentStatus = args.join(' ') 19 | if (!isInteractive()) { 20 | // do nothing 21 | } else if (!isOutputtingStatus) { 22 | isOutputtingStatus = true 23 | outInterval = setInterval(writeStatus, STATUS_WRITE_INTERVAl) 24 | outInterval.unref() 25 | patch() 26 | } 27 | } 28 | 29 | export function endStatus (...args: any[]) { 30 | currentStatus = args.join(' ') 31 | if (!isInteractive()) { 32 | if (currentStatus) console.log(currentStatus) 33 | return 34 | } 35 | if (currentStatus) { 36 | writeStatus(true) 37 | } 38 | isOutputtingStatus = false 39 | if (outInterval) { 40 | clearInterval(outInterval) 41 | outInterval = undefined 42 | } 43 | unpatch() 44 | console.log('') 45 | } 46 | 47 | export function genProgress (n: number, total: number): string { 48 | const numBlocks = Math.max(((n / total) * 10)|0, 1) 49 | return `${chalk.green(figures.squareSmallFilled.repeat(numBlocks))}${chalk.gray(figures.squareSmall.repeat(10 - numBlocks))}` 50 | } 51 | 52 | export function patch () { 53 | console.log = (...args) => { 54 | if (isOutputtingStatus) { 55 | process.stdout.write(ansiEscapes.cursorUp(1)) 56 | process.stdout.write(ansiEscapes.eraseLine) 57 | process.stdout.write(ansiEscapes.cursorUp(1)) 58 | process.stdout.write(ansiEscapes.eraseLine) 59 | consoleLog(...args, '\n\n') 60 | writeStatus() 61 | } else { 62 | consoleLog(...args) 63 | } 64 | } 65 | console.error = (...args) => { 66 | if (isOutputtingStatus) { 67 | process.stdout.write(ansiEscapes.cursorUp(1)) 68 | process.stdout.write(ansiEscapes.eraseLine) 69 | process.stdout.write(ansiEscapes.cursorUp(1)) 70 | process.stdout.write(ansiEscapes.eraseLine) 71 | consoleError(...args, '\n\n') 72 | writeStatus() 73 | } else { 74 | consoleError(...args) 75 | } 76 | } 77 | } 78 | 79 | export function unpatch () { 80 | console.log = consoleLog 81 | console.error = consoleError 82 | } 83 | 84 | function writeStatus (isFinal = false) { 85 | const f = (frameCounter++) % cliSpinners.dots.frames.length 86 | const spinner = isFinal ? chalk.green(figures.tick) : cliSpinners.dots.frames[f] 87 | const {columns, rows} = terminalSize() 88 | const actions = [ 89 | ansiEscapes.cursorSavePosition, 90 | ansiEscapes.cursorTo(0, rows - 3), 91 | ansiEscapes.eraseLine, 92 | ansiEscapes.cursorTo(0, rows - 2), 93 | ansiEscapes.eraseLine, 94 | `${spinner} ${currentStatus}`, 95 | '\n', 96 | ansiEscapes.cursorRestorePosition 97 | ] 98 | process.stdout.write(actions.join('')) 99 | } 100 | 101 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigValues, DEFAULT_CORE_SERVICES } from './config.js' 2 | import { InstallParams } from './services/index.js' 3 | import { spawn, ChildProcess } from 'child_process' 4 | import tmp from 'tmp' 5 | import path from 'path' 6 | import fs from 'fs' 7 | import { fileURLToPath } from 'url' 8 | import { generateBearerToken } from './lib/crypto.js' 9 | import { createApi } from './lib/rpc.js' 10 | 11 | const INSPECTOR_ENABLED = false 12 | const PORT = 10000 13 | 14 | export class Config implements ConfigValues { 15 | domain?: string 16 | port?: number 17 | serverDbId?: string 18 | coreServices: InstallParams[] = [ 19 | Object.assign({}, 20 | DEFAULT_CORE_SERVICES.find(c => c.id === 'core.hyper-daemon'), 21 | {config: {SIMULATE_HYPERSPACE: '1'}} 22 | ), 23 | Object.assign({}, DEFAULT_CORE_SERVICES.find(c => c.id === 'core.adb')) 24 | ] 25 | defaultMainService = '' 26 | systemAuthTokens: string[] = [] 27 | 28 | constructor (opts?: ConfigValues) { 29 | if (opts) { 30 | Object.assign(this, opts) 31 | } 32 | } 33 | 34 | addCoreService (desc: InstallParams) { 35 | this.coreServices.push(desc) 36 | } 37 | } 38 | 39 | export class TestInstance { 40 | url: string 41 | process: ChildProcess 42 | tmpdir: tmp.DirResult 43 | authToken: string 44 | 45 | constructor (url: string, process: ChildProcess, tmpdir: tmp.DirResult, authToken: string) { 46 | this.url = url 47 | this.process = process 48 | this.tmpdir = tmpdir 49 | this.authToken = authToken 50 | } 51 | 52 | api (apiDesc: string|NodeJS.Dict, {noAuth} = {noAuth: false}) { 53 | return createApi(this.url, apiDesc, noAuth ? undefined : this.authToken) 54 | } 55 | 56 | async close () { 57 | const p = new Promise(r => { 58 | if (this.process.exitCode !== null) r(undefined) 59 | this.process.on('exit', r) 60 | }) 61 | this.process.kill() 62 | await p 63 | this.tmpdir.removeCallback() 64 | await new Promise(r => setTimeout(r, 1e3)) 65 | } 66 | } 67 | 68 | export async function startAtek (config: Config = new Config()) { 69 | const cfgDir = tmp.dirSync({unsafeCleanup: true}) 70 | const cfgPath = path.join(cfgDir.name, 'config.json') 71 | const authToken = generateBearerToken() 72 | config.port = PORT 73 | config.systemAuthTokens = [authToken] 74 | fs.writeFileSync(cfgPath, JSON.stringify(config)) 75 | 76 | const binPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'bin.js') 77 | const env = Object.assign({}, process.env) 78 | env.CI = '1' // disables "interactive" CLI output 79 | if (INSPECTOR_ENABLED) env.NODE_OPTIONS = `--inspect=localhost:${PORT-1}` 80 | const serverProcess = spawn( 81 | 'node', 82 | [binPath, 'run', '--configDir', cfgDir.name, '--port', String(PORT)], 83 | { 84 | stdio: [process.stdin, process.stdout, process.stderr], 85 | env 86 | } 87 | ) 88 | 89 | const inspect = createApi(`http://localhost:${PORT}`, 'atek.cloud/inspect-api', authToken) 90 | let isReady = false 91 | for (let i = 0; i < 100; i++) { 92 | isReady = await inspect.call('isReady').then((v) => v, (err) => false) 93 | if (isReady) break 94 | await new Promise(r => setTimeout(r, 1e3)) 95 | } 96 | if (!isReady) throw new Error('Server failed to start') 97 | 98 | return new TestInstance(`http://localhost:${PORT}`, serverProcess, cfgDir, authToken) 99 | } 100 | -------------------------------------------------------------------------------- /src/setup-flow.ts: -------------------------------------------------------------------------------- 1 | import cfonts from 'cfonts' 2 | import chalk from 'chalk' 3 | import boxen from 'boxen' 4 | import figures from 'figures' 5 | import inquirer from 'inquirer' 6 | import terminalLink from 'terminal-link' 7 | import { users } from '@atek-cloud/adb-tables' 8 | import * as serverdb from './serverdb/index.js' 9 | import { createUser } from './users/index.js' 10 | import isInteractive from 'is-interactive' 11 | 12 | export async function run () { 13 | if (!isInteractive()) return 14 | 15 | const userRecords = (await users(serverdb.get()).list()).records 16 | if (userRecords.length > 0) return 17 | 18 | cfonts.say('WELCOME TO ATEK', {gradient: 'red,blue'}) 19 | 20 | console.log(chalk.yellow(boxen(`${figures.warning} ${chalk.bold('Warning: This is an unstable preview build')}. You may have to delete your Atek data due to breaking changes during this phase.`, {padding: 1, margin: 1, borderStyle: 'double'}))) 21 | const {understood} = await inquirer.prompt([{type: 'confirm', name: 'understood', message: 'Do you understand?', default: true}]) 22 | if (!understood) { 23 | console.log('Okay well... We\'re going to continue with setup but you\'ve been warned.') 24 | } 25 | 26 | console.log('') 27 | console.log('Let\'s create your first user') 28 | const {username, password} = await createUserPrompt() 29 | 30 | await createUser({username, password, role: 'admin'}) 31 | console.log(chalk.green(figures.tick), 'User', username, 'created as an admin') 32 | console.log('You\'re good to go!') 33 | console.log('') 34 | console.log(` Open ${terminalLink('http://localhost/', 'http://localhost')} to log into Atek.`) 35 | console.log('') 36 | } 37 | 38 | export async function createUserPrompt (): Promise<{username: string, password: string}> { 39 | const {username} = await createUsernamePrompt() 40 | const {password} = await createPasswordPrompt() 41 | return {username, password} 42 | } 43 | 44 | export function createUsernamePrompt (): Promise<{username: string}> { 45 | return inquirer.prompt([ 46 | {type: 'input', name: 'username', message: 'Username', validate: (input) => { 47 | if (input.length < 3) return 'Must be 3 or more characters long.' 48 | if (/[a-z][a-z0-9]*/i.test(input) === false) return 'Please stick to characters and numbers and start with a character' 49 | return true 50 | }} 51 | ]) 52 | } 53 | 54 | export async function createPasswordPrompt (): Promise<{password: string}> { 55 | let password 56 | do { 57 | const {pass1} = await inquirer.prompt([ 58 | {type: 'password', name: 'pass1', message: 'Password', mask: '*', validate: (input) => { 59 | if (input.length < 3) return 'Must be 3 or more characters long.' 60 | return true 61 | }} 62 | ]) 63 | const {pass2} = await inquirer.prompt([ 64 | {type: 'password', name: 'pass2', message: 'Confirm password', mask: '*'} 65 | ]) 66 | if (pass1 === pass2) { 67 | password = pass1 68 | break 69 | } 70 | console.log('Those passwords didn\'t match. Mind trying again?') 71 | } while (true) 72 | return {password} 73 | } 74 | 75 | export function createModUserPrompt (): Promise<{what: string}> { 76 | return inquirer.prompt([ 77 | {type: 'rawlist', name: 'what', message: 'What do you want to change?', choices: ['Password']} 78 | ]) 79 | } 80 | 81 | export async function confirm (message: string, def: boolean = true): Promise { 82 | const res = await inquirer.prompt([ 83 | {type: 'confirm', name: 'confirm', message, default: def} 84 | ]) 85 | return res.confirm 86 | } -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs' 3 | import { InstallParams } from './services/index.js' 4 | 5 | export const DEFAULT_HOST_PORT = 80 6 | let _activeConfig: Config | undefined = undefined 7 | 8 | export const DEFAULT_CORE_SERVICES: InstallParams[] = [ 9 | {id: 'core.hyper-daemon', sourceUrl: 'https://github.com/atek-cloud/hyper-daemon', desiredVersion: '2.0.0'}, 10 | {id: 'core.adb', sourceUrl: 'https://github.com/atek-cloud/adb', desiredVersion: '2.0.0'} 11 | ] 12 | const DEFAULT_DEFAULT_MAIN_SERVICE = 'https://github.com/atek-cloud/lonestar' // that's right. default default. it's the default for the default. 13 | 14 | export interface ConfigValues { 15 | domain?: string 16 | port?: number 17 | serverDbId?: string 18 | coreServices?: InstallParams[] 19 | defaultMainService?: string 20 | systemAuthTokens?: string[] 21 | } 22 | 23 | export class Config implements ConfigValues { 24 | configDir: string 25 | values: ConfigValues 26 | overrides: ConfigValues 27 | 28 | static setActiveConfig (cfg: Config) { 29 | _activeConfig = cfg 30 | } 31 | 32 | static getActiveConfig (): Config { 33 | if (!_activeConfig) throw new Error('No active host environment config set') 34 | return _activeConfig 35 | } 36 | 37 | constructor (configDir: string, opts: ConfigValues) { 38 | this.configDir = configDir 39 | this.values = {} 40 | this.read() 41 | this.overrides = opts 42 | } 43 | 44 | get filePath () { 45 | return path.join(this.configDir, 'config.json') 46 | } 47 | 48 | packageInstallPath (id: string): string { 49 | return path.join(this.configDir, 'packages', id) 50 | } 51 | 52 | serviceSocketFilePath (id: string): string { 53 | return path.join(this.configDir, 'sockets', `${id}.sock`) 54 | } 55 | 56 | serviceLogPath (id: string): string { 57 | return path.join(this.configDir, 'logs', `${id}.log`) 58 | } 59 | 60 | get domain () { 61 | return fallbacks(this.overrides.domain, this.values.domain, 'localhost') 62 | } 63 | 64 | get port () { 65 | return fallbacks(this.overrides.port, this.values.port, DEFAULT_HOST_PORT) 66 | } 67 | 68 | get serverDbId () { 69 | return fallbacks(this.overrides.serverDbId, this.values.serverDbId, undefined) 70 | } 71 | 72 | get coreServices () { 73 | return fallbacks(this.overrides.coreServices, this.values.coreServices, DEFAULT_CORE_SERVICES) 74 | } 75 | 76 | get defaultMainService () { 77 | return fallbacks(this.overrides.defaultMainService, this.values.defaultMainService, DEFAULT_DEFAULT_MAIN_SERVICE) 78 | } 79 | 80 | get systemAuthTokens () { 81 | return fallbacks(this.overrides.systemAuthTokens, this.values.systemAuthTokens, []) 82 | } 83 | 84 | isOverridden (key: string): boolean { 85 | return (key in this.overrides) 86 | } 87 | 88 | read () { 89 | let str 90 | try { 91 | str = fs.readFileSync(this.filePath, 'utf8') 92 | } catch (e) { 93 | // config doesnt exist, create it 94 | this.values = {} 95 | return 96 | } 97 | try { 98 | this.values = JSON.parse(str) 99 | } catch (e: any) { 100 | console.error('Failed to read config file', this.filePath) 101 | console.error(e) 102 | process.exit(1) 103 | } 104 | } 105 | 106 | update (values: ConfigValues) { 107 | Object.assign(this.values, values) 108 | this.write() 109 | } 110 | 111 | write () { 112 | try { fs.mkdirSync(this.configDir) } catch (e) {} 113 | fs.writeFileSync(this.filePath, JSON.stringify(this.values, null, 2), 'utf8') 114 | } 115 | } 116 | 117 | function fallbacks (...params: any[]): any { 118 | for (const param of params) { 119 | if (typeof param !== 'undefined') { 120 | return param 121 | } 122 | } 123 | return undefined 124 | } -------------------------------------------------------------------------------- /src/rpcapi/services.ts: -------------------------------------------------------------------------------- 1 | import { AtekRpcServer } from '@atek-cloud/node-rpc' 2 | import { createServer, ServiceInfo, InstallOpts, ConfigureOpts } from '@atek-cloud/services-api' 3 | import { users } from '@atek-cloud/adb-tables' 4 | import * as serverdb from '../serverdb/index.js' 5 | import * as services from '../services/index.js' 6 | import { Session } from '../httpapi/session-middleware.js' 7 | 8 | export function setup (): AtekRpcServer { 9 | return createServer({ 10 | list (): Promise<{services: ServiceInfo[]}> { 11 | this.session.assertIsAuthed() 12 | let items = services.list() 13 | if (!this.session.isAdminAuthed()) { 14 | items = items.filter(item => item.owningUserKey === this.session.auth.userKey) 15 | } 16 | return Promise.resolve({services: items.map(s => s.toJSON())}) 17 | }, 18 | 19 | // Fetch information about an installed service. 20 | get (id: string): Promise { 21 | this.session.assertIsAuthed() 22 | assertCanAccess(id, this.session) 23 | const service = services.get(id) 24 | if (service) return Promise.resolve(service.toJSON()) 25 | throw new Error(`No service found with id ${id}`) 26 | }, 27 | 28 | // Install a new service. 29 | async install (opts: InstallOpts): Promise { 30 | this.session.assertIsAuthed() 31 | 32 | let userKey = this.session.auth.userKey 33 | if (opts.user) { 34 | this.session.assertIsAdminAuthed() 35 | const userRecords = (await users(serverdb.get()).list()).records 36 | const userRecord = await userRecords.find((r: any) => r.value.username === opts.user) 37 | if (!userRecord) throw new Error(`No user found with the username of ${opts.user}`) 38 | userKey = userRecord.key 39 | } 40 | 41 | const service = await services.install(opts, userKey) 42 | if (service) return Promise.resolve(service.toJSON()) 43 | throw new Error(`No service created by install`) 44 | }, 45 | 46 | // Uninstall a service. 47 | async uninstall (id: string): Promise { 48 | assertCanAccess(id, this.session) 49 | await services.uninstall(id) 50 | }, 51 | 52 | // Change the settings of a service. 53 | async configure (id: string, opts: ConfigureOpts): Promise { 54 | assertCanAccess(id, this.session) 55 | await services.updateConfig(id, opts) 56 | }, 57 | 58 | // Start a service process. 59 | async start (id: string): Promise { 60 | assertCanAccess(id, this.session) 61 | await services.get(id)?.start() 62 | }, 63 | 64 | // Stop a service process. 65 | async stop (id: string): Promise { 66 | assertCanAccess(id, this.session) 67 | await services.get(id)?.stop() 68 | }, 69 | 70 | // Restart a service process. 71 | async restart (id: string): Promise { 72 | assertCanAccess(id, this.session) 73 | await services.get(id)?.restart() 74 | }, 75 | 76 | // Query the source package for software updates. 77 | checkForPackageUpdates (id: string): Promise<{hasUpdate: boolean, installedVersion: string, latestVersion: string}> { 78 | assertCanAccess(id, this.session) 79 | return services.checkForPackageUpdates(id) 80 | }, 81 | 82 | // Update the service to the highest version which matches "desiredVersion". 83 | updatePackage (id: string): Promise<{installedVersion: string, oldVersion: string}> { 84 | assertCanAccess(id, this.session) 85 | return services.updatePackage(id) 86 | } 87 | }) 88 | } 89 | 90 | function canAccess (id: string, session: Session) { 91 | if (!session.isAuthed()) return false 92 | const service = services.get(id) 93 | if (!service) return false 94 | if (session.isAdminAuthed()) return true 95 | return (service.owningUserKey === session.auth?.userKey) 96 | } 97 | 98 | function assertCanAccess (id: string, session: Session) { 99 | if (!canAccess(id, session)) throw new Error('Not authorized') 100 | } -------------------------------------------------------------------------------- /static/views/no-main-service.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 66 | 67 | 68 |
69 |

Welcome to Atek

70 | <% if (error === 'not-owning-user') { %> 71 |

Your home application is currently set to an app you didn't install! You'll need to install your own home app.

72 | <% } else { %> 73 |

You need to install a home application!

74 | <% } %> 75 |
76 |
77 |
78 |
79 |
80 |
81 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /test/users.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | import * as atek from '../dist/index.js' 5 | 6 | const AUTH_APP1_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'util', 'auth-app-1') 7 | const AUTH_APP2_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'util', 'auth-app-2') 8 | 9 | let inst: any 10 | test.after(async () => { 11 | await inst.close() 12 | }) 13 | 14 | test.serial('Load test instance', async t => { 15 | const cfg = new atek.test.Config() 16 | inst = await atek.test.startAtek(cfg) 17 | t.pass('Test instance loaded') 18 | }) 19 | 20 | test.serial('Create, list, get, update, delete users', async t => { 21 | const usersapi = inst.api('atek.cloud/users-api') 22 | 23 | const {users} = await usersapi.call('list') 24 | t.is(users.length, 0, 'No users created initially') 25 | 26 | const newUserRes = await usersapi.call('create', [{username: 'bob', password: 'hunter2'}]) 27 | t.is(typeof newUserRes.key, 'string', 'New user created') 28 | t.is(newUserRes.username, 'bob', 'New user created') 29 | 30 | const {users: users2} = await usersapi.call('list') 31 | t.is(users2.length, 1, 'Now 1 user active') 32 | 33 | const getRes = await usersapi.call('get', [newUserRes.key]) 34 | t.deepEqual(newUserRes, getRes, 'get() provies correct info') 35 | 36 | const updateRes = await usersapi.call('update', [newUserRes.key, {username: 'bobo', role: 'admin'}]) 37 | t.is(updateRes.key, newUserRes.key, 'update() provies correct info') 38 | t.is(updateRes.username, 'bobo', 'update() changes username') 39 | t.is(updateRes.role, 'admin', 'update() changes role') 40 | 41 | const getRes2 = await usersapi.call('get', [newUserRes.key]) 42 | t.deepEqual(updateRes, getRes2, 'get() is correct') 43 | 44 | await usersapi.call('delete', [newUserRes.key]) 45 | const {users: users3} = await usersapi.call('list') 46 | t.is(users3.length, 0, 'Delete successful') 47 | }) 48 | 49 | test.serial('Login, whoami, logout', async t => { 50 | const usersapi = inst.api('atek.cloud/users-api') 51 | const sessapi = inst.api('atek.cloud/user-sessions-api', {noAuth: true}) 52 | 53 | await usersapi.call('create', [{username: 'bob', password: 'hunter2'}]) 54 | 55 | const sess1 = await sessapi.call('whoami', []) 56 | t.is(sess1.isActive, false, 'Session not yet active') 57 | 58 | const sess2 = await sessapi.call('login', [{username: 'bob', password: 'hunter2'}]) 59 | t.is(sess2.isActive, true, 'Session now active') 60 | t.is(sess2.username, 'bob', 'Session now active') 61 | 62 | const sess3 = await sessapi.call('whoami', []) 63 | t.is(sess3.isActive, true, 'Session now active') 64 | t.is(sess3.username, 'bob', 'Session now active') 65 | 66 | await sessapi.call('logout', []) 67 | const sess4 = await sessapi.call('whoami', []) 68 | t.is(sess4.isActive, false, 'Session now inactive') 69 | }) 70 | 71 | test.serial('Services receive auth headers', async t => { 72 | const srvapi = inst.api('atek.cloud/services-api') 73 | const authApp1Api = inst.api('auth-app-one.com/api') 74 | const usersapi = inst.api('atek.cloud/users-api') 75 | const sessapi = inst.api('atek.cloud/user-sessions-api', {noAuth: true}) 76 | 77 | const installRes = await srvapi.call('install', [{sourceUrl: `file://${AUTH_APP1_PATH}`}]) 78 | t.is(installRes.status, 'active', 'New service is active') 79 | 80 | const installRes2 = await srvapi.call('install', [{sourceUrl: `file://${AUTH_APP2_PATH}`}]) 81 | t.is(installRes2.status, 'active', 'New service is active') 82 | 83 | const headers1 = await authApp1Api.call('getAuthHeaders', []) 84 | t.is(headers1.user, 'system', 'System user') 85 | t.falsy(headers1.service, 'No service') 86 | 87 | const headers2 = await authApp1Api.call('getAuthHeadersFromApp2', []) 88 | t.is(headers2.user, 'system', 'System user') // owning user is system because system installed the app 89 | t.is(headers2.service, installRes.key, 'Auth-app-1 service') 90 | 91 | const user = await usersapi.call('create', [{username: 'bob2', password: 'hunter2'}]) 92 | await sessapi.call('login', [{username: 'bob2', password: 'hunter2'}]) 93 | 94 | const authApp1ApiCookieAuth = inst.api('auth-app-one.com/api', {noAuth: true}) 95 | sessapi.copyCookiesTo(authApp1ApiCookieAuth.cookieJar) 96 | 97 | const headers3 = await authApp1ApiCookieAuth.call('getAuthHeaders', []) 98 | t.is(headers3.user, user.key, 'bob2 user') 99 | t.falsy(headers3.service, 'No service') 100 | }) -------------------------------------------------------------------------------- /src/users/index.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../config.js' 2 | import { User, NewUser, UserUpdate, UserSettings, UserSettingsUpdate } from '@atek-cloud/users-api' 3 | import { users } from '@atek-cloud/adb-tables' 4 | import { hashPassword } from '../lib/crypto.js' 5 | import { install } from '../services/index.js' 6 | import * as serverdb from '../serverdb/index.js' 7 | import lock from '../lib/lock.js' 8 | 9 | export async function getUser (userKey: string): Promise { 10 | const record = await users(serverdb.get()).get(userKey) 11 | if (!record) throw new Error(`User not found under key ${userKey}`) 12 | return { 13 | key: record.key, 14 | username: record.value.username, 15 | role: record.value.role 16 | } 17 | } 18 | 19 | export async function createUser ({username, password, role}: NewUser): Promise { 20 | const release = await lock('users:mutation') 21 | try { 22 | const {records} = await users(serverdb.get()).list() 23 | if (records.find((r: any) => r.value.username === username)) { 24 | throw new Error(`This username has already been taken`) 25 | } 26 | assertNotReserved(username) 27 | const record = await users(serverdb.get()).create({ 28 | username, 29 | hashedPassword: await hashPassword(password), 30 | role, 31 | settings: {} 32 | }) 33 | if (Config.getActiveConfig().defaultMainService) { 34 | try { 35 | const mainService = await install({ 36 | sourceUrl: Config.getActiveConfig().defaultMainService 37 | }, record.key) 38 | record.value.settings.mainServiceId = mainService.id 39 | await users(serverdb.get()).put(record.key, record.value) 40 | } catch (e) { 41 | console.error('Failed to install main service for', username, 'during user creation') 42 | console.error(e) 43 | } 44 | } 45 | return { 46 | key: record.key, 47 | username: record.value.username, 48 | role: record.value.role 49 | } 50 | } finally { 51 | release() 52 | } 53 | } 54 | 55 | export async function updateUser (userKey: string, user: UserUpdate): Promise { 56 | const release = await lock('users:mutation') 57 | try { 58 | const record = await users(serverdb.get()).get(userKey) 59 | if (!record) throw new Error(`User not found under key ${userKey}`) 60 | if (typeof user.username === 'string') { 61 | const {records} = await users(serverdb.get()).list() 62 | if (records.find((r: any) => r.value.username === user.username)) { 63 | throw new Error(`This username has already been taken`) 64 | } 65 | assertNotReserved(user.username) 66 | record.value.username = user.username 67 | } 68 | if (typeof user.password === 'string') record.value.hashedPassword = await hashPassword(user.password) 69 | if (typeof user.role === 'string') record.value.role = user.role 70 | await users(serverdb.get()).put(record.key, record.value) 71 | return { 72 | key: record.key, 73 | username: record.value.username, 74 | role: record.value.role 75 | } 76 | } finally { 77 | release() 78 | } 79 | } 80 | 81 | export async function deleteUser (userKey: string) { 82 | const release = await lock('users:mutation') 83 | try { 84 | await users(serverdb.get()).delete(userKey) 85 | } finally { 86 | release() 87 | } 88 | } 89 | export async function getUserSettings (userKey: string): Promise { 90 | const record = await users(serverdb.get()).get(userKey) 91 | if (!record) throw new Error(`User not found under key ${userKey}`) 92 | return record.value.settings || {} 93 | } 94 | 95 | export async function updateUserSettings (userKey: string, settings: UserSettingsUpdate): Promise { 96 | const release = await lock('users:mutation') 97 | try { 98 | const record = await users(serverdb.get()).get(userKey) 99 | if (!record) throw new Error(`User not found under key ${userKey}`) 100 | record.value.settings = record.value.settings || {} 101 | if (typeof settings.mainServiceId === 'string') record.value.settings.mainServiceId = settings.mainServiceId 102 | await users(serverdb.get()).put(record.key, record.value) 103 | return record.value.settings 104 | } finally { 105 | release() 106 | } 107 | } 108 | 109 | const RESERVED_USERNAMES = ['system'] 110 | function assertNotReserved (username: string) { 111 | if (RESERVED_USERNAMES.includes(username)) { 112 | throw new Error(`The username ${username} is reserved`) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /test/services.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | import * as atek from '../dist/index.js' 5 | 6 | const SIMPLE_APP_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'util', 'simple-app') 7 | 8 | let inst: any 9 | test.after(async () => { 10 | await inst.close() 11 | }) 12 | 13 | test.serial('Load test instance', async t => { 14 | const cfg = new atek.test.Config() 15 | inst = await atek.test.startAtek(cfg) 16 | t.pass('Test instance loaded') 17 | }) 18 | 19 | test.serial('Install, configure, and uninstall a service', async t => { 20 | const srvapi = inst.api('atek.cloud/services-api') 21 | 22 | const {services} = await srvapi.call('list') 23 | t.is(services.length, 2, 'Only 2 core services active initially') 24 | 25 | const installRes = await srvapi.call('install', [{sourceUrl: `file://${SIMPLE_APP_PATH}`}]) 26 | t.is(installRes.status, 'active', 'New service is active') 27 | t.is(typeof installRes.settings.id, 'string', 'ID is assigned') 28 | t.is(installRes.settings.sourceUrl, `file://${SIMPLE_APP_PATH}`, 'Source URL is correct') 29 | t.is(installRes.settings.package.sourceType, 'file', 'Source type is correct') 30 | t.is(installRes.settings.manifest.name, `NAME`, 'Manifest is correct') 31 | t.is(installRes.settings.manifest.description, `DESCRIPTION`, 'Manifest is correct') 32 | t.is(installRes.settings.manifest.author, `AUTHOR`, 'Manifest is correct') 33 | t.is(installRes.settings.manifest.license, `LICENSE`, 'Manifest is correct') 34 | 35 | const {services: services2} = await srvapi.call('list') 36 | t.is(services2.length, 3, 'Now 3 services active') 37 | 38 | const getRes = await srvapi.call('get', [installRes.settings.id]) 39 | t.deepEqual(installRes, getRes, 'get() provies correct info') 40 | 41 | await srvapi.call('uninstall', [installRes.settings.id]) 42 | const {services: services3} = await srvapi.call('list') 43 | t.is(services3.length, 2, 'Uninstall successful') 44 | }) 45 | 46 | test.serial('Limited API access to non-admin users', async t => { 47 | const usersapi = inst.api('atek.cloud/users-api') 48 | const sessapi1 = inst.api('atek.cloud/user-sessions-api', {noAuth: true}) 49 | const srvapi1 = inst.api('atek.cloud/services-api', {noAuth: true}) 50 | const sessapi2 = inst.api('atek.cloud/user-sessions-api', {noAuth: true}) 51 | const srvapi2 = inst.api('atek.cloud/services-api', {noAuth: true}) 52 | 53 | await usersapi.call('create', [{username: 'non-admin-1', password: 'hunter2'}]) 54 | await sessapi1.call('login', [{username: 'non-admin-1', password: 'hunter2'}]) 55 | sessapi1.copyCookiesTo(srvapi1.cookieJar) 56 | 57 | const {services} = await srvapi1.call('list') 58 | t.is(services.length, 0, 'No core services listed') 59 | 60 | const installRes = await srvapi1.call('install', [{sourceUrl: `file://${SIMPLE_APP_PATH}`}]) 61 | t.is(installRes.status, 'active', 'New service is active') 62 | 63 | const {services: services2} = await srvapi1.call('list') 64 | t.is(services2.length, 1, 'Now 1 service active') 65 | 66 | const getRes = await srvapi1.call('get', [installRes.settings.id]) 67 | t.deepEqual(installRes, getRes, 'get() provies correct info') 68 | 69 | await usersapi.call('create', [{username: 'non-admin-2', password: 'hunter2'}]) 70 | await sessapi2.call('login', [{username: 'non-admin-2', password: 'hunter2'}]) 71 | sessapi2.copyCookiesTo(srvapi2.cookieJar) 72 | 73 | const {services: services3} = await srvapi2.call('list') 74 | t.is(services3.length, 0, 'No services listed') 75 | 76 | t.truthy(await srvapi2.call('get', [installRes.settings.id]).then((res: any) => false, (err: any) => true), 'Cant get somebody elses service') 77 | 78 | await srvapi1.call('uninstall', [installRes.settings.id]) 79 | }) 80 | 81 | test.serial('Change a service ID after install', async t => { 82 | const srvapi = inst.api('atek.cloud/services-api') 83 | 84 | const {services} = await srvapi.call('list') 85 | t.is(services.length, 2, 'Only 2 core services active initially') 86 | 87 | const installRes = await srvapi.call('install', [{id: 'test1', sourceUrl: `file://${SIMPLE_APP_PATH}`}]) 88 | t.is(installRes.status, 'active', 'New service is active') 89 | t.is(installRes.settings.id, 'test1', 'ID is correct') 90 | 91 | const getRes1 = await srvapi.call('get', ['test1']) 92 | t.is(getRes1.settings.id, 'test1', 'Initial ID is correct') 93 | 94 | await srvapi.call('configure', ['test1', {id: 'test2'}]) 95 | 96 | const getRes2 = await srvapi.call('get', ['test2']) 97 | t.is(getRes2.settings.id, 'test2', 'ID is correctly changed') 98 | 99 | await srvapi.call('uninstall', ['test2']) 100 | const {services: services3} = await srvapi.call('list') 101 | t.is(services3.length, 2, 'Uninstall successful') 102 | }) 103 | -------------------------------------------------------------------------------- /src/httpapi/session-middleware.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { Config } from '../config.js' 4 | import * as serverdb from '../serverdb/index.js' 5 | import { users, userSessions } from '@atek-cloud/adb-tables' 6 | import * as services from '../services/index.js' 7 | 8 | const bindSessionTokens = new Map() 9 | 10 | export interface SessionAuth { 11 | sessionId?: string 12 | userKey?: string 13 | username?: string 14 | serviceKey?: string 15 | role?: string 16 | } 17 | 18 | export interface RequestWithSession extends express.Request { 19 | session?: Session 20 | } 21 | 22 | export class Session { 23 | req: express.Request | undefined 24 | res: express.Response | undefined 25 | auth: SessionAuth | undefined 26 | 27 | constructor (req: RequestWithSession | undefined, res: express.Response | undefined, auth: SessionAuth | undefined) { 28 | this.req = req 29 | this.res = res 30 | this.auth = auth 31 | } 32 | 33 | isAuthed (): boolean { 34 | return Boolean(this.auth && (this.auth.userKey || this.auth.serviceKey)) 35 | } 36 | 37 | assertIsAuthed () { 38 | if (!this.isAuthed()) throw new Error('Not authorized') 39 | } 40 | 41 | isAppAuthed (): boolean { 42 | return Boolean(this.auth && this.auth.serviceKey) 43 | } 44 | 45 | assertIsAppAuthed () { 46 | if (!this.isAppAuthed()) throw new Error('Not authorized') 47 | } 48 | 49 | isUserAuthed ({notApp} = {notApp: false}): boolean { 50 | return Boolean(this.auth && this.auth.userKey && (!notApp || !this.isAppAuthed())) 51 | } 52 | 53 | assertisUserAuthed () { 54 | if (!this.isUserAuthed()) throw new Error('Not authorized') 55 | } 56 | 57 | isAdminAuthed (): boolean { 58 | return this.isUserAuthed() && (this.auth?.userKey === 'system' || this.auth?.role === 'admin') 59 | } 60 | 61 | assertIsAdminAuthed () { 62 | if (!this.isAdminAuthed()) throw new Error('Not authorized') 63 | } 64 | 65 | async create ({userKey, username, role}: {userKey: string, username: string, role: string}): Promise { 66 | if (!this.req || !this.res) throw new Error('Unable to create session on this request') 67 | const sess = { 68 | sessionId: uuidv4(), 69 | userKey, 70 | username 71 | } 72 | await userSessions(serverdb.get()).create(sess) 73 | this.auth = { 74 | sessionId: sess.sessionId, 75 | userKey, 76 | username, 77 | role 78 | } 79 | this.res.cookie('session', sess.sessionId, { 80 | httpOnly: true, 81 | secure: true, 82 | sameSite: 'none' // must be none to enable the /_atek/bind-session redirects 83 | }) 84 | } 85 | 86 | async bind (sessionId: string): Promise { 87 | if (!this.req || !this.res) throw new Error('Unable to create session on this request') 88 | const sessionRecord = await userSessions(serverdb.get()).get(sessionId).catch((e: any) => undefined) 89 | if (!sessionRecord) return false 90 | const userRecord = await users(serverdb.get()).get(sessionRecord.value.userKey) 91 | if (!userRecord) return false 92 | this.res.cookie('session', sessionId, { 93 | httpOnly: true, 94 | secure: true, 95 | sameSite: 'none' 96 | }) 97 | this.auth = { 98 | sessionId: sessionRecord.value.sessionId, 99 | userKey: sessionRecord.value.userKey, 100 | username: userRecord.value.username, 101 | role: userRecord.value.role 102 | } 103 | return true 104 | } 105 | 106 | async destroy (): Promise { 107 | if (!this.req || !this.res) throw new Error('Unable to destroy session on this request') 108 | if (this.req.cookies.session) { 109 | this.res.clearCookie('session') 110 | this.auth = undefined 111 | await userSessions(serverdb.get()).delete(this.req.cookies.session) 112 | } 113 | } 114 | } 115 | 116 | export function setup () { 117 | return async (req: RequestWithSession, res: express.Response, next: express.NextFunction) => { 118 | const auth = await getSessionAuth(req.headers.authorization, req.cookies.session) 119 | req.session = new Session(req, res, auth) 120 | next() 121 | } 122 | } 123 | 124 | export async function getSessionAuth (authHeader: string|undefined, sessionCookie: string|undefined): Promise { 125 | let auth = undefined 126 | if (sessionCookie) { 127 | const sessionRecord = await userSessions(serverdb.get()).get(sessionCookie).catch((e: any) => undefined) 128 | if (sessionRecord?.value) { 129 | const userRecord = await users(serverdb.get()).get(sessionRecord.value.userKey) 130 | if (userRecord?.value) { 131 | auth = { 132 | sessionId: sessionRecord.value.sessionId, 133 | userKey: sessionRecord.value.userKey, 134 | username: userRecord.value.username, 135 | role: userRecord.value.role 136 | } 137 | } 138 | } 139 | } else if (authHeader && authHeader.startsWith('Bearer ')) { 140 | const token = authHeader.split(' ')[1] 141 | const srv = services.getByBearerToken(token) 142 | if (srv) { 143 | if (!srv.owningUserKey) return undefined 144 | if (srv.owningUserKey !== 'system') { 145 | const userRecord = await users(serverdb.get()).get(srv.owningUserKey) 146 | auth = { 147 | serviceKey: srv.serviceKey, 148 | userKey: srv.owningUserKey, 149 | username: userRecord.value.username, 150 | role: userRecord.value.role 151 | } 152 | } else { 153 | auth = { 154 | serviceKey: srv.serviceKey, 155 | userKey: 'system', 156 | username: 'system', 157 | role: 'admin' 158 | } 159 | } 160 | } else if (Config.getActiveConfig().systemAuthTokens.includes(token)){ 161 | auth = {userKey: 'system', serviceKey: 'system', username: 'system', role: 'admin'} 162 | } 163 | } 164 | return auth 165 | } 166 | 167 | export function genBindSessionToken (sessionId: string) { 168 | const token = uuidv4() 169 | bindSessionTokens.set(token, sessionId) 170 | return token 171 | } 172 | 173 | export async function attemptBindSession (req: RequestWithSession, res: express.Response): Promise { 174 | if (typeof req.query.bst === 'string') { 175 | const sessionId = bindSessionTokens.get(req.query.bst) 176 | if (sessionId) { 177 | bindSessionTokens.delete(req.query.bst) 178 | return (await req.session?.bind(sessionId)) || false 179 | } 180 | } 181 | return false 182 | } -------------------------------------------------------------------------------- /static/js/rpc.js: -------------------------------------------------------------------------------- 1 | // node_modules/jsonrpc-lite/dist/esnext/jsonrpc.js 2 | "use strict"; 3 | var hasOwnProperty = Object.prototype.hasOwnProperty; 4 | var isInteger = typeof Number.isSafeInteger === "function" ? Number.isSafeInteger : function(num) { 5 | return typeof num === "number" && isFinite(num) && num === Math.floor(num) && Math.abs(num) <= 9007199254740991; 6 | }; 7 | var JsonRpc = class { 8 | constructor() { 9 | this.jsonrpc = "2.0"; 10 | } 11 | serialize() { 12 | return JSON.stringify(this); 13 | } 14 | }; 15 | JsonRpc.VERSION = "2.0"; 16 | var RequestObject = class extends JsonRpc { 17 | constructor(id, method, params) { 18 | super(); 19 | this.id = id; 20 | this.method = method; 21 | if (params !== void 0) { 22 | this.params = params; 23 | } 24 | } 25 | }; 26 | var NotificationObject = class extends JsonRpc { 27 | constructor(method, params) { 28 | super(); 29 | this.method = method; 30 | if (params !== void 0) { 31 | this.params = params; 32 | } 33 | } 34 | }; 35 | var SuccessObject = class extends JsonRpc { 36 | constructor(id, result) { 37 | super(); 38 | this.id = id; 39 | this.result = result; 40 | } 41 | }; 42 | var ErrorObject = class extends JsonRpc { 43 | constructor(id, error) { 44 | super(); 45 | this.id = id; 46 | this.error = error; 47 | this.id = id; 48 | this.error = error; 49 | } 50 | }; 51 | var JsonRpcParsed = class { 52 | constructor(payload, type) { 53 | this.payload = payload; 54 | this.type = type; 55 | this.payload = payload; 56 | this.type = type; 57 | } 58 | }; 59 | var JsonRpcError = class { 60 | constructor(message, code, data) { 61 | this.message = message; 62 | this.code = isInteger(code) ? code : 0; 63 | if (data != null) { 64 | this.data = data; 65 | } 66 | } 67 | }; 68 | JsonRpcError.invalidRequest = function(data) { 69 | return new JsonRpcError("Invalid request", -32600, data); 70 | }; 71 | JsonRpcError.methodNotFound = function(data) { 72 | return new JsonRpcError("Method not found", -32601, data); 73 | }; 74 | JsonRpcError.invalidParams = function(data) { 75 | return new JsonRpcError("Invalid params", -32602, data); 76 | }; 77 | JsonRpcError.internalError = function(data) { 78 | return new JsonRpcError("Internal error", -32603, data); 79 | }; 80 | JsonRpcError.parseError = function(data) { 81 | return new JsonRpcError("Parse error", -32700, data); 82 | }; 83 | function request(id, method, params) { 84 | const object = new RequestObject(id, method, params); 85 | validateMessage(object, true); 86 | return object; 87 | } 88 | function parseObject(obj) { 89 | let err = null; 90 | let payload = null; 91 | let payloadType = "invalid"; 92 | if (obj == null || obj.jsonrpc !== JsonRpc.VERSION) { 93 | err = JsonRpcError.invalidRequest(obj); 94 | payloadType = "invalid"; 95 | } else if (!hasOwnProperty.call(obj, "id")) { 96 | const tmp = obj; 97 | payload = new NotificationObject(tmp.method, tmp.params); 98 | err = validateMessage(payload); 99 | payloadType = "notification"; 100 | } else if (hasOwnProperty.call(obj, "method")) { 101 | const tmp = obj; 102 | payload = new RequestObject(tmp.id, tmp.method, tmp.params); 103 | err = validateMessage(payload); 104 | payloadType = "request"; 105 | } else if (hasOwnProperty.call(obj, "result")) { 106 | const tmp = obj; 107 | payload = new SuccessObject(tmp.id, tmp.result); 108 | err = validateMessage(payload); 109 | payloadType = "success"; 110 | } else if (hasOwnProperty.call(obj, "error")) { 111 | const tmp = obj; 112 | payloadType = "error"; 113 | if (tmp.error == null) { 114 | err = JsonRpcError.internalError(tmp); 115 | } else { 116 | const errorObj = new JsonRpcError(tmp.error.message, tmp.error.code, tmp.error.data); 117 | if (errorObj.message !== tmp.error.message || errorObj.code !== tmp.error.code) { 118 | err = JsonRpcError.internalError(tmp); 119 | } else { 120 | payload = new ErrorObject(tmp.id, errorObj); 121 | err = validateMessage(payload); 122 | } 123 | } 124 | } 125 | if (err == null && payload != null) { 126 | return new JsonRpcParsed(payload, payloadType); 127 | } 128 | return new JsonRpcParsed(err != null ? err : JsonRpcError.invalidRequest(obj), "invalid"); 129 | } 130 | function validateMessage(obj, throwIt) { 131 | let err = null; 132 | if (obj instanceof RequestObject) { 133 | err = checkId(obj.id); 134 | if (err == null) { 135 | err = checkMethod(obj.method); 136 | } 137 | if (err == null) { 138 | err = checkParams(obj.params); 139 | } 140 | } else if (obj instanceof NotificationObject) { 141 | err = checkMethod(obj.method); 142 | if (err == null) { 143 | err = checkParams(obj.params); 144 | } 145 | } else if (obj instanceof SuccessObject) { 146 | err = checkId(obj.id); 147 | if (err == null) { 148 | err = checkResult(obj.result); 149 | } 150 | } else if (obj instanceof ErrorObject) { 151 | err = checkId(obj.id, true); 152 | if (err == null) { 153 | err = checkError(obj.error); 154 | } 155 | } 156 | if (throwIt && err != null) { 157 | throw err; 158 | } 159 | return err; 160 | } 161 | function checkId(id, maybeNull) { 162 | if (maybeNull && id === null) { 163 | return null; 164 | } 165 | return isString(id) || isInteger(id) ? null : JsonRpcError.internalError('"id" must be provided, a string or an integer.'); 166 | } 167 | function checkMethod(method) { 168 | return isString(method) ? null : JsonRpcError.invalidRequest(method); 169 | } 170 | function checkResult(result) { 171 | return result === void 0 ? JsonRpcError.internalError("Result must exist for success Response objects") : null; 172 | } 173 | function checkParams(params) { 174 | if (params === void 0) { 175 | return null; 176 | } 177 | if (Array.isArray(params) || isObject(params)) { 178 | try { 179 | JSON.stringify(params); 180 | return null; 181 | } catch (err) { 182 | return JsonRpcError.parseError(params); 183 | } 184 | } 185 | return JsonRpcError.invalidParams(params); 186 | } 187 | function checkError(err) { 188 | if (!(err instanceof JsonRpcError)) { 189 | return JsonRpcError.internalError("Error must be an instance of JsonRpcError"); 190 | } 191 | if (!isInteger(err.code)) { 192 | return JsonRpcError.internalError("Invalid error code. It must be an integer."); 193 | } 194 | if (!isString(err.message)) { 195 | return JsonRpcError.internalError("Message must exist or must be a string."); 196 | } 197 | return null; 198 | } 199 | function isString(obj) { 200 | return obj !== "" && typeof obj === "string"; 201 | } 202 | function isObject(obj) { 203 | return obj != null && typeof obj === "object" && !Array.isArray(obj); 204 | } 205 | 206 | // index.js 207 | var _id = 1; 208 | var RpcClient = class { 209 | constructor(url) { 210 | this.$url = url; 211 | } 212 | $setEndpoint(url) { 213 | this.$url = url; 214 | } 215 | async $rpc(methodName, params) { 216 | const responseBody = await (await fetch(this.$url, { 217 | method: "POST", 218 | headers: { "Content-Type": "application/json" }, 219 | body: JSON.stringify(request(_id++, methodName, removeUndefinedsAtEndOfArray(params))) 220 | })).json(); 221 | const parsed = parseObject(responseBody); 222 | if (parsed.type === "error") { 223 | const err = new Error(parsed.payload.error.message); 224 | err.code = parsed.payload.error.code; 225 | throw err; 226 | } else if (parsed.type === "success") { 227 | return parsed.payload.result; 228 | } 229 | } 230 | }; 231 | var rpcProxyHandler = { 232 | get(client, name) { 233 | if (name in client) { 234 | return client[name]; 235 | } else { 236 | return (...params) => client.$rpc(name, params); 237 | } 238 | } 239 | }; 240 | function create(url) { 241 | const client = url instanceof RpcClient ? url : new RpcClient(url); 242 | return new Proxy(client, rpcProxyHandler); 243 | } 244 | function removeUndefinedsAtEndOfArray(arr) { 245 | let len = arr.length; 246 | for (let i = len - 1; i >= 0; i--) { 247 | if (typeof arr[i] === "undefined") 248 | len--; 249 | else 250 | break; 251 | } 252 | return arr.slice(0, len); 253 | } 254 | export { 255 | RpcClient, 256 | create 257 | }; 258 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import subcommand from 'subcommand' 4 | import * as server from './index.js' 5 | import * as path from 'path' 6 | import { fileURLToPath } from 'url' 7 | import * as fs from 'fs' 8 | import * as os from 'os' 9 | import { Config } from './config.js' 10 | import { createApi } from './lib/rpc.js' 11 | import { createUserPrompt, createPasswordPrompt, createModUserPrompt, confirm } from './setup-flow.js' 12 | 13 | const PACKAGE_JSON_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json') 14 | 15 | const RUN_OPTS = [ 16 | {name: 'port', help: 'Set the port to run the server on (defaults to 80)'}, 17 | {name: 'domain', help: 'Set the domain the server will be accessed by (defaults to "localhost")'}, 18 | {name: 'configDir', help: 'Set the directory to read configuration from (defaults to "~/.atek")'} 19 | ] 20 | 21 | async function runCommand (args: any): Promise { 22 | await server.checkForUpdates(PACKAGE_JSON_PATH) 23 | server.start({ 24 | port: args.port, 25 | domain: args.domain, 26 | configDir: args.configDir 27 | }) 28 | } 29 | 30 | async function apiCall (args: any, apiId: string, method: string, params: any[]): Promise { 31 | const config = new Config(args.configDir || path.join(os.homedir(), '.atek'), {}) 32 | const api = createApi(`http://localhost:${config.port}`, {api: apiId}, config.systemAuthTokens[0]) 33 | try { 34 | return await api.call(method, params) 35 | } catch (e: any) { 36 | if (e.code === 'ECONNREFUSED') { 37 | console.error('Failed to connect to Atek.') 38 | console.error('Make sure Atek is running before calling this command:') 39 | console.error('') 40 | console.error(' atek run') 41 | console.error('') 42 | } else { 43 | console.error(e) 44 | } 45 | process.exit(1) 46 | } 47 | } 48 | 49 | async function lookupUserKey (args: any, username: string): Promise { 50 | const {users} = await apiCall(args, 'atek.cloud/users-api', 'list', []) 51 | const user = users.find((user: any) => user.username === username) 52 | if (!user) throw new Error(`User ${username} not found`) 53 | return user.key 54 | } 55 | 56 | function usage (args: any, help: string, usage: string) { 57 | console.log(help) 58 | if (usage) { 59 | console.log('') 60 | console.log(usage) 61 | } 62 | } 63 | 64 | const cmdOpts = { 65 | usage: { 66 | help: 'atek - Run the atek server', 67 | option: { 68 | name: 'help', 69 | abbr: 'h' 70 | }, 71 | command: function (args: any, help: string, usage: string) { 72 | console.log(help) 73 | console.log('') 74 | console.log(usage) 75 | console.log('Commands:') 76 | for (const cmd of cmdOpts.commands) { 77 | console.log(' ', cmd.help || `atek ${cmd.name}`) 78 | } 79 | } 80 | }, 81 | commands: [ 82 | { 83 | name: 'run', 84 | help: 'atek run - Run the atek server', 85 | options: RUN_OPTS, 86 | usage, 87 | command: runCommand 88 | }, 89 | { 90 | name: 'install', 91 | help: 'atek install {url_or_path} --user {owning_user} - Install a new service', 92 | usage, 93 | command: async (args: any) => { 94 | if (args._[0]) args.sourceUrl = args._[0] 95 | if (!args.sourceUrl) { 96 | console.error('URL or path is required:') 97 | console.error('atek install {url_or_path}') 98 | process.exit(1) 99 | } 100 | if (args.sourceUrl.startsWith('/')) { 101 | args.sourceUrl = `file://${args.sourceUrl}` 102 | } 103 | if (!args.user) { 104 | console.error('--user is required') 105 | console.error('(Specify "system" if installing for all users)') 106 | process.exit(1) 107 | } 108 | console.log(await apiCall(args, 'atek.cloud/services-api', 'install', [args])) 109 | } 110 | }, 111 | { 112 | name: 'uninstall', 113 | help: 'atek uninstall {id} - Uninstall a service', 114 | usage, 115 | command: async (args: any) => { 116 | await apiCall(args, 'atek.cloud/services-api', 'uninstall', [args.id || args._[0]]) 117 | } 118 | }, 119 | { 120 | name: 'update', 121 | help: 'atek update {id} - Update a service installation from its source', 122 | usage, 123 | command: async (args: any) => { 124 | console.log(await apiCall(args, 'atek.cloud/services-api', 'updatePackage', [args.id || args._[0]])) 125 | } 126 | }, 127 | { 128 | name: 'ls', 129 | help: 'atek ls - List active services', 130 | usage, 131 | command: async (args: any) => { 132 | const {services} = await apiCall(args, 'atek.cloud/services-api', 'list', []) 133 | const out: NodeJS.Dict = {} 134 | for (const srv of services) { 135 | out[srv.settings.id] = { 136 | Status: srv.status, 137 | Port: srv.settings.port, 138 | Source: srv.settings.sourceUrl, 139 | 'Installed by': srv.settings.installedBy 140 | } 141 | } 142 | console.table(out) 143 | } 144 | }, 145 | { 146 | name: 'get', 147 | help: 'atek get {id} - Get info about a service', 148 | usage, 149 | command: async (args: any) => { 150 | const srv = await apiCall(args, 'atek.cloud/services-api', 'get', [args.id || args._[0]]) 151 | console.log(srv.settings.id, `(${srv.status})`) 152 | console.log(srv.settings) 153 | } 154 | }, 155 | { 156 | name: 'cfg', 157 | help: 'atek cfg {id} ... - Configure a service or get its current configuration', 158 | usage, 159 | command: async (args: any) => { 160 | await apiCall(args, 'atek.cloud/services-api', 'configure', [args.id || args._[0], args]) 161 | } 162 | }, 163 | { 164 | name: 'start', 165 | help: 'atek start {id} - Start a service', 166 | usage, 167 | command: async (args: any) => { 168 | await apiCall(args, 'atek.cloud/services-api', 'start', [args.id || args._[0]]) 169 | } 170 | }, 171 | { 172 | name: 'stop', 173 | help: 'atek stop {id} - Stop a service', 174 | usage, 175 | command: async (args: any) => { 176 | await apiCall(args, 'atek.cloud/services-api', 'stop', [args.id || args._[0]]) 177 | } 178 | }, 179 | { 180 | name: 'restart', 181 | help: 'atek restart {id} - Restart a service', 182 | usage, 183 | command: async (args: any) => { 184 | await apiCall(args, 'atek.cloud/services-api', 'restart', [args.id || args._[0]]) 185 | } 186 | }, 187 | { 188 | name: 'mkuser', 189 | help: 'atek mkuser - Create a user', 190 | usage, 191 | command: async (args: any) => { 192 | const {username, password} = await createUserPrompt() 193 | await apiCall(args, 'atek.cloud/users-api', 'create', [{username, password}]) 194 | }, 195 | }, 196 | { 197 | name: 'moduser', 198 | help: 'atek moduser {id} - Modify a user', 199 | usage, 200 | command: async (args: any) => { 201 | const username = args.id || args._[0] 202 | const userKey = await lookupUserKey(args, username) 203 | const {what} = await createModUserPrompt() 204 | if (what === 'Password') { 205 | const {password} = await createPasswordPrompt() 206 | await apiCall(args, 'atek.cloud/users-api', 'update', [userKey, {password}]) 207 | } 208 | }, 209 | }, 210 | { 211 | name: 'deluser', 212 | help: 'atek deluser {id} - Delete a user', 213 | usage, 214 | command: async (args: any) => { 215 | const username = args.id || args._[0] 216 | const userKey = await lookupUserKey(args, username) 217 | if (await confirm(`Are you sure you want to delete ${username}?`, false)) { 218 | await apiCall(args, 'atek.cloud/users-api', 'delete', [userKey]) 219 | console.log(username, `(key=${userKey}) deleted`) 220 | } 221 | } 222 | } 223 | ], 224 | root: { 225 | name: 'run', 226 | help: 'Run the atek server', 227 | options: [ 228 | {name: 'version', abbr: 'v', help: 'Print the current version'}, 229 | ...RUN_OPTS 230 | ], 231 | command: (args: any) => { 232 | if (args.v || args.version) { 233 | const packageJson = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8') 234 | const pkg = JSON.parse(packageJson) 235 | console.log(pkg.version) 236 | } else { 237 | runCommand(args) 238 | } 239 | } 240 | } 241 | } 242 | const match = subcommand(cmdOpts) 243 | const matchedCmd = match(process.argv.slice(2)) -------------------------------------------------------------------------------- /src/services/instance.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import * as childProcess from 'child_process' 3 | import * as path from 'path' 4 | import { promises as fsp, createWriteStream, WriteStream } from 'fs' 5 | import { fileURLToPath } from 'url' 6 | import http from 'http' 7 | import { Config } from '../config.js' 8 | import { generateBearerToken } from '../lib/crypto.js' 9 | import { removeUndefinedsAtEndOfArray } from '../lib/functions.js' 10 | import lock from '../lib/lock.js' 11 | import WebSocket, { createWebSocketStream } from 'ws' 12 | import jsonrpc from 'jsonrpc-lite' 13 | import { Service as AtekService, ServiceManifest, ApiExportDesc, ServiceConfig } from '@atek-cloud/adb-tables' 14 | import { ServiceInfo, StatusEnum } from '@atek-cloud/services-api' 15 | import * as apiBroker from '../broker/index.js' 16 | import { getAuthHeaders } from './util.js' 17 | 18 | const NODE_PATH = process.execPath 19 | 20 | let _id = 1 // json-rpc ID incrementer 21 | 22 | export class ServiceInstance extends EventEmitter { 23 | serviceKey: string 24 | settings: AtekService 25 | socketPath: string 26 | protected config: ServiceConfig 27 | protected process: childProcess.ChildProcess | undefined 28 | protected logFileStream: WriteStream | undefined 29 | bearerToken: string 30 | 31 | constructor (serviceKey: string, settings: AtekService, config?: ServiceConfig) { 32 | super() 33 | this.serviceKey = serviceKey 34 | this.settings = settings 35 | this.socketPath = Config.getActiveConfig().serviceSocketFilePath(settings.id) 36 | this.config = {} 37 | this.process = undefined 38 | this.logFileStream = undefined 39 | this.bearerToken = generateBearerToken() 40 | if (config) this.setConfig(config) 41 | } 42 | 43 | get isActive (): boolean { 44 | return !!this.process 45 | } 46 | 47 | get id (): string { 48 | return this.settings.id 49 | } 50 | 51 | get owningUserKey () { 52 | return this.settings.owningUserKey 53 | } 54 | 55 | get manifest (): ServiceManifest { 56 | return this.settings.manifest || {} 57 | } 58 | 59 | get exportedApis (): ApiExportDesc[] { 60 | return Array.isArray(this.manifest?.exports) ? this.manifest?.exports : [] 61 | } 62 | 63 | setConfig (config: ServiceConfig) { 64 | config = Object.assign({}, config) 65 | for (const k in config) { 66 | if (k.toUpperCase() !== k) { 67 | const v = config[k] 68 | config[k.toUpperCase()] = v 69 | delete config[k] 70 | } 71 | } 72 | this.config = config 73 | } 74 | 75 | getConfig (): ServiceConfig { 76 | return this.config 77 | } 78 | 79 | getPackagePath (str: string): string { 80 | if (this.settings.package.sourceType === 'file') { 81 | const folderPath = fileURLToPath(this.settings.sourceUrl) 82 | return path.join(folderPath, str) 83 | } 84 | if (this.settings.package.sourceType === 'git') { 85 | const folderPath = Config.getActiveConfig().packageInstallPath(this.id) 86 | return path.join(folderPath, str) 87 | } 88 | throw new Error('Unknown package source type: ' + this.settings.package.sourceType) 89 | } 90 | 91 | lockServiceCtrl () { 92 | return lock(`service:${this.id}:process-ctrl`) 93 | } 94 | 95 | toJSON (): ServiceInfo { 96 | return { 97 | status: this.isActive ? StatusEnum.active : StatusEnum.inactive, 98 | key: this.serviceKey, 99 | settings: this.settings 100 | } 101 | } 102 | 103 | log (str: string, tag = 'SYS') { 104 | if (!this.logFileStream) { 105 | const cfg = Config.getActiveConfig() 106 | if (cfg) { 107 | this.logFileStream = createWriteStream(cfg.serviceLogPath(this.id), {flags: 'a', encoding: 'utf8'}) 108 | } 109 | } 110 | console.log(`[${tag} ${this.id}] ${str.replace(/(\n)+$/g, '')}`) 111 | this.logFileStream?.write(`[${tag} ${(new Date()).toLocaleString()}] ${str}\n`) 112 | } 113 | 114 | async setup (): Promise { 115 | // TODO needed? 116 | } 117 | 118 | async start (): Promise { 119 | this.log('Start() triggered') 120 | const release = await this.lockServiceCtrl() 121 | try { 122 | if (this.process) return 123 | 124 | // delete any socket file leftover from before 125 | await fsp.unlink(this.socketPath).catch(e => undefined) 126 | 127 | let thisProcess = this.process = await this.startNodeProcess() 128 | if (!this.process) throw new Error('Failed to start process') 129 | process.on('exit', () => thisProcess?.kill()) // make SURE this happens 130 | 131 | this.process 132 | .on('error', (...args) => this.emit('error', ...args)) 133 | .on('close', () => { 134 | this.log('Service process closed') 135 | this.process = undefined 136 | this.emit('stop') 137 | }) 138 | if (this.process.stdout) { 139 | this.process.stdout.on('data', data => this.log(stripANSICodes(data.toString('utf8')), 'LOG')) 140 | } 141 | if (this.process.stderr) { 142 | this.process.stderr.on('data', data => this.log(stripANSICodes(data.toString('utf8')), 'ERR')) 143 | } 144 | await this.awaitServerActive() 145 | 146 | for (const apiDesc of this.exportedApis) { 147 | apiBroker.registerProvider(this, (apiDesc.transport || 'rpc') as apiBroker.TransportEnum, apiDesc.api) 148 | } 149 | 150 | this.emit('start') 151 | } finally { 152 | release() 153 | } 154 | } 155 | 156 | async stop (): Promise { 157 | this.log('Stop() triggered') 158 | const release = await this.lockServiceCtrl() 159 | try { 160 | apiBroker.unregisterProviderAll(this) 161 | if (this.process) { 162 | let p = new Promise(r => { 163 | this.once('stop', r) 164 | }) 165 | this.process.kill() 166 | await p 167 | } 168 | } finally { 169 | release() 170 | } 171 | } 172 | 173 | async restart (): Promise { 174 | this.log('Restart() triggered') 175 | await this.stop() 176 | await this.start() 177 | } 178 | 179 | async awaitServerActive (): Promise { 180 | for (let i = 0; i < 100; i++) { 181 | try { 182 | let {res} = await this.sendHttpRequest({ 183 | method: 'HEAD', 184 | path: '/' 185 | }) 186 | if (res.statusCode && (res.statusCode >= 200 && res.statusCode <= 400)) { 187 | return true 188 | } 189 | } catch (e) {} 190 | await new Promise(r => setTimeout(r, 100)) 191 | } 192 | return false 193 | } 194 | 195 | async handleRpc (callDesc: apiBroker.CallDescription, methodName: string, params: unknown[], ctx: apiBroker.CallContext): Promise { 196 | const apiDesc = this.exportedApis.find(apiDesc => apiDesc.api === callDesc.api && (!apiDesc.transport || apiDesc.transport === 'rpc')) 197 | if (apiDesc) { 198 | const path = apiDesc.path || '/' 199 | const {res, body} = await this.sendHttpRequest({ 200 | method: 'POST', 201 | path, 202 | headers: { 203 | 'Content-Type': 'application/json', 204 | ...getAuthHeaders(ctx.session, this.serviceKey) 205 | } 206 | }, JSON.stringify(jsonrpc.request(_id++, methodName, removeUndefinedsAtEndOfArray(params)))) 207 | let parsedBody 208 | try { parsedBody = JSON.parse(body || '') } 209 | catch (e: any) { 210 | throw jsonrpc.JsonRpcError.parseError(e.toString()) 211 | } 212 | const parsed = jsonrpc.parseObject(parsedBody) 213 | if (parsed.type === 'error') { 214 | throw parsed.payload.error 215 | } else if (parsed.type === 'success') { 216 | return parsed.payload.result 217 | } else { 218 | return undefined 219 | } 220 | } 221 | throw new apiBroker.ServiceNotFound('API not found') 222 | } 223 | 224 | handleProxy (callDesc: apiBroker.CallDescription, socket: WebSocket, ctx: apiBroker.CallContext) { 225 | const apiDesc = this.exportedApis.find(apiDesc => apiDesc.api === callDesc.api && apiDesc.transport === 'proxy') 226 | if (apiDesc) { 227 | const remoteSocket = new WebSocket(`ws+unix://${this.socketPath}:${apiDesc.path || '/'}`, {headers: getAuthHeaders(ctx.session, this.serviceKey)}) 228 | const s1 = createWebSocketStream(socket) 229 | const s2 = createWebSocketStream(remoteSocket) 230 | s1.pipe(s2).pipe(s1) 231 | } else { 232 | throw new apiBroker.ServiceNotFound('API not found') 233 | } 234 | } 235 | 236 | async startNodeProcess (): Promise { 237 | const hostcfg = Config.getActiveConfig() 238 | 239 | let packageJson 240 | try { 241 | packageJson = JSON.parse(await fsp.readFile(this.getPackagePath('package.json'), 'utf8')) 242 | } catch (e) {} 243 | 244 | let scriptPath = '' 245 | if (packageJson?.main && await fsp.stat(this.getPackagePath(packageJson?.main)).catch(e => undefined)) { 246 | scriptPath = this.getPackagePath(packageJson?.main) 247 | } else if (await fsp.stat(this.getPackagePath('index.js')).catch(e => undefined)) { 248 | scriptPath = this.getPackagePath('index.js') 249 | } else { 250 | throw new Error('Package issue: neither package.json "main" nor /index.js could be found.') 251 | } 252 | const args = [ 253 | scriptPath 254 | ] 255 | const opts = { 256 | env: Object.assign({}, this.config, { 257 | ATEK_ASSIGNED_SOCKET_FILE: this.socketPath, 258 | ATEK_ASSIGNED_SERVICE_KEY: this.serviceKey, 259 | ATEK_HOST_PORT: String(hostcfg.port), 260 | ATEK_HOST_BEARER_TOKEN: this.bearerToken 261 | }) as NodeJS.ProcessEnv 262 | } 263 | this.log('----------------------') 264 | this.log(`Starting service process ${this.id}`) 265 | this.log(` WARNING: No sandbox is present. This application has full access to the host system.`) 266 | this.log(` Path: ${scriptPath}`) 267 | this.log(` Socket: ${this.socketPath}`) 268 | this.log(` Call: ${NODE_PATH} ${args.join(' ')}`) 269 | this.log(` Env: ${JSON.stringify(opts.env)}`) 270 | this.log('----------------------') 271 | return childProcess.spawn(NODE_PATH, args, opts) 272 | } 273 | 274 | sendHttpRequest (options: http.RequestOptions, body?: string|object): Promise<{res: http.IncomingMessage, body: string|undefined}> { 275 | if (typeof body !== 'undefined' && typeof body !== 'string') { 276 | body = JSON.stringify(body) 277 | } 278 | return new Promise((resolve, reject) => { 279 | const req = http.request({ 280 | socketPath: this.socketPath, 281 | ...options 282 | }, (res: http.IncomingMessage) => { 283 | let resBody = '' 284 | res.setEncoding('utf8') 285 | res.on('data', (chunk: string) => { resBody += chunk }) 286 | res.on('end', () => { resolve({res, body: resBody}) }) 287 | }) 288 | req.on('error', reject) 289 | if (typeof body !== 'undefined') req.write(body) 290 | req.end() 291 | }) 292 | } 293 | } 294 | 295 | const ANSI_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g 296 | function stripANSICodes (str: string): string { 297 | return str.replace(ANSI_REGEX, '') 298 | } 299 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { RateLimiter } from 'pauls-sliding-window-rate-limiter' 2 | import * as http from 'http' 3 | import { Socket } from 'net' 4 | import createExpressApp, * as express from 'express' 5 | import WebSocket, * as ws from 'ws' 6 | import httpProxy from 'http-proxy' 7 | import cors from 'cors' 8 | import cookieParser from 'cookie-parser' 9 | import isInstalledGlobally from 'is-installed-globally' 10 | import { selfupdate } from '@mishguru/selfupdate' 11 | import { parse as parseCookie } from '@tinyhttp/cookie' 12 | import adb from '@atek-cloud/adb-api' 13 | import * as cli from './lib/cli.js' 14 | import { Config, ConfigValues } from './config.js' 15 | import { generateBearerToken } from './lib/crypto.js' 16 | import * as services from './services/index.js' 17 | import { ServiceInstance } from './services/instance.js' 18 | import { getAuthHeaders } from './services/util.js' 19 | import { setup as setupServerDb } from './serverdb/index.js' 20 | import * as sessionMiddleware from './httpapi/session-middleware.js' 21 | import * as apiGatewayHttpApi from './httpapi/gateway.js' 22 | import * as rpcapi from './rpcapi/index.js' 23 | import * as setupFlow from './setup-flow.js' 24 | import { getUserSettings } from './users/index.js' 25 | // import * as perf from './lib/perf.js' TODO 26 | // import * as metrics from './lib/metrics.js' TODO 27 | import fs from 'fs' 28 | import * as path from 'path' 29 | import * as os from 'os' 30 | import { URL, fileURLToPath } from 'url' 31 | 32 | const HERE_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') 33 | 34 | let app 35 | 36 | interface StartOpts extends ConfigValues { 37 | configDir: string 38 | } 39 | 40 | declare module 'ws' { 41 | // HACK temporary workaround for the 'ws' module having bad types, should be fixed soon -prf 42 | class WebSocketServer extends ws.Server {} 43 | } 44 | 45 | export * as test from './test.js' 46 | 47 | export async function start (opts: StartOpts) { 48 | cli.status('Initializing Atek') 49 | const configDir = opts.configDir || path.join(os.homedir(), '.atek') 50 | const config = new Config(configDir, opts) 51 | if (!config.systemAuthTokens?.length) { 52 | config.update({systemAuthTokens: [generateBearerToken()]}) 53 | } 54 | Config.setActiveConfig(config) 55 | await fs.promises.mkdir(path.join(configDir, 'logs'), {recursive: true}) 56 | await fs.promises.mkdir(path.join(configDir, 'packages'), {recursive: true}) 57 | await fs.promises.mkdir(path.join(configDir, 'sockets'), {recursive: true}) 58 | // if (config.benchmarkMode) { 59 | // perf.enable() TODO 60 | // } 61 | // metrics.setup({configDir: opts.configDir}) TODO 62 | 63 | // configure any rpc apis atek is using 64 | adb.api.$setEndpoint({port: config.port}) 65 | adb.api.$setAuthHeader(`Bearer ${config.systemAuthTokens[0]}`) 66 | cli.endStatus() 67 | 68 | const server = createServer(config) 69 | 70 | // initiate the services layer 71 | await services.setup() 72 | await services.loadCoreServices() 73 | await setupServerDb() 74 | rpcapi.setup() 75 | services.loadUserServices().catch(err => { 76 | console.log('Error while loading user services:') 77 | console.log(err) 78 | }).then(() => { 79 | // run setup flow if needed 80 | setupFlow.run() 81 | }) 82 | 83 | process.on('SIGINT', close) 84 | process.on('SIGTERM', close) 85 | function close () { 86 | console.log('Shutting down, this may take a moment...') 87 | services.stopAll() 88 | server.close() 89 | process.exit(0) 90 | } 91 | 92 | return { 93 | server, 94 | close: () => { 95 | console.log('Shutting down, this may take a moment...') 96 | services.stopAll() 97 | server.close() 98 | } 99 | } 100 | } 101 | 102 | function createServer (config: Config) { 103 | app = createExpressApp() 104 | app.set('trust proxy', 'loopback') 105 | app.use(cors()) 106 | app.use(cookieParser()) 107 | app.use(sessionMiddleware.setup()) 108 | app.set('view engine', 'ejs') 109 | app.set('views', path.join(HERE_PATH, 'static', 'views')) 110 | 111 | // rate limiter 112 | const rl = new RateLimiter({ 113 | limit: 10000, 114 | window: 60e3 115 | }) 116 | app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { 117 | res.header('Cross-Origin-Opener-Policy', 'cross-origin') 118 | res.header('Cross-Origin-Resource-Policy', 'cross-origin') 119 | res.header('Cross-Origin-Embedder-Policy', 'require-corp') 120 | 121 | // metrics.httpRequest({path: req.url}) 122 | if (!rl.hit(req.ip)) { 123 | return res.status(429).json({ 124 | error: 'RateLimitError', 125 | message: 'Rate limit exceeded' 126 | }) 127 | } 128 | next() 129 | }) 130 | 131 | // grab the subdomain (it comes up a lot) 132 | app.use((req: sessionMiddleware.RequestWithSession, res: express.Response, next: express.NextFunction) => { 133 | const host = (req.headers.host || '').split(':')[0] 134 | res.locals.hostParts = host.split('.') 135 | res.locals.subdomain = res.locals.hostParts.length > 1 ? res.locals.hostParts.slice(0, -1).join('.') : '' 136 | next() 137 | }) 138 | 139 | // auth 140 | app.use(async (req: sessionMiddleware.RequestWithSession, res: express.Response, next: express.NextFunction) => { 141 | if (!isRequestSafe(config, req) && !req.session?.isAuthed()) { 142 | if (res.locals.subdomain) { 143 | if (await sessionMiddleware.attemptBindSession(req, res)) { 144 | return next() 145 | } 146 | return res.redirect(`http://${config.domain}/_atek/bind-session?service=${encodeURIComponent(res.locals.subdomain)}&path=${req.originalUrl}`) 147 | } else { 148 | return res.redirect(`http://${config.domain}/_atek/login`) 149 | } 150 | } else { 151 | next() 152 | } 153 | }) 154 | 155 | // subdomain proxies 156 | app.use((req: sessionMiddleware.RequestWithSession, res: express.Response, next: express.NextFunction) => { 157 | if (res.locals.subdomain) { 158 | const service = services.get(res.locals.subdomain) 159 | if (service) { 160 | Object.assign(req.headers, getAuthHeaders(req.session, service.serviceKey)) 161 | try { 162 | return getProxy(service).web(req, res, undefined, (e: Error) => { 163 | if (e) proxyError(e, res, res.locals.subdomain) 164 | }) 165 | } catch (e: any) { 166 | return proxyError(e, res, res.locals.subdomain) 167 | } 168 | } else { 169 | res.status(404).end(`Not found: no service is currently hosted at ${req.headers.host}`) 170 | } 171 | } else { 172 | next() 173 | } 174 | }) 175 | 176 | // API servers 177 | app.use('/_atek', express.json()) 178 | apiGatewayHttpApi.setup(app) 179 | 180 | // login and session acquisition 181 | app.use('/_atek/js/', express.static(path.join(HERE_PATH, 'static', 'js'))) 182 | app.use('/_atek/login/', express.static(path.join(HERE_PATH, 'static', 'login'))) 183 | app.get('/_atek/bind-session', (req: sessionMiddleware.RequestWithSession, res: express.Response) => { 184 | const serviceId = req.query.service 185 | const redirectPath = typeof req.query.path === 'string' ? req.query.path : '/' 186 | if (typeof serviceId !== 'string' || !services.get(serviceId)) { 187 | // invalid service 188 | return res.status(400).json({error: 'Not authorized'}) 189 | } 190 | 191 | const redirectTo = (new URL(redirectPath, `http://${serviceId}.localhost/`)) 192 | redirectTo.host = `${serviceId}.localhost` 193 | 194 | if (!req.session?.auth?.sessionId) { 195 | // no active session, try to login 196 | return res.redirect(`/_atek/login?redirect=${encodeURIComponent(redirectTo.toString())}`) 197 | } 198 | 199 | const bindSessionToken = sessionMiddleware.genBindSessionToken(req.session?.auth?.sessionId) 200 | redirectTo.searchParams.set('bst', bindSessionToken) 201 | res.redirect(redirectTo.toString()) 202 | }) 203 | app.use('/_atek', (req: express.Request, res: express.Response) => json404(res, 'Not found')) 204 | 205 | // "main service" 206 | app.use(async (req: sessionMiddleware.RequestWithSession, res: express.Response) => { 207 | if (!req.session?.isUserAuthed({notApp: true})) { 208 | return res.status(200).json({message: 'Atek online'}) 209 | } 210 | const userKey = req.session.auth?.userKey || '' 211 | let mainServiceId: string|undefined 212 | try { 213 | const userSettings = await getUserSettings(userKey) 214 | mainServiceId = userSettings?.mainServiceId 215 | } catch (e) { 216 | console.error('Failed to lookup user settings for user key=', userKey) 217 | console.error(e) 218 | return res.status(500).json({error: 'Internal server error. Consult your server logs for more information.'}) 219 | } 220 | const service = services.get(mainServiceId) 221 | if (!service) { 222 | return res.render('no-main-service', {userKey, error: '', defaultSourceUrl: config.defaultMainService}) 223 | } else if (service.owningUserKey !== userKey) { 224 | return res.render('no-main-service', { 225 | userKey, 226 | error: 'not-owning-user', 227 | defaultSourceUrl: config.defaultMainService 228 | }) 229 | } else { 230 | try { 231 | Object.assign(req.headers, getAuthHeaders(req.session, service.serviceKey)) 232 | return getProxy(service).web(req, res, undefined, (e: Error) => { 233 | if (e) proxyError(e, res, mainServiceId || '') 234 | }) 235 | } catch (e: any) { 236 | return proxyError(e, res, mainServiceId) 237 | } 238 | } 239 | }) 240 | 241 | const wsServer = new ws.WebSocketServer({ noServer: true }) 242 | wsServer.on('connection', async (socket: WebSocket, req: http.IncomingMessage) => { 243 | if (/\/_atek\/gateway(\?|\/$|$)/.test(req.url || '/')) { 244 | const cookie: any = req.headers.cookie ? parseCookie(req.headers.cookie) : undefined 245 | const session = new sessionMiddleware.Session(undefined, undefined, await sessionMiddleware.getSessionAuth(req.headers.authorization, cookie?.session)) 246 | apiGatewayHttpApi.handleWebSocket(socket, req, session) 247 | } else { 248 | socket.close() 249 | } 250 | }) 251 | 252 | const server = new http.Server(app) 253 | server.listen(config.port, () => { 254 | console.log(`Application server listening at http://localhost:${config.port}`) 255 | }) 256 | server.on('upgrade', (request: http.IncomingMessage, socket, head) => { 257 | wsServer.handleUpgrade(request, (socket as Socket), head, socket => { 258 | wsServer.emit('connection', socket, request) 259 | }) 260 | }) 261 | 262 | return server 263 | } 264 | 265 | export async function checkForUpdates (packageJsonPath: string) { 266 | let packageJson 267 | try { 268 | packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) 269 | } catch (e) { 270 | console.log('Failed to read package.json, unable to run auto-updater') 271 | console.log(' Attempted to read:', packageJsonPath) 272 | console.log(' Error:', e) 273 | } 274 | if (!packageJson || !isInstalledGlobally) { 275 | console.log('Skipping auto-update as this app is not running as a global NPM module.') 276 | } else { 277 | await selfupdate(packageJson) 278 | } 279 | 280 | console.log('Running Atek', packageJson.version) 281 | } 282 | 283 | function json404 (res: express.Response, e: Error | string) { 284 | res.status(404).json({error: true, message: e instanceof Error && e.message ? e.message : e.toString()}) 285 | } 286 | 287 | function proxyError (e: Error, res: express.Response, id: string) { 288 | console.error('Failed to proxy request to', id) 289 | console.error(e) 290 | return res.status(500).end('Internal server error') 291 | } 292 | 293 | 294 | const proxies = new Map() 295 | function getProxy (service: ServiceInstance): httpProxy { 296 | const proxyId = service.id 297 | let proxy = proxies.get(proxyId) 298 | if (!proxy) { 299 | proxy = httpProxy.createProxyServer({ 300 | // @ts-ignore socketPath is supported, but not included in their types 301 | target: {socketPath: service.socketPath} 302 | }) 303 | proxies.set(proxyId, proxy) 304 | } 305 | return proxy 306 | } 307 | 308 | function isRequestSafe (config: Config, req: express.Request): boolean { 309 | const host = (req.headers.host || '').split(':')[0] 310 | return (!host || host === config.domain) && req.path.startsWith('/_atek/') 311 | } -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | import { ServiceInstance } from './instance.js' 2 | import adb from '@atek-cloud/adb-api' 3 | import { services, Service, SourceTypeEnum, ServiceConfig, ServiceManifest, SERVICE } from '@atek-cloud/adb-tables' 4 | import * as serverdb from '../serverdb/index.js' 5 | import * as git from '../lib/git.js' 6 | import * as npm from '../lib/npm.js' 7 | import { fileURLToPath } from 'url' 8 | import * as path from 'path' 9 | import { promises as fsp } from 'fs' 10 | import * as cli from '../lib/cli.js' 11 | import chalk from 'chalk' 12 | import { Config } from '../config.js' 13 | import lock from '../lib/lock.js' 14 | import { sourceUrlToId, getAvailableId, getServiceRecordById } from './util.js' 15 | import { createValidator } from '../schemas/util.js' 16 | 17 | const manifestValidator = createValidator(SERVICE.DEFINITION.definitions.ServiceManifest) 18 | 19 | export interface InstallParams { 20 | sourceUrl: string 21 | id?: string 22 | desiredVersion?: string 23 | config?: ServiceConfig 24 | } 25 | 26 | export interface UpdateParams { 27 | sourceUrl?: string 28 | id?: string 29 | desiredVersion?: string 30 | config?: ServiceConfig 31 | } 32 | 33 | // globals 34 | // = 35 | 36 | const activeServices = new Map() 37 | 38 | // exported api 39 | // = 40 | 41 | export function setup (): void { 42 | process.on('exit', () => stopAll()) 43 | } 44 | 45 | export async function loadCoreServices (): Promise { 46 | const cfg = Config.getActiveConfig() 47 | cli.status('Loading core services') 48 | let i = 0 49 | for (const serviceParams of cfg.coreServices) { 50 | cli.status('Loading core services', cli.genProgress(i++, cfg.coreServices.length), 'Now loading:', chalk.green(serviceParams.sourceUrl)) 51 | await loadCoreService(serviceParams) 52 | } 53 | cli.endStatus('Loaded core services') 54 | 55 | await adb.api.init({serverDbId: cfg.serverDbId || ''}) 56 | const {serverDbId} = await adb.api.getConfig() 57 | if (!cfg.isOverridden('serverDbId') && cfg.serverDbId !== serverDbId) { 58 | console.log('HOST: Created new server database, id:', serverDbId) 59 | cfg.update({serverDbId}) 60 | } 61 | } 62 | 63 | export async function loadUserServices (): Promise { 64 | cli.status('Loading installed services') 65 | let i = 0 66 | const srvRecords = (await services(serverdb.get()).list()).records 67 | for (const srvRecord of srvRecords) { 68 | cli.status('Loading installed services', cli.genProgress(i++, srvRecords.length), 'Now loading:', chalk.green(srvRecord.value.sourceUrl)) 69 | try { 70 | await load(srvRecord.key, srvRecord.value) 71 | } catch (e) { 72 | console.error('Failed to load', srvRecord.value.id) 73 | console.error(e) 74 | } 75 | } 76 | cli.endStatus('Loaded installed services') 77 | } 78 | 79 | export async function install (params: InstallParams, authedUserKey: string): Promise { 80 | let recordValue 81 | if (!params.sourceUrl) { 82 | throw new Error('Source URL is required') 83 | } 84 | if (params.id && await getServiceRecordById(params.id).catch(e => undefined)) { 85 | throw new Error('App already exists under the ID: ' + params.id) 86 | } 87 | 88 | if (!params.id) { 89 | params.id = await getAvailableId(params.sourceUrl) 90 | } 91 | 92 | let recordKey = undefined 93 | const release = await lock(`services:${params.id}:ctrl`) 94 | try { 95 | const {sourceType, installedVersion} = await fetchPackage(params) 96 | const manifest = await readManifestFile(params.id, params.sourceUrl) 97 | if (sourceType !== 'file') { 98 | await npm.setupPackage(params.id, getInstallPath(params.id, params.sourceUrl)) 99 | } 100 | 101 | recordValue = { 102 | id: params.id, 103 | owningUserKey: authedUserKey, 104 | sourceUrl: params.sourceUrl, 105 | desiredVersion: params.desiredVersion, 106 | package: { 107 | sourceType: sourceType as SourceTypeEnum, 108 | installedVersion 109 | }, 110 | manifest, 111 | config: params.config 112 | } 113 | const res = await services(serverdb.get()).create(recordValue) 114 | recordKey = res.key 115 | } finally { 116 | release() 117 | } 118 | const inst = await load(recordKey, recordValue) 119 | if (!inst) throw new Error('Failed to load installed service') 120 | return inst 121 | } 122 | 123 | export async function updateConfig (id: string, params: UpdateParams): Promise { 124 | const release = await lock(`services:${id}:ctrl`) 125 | try { 126 | const record = await getServiceRecordById(id) 127 | 128 | const isIdChanged = typeof params.id === 'string' && params.id && params.id !== id 129 | if (isIdChanged && params.id) { 130 | if (await getServiceRecordById(params.id).catch(e => undefined)) { 131 | throw new Error('App already exists under the ID: ' + params.id) 132 | } 133 | record.value.id = params.id 134 | } 135 | if (typeof params.sourceUrl === 'string') record.value.sourceUrl = params.sourceUrl 136 | if (typeof params.desiredVersion === 'string') record.value.desiredVersion = params.desiredVersion 137 | if (params.config) { 138 | record.value.config = record.value.config ?? {} 139 | Object.assign(record.value.config, params.config) 140 | } 141 | 142 | await services(serverdb.get()).put(record.key, record.value) 143 | 144 | const inst = get(id) 145 | if (inst && isIdChanged && params.id) { 146 | activeServices.delete(id) 147 | activeServices.set(params.id, inst) 148 | } 149 | 150 | if (inst) { 151 | inst.settings = record.value 152 | if (record.value.config) inst.setConfig(record.value.config) 153 | } 154 | } finally { 155 | release() 156 | } 157 | } 158 | 159 | export async function uninstall (id: string): Promise { 160 | const release = await lock(`services:${id}:ctrl`) 161 | try { 162 | const record = await getServiceRecordById(id) 163 | 164 | get(id)?.stop() 165 | if (record.value?.package.sourceType === 'git') { 166 | await fsp.rm(Config.getActiveConfig().packageInstallPath(id), {recursive: true}).catch(e => undefined) 167 | } 168 | await services(serverdb.get()).delete(record.key) 169 | activeServices.delete(id) 170 | } finally { 171 | release() 172 | } 173 | } 174 | 175 | export async function load (serviceKey: string, settings: Service, config: ServiceConfig = {}): Promise { 176 | const id = settings.id 177 | const release = await lock(`services:${id}:ctrl`) 178 | try { 179 | if (!activeServices.has(id)) { 180 | activeServices.set(id, new ServiceInstance(serviceKey, settings, Object.assign({}, settings.config, config))) 181 | await activeServices.get(id)?.setup() 182 | await activeServices.get(id)?.start() 183 | } 184 | return activeServices.get(id) 185 | } finally { 186 | release() 187 | } 188 | } 189 | 190 | export async function loadCoreService (params: InstallParams): Promise { 191 | if (!params.sourceUrl) { 192 | throw new Error('Source URL is required') 193 | } 194 | 195 | if (!params.id) { 196 | params.id = `core.${sourceUrlToId(params.sourceUrl)}` 197 | } 198 | 199 | console.log('Loading core service', params) 200 | const {sourceType, installedVersion, didChange} = await fetchPackage(params) 201 | const manifest = await readManifestFile(params.id, params.sourceUrl) 202 | if (sourceType !== 'file' && didChange) { 203 | await npm.setupPackage(params.id, getInstallPath(params.id, params.sourceUrl)) 204 | } 205 | 206 | const recordValue = { 207 | id: params.id, 208 | owningUserKey: 'system', 209 | sourceUrl: params.sourceUrl, 210 | desiredVersion: params.desiredVersion, 211 | package: { 212 | sourceType: sourceType as SourceTypeEnum, 213 | installedVersion 214 | }, 215 | manifest 216 | } 217 | const inst = await load(genCoreServiceKey(), recordValue, params.config) 218 | if (!inst) throw new Error('Failed to load core service') 219 | return inst 220 | } 221 | 222 | export function get (id: string): ServiceInstance | undefined { 223 | return activeServices.get(id) 224 | } 225 | 226 | export function getByKey (serviceKey: string): ServiceInstance | undefined { 227 | return list().find(srv => srv.serviceKey === serviceKey) 228 | } 229 | 230 | export function list (): ServiceInstance[] { 231 | return Array.from(activeServices.values()) 232 | } 233 | 234 | export function getByBearerToken (bearerToken: string): ServiceInstance | undefined { 235 | return list().find(app => app.bearerToken === bearerToken) 236 | } 237 | 238 | export function stopAll (): void { 239 | for (const id in services) { 240 | get(id)?.stop() 241 | } 242 | } 243 | 244 | export async function checkForPackageUpdates (id: string): Promise<{hasUpdate: boolean, installedVersion: string, latestVersion: string}> { 245 | const record = await getServiceRecordById(id) 246 | await git.fetch(id) 247 | const latestVersion = await git.getLatestVersion(id, record.value.desiredVersion || 'latest') 248 | return { 249 | hasUpdate: latestVersion !== record.value.package.installedVersion, 250 | installedVersion: record.value.package.installedVersion || 'latest', 251 | latestVersion 252 | } 253 | } 254 | 255 | export async function updatePackage (id: string): Promise<{installedVersion: string, oldVersion: string}> { 256 | const record = await getServiceRecordById(id) 257 | 258 | await git.fetch(id) 259 | const latestVersion = await git.getLatestVersion(id, record.value.desiredVersion || 'latest') 260 | if (latestVersion === record.value.package.installedVersion) { 261 | return {installedVersion: latestVersion, oldVersion: latestVersion} 262 | } 263 | await git.checkout(id, latestVersion) 264 | 265 | const manifest = await readManifestFile(id, record.value.sourceUrl) 266 | if (record.value.package.sourceType !== SourceTypeEnum.file) { 267 | await npm.setupPackage(id, getInstallPath(id, record.value.sourceUrl)) 268 | } 269 | 270 | const oldVersion = record.value.package.installedVersion || '' 271 | record.value.manifest = manifest 272 | record.value.package.installedVersion = latestVersion 273 | await services(serverdb.get()).put(record.key, record.value) 274 | 275 | const inst = get(id) 276 | if (inst) inst.settings = record.value 277 | 278 | return {installedVersion: latestVersion, oldVersion} 279 | } 280 | 281 | export function getInstallPath (id: string, sourceUrl: string): string { 282 | if (sourceUrl.startsWith('file://')) { 283 | return fileURLToPath(sourceUrl) 284 | } else { 285 | return Config.getActiveConfig().packageInstallPath(id) 286 | } 287 | } 288 | 289 | // internal methods 290 | // = 291 | 292 | async function fetchPackage (params: InstallParams) { 293 | if (!params.id) throw new Error('ID is required') 294 | 295 | let sourceType = 'file' 296 | let installedVersion = undefined 297 | let didChange = false 298 | if (params?.sourceUrl && !params.sourceUrl.startsWith('file://')) { 299 | const prevVersion = await git.getCurrentVersion(params.id).catch(e => undefined) 300 | try { 301 | await git.clone(params.id, params.sourceUrl) 302 | } catch (e: any) { 303 | if (e.name !== 'CheckoutConflictError') { 304 | throw new Error(`Failed to install app. Is it a Git repo? ${e.toString()}`) 305 | } 306 | console.log('Git clone failed due to a conflict in the package directory. Reinstalling package...') 307 | await fsp.rm(Config.getActiveConfig().packageInstallPath(params.id), {recursive: true}) 308 | try { 309 | await git.clone(params.id, params.sourceUrl) 310 | } catch (e: any) { 311 | throw new Error(`Failed to install app. Is it a Git repo? ${e.toString()}`) 312 | } 313 | } 314 | sourceType = 'git' 315 | installedVersion = await git.getLatestVersion(params.id, params.desiredVersion || 'latest') 316 | if (!installedVersion) { 317 | throw new Error(`This git repo has not published any releases.`) 318 | } 319 | await git.checkout(params.id, installedVersion) 320 | didChange = prevVersion !== installedVersion 321 | console.log({didChange, prevVersion, installedVersion}) 322 | } 323 | 324 | return {sourceType, installedVersion, didChange} 325 | } 326 | 327 | async function readManifestFile (id: string, sourceUrl: string): Promise { 328 | try { 329 | const installPath = getInstallPath(id, sourceUrl) 330 | const obj = JSON.parse(await fsp.readFile(path.join(installPath, 'atek.json'), 'utf8')) 331 | assertIsManifest(obj) 332 | return obj 333 | } catch (e) { 334 | console.error(`No valid atek.json manifest file found for`, sourceUrl) 335 | console.error(e) 336 | return undefined 337 | } 338 | } 339 | 340 | function assertIsManifest (obj: any): asserts obj is ServiceManifest { 341 | manifestValidator.assert(obj) 342 | } 343 | 344 | let _genCoreServiceKey = 1000 345 | function genCoreServiceKey () { 346 | return `${_genCoreServiceKey++}` 347 | } 348 | --------------------------------------------------------------------------------