update structure
This commit is contained in:
8
ts/http/index.ts
Normal file
8
ts/http/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* HTTP functionality module
|
||||
*/
|
||||
|
||||
// Export submodules
|
||||
export * from './port80/index.js';
|
||||
export * from './router/index.js';
|
||||
export * from './redirects/index.js';
|
106
ts/http/models/http-types.ts
Normal file
106
ts/http/models/http-types.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
ForwardConfig,
|
||||
DomainOptions,
|
||||
AcmeOptions
|
||||
} from '../../certificate/models/certificate-types.js';
|
||||
|
||||
/**
|
||||
* HTTP-specific event types
|
||||
*/
|
||||
export enum HttpEvents {
|
||||
REQUEST_RECEIVED = 'request-received',
|
||||
REQUEST_FORWARDED = 'request-forwarded',
|
||||
REQUEST_HANDLED = 'request-handled',
|
||||
REQUEST_ERROR = 'request-error',
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP status codes as an enum for better type safety
|
||||
*/
|
||||
export enum HttpStatus {
|
||||
OK = 200,
|
||||
MOVED_PERMANENTLY = 301,
|
||||
FOUND = 302,
|
||||
TEMPORARY_REDIRECT = 307,
|
||||
PERMANENT_REDIRECT = 308,
|
||||
BAD_REQUEST = 400,
|
||||
NOT_FOUND = 404,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
NOT_IMPLEMENTED = 501,
|
||||
SERVICE_UNAVAILABLE = 503,
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a domain configuration with certificate status information
|
||||
*/
|
||||
export interface DomainCertificate {
|
||||
options: DomainOptions;
|
||||
certObtained: boolean;
|
||||
obtainingInProgress: boolean;
|
||||
certificate?: string;
|
||||
privateKey?: string;
|
||||
expiryDate?: Date;
|
||||
lastRenewalAttempt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base error class for HTTP-related errors
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to certificate operations
|
||||
*/
|
||||
export class CertificateError extends HttpError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly domain: string,
|
||||
public readonly isRenewal: boolean = false
|
||||
) {
|
||||
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
|
||||
this.name = 'CertificateError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to server operations
|
||||
*/
|
||||
export class ServerError extends HttpError {
|
||||
constructor(message: string, public readonly code?: string) {
|
||||
super(message);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect configuration for HTTP requests
|
||||
*/
|
||||
export interface RedirectConfig {
|
||||
source: string; // Source path or pattern
|
||||
destination: string; // Destination URL
|
||||
type: HttpStatus; // Redirect status code
|
||||
preserveQuery?: boolean; // Whether to preserve query parameters
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP router configuration
|
||||
*/
|
||||
export interface RouterConfig {
|
||||
routes: Array<{
|
||||
path: string;
|
||||
handler: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void;
|
||||
}>;
|
||||
notFoundHandler?: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void;
|
||||
}
|
||||
|
||||
// Backward compatibility interfaces
|
||||
export { HttpError as Port80HandlerError };
|
||||
export { CertificateError as CertError };
|
||||
export type IDomainCertificate = DomainCertificate;
|
221
ts/http/port80/challenge-responder.ts
Normal file
221
ts/http/port80/challenge-responder.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
CertificateEvents
|
||||
} from '../../certificate/events/certificate-events.js';
|
||||
import type {
|
||||
CertificateData,
|
||||
CertificateFailure,
|
||||
CertificateExpiring
|
||||
} from '../../certificate/models/certificate-types.js';
|
||||
|
||||
/**
|
||||
* Handles ACME HTTP-01 challenge responses
|
||||
*/
|
||||
export class ChallengeResponder extends plugins.EventEmitter {
|
||||
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
||||
private http01Handler: plugins.smartacme.handlers.Http01MemoryHandler | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new challenge responder
|
||||
* @param useProduction Whether to use production ACME servers
|
||||
* @param email Account email for ACME
|
||||
* @param certificateStore Directory to store certificates
|
||||
*/
|
||||
constructor(
|
||||
private readonly useProduction: boolean = false,
|
||||
private readonly email: string = 'admin@example.com',
|
||||
private readonly certificateStore: string = './certs'
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the ACME client
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
// Initialize SmartAcme
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
useProduction: this.useProduction,
|
||||
accountEmail: this.email,
|
||||
directoryUrl: this.useProduction
|
||||
? 'https://acme-v02.api.letsencrypt.org/directory' // Production
|
||||
: 'https://acme-staging-v02.api.letsencrypt.org/directory', // Staging
|
||||
});
|
||||
|
||||
// Initialize HTTP-01 challenge handler
|
||||
this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
|
||||
this.smartAcme.useHttpChallenge(this.http01Handler);
|
||||
|
||||
// Ensure certificate store directory exists
|
||||
await this.ensureCertificateStore();
|
||||
|
||||
// Subscribe to SmartAcme events
|
||||
this.smartAcme.on('certificate-issued', (data: any) => {
|
||||
const certData: CertificateData = {
|
||||
domain: data.domain,
|
||||
certificate: data.cert,
|
||||
privateKey: data.key,
|
||||
expiryDate: new Date(data.expiryDate),
|
||||
};
|
||||
this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData);
|
||||
});
|
||||
|
||||
this.smartAcme.on('certificate-renewed', (data: any) => {
|
||||
const certData: CertificateData = {
|
||||
domain: data.domain,
|
||||
certificate: data.cert,
|
||||
privateKey: data.key,
|
||||
expiryDate: new Date(data.expiryDate),
|
||||
};
|
||||
this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData);
|
||||
});
|
||||
|
||||
this.smartAcme.on('certificate-error', (data: any) => {
|
||||
const error: CertificateFailure = {
|
||||
domain: data.domain,
|
||||
error: data.error instanceof Error ? data.error.message : String(data.error),
|
||||
isRenewal: data.isRenewal || false,
|
||||
};
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, error);
|
||||
});
|
||||
|
||||
await this.smartAcme.initialize();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to initialize ACME client: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure certificate store directory exists
|
||||
*/
|
||||
private async ensureCertificateStore(): Promise<void> {
|
||||
try {
|
||||
await plugins.fs.promises.mkdir(this.certificateStore, { recursive: true });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create certificate store: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP request and check if it's an ACME challenge
|
||||
* @param req HTTP request
|
||||
* @param res HTTP response
|
||||
* @returns true if the request was handled as an ACME challenge
|
||||
*/
|
||||
public handleRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!this.http01Handler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = req.url || '/';
|
||||
|
||||
// Check if this is an ACME challenge request
|
||||
if (url.startsWith('/.well-known/acme-challenge/')) {
|
||||
const token = url.split('/').pop() || '';
|
||||
|
||||
if (token) {
|
||||
const response = this.http01Handler.getResponse(token);
|
||||
|
||||
if (response) {
|
||||
// This is a valid ACME challenge
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
||||
res.writeHead(200);
|
||||
res.end(response);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid ACME challenge
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a certificate for a domain
|
||||
* @param domain Domain name
|
||||
* @param isRenewal Whether this is a renewal
|
||||
*/
|
||||
public async requestCertificate(domain: string, isRenewal: boolean = false): Promise<CertificateData> {
|
||||
if (!this.smartAcme) {
|
||||
throw new Error('ACME client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.smartAcme.getCertificate(domain);
|
||||
|
||||
const certData: CertificateData = {
|
||||
domain,
|
||||
certificate: result.cert,
|
||||
privateKey: result.key,
|
||||
expiryDate: new Date(result.expiryDate),
|
||||
};
|
||||
|
||||
// Emit appropriate event
|
||||
if (isRenewal) {
|
||||
this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData);
|
||||
} else {
|
||||
this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData);
|
||||
}
|
||||
|
||||
return certData;
|
||||
} catch (error) {
|
||||
// Construct failure object
|
||||
const failure: CertificateFailure = {
|
||||
domain,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
isRenewal,
|
||||
};
|
||||
|
||||
// Emit failure event
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
|
||||
|
||||
throw new Error(`Failed to ${isRenewal ? 'renew' : 'obtain'} certificate for ${domain}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a certificate is expiring soon
|
||||
* @param domain Domain name
|
||||
* @param certificate Certificate data
|
||||
* @param thresholdDays Days before expiry to trigger a renewal
|
||||
*/
|
||||
public checkCertificateExpiry(
|
||||
domain: string,
|
||||
certificate: CertificateData,
|
||||
thresholdDays: number = 30
|
||||
): void {
|
||||
if (!certificate.expiryDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiryDate = certificate.expiryDate;
|
||||
const daysDifference = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDifference <= thresholdDays) {
|
||||
const expiryInfo: CertificateExpiring = {
|
||||
domain,
|
||||
expiryDate,
|
||||
daysRemaining: daysDifference,
|
||||
};
|
||||
|
||||
this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiryInfo);
|
||||
|
||||
// Automatically attempt renewal if expiring
|
||||
if (this.smartAcme) {
|
||||
this.requestCertificate(domain, true).catch(error => {
|
||||
console.error(`Failed to auto-renew certificate for ${domain}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
3
ts/http/port80/index.ts
Normal file
3
ts/http/port80/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* Port 80 handling
|
||||
*/
|
@ -1,57 +1,33 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { Port80HandlerEvents } from '../common/types.js';
|
||||
import { CertificateEvents } from '../../certificate/events/certificate-events.js';
|
||||
import type {
|
||||
IForwardConfig,
|
||||
IDomainOptions,
|
||||
ICertificateData,
|
||||
ICertificateFailure,
|
||||
ICertificateExpiring,
|
||||
IAcmeOptions
|
||||
} from '../common/types.js';
|
||||
// (fs and path I/O moved to CertProvisioner)
|
||||
ForwardConfig,
|
||||
DomainOptions,
|
||||
CertificateData,
|
||||
CertificateFailure,
|
||||
CertificateExpiring,
|
||||
AcmeOptions
|
||||
} from '../../certificate/models/certificate-types.js';
|
||||
import {
|
||||
HttpEvents,
|
||||
HttpStatus,
|
||||
HttpError,
|
||||
CertificateError,
|
||||
ServerError,
|
||||
DomainCertificate
|
||||
} from '../models/http-types.js';
|
||||
import { ChallengeResponder } from './challenge-responder.js';
|
||||
|
||||
/**
|
||||
* Custom error classes for better error handling
|
||||
*/
|
||||
export class Port80HandlerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'Port80HandlerError';
|
||||
}
|
||||
// Re-export for backward compatibility
|
||||
export {
|
||||
HttpError as Port80HandlerError,
|
||||
CertificateError,
|
||||
ServerError
|
||||
}
|
||||
|
||||
export class CertificateError extends Port80HandlerError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly domain: string,
|
||||
public readonly isRenewal: boolean = false
|
||||
) {
|
||||
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
|
||||
this.name = 'CertificateError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends Port80HandlerError {
|
||||
constructor(message: string, public readonly code?: string) {
|
||||
super(message);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Represents a domain configuration with certificate status information
|
||||
*/
|
||||
interface IDomainCertificate {
|
||||
options: IDomainOptions;
|
||||
certObtained: boolean;
|
||||
obtainingInProgress: boolean;
|
||||
certificate?: string;
|
||||
privateKey?: string;
|
||||
expiryDate?: Date;
|
||||
lastRenewalAttempt?: Date;
|
||||
}
|
||||
// Port80Handler events enum for backward compatibility
|
||||
export const Port80HandlerEvents = CertificateEvents;
|
||||
|
||||
/**
|
||||
* Configuration options for the Port80Handler
|
||||
@ -64,25 +40,22 @@ interface IDomainCertificate {
|
||||
* Now with glob pattern support for domain matching
|
||||
*/
|
||||
export class Port80Handler extends plugins.EventEmitter {
|
||||
private domainCertificates: Map<string, IDomainCertificate>;
|
||||
// SmartAcme instance for certificate management
|
||||
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
||||
private smartAcmeHttp01Handler!: plugins.smartacme.handlers.Http01MemoryHandler;
|
||||
private domainCertificates: Map<string, DomainCertificate>;
|
||||
private challengeResponder: ChallengeResponder | null = null;
|
||||
private server: plugins.http.Server | null = null;
|
||||
|
||||
|
||||
// Renewal scheduling is handled externally by SmartProxy
|
||||
// (Removed internal renewal timer)
|
||||
private isShuttingDown: boolean = false;
|
||||
private options: Required<IAcmeOptions>;
|
||||
private options: Required<AcmeOptions>;
|
||||
|
||||
/**
|
||||
* Creates a new Port80Handler
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(options: IAcmeOptions = {}) {
|
||||
constructor(options: AcmeOptions = {}) {
|
||||
super();
|
||||
this.domainCertificates = new Map<string, IDomainCertificate>();
|
||||
|
||||
this.domainCertificates = new Map<string, DomainCertificate>();
|
||||
|
||||
// Default options
|
||||
this.options = {
|
||||
port: options.port ?? 80,
|
||||
@ -97,6 +70,32 @@ export class Port80Handler extends plugins.EventEmitter {
|
||||
autoRenew: options.autoRenew ?? true,
|
||||
domainForwards: options.domainForwards ?? []
|
||||
};
|
||||
|
||||
// Initialize challenge responder
|
||||
if (this.options.enabled) {
|
||||
this.challengeResponder = new ChallengeResponder(
|
||||
this.options.useProduction,
|
||||
this.options.accountEmail,
|
||||
this.options.certificateStore
|
||||
);
|
||||
|
||||
// Forward certificate events from the challenge responder
|
||||
this.challengeResponder.on(CertificateEvents.CERTIFICATE_ISSUED, (data: CertificateData) => {
|
||||
this.emit(CertificateEvents.CERTIFICATE_ISSUED, data);
|
||||
});
|
||||
|
||||
this.challengeResponder.on(CertificateEvents.CERTIFICATE_RENEWED, (data: CertificateData) => {
|
||||
this.emit(CertificateEvents.CERTIFICATE_RENEWED, data);
|
||||
});
|
||||
|
||||
this.challengeResponder.on(CertificateEvents.CERTIFICATE_FAILED, (error: CertificateFailure) => {
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, error);
|
||||
});
|
||||
|
||||
this.challengeResponder.on(CertificateEvents.CERTIFICATE_EXPIRING, (expiry: CertificateExpiring) => {
|
||||
this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiry);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
3
ts/http/redirects/index.ts
Normal file
3
ts/http/redirects/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* HTTP redirects
|
||||
*/
|
3
ts/http/router/index.ts
Normal file
3
ts/http/router/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* HTTP routing
|
||||
*/
|
Reference in New Issue
Block a user