Files
speckle-server/packages/server/app.ts
T
Iain Sproat a537d34dcc Rate limit all endpoints (#1213)
* Demonstration of bug to test when middleware added
- Adding middleware, even no-op, causes test to fail

* Make middleware async, but introduce delay.
Revert test back to original.

* Revert tests

* Add a 1ms sleep to the test to reduce likelihood of flakiness

* Rate limiting on all express endpoints using middleware

* Adds all configuration for existing rate limited endpoints

* It is helpful to add the package to yarn first

* Implements respectsLimits using Redis rate limiter

* Fix for test `Should rate-limit user creation`
- if rate limit error, post to `/auth/local/register` will return a 429 status code

* All rate limiting provided by new ratelimiter.ts

* Consolidate typescript interfaces

* Amend signature of function to require source to be passed in, and not try to guess it from the request

* Rename respectsLimits to isWithinRateLimits

* Throw within catch of Promise

* Replace rejectsRequestWithRatelimitStatusIfNeeded throughout code

* Sending rate limit response should deal with other types of error
- Sentry notified of the error

* Express middleware rate limits by a 3 second burst or a daily rate
- Provide action when generating 429 response

* Prevent DOS of Redis

* Add 'Retry-After' for all cases when responding with 429 status code
- default of 1 day, but dynamic based on available information

* Generate rate limiters once, on init
- Improved and consistent handling of exit from functions
- fixed environment variable names

* WIP Refactor rate limiting setup

Co-authored-by: Iain Sproat <iainsproat@users.noreply.github.com>

* WIP: fixed references, now runs but tests fail

* Use getSourceFromRequest where possible

* WIP: unit tests for rate limiter

* Unit tests for ratelimiter

* feat(IFC): WIP IFC parser improvements

* Revert "feat(IFC): WIP IFC parser improvements"

This reverts commit 093089a2c4.

* refactor authz, rate limiting middleware to global

Co-authored-by: Kristaps Fabians Geikins <fabis94@users.noreply.github.com>
Co-authored-by: Iain Sproat <iainsproat@users.noreply.github.com>

* invites tests fix

* fix(server ratelimiter): export public interfaces

* Unit test for rate limiter use in memory rate limiter
- in memory rate limiter is configured with zero limit by default

* Fixed #1219 (#1221)

* WIP: improve auth test for rate limiting user creation

* ci(circleci config): publishing was broken when main branch was tagged (i.e. for releases) (#1224)

* Gitignore CPU profiles

* All tests are  now passing locally

* Fixed an issue in the frontend which was causing the views not to work. Fixed an issue with object selection camera animation where the dolly lerp factor was much too high for smooth animation (#1225)

* feat(structured logging): implements structured logging for backend (#1217)

* each log line is a json object
* structured logging allows logs to be ingested by machines and the logs to be indexed and queried addresses #1105
* structured logging allows arbitrary properties to be appended to each log line, and ingestion of logs to remain robust
* Structured logging provided by `pino` library
* Add `express-pino-logger` dependency
* Remove `debug`, `morgan`, and `morgan-debug` and replace with structured logging
* `console.log` & `console.error` replaced with structured logging in backend
* Remove `DEBUG` environment variable and replace with `LOG_LEVEL`
- Note that there is a test which reads from a logged line on `stdout`. This is not robust, it would be better to use the childProcess.pid to look up the port number.
* Log errors at points we explicitly send error to Sentry
* Amend indentation of a couple of log messages to align indentation with others

* Revert "feat(structured logging): implements structured logging for backend (#1217)" (#1227)

This reverts commit 84cb74e8b3.

* Move error to core/errors
- augmented typescript types moved to type-augmentations

* Added a missing wait in the screenshot generation loop (#1228)

* refactor(server rest api): remove duplicate rate limit requests

* feat(server rate limits): increase rate limits for the upload endpoints

* chore(server rate limits): final cleanup

Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com>
Co-authored-by: Iain Sproat <iainsproat@users.noreply.github.com>
Co-authored-by: Dimitrie Stefanescu <didimitrie@gmail.com>
Co-authored-by: Kristaps Fabians Geikins <fabis94@users.noreply.github.com>
Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com>
Co-authored-by: Alexandru Popovici <alexandrupopoviciioan@gmail.com>
2022-11-29 17:06:11 +01:00

320 lines
9.5 KiB
TypeScript

/* istanbul ignore file */
import './bootstrap'
import http from 'http'
import express, { Express } from 'express'
// `express-async-errors` patches express to catch errors in async handlers. no variable needed
import 'express-async-errors'
import compression from 'compression'
import logger from 'morgan-debug'
import debug from 'debug'
import { createTerminus } from '@godaddy/terminus'
import * as Sentry from '@sentry/node'
import Logging from '@/logging'
import { errorLoggingMiddleware } from '@/logging/errorLogging'
import prometheusClient from 'prom-client'
import {
ApolloServer,
ForbiddenError,
ApolloServerExpressConfig
} from 'apollo-server-express'
import { SubscriptionServer } from 'subscriptions-transport-ws'
import { execute, subscribe } from 'graphql'
import knex from '@/db/knex'
import { monitorActiveConnections } from '@/logging/httpServerMonitoring'
import { buildErrorFormatter } from '@/modules/core/graph/setup'
import { isDevEnv, isTestEnv } from '@/modules/shared/helpers/envHelper'
import * as ModulesSetup from '@/modules'
import { Optional } from '@/modules/shared/helpers/typeHelper'
import { createRateLimiterMiddleware } from '@/modules/core/services/ratelimiter'
import { get, has, isString, toNumber } from 'lodash'
import { authContextMiddleware, buildContext } from '@/modules/shared/middleware'
let graphqlServer: ApolloServer
/**
* TODO: subscriptions-transport-ws is no longer maintained, we should migrate to graphql-ws insted. The problem
* is that graphql-ws uses an entirely different protocol, so the client-side has to change as well, and so old clients
* will be unable to use any WebSocket/subscriptions functionality with the updated server
*/
function buildApolloSubscriptionServer(
apolloServer: ApolloServer,
server: http.Server
): SubscriptionServer {
const schema = ModulesSetup.graphSchema()
// Init metrics
prometheusClient.register.removeSingleMetric('speckle_server_apollo_connect')
const metricConnectCounter = new prometheusClient.Counter({
name: 'speckle_server_apollo_connect',
help: 'Number of connects'
})
prometheusClient.register.removeSingleMetric('speckle_server_apollo_clients')
const metricConnectedClients = new prometheusClient.Gauge({
name: 'speckle_server_apollo_clients',
help: 'Number of currently connected clients'
})
return SubscriptionServer.create(
{
schema,
execute,
subscribe,
validationRules: apolloServer.requestOptions.validationRules,
onConnect: async (connectionParams: Record<string, unknown>) => {
metricConnectCounter.inc()
metricConnectedClients.inc()
// Resolve token
let token: string
try {
let header: Optional<string>
const possiblePaths = [
'Authorization',
'authorization',
'headers.Authorization',
'headers.authorization'
]
for (const possiblePath of possiblePaths) {
if (has(connectionParams, possiblePath)) {
header = get(connectionParams, possiblePath) as string
if (header) break
}
}
if (!header) {
throw new Error("Couldn't resolve auth header for subscription")
}
token = header.split(' ')[1]
if (!token) {
throw new Error("Couldn't resolve token from auth header")
}
} catch (e) {
throw new ForbiddenError('You need a token to subscribe')
}
// Build context (Apollo Server v3 no longer triggers context building automatically
// for subscriptions)
try {
return await buildContext({ req: null, token })
} catch (e) {
throw new ForbiddenError('Subscription context build failed')
}
},
onDisconnect: () => {
metricConnectedClients.dec()
}
},
{
server,
path: apolloServer.graphqlPath
}
)
}
/**
* Create Apollo Server instance
* @param optionOverrides Optionally override ctor options
* @param subscriptionServerResolver If you expect to use subscriptions on this instance,
* pass in a callable that resolves the subscription server
*/
export async function buildApolloServer(
optionOverrides?: Partial<ApolloServerExpressConfig>,
subscriptionServerResolver?: () => SubscriptionServer
): Promise<ApolloServer> {
const debug = optionOverrides?.debug || isDevEnv() || isTestEnv()
const schema = ModulesSetup.graphSchema()
const server = new ApolloServer({
schema,
context: buildContext,
plugins: [
require('@/logging/apolloPlugin'),
...(subscriptionServerResolver
? [
{
async serverWillStart() {
return {
async drainServer() {
subscriptionServerResolver().close()
}
}
}
}
]
: [])
],
introspection: true,
cache: 'bounded',
persistedQueries: false,
csrfPrevention: true,
formatError: buildErrorFormatter(debug),
debug,
...optionOverrides
})
await server.start()
return server
}
/**
* Initialises all server (express/subscription/http) instances
*/
export async function init() {
const app = express()
app.disable('x-powered-by')
Logging(app)
// Moves things along automatically on restart.
// Should perhaps be done manually?
await knex.migrate.latest()
if (process.env.NODE_ENV !== 'test') {
app.use(logger('speckle', 'dev', {}))
}
if (process.env.COMPRESSION) {
app.use(compression())
}
app.use(express.json({ limit: '100mb' }))
app.use(express.urlencoded({ limit: '100mb', extended: false }))
// Trust X-Forwarded-* headers (for https protocol detection)
app.enable('trust proxy')
// Log errors
app.use(errorLoggingMiddleware)
app.use(authContextMiddleware)
app.use(createRateLimiterMiddleware())
app.use(Sentry.Handlers.errorHandler())
// Initialize default modules, including rest api handlers
await ModulesSetup.init(app)
// Initialize graphql server
// (Apollo Server v3 has an ugly API here - the ApolloServer ctor needs SubscriptionServer,
// and the SubscriptionServer ctor needs ApolloServer...hence the callback passed into buildApolloServer)
// eslint-disable-next-line prefer-const
let subscriptionServer: SubscriptionServer
graphqlServer = await buildApolloServer(undefined, () => subscriptionServer)
graphqlServer.applyMiddleware({ app })
// Expose prometheus metrics
app.get('/metrics', async (req, res) => {
try {
res.set('Content-Type', prometheusClient.register.contentType)
res.end(await prometheusClient.register.metrics())
} catch (ex: unknown) {
res.status(500).end(ex instanceof Error ? ex.message : `${ex}`)
}
})
// Init HTTP server & subscription server
const server = http.createServer(app)
subscriptionServer = buildApolloSubscriptionServer(graphqlServer, server)
return { app, graphqlServer, server, subscriptionServer }
}
export async function shutdown(): Promise<void> {
await ModulesSetup.shutdown()
}
const shouldUseFrontendProxy = () => process.env.NODE_ENV === 'development'
async function createFrontendProxy() {
const frontendHost = process.env.FRONTEND_HOST || 'localhost'
const frontendPort = process.env.FRONTEND_PORT || 8080
const { createProxyMiddleware } = await import('http-proxy-middleware')
// even tho it has default values, it fixes http-proxy setting `Connection: close` on each request
// slowing everything down
const defaultAgent = new http.Agent()
return createProxyMiddleware({
target: `http://${frontendHost}:${frontendPort}`,
changeOrigin: true,
ws: false,
logLevel: 'silent',
agent: defaultAgent
})
}
/**
* Starts a http server, hoisting the express app to it.
*/
export async function startHttp(
server: http.Server,
app: Express,
customPortOverride?: number
) {
let bindAddress = process.env.BIND_ADDRESS || '127.0.0.1'
let port = process.env.PORT ? toNumber(process.env.PORT) : 3000
// Handles frontend proxying:
// Dev mode -> proxy form the local webpack server
if (customPortOverride || customPortOverride === 0) port = customPortOverride
if (shouldUseFrontendProxy()) {
// app.use('/', frontendProxy)
app.use(await createFrontendProxy())
debug('speckle:startup')('✨ Proxying frontend (dev mode):')
debug('speckle:startup')(`👉 main application: http://localhost:${port}/`)
}
// Production mode
else {
bindAddress = process.env.BIND_ADDRESS || '0.0.0.0'
}
monitorActiveConnections(server)
app.set('port', port)
// large timeout to allow large downloads on slow connections to finish
createTerminus(server, {
signals: ['SIGTERM', 'SIGINT'],
timeout: 5 * 60 * 1000,
beforeShutdown: async () => {
debug('speckle:shutdown')('Shutting down (signal received)...')
},
onSignal: async () => {
await shutdown()
},
onShutdown: () => {
debug('speckle:shutdown')('Shutdown completed')
process.exit(0)
}
})
server.on('listening', () => {
const address = server.address()
const addressString = isString(address) ? address : address?.address
const port = isString(address) ? null : address?.port
debug('speckle:startup')(
`🚀 My name is Speckle Server, and I'm running at ${addressString}:${port}`
)
app.emit('appStarted')
})
server.listen(port, bindAddress)
server.keepAliveTimeout = 61 * 1000
server.headersTimeout = 65 * 1000
return { server }
}