-
Notifications
You must be signed in to change notification settings - Fork 422
Scheduled Restarts Feature #2630
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: beta-5.9.1
Are you sure you want to change the base?
Conversation
Co-Authored-By: Donavan Becker <beckersmarthome@icloud.com>
| return '' | ||
| } | ||
|
|
||
| return webroot.replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '') |
Check failure
Code scanning / CodeQL
Polynomial regular expression used on uncontrolled data High
regular expression
a user-provided value
| const cachedAccessoriesBackup = join(cachedAccessoriesDir, `.cachedAccessories.${id}.bak`) | ||
|
|
||
| if (await pathExists(cachedAccessories)) { | ||
| await unlink(cachedAccessories) |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
user-provided value
This path depends on a
user-provided value
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 6 days ago
The best way to fix this problem is to ensure that the device ID passed from user input (route/query parameters) cannot be used to escape the intended directory or craft dangerous file paths. This can be achieved by strictly validating the deviceId value before passing it to any function that performs filesystem actions:
- Restrict deviceId to a safe pattern, e.g., allow only [a-zA-Z0-9] and maybe a few safe symbols (if needed), and forbid path-separators and dots.
- Use a helper function to validate deviceId against a regex before usage; if it fails, throw BadRequestException.
- Implement the validation centrally in the ServerService before any file path is constructed and any fs operation is performed, e.g., at the beginning of
deleteDevicePairing,deleteDeviceAccessories, etc. - Optionally, ensure (defensively) that after resolving the target file path, it remains within the expected directory (i.e., after path resolution, compare with intended root).
- Minimal change: Add a method for the validation, call it at the entry points in ServerService's public methods which take deviceId from user input (
deleteDevicePairing,deleteDeviceMatterConfig,deleteDeviceAccessories).
You only need to edit src/modules/server/server.service.ts.
Steps:
- Add an internal
validateDeviceId(id: string)helper that throws on invalid input. - Call this method at the start of all relevant public methods that receive a device ID from user input (
deleteDevicePairing,deleteDeviceMatterConfig,deleteDeviceAccessories). - Do not change business logic, just fail fast on bad input and prevent dangerous values from propagating.
-
Copy modified lines R45-R58 -
Copy modified line R453 -
Copy modified line R474 -
Copy modified line R556
| @@ -42,6 +42,20 @@ | ||
| export class ServerService { | ||
| private serverServiceCache = new NodeCache({ stdTTL: 300 }) | ||
|
|
||
| /** | ||
| * Validate a deviceId value for use as part of a filename. | ||
| * Only allows alphanumeric and uppercase letters (and optionally strip colons) | ||
| * Throws a BadRequestException if invalid. | ||
| * You may adjust the regex for business logic if needed. | ||
| */ | ||
| private validateDeviceId(id: string) { | ||
| // Only allow letters, numbers (and optionally colons if needed) | ||
| // This pattern: No directory traversal, no slashes, no dots, no spaces | ||
| const safePattern = /^[A-Za-z0-9:\-]+$/; | ||
| if (!safePattern.test(id)) { | ||
| throw new BadRequestException(`Invalid deviceId format: ${id}`); | ||
| } | ||
| } | ||
| private readonly accessoryId: string | ||
| private readonly accessoryInfoPath: string | ||
|
|
||
| @@ -436,6 +450,7 @@ | ||
| * Remove a device pairing | ||
| */ | ||
| public async deleteDevicePairing(id: string, resetPairingInfo: boolean) { | ||
| this.validateDeviceId(id); | ||
| this.logger.warn(`Shutting down Homebridge before resetting paired bridge ${id}...`) | ||
|
|
||
| // Wait for homebridge to stop | ||
| @@ -456,6 +471,7 @@ | ||
| * @throws InternalServerErrorException if removal fails | ||
| */ | ||
| public async deleteDeviceMatterConfig(id: string): Promise<{ ok: boolean }> { | ||
| this.validateDeviceId(id); | ||
| try { | ||
| const configFile = await this.configEditorService.getConfigFile() | ||
| // Format username with colons if not already present | ||
| @@ -537,6 +553,7 @@ | ||
| * Remove a device's accessories | ||
| */ | ||
| public async deleteDeviceAccessories(id: string) { | ||
| this.validateDeviceId(id); | ||
| this.logger.warn(`Shutting down Homebridge before removing accessories for paired bridge ${id}...`) | ||
|
|
||
| // Wait for homebridge to stop. |
| await unlink(cachedAccessories) | ||
| this.logger.warn(`Bridge ${id} accessory removal: removed ${cachedAccessories}.`) | ||
| if (await pathExists(cachedAccessoriesBackup)) { | ||
| await unlink(cachedAccessoriesBackup) |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
user-provided value
This path depends on a
user-provided value
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 6 days ago
The best way to fix this issue is to validate and sanitize the deviceId parameter before using it in any file path operations. Since deviceId can be highly variable and may be passed via URL params, it is safest to restrict it to allowed characters (e.g., alphanumerics, possibly colons or dashes if needed), or alternatively, to ensure all constructed paths are strictly contained within expected parent directories.
Approach:
- Implement a helper function to validate
deviceId. Only allow device IDs that match a safe regex, e.g.,/^[A-Za-z0-9:-]{1,64}$/. If it doesn't match, throw a BadRequestException or return early. - Apply this validation in every relevant method that accepts a raw
idparameter before using it in any path construction (notably, indeleteSingleDeviceAccessories,deleteSingleDevicePairing,deleteDevicePairing,deleteDeviceMatterConfig, and any public method that takes an id). - Optionally, after constructing file paths, resolve and check that the resulting path is within an allowed directory (containment check); however, in this case, strict validation of the ID suffices unless the format of accepted IDs must be broad.
File(s)/region(s) to change:
src/modules/server/server.service.ts: Add a validation method (e.g.,isValidDeviceId(id: string)). Use it in relevant public and private methods before constructing paths.- If coverage for all relevant uses is required, apply this to all entry-points for the parameter in the file.
Needed changes:
- Add a helper function for validating device IDs.
- At the top of each method taking an ID and using it for file path construction, validate the ID and throw a BadRequestException if it is invalid.
- Add appropriate imports if not present.
-
Copy modified line R25 -
Copy modified lines R52-R57 -
Copy modified lines R77-R79 -
Copy modified lines R115-R117 -
Copy modified lines R452-R454 -
Copy modified lines R475-R477 -
Copy modified lines R559-R561
| @@ -22,6 +22,7 @@ | ||
| InternalServerErrorException, | ||
| NotFoundException, | ||
| ServiceUnavailableException, | ||
| BadRequestException, | ||
| } from '@nestjs/common' | ||
| import { pathExists, readJson, remove, writeJson } from 'fs-extra/esm' | ||
| import NodeCache from 'node-cache' | ||
| @@ -48,6 +49,12 @@ | ||
| public setupCode: string | null = null | ||
| public paired: boolean = false | ||
|
|
||
| // Accept only strict device ids (hex, numbers, colons, dashes, up to 64 chars) | ||
| private isValidDeviceId(id: string): boolean { | ||
| // Accept: 0E:3C:22:18:EC:79, 0E3C2218EC79, etc. Adjust as needed for your format. | ||
| return /^[A-Za-z0-9:-]{1,64}$/.test(id) | ||
| } | ||
|
|
||
| constructor( | ||
| @Inject(ConfigService) private readonly configService: ConfigService, | ||
| @Inject(ConfigEditorService) private readonly configEditorService: ConfigEditorService, | ||
| @@ -67,6 +74,9 @@ | ||
| * @private | ||
| */ | ||
| private async deleteSingleDeviceAccessories(id: string, cachedAccessoriesDir: string, protocol: 'hap' | 'matter' | 'both' = 'both') { | ||
| if (!this.isValidDeviceId(id)) { | ||
| throw new BadRequestException(`Invalid device id format.`) | ||
| } | ||
| // Clean HAP accessories | ||
| if (protocol === 'hap' || protocol === 'both') { | ||
| const cachedAccessories = join(cachedAccessoriesDir, `cachedAccessories.${id}`) | ||
| @@ -102,6 +112,9 @@ | ||
| * @private | ||
| */ | ||
| private async deleteSingleDevicePairing(id: string, resetPairingInfo: boolean) { | ||
| if (!this.isValidDeviceId(id)) { | ||
| throw new BadRequestException(`Invalid device id format.`) | ||
| } | ||
| const persistPath = join(this.configService.storagePath, 'persist') | ||
| const accessoryInfo = join(persistPath, `AccessoryInfo.${id}.json`) | ||
| const identifierCache = join(persistPath, `IdentifierCache.${id}.json`) | ||
| @@ -436,6 +449,9 @@ | ||
| * Remove a device pairing | ||
| */ | ||
| public async deleteDevicePairing(id: string, resetPairingInfo: boolean) { | ||
| if (!this.isValidDeviceId(id)) { | ||
| throw new BadRequestException(`Invalid device id format.`) | ||
| } | ||
| this.logger.warn(`Shutting down Homebridge before resetting paired bridge ${id}...`) | ||
|
|
||
| // Wait for homebridge to stop | ||
| @@ -456,6 +472,9 @@ | ||
| * @throws InternalServerErrorException if removal fails | ||
| */ | ||
| public async deleteDeviceMatterConfig(id: string): Promise<{ ok: boolean }> { | ||
| if (!this.isValidDeviceId(id)) { | ||
| throw new BadRequestException(`Invalid device id format.`) | ||
| } | ||
| try { | ||
| const configFile = await this.configEditorService.getConfigFile() | ||
| // Format username with colons if not already present | ||
| @@ -537,6 +556,9 @@ | ||
| * Remove a device's accessories | ||
| */ | ||
| public async deleteDeviceAccessories(id: string) { | ||
| if (!this.isValidDeviceId(id)) { | ||
| throw new BadRequestException(`Invalid device id format.`) | ||
| } | ||
| this.logger.warn(`Shutting down Homebridge before removing accessories for paired bridge ${id}...`) | ||
|
|
||
| // Wait for homebridge to stop. |
bbbb1e3 to
501f284
Compare
This PR adds the ability to schedule automatic restarts of Homebridge and individual child bridges using cron expressions. Users can configure restart schedules globally for the main Homebridge process or individually for each child bridge, with optional timezone support.
Motivation
Many users want to schedule regular restarts of Homebridge to:
Previously, users had to rely on external cron jobs or system schedulers. This feature brings scheduling natively into the Homebridge Config UI X, making it accessible to all users regardless of their technical expertise.
Changes
Backend
Config Interfaces (
src/core/config/config.interfaces.ts)scheduledRestartfield toHomebridgeUiConfigfor global Homebridge restartsscheduledRestartfield toPluginChildBridgefor per-child-bridge restarts{ enabled?: boolean, cron?: string, timezone?: string }Restart Scheduler Service (
src/modules/server/restart-scheduler.service.ts)node-scheduleOnModuleInitto schedule jobs on startuprefreshSchedules(config?)method to cancel and reschedule all jobs when config changesrestart-homebridgefor main,restart-child-{DEVICEID}for child bridgesModule Wiring
RestartSchedulerServiceintoServerModulewith token-based providerConfigEditorServiceto use optional lazy lookup viaModuleRefto avoid circular dependenciesrefreshSchedules()after config saves to automatically update schedulesFrontend
Global Settings (
ui/src/app/modules/settings/)ScheduledRestartComponentmodal for configuring global Homebridge restarts/config-editor/uiendpointChild Bridge Settings (
ui/src/app/core/manage-plugins/plugin-bridge/)_bridge.scheduledRestartin plugin config blocksInternationalization (
ui/src/i18n/en.json)restart.schedule.*keys for global settingschild_bridge.config.scheduled_restart.*keys for per-bridge settingsScreenshots
Child Bridge Settings
Global Settings List
Global Settings Modal
Usage Examples
Schedule Daily Restart at 3 AM
{ "scheduledRestart": { "enabled": true, "cron": "0 3 * * *" } }Schedule Weekly Restart (Sunday at 4 AM, Pacific Time)
{ "scheduledRestart": { "enabled": true, "cron": "0 4 * * 0", "timezone": "America/Los_Angeles" } }Schedule Child Bridge Restart Every 6 Hours
{ "platform": "AirQuality", "_bridge": { "username": "0E:AE:C6:D3:94:EB", "port": 47593, "scheduledRestart": { "enabled": true, "cron": "0 */6 * * *" } } }Technical Details
Cron Expression Format
Uses standard cron syntax:
minute hour day month day-of-week*= any value*/n= every n units0-23= specific range1,3,5= specific valuesTimezone Support
America/New_York,Europe/London)Job Management
Dependency Management
UIX_RESTART_SCHEDULER) to avoid circular dependenciesModuleRefensures compatibility with test modulesTesting
Manual Testing Checklist
Breaking Changes
None. This is a completely new feature that is opt-in.
Migration Guide
No migration needed. Existing configurations continue to work without any changes.
Dependencies
No new npm dependencies. Uses existing
node-schedulethrough Homebridge'sSchedulerService.Documentation
Related Issues
Closes #[issue-number] (if applicable)
Checklist
npm run test)npm run lint)npm run build)Additional Notes
This feature integrates seamlessly with the existing Homebridge UI architecture and follows established patterns for configuration management and scheduling. The implementation is robust, with proper error handling and graceful degradation if the scheduler service is unavailable.
Future enhancements could include: