-
Notifications
You must be signed in to change notification settings - Fork 871
Use IP certificates #1688
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Use IP certificates #1688
Conversation
- Add `acme-client` dependency to `src/shadowbox`. - Implement `CertificateManager` to automate ACME HTTP-01 challenges on port 80. - Integrate `CertificateManager` into `main.ts` for zero-downtime certificate renewal via `setSecureContext`. - Update `install_server.sh` to ensure write permissions for certificate updates. - Update Node.js engine requirement to `>=18.0.0` for `crypto.X509Certificate` support.
- Refactor `CertificateManager` to use a factory function (`createCertificateManager`) for dependency injection of the ACME client. - Update `main.ts` to use the factory and initialize certificate management asynchronously. - Remove unused dependencies (`node-fetch`, `mkdirp`) from `src/shadowbox`. - Ensure correct ACME client usage (v5 API). - Update `install_server.sh` to grant write permissions to the persisted state directory for certificate updates. - Update Node.js engine requirement to `>=18.0.0` for `crypto.X509Certificate` support.
- Refactor `CertificateManager` to use a factory function (`createCertificateManager`) for dependency injection of the ACME client. - Update `main.ts` to use `async/await` within an IIFE for cleaner initialization of the certificate manager, handling errors gracefully without blocking startup. - Remove unused dependencies (`node-fetch`, `mkdirp`) from `src/shadowbox`. - Ensure correct ACME client usage (v5 API). - Update `install_server.sh` to grant write permissions to the persisted state directory for certificate updates. - Update Node.js engine requirement to `>=18.0.0` for `crypto.X509Certificate` support.
| } else { | ||
| logging.info('Generating new ACME account key...'); | ||
| accountKey = await acme.crypto.createPrivateKey(); | ||
| fs.writeFileSync(accountKeyPath, accountKey); |
Check failure
Code scanning / CodeQL
Potential file system race condition High
was checked
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds automatic TLS certificate management using the ACME protocol to support IP-based certificates from Let's Encrypt. The implementation introduces a new CertificateManager class that handles certificate issuance, renewal, and hot-reloading without server downtime.
Key changes:
- Implements automated certificate lifecycle management with periodic renewal checks
- Adds HTTP-01 challenge server for ACME validation
- Integrates with existing server infrastructure for seamless certificate updates
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/shadowbox/server/certificate_manager.ts | New module implementing ACME-based certificate management with automatic renewal and HTTP-01 challenge handling |
| src/shadowbox/server/main.ts | Integrates certificate manager with async initialization pattern that doesn't block server startup |
| src/shadowbox/package.json | Adds acme-client dependency and updates Node.js types to v18 along with testing dependencies |
| src/server_manager/install_scripts/install_server.sh | Ensures state directory has proper write permissions for certificate storage |
| package.json | Relaxes Node.js version constraint from exact 18.x.x to >=18.0.0 |
| package-lock.json | Updates lock file with new dependencies including ACME client and cryptographic libraries |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } else { | ||
| logging.info('Generating new ACME account key...'); | ||
| accountKey = await acme.crypto.createPrivateKey(); | ||
| fs.writeFileSync(accountKeyPath, accountKey); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ACME account key file is created with default permissions using fs.writeFileSync, which may result in world-readable permissions depending on the umask. Private cryptographic keys should be protected with restricted permissions (e.g., 0600) to prevent unauthorized access. Use fs.writeFileSync with appropriate mode option or call fs.chmodSync after writing to ensure the key file is only readable by the owner.
| fs.writeFileSync(accountKeyPath, accountKey); | |
| fs.writeFileSync(accountKeyPath, accountKey, {mode: 0o600}); |
| // Save to disk | ||
| logging.info('Certificate obtained successfully. Saving...'); | ||
| fs.writeFileSync(this.certFile, cert); | ||
| fs.writeFileSync(this.keyFile, key); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The private key file is written with default permissions using fs.writeFileSync, which may result in world-readable permissions depending on the umask. Private keys must be protected with restricted permissions (e.g., 0600) to prevent unauthorized access and maintain server security. Use fs.writeFileSync with the mode option set to 0o600 or call fs.chmodSync immediately after writing.
| fs.writeFileSync(this.keyFile, key); | |
| fs.writeFileSync(this.keyFile, key, { mode: 0o600 }); |
| const order = await this.client.createOrder({ | ||
| identifiers: [{ type: 'ip', value: this.hostname }], |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no check to verify that the 'hostname' parameter is a valid IP address before attempting to create an ACME order with type 'ip'. If a hostname (DNS name) is passed instead of an IP address, the ACME order will likely fail. Consider adding validation to ensure the hostname is a valid IP address format, or provide clearer error messaging when the ACME order fails due to invalid identifier type.
| setInterval(() => { | ||
| this.checkAndRenew().catch((e) => { | ||
| logging.error(`Scheduled certificate renewal failed: ${e}`); | ||
| }); | ||
| }, CHECK_INTERVAL_MS); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The setInterval call is not stored in a variable, which means there's no way to clear this interval if the CertificateManager needs to be stopped or shut down gracefully. This creates a resource leak where the interval continues running even if the manager is no longer needed. Consider storing the interval ID and providing a stop/cleanup method to clear it when needed.
| challengeServer.close(); | ||
| logging.info('ACME challenge server closed.'); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The challengeServer.close() call in the finally block doesn't wait for the server to fully close before continuing. The http.Server.close() method is asynchronous and takes an optional callback. If an error occurs during certificate renewal and the server hasn't fully closed yet, this could leave the port bound or cause issues with subsequent renewal attempts. Consider wrapping this in a promise to ensure the server is fully closed before exiting the function.
| challengeServer.close(); | |
| logging.info('ACME challenge server closed.'); | |
| await new Promise<void>((resolve) => { | |
| challengeServer.close(() => { | |
| logging.info('ACME challenge server closed.'); | |
| resolve(); | |
| }); | |
| }); |
| // IP certificates usually don't need SANs if CN is IP, or maybe they do? | ||
| // Let's Encrypt for IP requires the IP in SANs. |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment suggests uncertainty about whether IP certificates need Subject Alternative Names (SANs), followed by a statement that Let's Encrypt requires them. This creates confusion about the implementation. Either remove the uncertain first sentence or rephrase to clearly state the requirement without expressing doubt, as the code already implements the correct behavior.
| // IP certificates usually don't need SANs if CN is IP, or maybe they do? | |
| // Let's Encrypt for IP requires the IP in SANs. | |
| // For Let's Encrypt IP certificates, the IP address must be included in the Subject Alternative Names (SANs). |
| try { | ||
| // Start server on port 80 | ||
| await new Promise<void>((resolve, reject) => { | ||
| challengeServer.listen(80, '0.0.0.0', () => { |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The challenge server binds to 0.0.0.0 which makes it accessible from all network interfaces. While this is necessary for ACME HTTP-01 challenges to work, it temporarily exposes the server on port 80 to the public internet during certificate renewal. Consider adding rate limiting or request validation to prevent potential abuse during the challenge period, or document this security consideration.
| if (e.code === 'EADDRINUSE') { | ||
| logging.error('Port 80 is in use. Cannot perform HTTP-01 challenge.'); | ||
| } |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handler only checks for the 'code' property on the error object, but TypeScript errors may not have this property. The code assumes 'e.code' exists without proper type checking. Consider using type guards or optional chaining (e?.code) to safely access this property, or cast the error to a NodeJS.ErrnoException type that has the code property.
| } as any); | ||
| /* eslint-enable @typescript-eslint/no-explicit-any */ | ||
|
|
||
| const authorizations = await this.client.getAuthorizations(order); |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code assumes that authorizations[0] will always exist without checking if the authorizations array is non-empty. If the ACME server returns an empty authorizations array, this will cause a runtime error when trying to access properties on undefined. Add a check to verify the array has at least one element before accessing authorizations[0].
| const authorizations = await this.client.getAuthorizations(order); | |
| const authorizations = await this.client.getAuthorizations(order); | |
| if (!authorizations || authorizations.length === 0) { | |
| throw new Error('No authorizations returned from ACME server'); | |
| } |
No description provided.