Restructure per PR suggestions

This commit is contained in:
Ben Schwartz 2022-05-11 12:46:06 -04:00
parent 2633379ec5
commit 41ae0f6dbb
20 changed files with 3049 additions and 3624 deletions

2
.npmrc
View file

@ -1,2 +1,2 @@
;enforces that the user is `npm install`ing with the correct node version
engine-strict=true
prefer-dedupe=true

5984
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,11 @@
"@google-cloud/storage here only to help Typescript code using @google-cloud/bigquery compile"
],
"dependencies": {
"@google-cloud/bigquery": "^2.0.3",
"@google-cloud/bigquery": "^5.12.0",
"express": "^4.17.1"
},
"devDependencies": {
"@google-cloud/storage": "^2.3.1",
"@google-cloud/storage": "^5.19.4",
"@types/express": "^4.17.12"
},
"scripts": {

View file

@ -0,0 +1,94 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as crypto from 'crypto';
import * as tls from 'tls';
import * as https from 'https';
import * as forge from 'node-forge';
import {fetchWithPin} from './fetch';
import {AddressInfo} from 'net';
describe('fetchWithPin', () => {
it('throws on pin mismatch (remote)', async () => {
const result = fetchWithPin(
{url: 'https://www.gstatic.com/', method: 'GET'},
'incorrect fingerprint'
);
await expectAsync(result).toBeRejectedWithError(Error, /Fingerprint mismatch/);
});
// Make a certificate.
const {privateKey, publicKey} = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = publicKey;
cert.sign(privateKey); // Self-signed cert
// Serialize the certificate for `tls.createServer()`.
const keyPem = forge.pki.privateKeyToPem(privateKey);
const certPem = forge.pki.certificateToPem(cert);
// Compute the certificate fingerprint.
const certDer = forge.pki.pemToDer(certPem);
const sha256 = crypto.createHash('sha256');
const certSha256 = sha256.update(certDer.data, 'binary').digest().toString('binary');
it('throws on pin mismatch (local)', async () => {
const server = tls.createServer({key: keyPem, cert: certPem});
await new Promise<void>((fulfill) => server.listen(0, fulfill));
const address = server.address() as AddressInfo;
const req = {
url: `https://localhost:${address.port}/foo`,
method: 'GET',
};
// Fail if the TLS handshake completes.
server.on('secureConnection', fail);
const clientClosed = new Promise((fulfill) =>
server.on('connection', (socket) => socket.on('close', fulfill))
);
const result = fetchWithPin(req, 'incorrect fingerprint');
await expectAsync(result).toBeRejectedWithError(Error, /Fingerprint mismatch/);
// Don't stop the test until the client has closed the TCP socket.
await clientClosed;
});
it('succeeds on pin match', async () => {
const server = https.createServer({key: keyPem, cert: certPem});
await new Promise<void>((fulfill) => server.listen(0, fulfill));
const address = server.address() as AddressInfo;
const req = {
url: `https://localhost:${address.port}/foo`,
method: 'GET',
};
server.on('request', (incoming, response) => {
expect(incoming.url).toBe('/foo');
expect(incoming.method).toBe('GET');
response.writeHead(200);
response.write('test test');
response.end();
});
const result = fetchWithPin(req, certSha256);
await expectAsync(result).toBeResolvedTo({
status: 200,
body: 'test test',
});
});
});

View file

@ -0,0 +1,70 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as https from 'https';
import {TLSSocket} from 'tls';
import {urlToHttpOptions} from 'url';
import type {IncomingMessage} from 'http';
import type {HttpRequest, HttpResponse} from './types';
export const fetchWithPin = async (
req: HttpRequest,
fingerprint: string
): Promise<HttpResponse> => {
const response = await new Promise<IncomingMessage>((resolve, reject) => {
const options: https.RequestOptions = {
...urlToHttpOptions(new URL(req.url)),
method: req.method,
headers: req.headers,
rejectUnauthorized: false, // Disable certificate chain validation.
};
const request = https.request(options, resolve).on('error', reject);
// Enforce certificate fingerprint match.
request.on('socket', (socket: TLSSocket) =>
socket.on('secureConnect', () => {
const certificate = socket.getPeerCertificate();
// Parse fingerprint in "AB:CD:EF" form.
const sha2hex = certificate.fingerprint256.replace(/:/g, '');
const sha2binary = Buffer.from(sha2hex, 'hex').toString('binary');
if (sha2binary !== fingerprint) {
request.emit(
'error',
new Error(`Fingerprint mismatch: expected ${fingerprint}, not ${sha2binary}`)
);
request.destroy();
return;
}
})
);
if (req.body) {
request.write(req.body);
}
request.end();
});
const chunks: Buffer[] = [];
for await (const chunk of response) {
chunks.push(chunk);
}
return {
status: response.statusCode,
body: Buffer.concat(chunks).toString(),
};
};

View file

@ -0,0 +1,34 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This file is imported by both the Electron and Renderer process code,
// so it cannot contain any imports that are not available in both
// environments.
// These type definitions are designed to bridge the differences between
// the Fetch API and the Node.JS HTTP API, while also being compatible
// with the Structured Clone algorithm so that they can be passed between
// the Electron and Renderer processes.
export interface HttpRequest {
url: string;
method: string;
headers?: Record<string, string>;
body?: string;
}
export interface HttpResponse {
status: number;
body?: string;
}

View file

@ -17,11 +17,10 @@ import * as dotenv from 'dotenv';
import * as electron from 'electron';
import {autoUpdater} from 'electron-updater';
import * as path from 'path';
import * as https from 'https';
import * as tls from 'tls';
import {URL, URLSearchParams, urlToHttpOptions} from 'url';
import type {IncomingMessage, OutgoingHttpHeaders} from 'http';
import {URL, URLSearchParams} from 'url';
import type {HttpRequest, HttpResponse} from './http/types';
import {fetchWithPin} from './http/fetch';
import * as menu from './menu';
const app = electron.app;
@ -240,68 +239,14 @@ function main() {
}
});
// Proxy for fetch calls that require fingerprint pinning. Assumes response is JSON.
// Proxy for fetch calls that require fingerprint pinning.
ipcMain.handle(
'fetch-with-pin',
async (
(
event: Electron.IpcMainInvokeEvent,
url: string,
fingerprint: string,
method: string,
bodyJson: object,
bodyForm: {[id: string]: string}
): Promise<object | void> => {
const headers: OutgoingHttpHeaders = {};
if (bodyJson) {
headers['Content-Type'] = 'application/json';
} else if (bodyForm) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
const response = await new Promise<IncomingMessage>((resolve, reject) => {
const options: https.RequestOptions = {
...urlToHttpOptions(new URL(url)),
method,
headers,
rejectUnauthorized: false, // Disable certificate chain validation.
};
const request = https.request(options, resolve).on('error', reject);
// Enforce certificate fingerprint match.
request.on('socket', (socket: tls.TLSSocket) =>
socket.on('secureConnect', () => {
const certificate = socket.getPeerCertificate();
// Parse fingerprint in "AB:CD:EF" form.
const sha2hex = certificate.fingerprint256.replace(/:/g, '');
const sha2binary = Buffer.from(sha2hex, 'hex').toString('binary');
if (sha2binary !== fingerprint) {
request.emit('error', new Error('Fingerprint mismatch'));
return request.destroy();
}
})
);
if (bodyJson) {
request.write(JSON.stringify(bodyJson));
} else if (bodyForm) {
request.write(new URLSearchParams(bodyForm).toString());
}
request.end();
});
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`Unexpected status code: ${response.statusCode}`);
}
const chunks: Buffer[] = [];
for await (const chunk of response) {
chunks.push(chunk);
}
const responseBody = Buffer.concat(chunks).toString();
if (responseBody) {
return JSON.parse(responseBody);
}
}
req: HttpRequest,
fingerprint: string
): Promise<HttpResponse> => fetchWithPin(req, fingerprint)
);
// Restores the mainWindow if minimized and brings it into focus.

View file

@ -18,6 +18,7 @@ import {URL} from 'url';
import * as digitalocean_oauth from './digitalocean_oauth';
import * as gcp_oauth from './gcp_oauth';
import {HttpRequest, HttpResponse} from './http/types';
import {redactManagerUrl} from './util';
// This file is run in the renderer process *before* nodeIntegration is disabled.
@ -49,13 +50,8 @@ if (sentryDsn) {
contextBridge.exposeInMainWorld(
'fetchWithPin',
(
url: string,
fingerprint: string,
method: string,
bodyJson: object,
bodyForm: {[k: string]: string}
) => ipcRenderer.invoke('fetch-with-pin', url, fingerprint, method, bodyJson, bodyForm)
(request: HttpRequest, fingerprint: string): Promise<HttpResponse> =>
ipcRenderer.invoke('fetch-with-pin', request, fingerprint)
);
contextBridge.exposeInMainWorld('openImage', (basename: string) => {

View file

@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import type {HttpResponse} from '../electron_app/http/types';
export class OutlineError extends Error {
constructor(message?: string) {
// ref:
@ -45,7 +47,7 @@ export class ServerInstallFailedError extends OutlineError {
// Thrown when a Shadowbox API request fails.
export class ServerApiError extends OutlineError {
constructor(message: string, public readonly response?: Response) {
constructor(message: string, public readonly response?: HttpResponse) {
super(message);
}

View file

@ -12,21 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
export function asciiToHex(text: string) {
// Assumes that text is no more than 8 bits per char, i.e. no unicode.
const hexBytes: string[] = [];
for (let i = 0; i < text.length; ++i) {
const charCode = text.charCodeAt(i);
if (charCode > 0xff) {
// Consider supporting non-ascii characters:
// http://monsur.hossa.in/2012/07/20/utf-8-in-javascript.html
throw new Error(`Cannot encode wide character with value ${charCode}`);
}
hexBytes.push(('0' + charCode.toString(16)).slice(-2));
}
return hexBytes.join('');
}
export function hexToString(hexString: string) {
const bytes: string[] = [];
if (hexString.length % 2 !== 0) {

View file

@ -46,11 +46,11 @@
"dotenv": "~8.2.0",
"electron-updater": "^4.1.2",
"express": "^4.17.1",
"google-auth-library": "^7.0.2",
"google-auth-library": "^8.0.2",
"intl-messageformat": "^7",
"jsonic": "^0.3.1",
"lit-element": "^2.3.1",
"node-forge": "^0.10.0",
"node-forge": "^1.3.1",
"request": "^2.87.0",
"web-animations-js": "^2.3.1"
},
@ -71,7 +71,7 @@
},
"devDependencies": {
"@types/node": "^16.11.29",
"@types/node-forge": "^0.6.9",
"@types/node-forge": "^1.0.2",
"@types/polymer": "^1.2.9",
"@types/puppeteer": "^5.4.2",
"@types/request": "^2.47.1",
@ -100,7 +100,7 @@
"ts-loader": "^7.0.1",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3"
"webpack-dev-server": "^4.9.0"
},
"resolutions": {
"inherits": "2.0.3",

View file

@ -1,20 +0,0 @@
// Copyright 2018 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Extension to @types/node-forge to add the missing definitions that we need.
declare module 'node-forge' {
namespace ssh {
function publicKeyToOpenSSH(privateKey?: string, passphrase?: string): string;
}
}

View file

@ -14,13 +14,10 @@
// Functions made available to the renderer process via preload.ts.
declare function fetchWithPin(
url: string,
fingerprint: string,
method: string,
bodyJson: object,
bodyForm: {[k: string]: string}
): Promise<object | void>;
type HttpRequest = import('../electron_app/http/types').HttpRequest;
type HttpResponse = import('../electron_app/http/types').HttpResponse;
declare function fetchWithPin(request: HttpRequest, fingerprint: string): Promise<HttpResponse>;
declare function openImage(basename: string): void;
declare function onUpdateDownloaded(callback: () => void): void;

View file

@ -13,15 +13,8 @@
// limitations under the License.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).fetchWithPin = (
url: string,
_fingerprint: string,
method: string,
_bodyJson: object,
_bodyForm: {[k: string]: string}
): Promise<object | void> => {
console.log(`${method} request for ${url}`);
throw new Error('Custom fetch is only support in Electron');
(window as any).fetchWithPin = (_request: HttpRequest, _fingerprint: string) => {
return Promise.reject(new Error('Fingerprint pins are not supported'));
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -19,6 +19,7 @@ import {sleep} from '../infrastructure/sleep';
import {ValueStream} from '../infrastructure/value_stream';
import {Region} from '../model/digitalocean';
import * as server from '../model/server';
import {makePathApiClient} from './path_api';
import {ShadowboxServer} from './shadowbox_server';
@ -185,7 +186,7 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
// these methods throw exceptions if the fields are unavailable.
const certificateFingerprint = this.getCertificateFingerprint();
const apiAddress = this.getManagementApiAddress();
this.setManagementApiUrl(apiAddress, certificateFingerprint);
this.setManagementApi(makePathApiClient(apiAddress, certificateFingerprint));
return true;
} catch (e) {
// Install state not yet ready.
@ -260,7 +261,7 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
return apiAddress;
}
// Gets the certificate fingerprint in base64 format, throws an error if
// Gets the certificate fingerprint in binary, throws an error if
// unavailable.
private getCertificateFingerprint(): string {
const fingerprint = this.getTagMap().get(CERTIFICATE_FINGERPRINT_TAG);

View file

@ -19,6 +19,7 @@ import {ValueStream} from '../infrastructure/value_stream';
import {Zone} from '../model/gcp';
import * as server from '../model/server';
import {DataAmount, ManagedServerHost, MonetaryCost} from '../model/server';
import {makePathApiClient} from './path_api';
import {ShadowboxServer} from './shadowbox_server';
@ -178,7 +179,7 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer {
if (outlineGuestAttributes.has('apiUrl') && outlineGuestAttributes.has('certSha256')) {
const certSha256 = outlineGuestAttributes.get('certSha256');
const apiUrl = outlineGuestAttributes.get('apiUrl');
this.setManagementApiUrl(apiUrl, atob(certSha256));
this.setManagementApi(makePathApiClient(apiUrl, atob(certSha256)));
this.setInstallState(InstallState.COMPLETED);
break;
} else if (outlineGuestAttributes.has('install-error')) {

View file

@ -14,6 +14,7 @@
import {hexToString} from '../infrastructure/hex_encoding';
import * as server from '../model/server';
import {makePathApiClient} from './path_api';
import {ShadowboxServer} from './shadowbox_server';
@ -25,7 +26,7 @@ class ManualServer extends ShadowboxServer implements server.ManualServer {
) {
super(id);
const fingerprint = hexToString(manualServerConfig.certSha256);
this.setManagementApiUrl(manualServerConfig.apiUrl, fingerprint);
this.setManagementApi(makePathApiClient(manualServerConfig.apiUrl, fingerprint));
}
getCertificateFingerprint() {

View file

@ -0,0 +1,81 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {makePathApiClient, PathApiClient} from './path_api';
describe('PathApi', () => {
describe('local', () => {
// Mock fetcher
let lastRequest: HttpRequest;
let nextResponse: Promise<HttpResponse>;
const fetcher = (request: HttpRequest) => {
lastRequest = request;
return nextResponse;
};
beforeEach(() => {
lastRequest = undefined;
nextResponse = undefined;
});
const api = new PathApiClient('https://asdf.test/foo', fetcher);
it('GET', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.request('bar')).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'GET',
});
});
it('PUT form data', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.requestForm('bar', 'PUT', {name: 'value'})).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'PUT',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=value',
});
});
it('POST JSON data', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.requestJson('bar', 'POST', {key: 'value'})).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '{"key":"value"}',
});
});
});
// These tests rely on the fetch() function, which is not available in Node (yet).
if (!process?.versions?.node) {
describe('remote', () => {
const api = makePathApiClient('https://api.github.com/repos/Jigsaw-Code/');
it('GET', async () => {
const response = await api.request<{name: string}>('outline-server');
expect(response.name).toEqual('outline-server');
});
});
}
});

View file

@ -0,0 +1,133 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as errors from '../infrastructure/errors';
import {HttpRequest, HttpResponse} from '../electron_app/http/types';
async function fetchWrapper(request: HttpRequest): Promise<HttpResponse> {
const response = await fetch(request.url, request);
return {
status: response.status,
body: await response.text(),
};
}
// A Fetcher provides the HTTP client functionality for PathApi.
export type Fetcher = typeof fetchWrapper;
/**
* @param fingerprint A SHA-256 hash of the expected leaf certificate, in binary encoding.
* @returns An HTTP client that enforces `fingerprint`, if set.
*/
function makeFetcher(fingerprint?: string): Fetcher {
if (fingerprint) {
return (request) => fetchWithPin(request, fingerprint);
}
return fetchWrapper;
}
/**
* @param base A valid URL
* @param fingerprint A SHA-256 hash of the expected leaf certificate, in binary encoding.
* @returns A fully initialized API client.
*/
export function makePathApiClient(base: string, fingerprint?: string): PathApiClient {
return new PathApiClient(base, makeFetcher(fingerprint));
}
/**
* Provides access to an HTTP API of the kind exposed by the Shadowbox server.
*
* An API is defined by a `base` URL, under which all endpoints are defined.
* Request bodies are JSON, HTML-form data, or empty. Response bodies are
* JSON or empty.
*
* If a fingerprint is set, requests are proxied through Node.JS to enable
* certificate pinning.
*/
export class PathApiClient {
/**
* @param base A valid URL
* @param fingerprint A SHA-256 hash of the expected leaf certificate, in binary encoding.
*/
constructor(public readonly base: string, public readonly fetcher: Fetcher) {}
/**
* Makes a request relative to the base URL with a JSON body.
*
* @param path Relative path (no initial '/')
* @param method HTTP method
* @param body JSON-compatible object
* @returns Response body (JSON or void)
*/
async requestJson<T>(path: string, method: string, body: object): Promise<T> {
return this.request(path, method, 'application/json', JSON.stringify(body));
}
/**
* Makes a request relative to the base URL with an HTML-form style body.
*
* @param path Relative path (no initial '/')
* @param method HTTP method
* @param params Form data to send
* @returns Response body (JSON or void)
*/
async requestForm<T>(path: string, method: string, params: Record<string, string>): Promise<T> {
const body = new URLSearchParams(params);
return this.request(path, method, 'application/x-www-form-urlencoded', body.toString());
}
/**
* Makes a request relative to the base URL.
*
* @param path Relative path (no initial '/')
* @param method HTTP method
* @param contentType Content-Type header value
* @param body Request body
* @returns Response body (JSON or void)
*/
async request<T>(path: string, method = 'GET', contentType?: string, body?: string): Promise<T> {
let base = this.base;
if (!base.endsWith('/')) {
base += '/';
}
const url = base + path;
const request: HttpRequest = {url, method};
if (contentType) {
request.headers = {'Content-Type': contentType};
}
if (body) {
request.body = body;
}
let response: HttpResponse;
try {
response = await this.fetcher(request);
} catch (e) {
throw new errors.ServerApiError(
`API request to ${path} failed due to network error: ${e.message}`
);
}
if (response.status < 200 || response.status >= 300) {
throw new errors.ServerApiError(
`API request to ${path} failed with status ${response.status}`,
response
);
}
if (!response.body) {
return;
}
// Assume JSON and unsafe cast to `T`.
return JSON.parse(response.body);
}
}

View file

@ -14,8 +14,8 @@
import * as semver from 'semver';
import * as errors from '../infrastructure/errors';
import * as server from '../model/server';
import {PathApiClient} from './path_api';
interface AccessKeyJson {
id: string;
@ -54,15 +54,8 @@ function makeAccessKeyModel(apiAccessKey: AccessKeyJson): server.AccessKey {
return apiAccessKey as server.AccessKey;
}
// Represents either a JSON payload or a FormData payload.
interface ApiRequestBody {
json?: object;
form?: {[name: string]: string};
}
export class ShadowboxServer implements server.Server {
private managementApiAddress: string;
private fingerprint: string;
private api: PathApiClient;
private serverConfig: ServerConfigJson;
constructor(private readonly id: string) {}
@ -73,37 +66,35 @@ export class ShadowboxServer implements server.Server {
listAccessKeys(): Promise<server.AccessKey[]> {
console.info('Listing access keys');
return this.apiRequest<{accessKeys: AccessKeyJson[]}>('access-keys').then((response) => {
return this.api.request<{accessKeys: AccessKeyJson[]}>('access-keys').then((response) => {
return response.accessKeys.map(makeAccessKeyModel);
});
}
async addAccessKey(): Promise<server.AccessKey> {
console.info('Adding access key');
return makeAccessKeyModel(await this.apiRequest<AccessKeyJson>('access-keys', 'POST'));
return makeAccessKeyModel(await this.api.request<AccessKeyJson>('access-keys', 'POST'));
}
renameAccessKey(accessKeyId: server.AccessKeyId, name: string): Promise<void> {
console.info('Renaming access key');
return this.apiRequest<void>('access-keys/' + accessKeyId + '/name', 'PUT', {
form: {name: name},
});
return this.api.requestForm<void>('access-keys/' + accessKeyId + '/name', 'PUT', {name});
}
removeAccessKey(accessKeyId: server.AccessKeyId): Promise<void> {
console.info('Removing access key');
return this.apiRequest<void>('access-keys/' + accessKeyId, 'DELETE');
return this.api.request<void>('access-keys/' + accessKeyId, 'DELETE');
}
async setDefaultDataLimit(limit: server.DataLimit): Promise<void> {
console.info(`Setting server default data limit: ${JSON.stringify(limit)}`);
await this.apiRequest<void>(this.getDefaultDataLimitPath(), 'PUT', {json: {limit}});
await this.api.requestJson<void>(this.getDefaultDataLimitPath(), 'PUT', {limit});
this.serverConfig.accessKeyDataLimit = limit;
}
async removeDefaultDataLimit(): Promise<void> {
console.info(`Removing server default data limit`);
await this.apiRequest<void>(this.getDefaultDataLimitPath(), 'DELETE');
await this.api.request<void>(this.getDefaultDataLimitPath(), 'DELETE');
delete this.serverConfig.accessKeyDataLimit;
}
@ -122,16 +113,16 @@ export class ShadowboxServer implements server.Server {
async setAccessKeyDataLimit(keyId: server.AccessKeyId, limit: server.DataLimit): Promise<void> {
console.info(`Setting data limit of ${limit.bytes} bytes for access key ${keyId}`);
await this.apiRequest<void>(`access-keys/${keyId}/data-limit`, 'PUT', {json: {limit}});
await this.api.requestJson<void>(`access-keys/${keyId}/data-limit`, 'PUT', {limit});
}
async removeAccessKeyDataLimit(keyId: server.AccessKeyId): Promise<void> {
console.info(`Removing data limit from access key ${keyId}`);
await this.apiRequest<void>(`access-keys/${keyId}/data-limit`, 'DELETE');
await this.api.request<void>(`access-keys/${keyId}/data-limit`, 'DELETE');
}
async getDataUsage(): Promise<server.BytesByAccessKey> {
const jsonResponse = await this.apiRequest<DataUsageByAccessKeyJson>('metrics/transfer');
const jsonResponse = await this.api.request<DataUsageByAccessKeyJson>('metrics/transfer');
const usageMap = new Map<server.AccessKeyId, number>();
for (const [accessKeyId, bytes] of Object.entries(jsonResponse.bytesTransferredByUserId)) {
usageMap.set(accessKeyId, bytes ?? 0);
@ -145,7 +136,7 @@ export class ShadowboxServer implements server.Server {
async setName(name: string): Promise<void> {
console.info('Setting server name');
await this.apiRequest<void>('name', 'PUT', {json: {name}});
await this.api.requestJson<void>('name', 'PUT', {name});
this.serverConfig.name = name;
}
@ -160,7 +151,7 @@ export class ShadowboxServer implements server.Server {
async setMetricsEnabled(metricsEnabled: boolean): Promise<void> {
const action = metricsEnabled ? 'Enabling' : 'Disabling';
console.info(`${action} metrics`);
await this.apiRequest<void>('metrics/enabled', 'PUT', {json: {metricsEnabled}});
await this.api.requestJson<void>('metrics/enabled', 'PUT', {metricsEnabled});
this.serverConfig.metricsEnabled = metricsEnabled;
}
@ -195,15 +186,13 @@ export class ShadowboxServer implements server.Server {
async setHostnameForAccessKeys(hostname: string): Promise<void> {
console.info(`setHostname ${hostname}`);
this.serverConfig.hostnameForAccessKeys = hostname;
await this.apiRequest<void>('server/hostname-for-access-keys', 'PUT', {json: {hostname}});
await this.api.requestJson<void>('server/hostname-for-access-keys', 'PUT', {hostname});
this.serverConfig.hostnameForAccessKeys = hostname;
}
getHostnameForAccessKeys(): string {
try {
return (
this.serverConfig?.hostnameForAccessKeys ?? new URL(this.managementApiAddress).hostname
);
return this.serverConfig?.hostnameForAccessKeys ?? new URL(this.api.base).hostname;
} catch (e) {
return '';
}
@ -222,77 +211,20 @@ export class ShadowboxServer implements server.Server {
async setPortForNewAccessKeys(newPort: number): Promise<void> {
console.info(`setPortForNewAccessKeys: ${newPort}`);
await this.apiRequest<void>('server/port-for-new-access-keys', 'PUT', {json: {port: newPort}});
await this.api.requestJson<void>('server/port-for-new-access-keys', 'PUT', {port: newPort});
this.serverConfig.portForNewAccessKeys = newPort;
}
private async getServerConfig(): Promise<ServerConfigJson> {
console.info('Retrieving server configuration');
return await this.apiRequest<ServerConfigJson>('server');
return await this.api.request<ServerConfigJson>('server');
}
protected setManagementApiUrl(apiAddress: string, fingerprint?: string): void {
this.managementApiAddress = apiAddress;
this.fingerprint = fingerprint;
protected setManagementApi(api: PathApiClient): void {
this.api = api;
}
getManagementApiUrl() {
return this.managementApiAddress;
}
// Makes a request to the management API.
private apiRequest<T extends object | void>(
path: string,
method = 'GET',
body?: ApiRequestBody
): Promise<T> {
try {
let apiAddress = this.managementApiAddress;
if (!apiAddress) {
const msg = 'Management API address unavailable';
console.error(msg);
throw new Error(msg);
}
if (!apiAddress.endsWith('/')) {
apiAddress += '/';
}
const url = apiAddress + path;
if (this.fingerprint) {
return fetchWithPin(url, this.fingerprint, method, body?.json, body?.form) as Promise<T>;
}
const options: RequestInit = {method};
if (body?.json) {
options.body = JSON.stringify(body.json);
options.headers = new Headers({'Content-Type': 'application/json'});
} else if (body?.form) {
options.body = new FormData();
for (const key in body.form) {
options.body.set(key, body.form[key]);
}
}
return fetch(url, options)
.then(
(response) => {
if (!response.ok) {
throw new errors.ServerApiError(
`API request to ${path} failed with status ${response.status}`,
response
);
}
return response.text();
},
(_error) => {
throw new errors.ServerApiError(`API request to ${path} failed due to network error`);
}
)
.then((body) => {
if (!body) {
return;
}
return JSON.parse(body);
});
} catch (error) {
return Promise.reject(error);
}
getManagementApiUrl(): string {
return this.api.base;
}
}