Skip to content

Commit

Permalink
Add notification service
Browse files Browse the repository at this point in the history
  • Loading branch information
mattwebbio committed Sep 18, 2022
1 parent 6926670 commit 98a4ac7
Show file tree
Hide file tree
Showing 13 changed files with 687 additions and 213 deletions.
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ It is recommended you run this service with Docker.

## Environment Variables

| Variable | Required | Default | Examples | Description |
### Sync Configuration

| Environment Variable | Required | Default | Examples | Description |
| ----------------------------- | -------- | ------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `PRIMARY_HOST_BASE_URL` | Yes | N/A | `http://192.168.1.2` or `https://pihole.example.com` | The base URL of your Pi-hole, including the scheme (HTTP or HTTPS) and port but not including a following slash. |
| `PRIMARY_HOST_PASSWORD` | Yes | N/A | `mypassword` | The password used to log in to the admin interface. |
Expand All @@ -99,13 +101,25 @@ It is recommended you run this service with Docker.
| `SYNC_LOCALCNAMERECORDS` | No | `true` | `true`/`false` | Copies local CNAME records |
| `SYNC_FLUSHTABLES` | No | `true` | `true`/`false` | Clears existing data on the secondary (copy target) Pi-hole |
| `RUN_ONCE` | No | `false` | `true`/`false` | By default, `orbital-sync` runs indefinitely until stopped. Setting `RUN_ONCE` to `true` forces it to exit immediately after the first sync. |
| `VERBOSE` | No | `false` | `true`/`false` | Whether to output extra log output. Used for debugging. |
| `HONEYBADGER_API_KEY` | No | N/A | `hbp_xxxxxxxxxxxxxxxxxx` | Get notifications to honeybadger.io when the process crashes for any reason by creating a new project and putting your API key here. |

Secondary hosts must be sequential (`SECONDARY_HOST_1_BASE_URL`, `SECONDARY_HOST_2_BASE_URL`,
`SECONDARY_HOST_3_BASE_URL`, and so on) and start at number `1`. Any gaps (for example, `3` to `5` skipping `4`) will
result in hosts after the gap being skipped in the sync process.

### Notifications

| Environment Variable | Required | Default | Examples | Description |
| --------------------- | -------- | ------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `HONEYBADGER_API_KEY` | No | N/A | `hbp_xxxxxxxxxxxxxxxxxx` | Get notifications to honeybadger.io when the process crashes for any reason by creating a new project and putting your API key here. |
| `VERBOSE` | No | `false` | `true`/`false` | Whether to output extra log output. Used for debugging. |
| `TZ` | No | N/A | `America/Los_Angeles` | The timezone for the timestamps displayed in log output. |

### Error Handling

| Environment Variable | Required | Default | Examples | Description |
| -------------------- | -------- | ------- | -------------- | -------------------------------- |
| `EXIT_ON_ERROR` | No | `false` | `true`/`false` | Exits if **_any_** errors occur. |

## Disclaimer

[![GitHub](https://img.shields.io/github/license/mattwebbio/orbital-sync?style=for-the-badge)](LICENSE)
Expand Down
111 changes: 87 additions & 24 deletions src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import { jest } from '@jest/globals';
import nock from 'nock';
import { Blob } from 'node-fetch';
import {
BackupDownloadError,
BackupUploadError,
Client,
LoginError,
MalformedTokenError,
NoTokenError
} from './client';
import { ErrorNotification } from './notify';
import { Client } from './client';
import type { Host } from './config';
import { Config } from './config';

Expand All @@ -35,34 +29,62 @@ describe('Client', () => {
});

describe('create', () => {
test('should throw LoginError if status code is not ok', async () => {
test('should throw error if status code is not ok', async () => {
const initialRequest = nock(host.baseUrl).get('/admin/index.php?login').reply(200);
const loginRequest = nock(host.baseUrl).post('/admin/index.php?login').reply(500);

await expect(Client.create(host)).rejects.toBeInstanceOf(LoginError);

const expectError = expect(Client.create(host)).rejects;

await expectError.toBeInstanceOf(ErrorNotification);
await expectError.toMatchObject({
message:
'Error: there was an error logging in to "http://10.0.0.2" - are you able to log in with the configured password?',
verbose: {
host: 'http://10.0.0.2',
status: 500,
responseBody: ''
}
});
initialRequest.done();
loginRequest.done();
});

test('should throw NoTokenError if no token is present', async () => {
test('should throw error if no token is present', async () => {
const initialRequest = nock(host.baseUrl).get('/admin/index.php?login').reply(200);
const loginRequest = nock(host.baseUrl).post('/admin/index.php?login').reply(200);

await expect(Client.create(host)).rejects.toBeInstanceOf(NoTokenError);

const expectError = expect(Client.create(host)).rejects;

await expectError.toBeInstanceOf(ErrorNotification);
await expectError.toMatchObject({
message:
'Error: no token could be found while logging in to "http://10.0.0.2" - are you able to log in with the configured password?',
verbose: {
host: 'http://10.0.0.2',
innerHtml: ''
}
});
initialRequest.done();
loginRequest.done();
});

test('should throw MalformedTokenError if token is in incorrect format', async () => {
test('should throw error if token is in incorrect format', async () => {
const initialRequest = nock(host.baseUrl).get('/admin/index.php?login').reply(200);
const loginRequest = nock(host.baseUrl)
.post('/admin/index.php?login')
.reply(200, '<html><body><div id="token">abcdef</div></body></html>');

await expect(Client.create(host)).rejects.toBeInstanceOf(MalformedTokenError);

const expectError = expect(Client.create(host)).rejects;

await expectError.toBeInstanceOf(ErrorNotification);
await expectError.toMatchObject({
message:
'Error: a token was found but could not be validated while logging in to "http://10.0.0.2" - are you able to log in with the configured password?',
verbose: {
host: 'http://10.0.0.2',
token: 'abcdef'
}
});
initialRequest.done();
loginRequest.done();
});
Expand Down Expand Up @@ -95,18 +117,38 @@ describe('Client', () => {
teleporter.done();
});

test('should throw BackupDownloadError if response is non-200', async () => {
test('should throw error if response is non-200', async () => {
teleporter.post('/admin/scripts/pi-hole/php/teleporter.php').reply(500);

await expect(client.downloadBackup()).rejects.toBeInstanceOf(BackupDownloadError);
const expectError = expect(client.downloadBackup()).rejects;

await expectError.toBeInstanceOf(ErrorNotification);
await expectError.toMatchObject({
message: 'Error: failed to download backup from "http://10.0.0.2".',
verbose: {
host: 'http://10.0.0.2',
status: 500,
responseBody: ''
}
});
});

test('should throw BackupDownloadError if content type is not gzip', async () => {
test('should throw error if content type is not gzip', async () => {
teleporter
.post('/admin/scripts/pi-hole/php/teleporter.php')
.reply(200, undefined, { 'content-type': 'text/html' });

await expect(client.downloadBackup()).rejects.toBeInstanceOf(BackupDownloadError);
const expectError = expect(client.downloadBackup()).rejects;

await expectError.toBeInstanceOf(ErrorNotification);
await expectError.toMatchObject({
message: 'Error: failed to download backup from "http://10.0.0.2".',
verbose: {
host: 'http://10.0.0.2',
status: 200,
responseBody: ''
}
});
});

test('should return response data', async () => {
Expand Down Expand Up @@ -160,13 +202,33 @@ describe('Client', () => {
test('should throw BackupUploadError if response is non-200', async () => {
teleporter.post('/admin/scripts/pi-hole/php/teleporter.php').reply(500);

await expect(client.uploadBackup(backup)).rejects.toBeInstanceOf(BackupUploadError);
const expectError = expect(client.uploadBackup(backup)).rejects;

await expectError.toBeInstanceOf(ErrorNotification);
await expectError.toMatchObject({
message: 'Error: failed to upload backup to "http://10.0.0.2".',
verbose: {
host: 'http://10.0.0.2',
status: 500,
responseBody: ''
}
});
});

test('should throw BackupUploadError if response does not end with "OK"', async () => {
teleporter.post('/admin/scripts/pi-hole/php/teleporter.php').reply(200);

await expect(client.uploadBackup(backup)).rejects.toBeInstanceOf(BackupUploadError);
const expectError = expect(client.uploadBackup(backup)).rejects;

await expectError.toBeInstanceOf(ErrorNotification);
await expectError.toMatchObject({
message: 'Error: failed to upload backup to "http://10.0.0.2".',
verbose: {
host: 'http://10.0.0.2',
status: 200,
responseBody: ''
}
});
});

test('should upload backup successfully', async () => {
Expand All @@ -192,8 +254,9 @@ describe('Client', () => {
'OK'
);

await client.uploadBackup(backup);
const result = await client.uploadBackup(backup);

expect(result).toStrictEqual(true);
expect(syncOptions).toHaveBeenCalled();
expect(requestBody).toContain(
'name="token"\r\n\r\nabcdefgijklmnopqrstuvwxyzabcdefgijklmnopqrst'
Expand Down
90 changes: 50 additions & 40 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import nodeFetch, {
Response
} from 'node-fetch';
import { parse } from 'node-html-parser';
import { log } from './log.js';
import { Config } from './config.js';
import { ErrorNotification } from './notify.js';
import type { Host } from './config.js';
import { Config } from './config.js';
import { Log } from './log.js';

export class Client {
private constructor(
Expand All @@ -20,7 +21,7 @@ export class Client {
) {}

public static async create(host: Host): Promise<Client> {
log(chalk.yellow(`➡️ Signing in to ${host.baseUrl}...`));
Log.info(chalk.yellow(`➡️ Signing in to ${host.baseUrl}...`));
const fetch = fetchCookie(nodeFetch);

await fetch(`${host.baseUrl}/admin/index.php?login`, { method: 'GET' });
Expand All @@ -32,30 +33,48 @@ export class Client {
method: 'POST'
});
if (response.status !== 200)
throw new LoginError(host, {
status: response.status,
responseBody: await response.text()
throw new ErrorNotification({
message: `Error: there was an error logging in to "${host.baseUrl}" - are you able to log in with the configured password?`,
verbose: {
host: host.baseUrl,
status: response.status,
responseBody: await response.text()
}
});

const token = this.parseResponseForToken(host, await response.text());

log(chalk.green(`✔️ Successfully signed in to ${host.baseUrl}!`));
Log.info(chalk.green(`✔️ Successfully signed in to ${host.baseUrl}!`));
return new this(fetch, host, token);
}

private static parseResponseForToken(host: Host, responseBody: string): string {
const root = parse(responseBody);
const tokenDiv = root.querySelector('#token');
if (!tokenDiv) throw new NoTokenError(host, { responseBody: root.innerHTML });
if (!tokenDiv)
throw new ErrorNotification({
message: `Error: no token could be found while logging in to "${host.baseUrl}" - are you able to log in with the configured password?`,
verbose: {
host: host.baseUrl,
innerHtml: root.innerHTML
}
});

const token = tokenDiv.innerText;
if (token.length != 44) throw new MalformedTokenError(host, { responseBody: token });
if (token.length != 44)
throw new ErrorNotification({
message: `Error: a token was found but could not be validated while logging in to "${host.baseUrl}" - are you able to log in with the configured password?`,
verbose: {
host: host.baseUrl,
token: token
}
});

return token;
}

public async downloadBackup(): Promise<Blob> {
log(chalk.yellow(`➡️ Downloading backup from ${this.host.baseUrl}...`));
Log.info(chalk.yellow(`➡️ Downloading backup from ${this.host.baseUrl}...`));
const form = this.generateForm();

const response = await this.fetch(
Expand All @@ -69,19 +88,23 @@ export class Client {
response.status !== 200 ||
response.headers.get('content-type') !== 'application/gzip'
)
throw new BackupDownloadError(this.host, {
status: response.status,
responseBody: await response.text()
throw new ErrorNotification({
message: `Error: failed to download backup from "${this.host.baseUrl}".`,
verbose: {
host: this.host.baseUrl,
status: response.status,
responseBody: await response.text()
}
});

const data = await response.arrayBuffer();

log(chalk.green(`✔️ Backup from ${this.host.baseUrl} completed!`));
Log.info(chalk.green(`✔️ Backup from ${this.host.baseUrl} completed!`));
return new Blob([data]);
}

public async uploadBackup(backup: Blob): Promise<void> {
log(chalk.yellow(`➡️ Uploading backup to ${this.host.baseUrl}...`));
public async uploadBackup(backup: Blob): Promise<true | never> {
Log.info(chalk.yellow(`➡️ Uploading backup to ${this.host.baseUrl}...`));

const form = this.generateForm();
form.append('action', 'in');
Expand All @@ -96,13 +119,19 @@ export class Client {
);
const text = await response.text();
if (response.status !== 200 || !text.endsWith('OK'))
throw new BackupUploadError(this.host, {
status: response.status,
responseBody: text
throw new ErrorNotification({
message: `Error: failed to upload backup to "${this.host.baseUrl}".`,
verbose: {
host: this.host.baseUrl,
status: response.status,
responseBody: text
}
});

log(chalk.green(`✔️ Backup uploaded to ${this.host.baseUrl}!`));
if (Config.verboseMode) log(`Result:\n${chalk.blue(text)}`);
Log.info(chalk.green(`✔️ Backup uploaded to ${this.host.baseUrl}!`));
if (Config.verboseMode) Log.info(`Result:\n${chalk.blue(text)}`);

return true;
}

private generateForm(): typeof FormData.prototype {
Expand All @@ -126,23 +155,4 @@ export class Client {
}
}

class BaseError extends Error {
constructor(
host: Host,
{ status, responseBody }: { status?: number; responseBody?: string }
) {
let msg = `Host: ${host.baseUrl}`;
if (status) msg += `\n\nStatus Code:\n${status}`;
if (responseBody) msg += `\n\nResponse Body:\n${responseBody}`;

super(msg);
}
}

export class LoginError extends BaseError {}
export class NoTokenError extends BaseError {}
export class MalformedTokenError extends BaseError {}
export class BackupDownloadError extends BaseError {}
export class BackupUploadError extends BaseError {}

type NodeFetchCookie = ReturnType<typeof fetchCookie<RequestInfo, RequestInit, Response>>;
Loading

0 comments on commit 98a4ac7

Please sign in to comment.