├── 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 | 
--------------------------------------------------------------------------------
/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 |
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 |
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 |
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