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
|
cache-name: cache-node-modules
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json,**/package.json') }}
|
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 }}-
|
|
||||||
|
|
||||||
- name: Cache eslint
|
- name: Cache eslint
|
||||||
id: cache-eslint
|
id: cache-eslint
|
||||||
|
@ -51,11 +47,7 @@ jobs:
|
||||||
cache-name: cache-eslint
|
cache-name: cache-eslint
|
||||||
with:
|
with:
|
||||||
path: .eslintcache
|
path: .eslintcache
|
||||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json,**/package.json') }}
|
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
|
||||||
${{ runner.os }}-build-
|
|
||||||
${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Cache TypeScript
|
- name: Cache TypeScript
|
||||||
id: cache-typescript
|
id: cache-typescript
|
||||||
|
@ -64,11 +56,7 @@ jobs:
|
||||||
cache-name: cache-typescript
|
cache-name: cache-typescript
|
||||||
with:
|
with:
|
||||||
path: .tsbuildinfo
|
path: .tsbuildinfo
|
||||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json,**/package.json') }}
|
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
|
||||||
${{ runner.os }}-build-
|
|
||||||
${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Cache prettier
|
- name: Cache prettier
|
||||||
id: cache-prettier
|
id: cache-prettier
|
||||||
|
@ -77,11 +65,7 @@ jobs:
|
||||||
cache-name: cache-prettier
|
cache-name: cache-prettier
|
||||||
with:
|
with:
|
||||||
path: node_modules/.cache/prettier/.prettier-cache
|
path: node_modules/.cache/prettier/.prettier-cache
|
||||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json,**/package.json') }}
|
key: ${{ env.cache-name }}-${{ hashFiles('**/package-lock.json', '**/package.json') }}
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
|
||||||
${{ runner.os }}-build-
|
|
||||||
${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
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
|
// Timeout for the service discovery (in seconds). Default: 20
|
||||||
"discovery_timeout": number,
|
"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",
|
"type": "integer",
|
||||||
"required": false,
|
"required": false,
|
||||||
"default": 20
|
"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": {
|
"devDependencies": {
|
||||||
"@jest/globals": "^29.3.0",
|
"@jest/globals": "^29.3.0",
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/node": "^18.11.9",
|
"@types/node": "^18.11.9",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.42.0",
|
"@typescript-eslint/eslint-plugin": "^5.42.0",
|
||||||
|
@ -56,6 +57,9 @@
|
||||||
"typescript": "^4.4.4"
|
"typescript": "^4.4.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/auth": "^4.1.0",
|
||||||
|
"@fastify/basic-auth": "^4.0.0",
|
||||||
|
"bcrypt": "^5.1.0",
|
||||||
"fastify": "^4.9.2",
|
"fastify": "^4.9.2",
|
||||||
"hap-node-client": "^0.1.25",
|
"hap-node-client": "^0.1.25",
|
||||||
"zod": "^3.19.1"
|
"zod": "^3.19.1"
|
||||||
|
|
|
@ -15,7 +15,7 @@ export interface HttpServerController {
|
||||||
|
|
||||||
export type HttpAdapter = (config: HttpServer) => Promise<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 {
|
export interface HttpServer {
|
||||||
log?: Logger
|
log?: Logger
|
||||||
|
@ -24,6 +24,6 @@ export interface HttpServer {
|
||||||
onRequest(): HttpResponse | undefined
|
onRequest(): HttpResponse | undefined
|
||||||
onMetrics(): HttpResponse
|
onMetrics(): HttpResponse
|
||||||
onNotFound(): HttpResponse
|
onNotFound(): HttpResponse
|
||||||
onError(error: unknown): HttpResponse
|
onError(error: Error): HttpResponse
|
||||||
updateMetrics(metrics: Metric[]): void
|
updateMetrics(metrics: Metric[]): void
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import Fastify, { type FastifyReply, type FastifyRequest, type HookHandlerDoneFunction } from 'fastify'
|
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 type { HttpAdapter, HttpResponse, HttpServer } from './api'
|
||||||
|
import fastifyAuth from '@fastify/auth'
|
||||||
|
import fastifyBasicAuth from '@fastify/basic-auth'
|
||||||
|
|
||||||
function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void {
|
function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void {
|
||||||
if (response.statusCode) {
|
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}"`
|
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) => {
|
function createFastify(server: HttpServer): ReturnType<typeof Fastify> {
|
||||||
const fastify = Fastify({
|
const config = { logger: false }
|
||||||
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,
|
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) => {
|
fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
if (reply.statusCode >= 400) {
|
if (reply.statusCode >= 400) {
|
||||||
|
|
|
@ -18,4 +18,12 @@ export const ConfigBoundary = z.object({
|
||||||
.int()
|
.int()
|
||||||
.describe('Discovery timeout after which the current discovery is considered failed')
|
.describe('Discovery timeout after which the current discovery is considered failed')
|
||||||
.default(20),
|
.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)
|
this.log?.error('HTTP request error: %o', error)
|
||||||
return {
|
return {
|
||||||
statusCode: 500,
|
|
||||||
headers: headers(textContentType),
|
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 } {
|
function createTestServer(): { http: Server; prometheus: HttpServer } {
|
||||||
|
return createTestServerWithBasicAuth({})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestServerWithBasicAuth(basicAuth: Record<string, string>): { http: Server; prometheus: HttpServer } {
|
||||||
const http = createServer()
|
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)
|
prometheus.serverFactory = (handler) => http.on('request', handler)
|
||||||
fastifyServe(prometheus).catch((err: Error) => {
|
fastifyServe(prometheus).catch((err: Error) => {
|
||||||
if (!('code' in err) || (err as unknown as { code: unknown }).code !== 'ERR_SERVER_ALREADY_LISTEN') {
|
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 }
|
return { http, prometheus }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secretAsBcrypt = '$2b$12$B8C9hsi2idheYOdSM9au0.6DbD6z44iI5dZo.72AYLsAEiNdnqNPG'
|
||||||
|
|
||||||
describe('Fastify HTTP adapter', () => {
|
describe('Fastify HTTP adapter', () => {
|
||||||
test('Serves 503 everywhere while metrics are not available', () => {
|
test('Serves 503 everywhere while metrics are not available', () => {
|
||||||
return request(createTestServer().http)
|
return request(createTestServer().http)
|
||||||
|
@ -64,4 +75,50 @@ describe('Fastify HTTP adapter', () => {
|
||||||
].join('\n'),
|
].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