mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-05-13 13:58:57 +00:00
Restructure per PR suggestions
This commit is contained in:
parent
2633379ec5
commit
41ae0f6dbb
20 changed files with 3049 additions and 3624 deletions
2
.npmrc
2
.npmrc
|
|
@ -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
5984
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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": {
|
||||
|
|
|
|||
94
src/server_manager/electron_app/http/fetch.spec.ts
Normal file
94
src/server_manager/electron_app/http/fetch.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
70
src/server_manager/electron_app/http/fetch.ts
Normal file
70
src/server_manager/electron_app/http/fetch.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
34
src/server_manager/electron_app/http/types.ts
Normal file
34
src/server_manager/electron_app/http/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
20
src/server_manager/types/node-forge.d.ts
vendored
20
src/server_manager/types/node-forge.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
11
src/server_manager/types/preload.d.ts
vendored
11
src/server_manager/types/preload.d.ts
vendored
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
81
src/server_manager/web_app/path_api.spec.ts
Normal file
81
src/server_manager/web_app/path_api.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
133
src/server_manager/web_app/path_api.ts
Normal file
133
src/server_manager/web_app/path_api.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue