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:
parent
edf4e605e5
commit
f8007b55ca
11 changed files with 743 additions and 120 deletions
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
|
@ -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
|
||||
|
|
12
README.md
12
README.md
|
@ -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>"
|
||||
}
|
||||
},
|
||||
// …
|
||||
]
|
||||
|
|
|
@ -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
678
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
@ -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
9
src/security.ts
Normal 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] || '')
|
||||
}
|
|
@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue