11import { Elysia , t } from 'elysia' ;
22import { cors } from '@elysiajs/cors' ;
3+ import { join , resolve } from 'node:path' ;
34import { loadService , executeService , runPreflight , getImageName , buildServiceImage } from '@ignite/core' ;
45import { logger } from '@ignite/shared' ;
56import type {
@@ -14,19 +15,101 @@ export interface ServerOptions {
1415 port ?: number ;
1516 host ?: string ;
1617 servicesPath ?: string ;
18+ /** API key for bearer token authentication. If not set, auth is disabled (NOT RECOMMENDED for production) */
19+ apiKey ?: string ;
20+ /** Rate limit: max requests per window (default: 60) */
21+ rateLimit ?: number ;
22+ /** Rate limit window in milliseconds (default: 60000 = 1 minute) */
23+ rateLimitWindow ?: number ;
24+ }
25+
26+ // Service name validation: lowercase alphanumeric with hyphens, 2-63 chars (Docker compatible)
27+ const SERVICE_NAME_REGEX = / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 - ] { 0 , 61 } [ a - z 0 - 9 ] $ | ^ [ a - z 0 - 9 ] $ / ;
28+
29+ /**
30+ * Validates and sanitizes service name to prevent path traversal and ensure Docker compatibility
31+ */
32+ function validateServiceName ( name : string ) : { valid : boolean ; error ?: string } {
33+ if ( ! name || typeof name !== 'string' ) {
34+ return { valid : false , error : 'Service name is required' } ;
35+ }
36+
37+ // Block path traversal attempts
38+ if ( name . includes ( '..' ) || name . includes ( '/' ) || name . includes ( '\\' ) ) {
39+ return { valid : false , error : 'Service name contains invalid characters' } ;
40+ }
41+
42+ // Validate Docker-compatible naming
43+ if ( ! SERVICE_NAME_REGEX . test ( name ) ) {
44+ return { valid : false , error : 'Service name must be lowercase alphanumeric with hyphens (1-63 chars)' } ;
45+ }
46+
47+ return { valid : true } ;
48+ }
49+
50+ /**
51+ * Simple in-memory rate limiter
52+ */
53+ function createRateLimiter ( maxRequests : number , windowMs : number ) {
54+ const requests = new Map < string , { count : number ; resetTime : number } > ( ) ;
55+
56+ return {
57+ check ( clientId : string ) : { allowed : boolean ; retryAfter ?: number } {
58+ const now = Date . now ( ) ;
59+ const record = requests . get ( clientId ) ;
60+
61+ if ( ! record || now > record . resetTime ) {
62+ requests . set ( clientId , { count : 1 , resetTime : now + windowMs } ) ;
63+ return { allowed : true } ;
64+ }
65+
66+ if ( record . count >= maxRequests ) {
67+ return { allowed : false , retryAfter : Math . ceil ( ( record . resetTime - now ) / 1000 ) } ;
68+ }
69+
70+ record . count ++ ;
71+ return { allowed : true } ;
72+ } ,
73+
74+ // Cleanup old entries periodically
75+ cleanup ( ) {
76+ const now = Date . now ( ) ;
77+ for ( const [ key , record ] of requests . entries ( ) ) {
78+ if ( now > record . resetTime ) {
79+ requests . delete ( key ) ;
80+ }
81+ }
82+ }
83+ } ;
1784}
1885
1986const startTime = Date . now ( ) ;
2087
2188export function createServer ( options : ServerOptions = { } ) {
22- const { port = 3000 , host = 'localhost' , servicesPath = './services' } = options ;
89+ const {
90+ port = 3000 ,
91+ host = 'localhost' ,
92+ servicesPath = './services' ,
93+ apiKey,
94+ rateLimit = 60 ,
95+ rateLimitWindow = 60000 ,
96+ } = options ;
97+
98+ const resolvedServicesPath = resolve ( servicesPath ) ;
99+ const rateLimiter = createRateLimiter ( rateLimit , rateLimitWindow ) ;
100+
101+ const cleanupInterval = setInterval ( ( ) => rateLimiter . cleanup ( ) , rateLimitWindow ) ;
23102
24103 const app = new Elysia ( )
25104 . use ( cors ( ) )
26105 . onError ( ( { code, error, set } ) => {
27106 const errorMessage = error instanceof Error ? error . message : String ( error ) ;
28- logger . error ( `Request error: ${ errorMessage } ` ) ;
29- set . status = code === 'NOT_FOUND' ? 404 : 500 ;
107+ if ( ! errorMessage . includes ( 'Rate limit' ) && ! errorMessage . includes ( 'Unauthorized' ) ) {
108+ logger . error ( `Request error: ${ errorMessage } ` ) ;
109+ }
110+ if ( set . status === 200 ) {
111+ set . status = code === 'NOT_FOUND' ? 404 : 500 ;
112+ }
30113 return {
31114 error : errorMessage ,
32115 code : String ( code ) ,
@@ -37,14 +120,46 @@ export function createServer(options: ServerOptions = {}) {
37120 version : '0.1.0' ,
38121 uptime : Math . floor ( ( Date . now ( ) - startTime ) / 1000 ) ,
39122 } ) )
123+ . derive ( ( { request, set } ) => {
124+ const clientIp = request . headers . get ( 'x-forwarded-for' ) || 'unknown' ;
125+ const rateLimitResult = rateLimiter . check ( clientIp ) ;
126+
127+ if ( ! rateLimitResult . allowed ) {
128+ set . status = 429 ;
129+ set . headers [ 'Retry-After' ] = String ( rateLimitResult . retryAfter ) ;
130+ throw new Error ( `Rate limit exceeded. Retry after ${ rateLimitResult . retryAfter } seconds` ) ;
131+ }
132+
133+ if ( apiKey ) {
134+ const authHeader = request . headers . get ( 'authorization' ) ;
135+ const token = authHeader ?. startsWith ( 'Bearer ' ) ? authHeader . slice ( 7 ) : null ;
136+
137+ if ( ! token || token !== apiKey ) {
138+ set . status = 401 ;
139+ throw new Error ( 'Unauthorized: Invalid or missing API key' ) ;
140+ }
141+ }
142+
143+ return { } ;
144+ } )
40145 . post (
41146 '/services/:serviceName/execute' ,
42147 async ( { params, body, set } ) : Promise < ServiceExecutionResponse > => {
43148 const { serviceName } = params ;
44- const { input, skipPreflight, skipBuild } = body as ServiceExecutionRequest ;
149+ const { input, skipPreflight, skipBuild, audit } = body as ServiceExecutionRequest ;
150+
151+ const validation = validateServiceName ( serviceName ) ;
152+ if ( ! validation . valid ) {
153+ set . status = 400 ;
154+ return {
155+ success : false ,
156+ serviceName,
157+ error : validation . error ,
158+ } ;
159+ }
45160
46161 try {
47- const servicePath = ` ${ servicesPath } / ${ serviceName } ` ;
162+ const servicePath = join ( resolvedServicesPath , serviceName ) ;
48163 const service = await loadService ( servicePath ) ;
49164
50165 let preflightResult = undefined ;
@@ -66,7 +181,7 @@ export function createServer(options: ServerOptions = {}) {
66181 }
67182 }
68183
69- const metrics = await executeService ( service , { input, skipBuild } ) ;
184+ const metrics = await executeService ( service , { input, skipBuild, audit } ) ;
70185
71186 return {
72187 success : true ,
@@ -90,14 +205,24 @@ export function createServer(options: ServerOptions = {}) {
90205 input : t . Optional ( t . Unknown ( ) ) ,
91206 skipPreflight : t . Optional ( t . Boolean ( ) ) ,
92207 skipBuild : t . Optional ( t . Boolean ( ) ) ,
208+ audit : t . Optional ( t . Boolean ( ) ) ,
93209 } ) ,
94210 }
95211 )
96212 . get ( '/services/:serviceName/preflight' , async ( { params, set } ) : Promise < ServicePreflightResponse | ErrorResponse > => {
97213 const { serviceName } = params ;
98214
215+ const validation = validateServiceName ( serviceName ) ;
216+ if ( ! validation . valid ) {
217+ set . status = 400 ;
218+ return {
219+ error : validation . error ! ,
220+ code : 'INVALID_SERVICE_NAME' ,
221+ } ;
222+ }
223+
99224 try {
100- const servicePath = ` ${ servicesPath } / ${ serviceName } ` ;
225+ const servicePath = join ( resolvedServicesPath , serviceName ) ;
101226 const service = await loadService ( servicePath ) ;
102227 const imageName = getImageName ( service . config . service . name ) ;
103228
@@ -121,7 +246,7 @@ export function createServer(options: ServerOptions = {}) {
121246 . get ( '/services' , async ( { set } ) : Promise < { services : string [ ] } | ErrorResponse > => {
122247 try {
123248 const { readdir } = await import ( 'node:fs/promises' ) ;
124- const entries = await readdir ( servicesPath , { withFileTypes : true } ) ;
249+ const entries = await readdir ( resolvedServicesPath , { withFileTypes : true } ) ;
125250 const services = entries . filter ( ( e ) => e . isDirectory ( ) ) . map ( ( e ) => e . name ) ;
126251 return { services } ;
127252 } catch ( err ) {
@@ -146,6 +271,7 @@ export function createServer(options: ServerOptions = {}) {
146271 } ,
147272 stop : ( ) => {
148273 if ( isRunning ) {
274+ clearInterval ( cleanupInterval ) ;
149275 app . stop ( ) ;
150276 isRunning = false ;
151277 logger . info ( 'Ignite HTTP server stopped' ) ;
0 commit comments