Add experimental support for basic auth and TLS (#23)

Allows restricting access to the monitoring endpoint using basic auth
and configure TLS certificates.
This commit is contained in:
Lars Strojny 2022-11-16 22:19:08 +01:00 committed by GitHub
parent edf4e605e5
commit f8007b55ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 743 additions and 120 deletions

View file

@ -38,11 +38,7 @@ jobs:
cache-name: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json,**/package.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
- name: Cache eslint
id: cache-eslint
@ -51,11 +47,7 @@ jobs:
cache-name: cache-eslint
with:
path: .eslintcache
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json,**/package.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
- name: Cache TypeScript
id: cache-typescript
@ -64,11 +56,7 @@ jobs:
cache-name: cache-typescript
with:
path: .tsbuildinfo
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json,**/package.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
- name: Cache prettier
id: cache-prettier
@ -77,11 +65,7 @@ jobs:
cache-name: cache-prettier
with:
path: node_modules/.cache/prettier/.prettier-cache
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json,**/package.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
- name: Install dependencies
run: npm install

View file

@ -142,6 +142,18 @@ Once *Prometheus* is restarted, metrics with the `homebridge_` prefix should sta
// Timeout for the service discovery (in seconds). Default: 20
"discovery_timeout": number,
// Path to TLS certificate file (in PEM format)
"tls_cert_file": string,
// Path to TLS key file
"tls_key_file": string,
// Usernames and passwords for basic auth. Key is the username, value is the password.
// Password must be encoded with bcrypt
"basic_auth": {
"username": "<password encoded with bcrypt>"
}
},
// …
]

View file

@ -52,6 +52,22 @@
"type": "integer",
"required": false,
"default": 20
},
"tls_cert_file": {
"description": "Path to TLS certificate file (in PEM format)",
"type": "string",
"required": false
},
"tls_key_file": {
"description": "Path to TLS key file",
"type": "string",
"required": false
},
"basic_auth": {
"description": "Usernames and passwords for basic auth. Key is the username, value is the password. Password must be encoded with bcrypt",
"type": "object",
"additionalProperties": { "type": "string" },
"required": false
}
}
}

678
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,7 @@
],
"devDependencies": {
"@jest/globals": "^29.3.0",
"@types/bcrypt": "^5.0.0",
"@types/node": "^18.11.9",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.42.0",
@ -56,6 +57,9 @@
"typescript": "^4.4.4"
},
"dependencies": {
"@fastify/auth": "^4.1.0",
"@fastify/basic-auth": "^4.0.0",
"bcrypt": "^5.1.0",
"fastify": "^4.9.2",
"hap-node-client": "^0.1.25",
"zod": "^3.19.1"

View file

@ -15,7 +15,7 @@ export interface HttpServerController {
export type HttpAdapter = (config: HttpServer) => Promise<HttpServerController>
export type HttpConfig = Pick<Config, 'debug' | 'port' | 'prefix'>
export type HttpConfig = Pick<Config, 'debug' | 'port' | 'prefix' | 'basic_auth' | 'tls_cert_file' | 'tls_key_file'>
export interface HttpServer {
log?: Logger
@ -24,6 +24,6 @@ export interface HttpServer {
onRequest(): HttpResponse | undefined
onMetrics(): HttpResponse
onNotFound(): HttpResponse
onError(error: unknown): HttpResponse
onError(error: Error): HttpResponse
updateMetrics(metrics: Metric[]): void
}

View file

@ -1,5 +1,9 @@
import Fastify, { type FastifyReply, type FastifyRequest, type HookHandlerDoneFunction } from 'fastify'
import { readFileSync } from 'fs'
import { isAuthenticated } from '../../security'
import type { HttpAdapter, HttpResponse, HttpServer } from './api'
import fastifyAuth from '@fastify/auth'
import fastifyBasicAuth from '@fastify/basic-auth'
function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void {
if (response.statusCode) {
@ -22,11 +26,45 @@ function formatCombinedLog(request: FastifyRequest, reply: FastifyReply): string
return `${remoteAddress} - "${request.method} ${request.url} HTTP/${request.raw.httpVersion}" ${reply.statusCode} "${request.protocol}://${request.hostname}" "${userAgent}" "${contentType}"`
}
export const fastifyServe: HttpAdapter = async (server: HttpServer) => {
const fastify = Fastify({
logger: false,
function createFastify(server: HttpServer): ReturnType<typeof Fastify> {
const config = { logger: false }
if (server.config.tls_cert_file && server.config.tls_key_file) {
server.log?.debug('Running with TLS enabled')
return Fastify({
...config,
https: {
key: readFileSync(server.config.tls_key_file),
cert: readFileSync(server.config.tls_cert_file),
},
})
}
return Fastify({
...config,
serverFactory: server.serverFactory,
})
}
export const fastifyServe: HttpAdapter = async (server: HttpServer) => {
const fastify = createFastify(server)
if (server.config.basic_auth && Object.keys(server.config.basic_auth).length > 0) {
const users = server.config.basic_auth
const validate = async (username: string, password: string) => {
if (!(await isAuthenticated(username, password, users))) {
throw new Error('Unauthorized')
}
}
await fastify.register(fastifyAuth)
await fastify.register(fastifyBasicAuth, { validate, authenticate: true })
fastify.after(() => {
fastify.addHook('preHandler', fastify.auth([fastify.basicAuth]))
})
}
fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply) => {
if (reply.statusCode >= 400) {

View file

@ -18,4 +18,12 @@ export const ConfigBoundary = z.object({
.int()
.describe('Discovery timeout after which the current discovery is considered failed')
.default(20),
tls_cert_file: z.string().describe('Path to TLS certificate file (in PEM format)').optional(),
tls_key_file: z.string().describe('Path to TLS key file').optional(),
basic_auth: z
.record(z.string())
.describe(
'Usernames and passwords for basic auth. Key is the username, value is the password. Password must be encoded with bcrypt',
)
.optional(),
})

View file

@ -74,12 +74,11 @@ export class PrometheusServer implements HttpServer {
}
}
onError(error: unknown): HttpResponse {
onError(error: Error): HttpResponse {
this.log?.error('HTTP request error: %o', error)
return {
statusCode: 500,
headers: headers(textContentType),
body: 'Server error',
body: error.message,
}
}

9
src/security.ts Normal file
View file

@ -0,0 +1,9 @@
import { compare } from 'bcrypt'
export function isAuthenticated(
username: string,
plainPassword: string,
map: Record<string, string>,
): Promise<boolean> {
return compare(plainPassword, map[username] || '')
}

View file

@ -10,8 +10,17 @@ class TestablePrometheusServer extends PrometheusServer {
}
function createTestServer(): { http: Server; prometheus: HttpServer } {
return createTestServerWithBasicAuth({})
}
function createTestServerWithBasicAuth(basicAuth: Record<string, string>): { http: Server; prometheus: HttpServer } {
const http = createServer()
const prometheus = new TestablePrometheusServer({ port: 0, debug: false, prefix: 'homebridge' })
const prometheus = new TestablePrometheusServer({
port: 0,
debug: false,
prefix: 'homebridge',
basic_auth: basicAuth,
})
prometheus.serverFactory = (handler) => http.on('request', handler)
fastifyServe(prometheus).catch((err: Error) => {
if (!('code' in err) || (err as unknown as { code: unknown }).code !== 'ERR_SERVER_ALREADY_LISTEN') {
@ -22,6 +31,8 @@ function createTestServer(): { http: Server; prometheus: HttpServer } {
return { http, prometheus }
}
const secretAsBcrypt = '$2b$12$B8C9hsi2idheYOdSM9au0.6DbD6z44iI5dZo.72AYLsAEiNdnqNPG'
describe('Fastify HTTP adapter', () => {
test('Serves 503 everywhere while metrics are not available', () => {
return request(createTestServer().http)
@ -64,4 +75,50 @@ describe('Fastify HTTP adapter', () => {
].join('\n'),
)
})
test('Basic auth denied without user', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
testServer.prometheus.updateMetrics([])
return request(testServer.http)
.get('/metrics')
.expect(401)
.expect('Content-Type', 'text/plain; charset=utf-8')
.expect('Missing or bad formatted authorization header')
})
test('Basic auth denied with incorrect user', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
testServer.prometheus.updateMetrics([])
return request(testServer.http)
.get('/metrics')
.auth('john', 'secret')
.expect(401)
.expect('Content-Type', 'text/plain; charset=utf-8')
.expect('Unauthorized')
})
test('Basic auth grants access', () => {
const testServer = createTestServerWithBasicAuth({ joanna: secretAsBcrypt })
const timestamp = new Date('2020-01-01 00:00:00 UTC')
testServer.prometheus.updateMetrics([
new Metric('metric', 0.1, timestamp, { name: 'metric' }),
new Metric('total_something', 100, timestamp, { name: 'counter' }),
])
return request(testServer.http)
.get('/metrics')
.auth('joanna', 'secret')
.expect(200)
.expect('Content-Type', 'text/plain; charset=utf-8; version=0.0.4')
.expect(
[
'# TYPE homebridge_metric gauge',
'homebridge_metric{name="metric"} 0.1 1577836800000',
'# TYPE homebridge_something_total counter',
'homebridge_something_total{name="counter"} 100 1577836800000',
].join('\n'),
)
})
})