feat(systemd-manager): Support sudo password and root detection in SystemdManager; add user/group support in services and templates; add tests and expand README

This commit is contained in:
2025-09-03 11:16:45 +00:00
parent 0ae3b9a5ec
commit 972688b8be
9 changed files with 552 additions and 78 deletions

View File

@@ -1,5 +1,17 @@
# Changelog
## 2025-09-03 - 2.1.0 - feat(systemd-manager)
Support sudo password and root detection in SystemdManager; add user/group support in services and templates; add tests and expand README
- SystemdManager now accepts an optional sudoPassword and detects root status on startup
- Commands executed via SystemdManager are automatically prefixed with sudo when not running as root; supports interactive PTY flow to provide sudo password
- saveService/deleteService now use direct writes when running as root and fallback to sudo-based moves/removals when not root
- Service model extended with optional user and group properties
- Template manager updated to include user/group comments and to emit User/Group directives in generated systemd unit files when provided
- Exports updated (ts/index.ts) to include SmartDaemonService
- New/updated tests added to verify root detection, service creation, unit file generation and sudoPassword option
- README expanded with detailed usage examples, API docs and troubleshooting/security guidance
## 2025-08-29 - 2.0.9 - fix(tests)
update test runner and developer dependencies; add pnpm workspace and packageManager

441
readme.md
View File

@@ -1,96 +1,411 @@
# @push.rocks/smartdaemon
start scripts as long running daemons and manage them
# @push.rocks/smartdaemon 🚀
**Turn your Node.js scripts into production-ready system daemons**
## Install
To install `@push.rocks/smartdaemon`, run the following command in your terminal:
[![npm version](https://img.shields.io/npm/v/@push.rocks/smartdaemon.svg?style=flat-square)](https://www.npmjs.com/package/@push.rocks/smartdaemon)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
> Seamlessly manage long-running processes, background services, and system daemons with smart root detection, automatic privilege escalation, and systemd integration.
## 🎯 What is SmartDaemon?
`@push.rocks/smartdaemon` is your Swiss Army knife for turning Node.js applications into bulletproof system services. Whether you're deploying microservices, running scheduled tasks, or managing background workers, SmartDaemon handles the complex stuff so you can focus on your business logic.
### ✨ Key Features
- 🔐 **Smart Privilege Management**: Automatically detects root status and handles sudo operations seamlessly
- 👤 **User/Group Control**: Run services under specific users for enhanced security
- 🔄 **Systemd Integration**: Full Linux systemd support for production-grade service management
- 🛡️ **Auto-Recovery**: Built-in service restart on failure
- 📝 **Declarative Service Definitions**: Simple, clear service configuration
- 🔌 **Interactive & Non-Interactive Modes**: Support for both passwordless sudo and interactive authentication
- 🎛️ **Complete Lifecycle Management**: Start, stop, enable, disable, and reload services with ease
## 📦 Installation
```bash
# Using npm
npm install @push.rocks/smartdaemon --save
# Using pnpm (recommended)
pnpm add @push.rocks/smartdaemon
# Using yarn
yarn add @push.rocks/smartdaemon
```
This will add `@push.rocks/smartdaemon` to your project's dependencies.
## Usage
## 🚀 Quick Start
`@push.rocks/smartdaemon` is a powerful module designed to help you manage long-running daemons for your applications. Whether you're running background services, scheduled tasks, or any other continuous or long-term operations in a Node.js environment, `smartdaemon` provides an intuitive and flexible API to control these processes.
### Getting Started
First, ensure you import `SmartDaemon` from the package in your TypeScript files:
### Basic Usage
```typescript
import { SmartDaemon } from '@push.rocks/smartdaemon';
// Initialize SmartDaemon
const daemon = new SmartDaemon();
// Create and enable a service
const myService = await daemon.addService({
name: 'my-api-server',
description: 'My awesome API server',
command: 'node server.js',
workingDir: '/opt/myapp',
version: '1.0.0'
});
// Enable and start the service
await myService.enable(); // Creates systemd service file
await myService.start(); // Starts the service
```
### Initialize SmartDaemon
Create an instance of `SmartDaemon`. This instance will be the central point for managing your services.
### Running Services as Specific Users 👥
Security best practice: Never run services as root unless absolutely necessary!
```typescript
const myDaemon = new SmartDaemon();
const webService = await daemon.addService({
name: 'web-server',
description: 'Production web server',
command: 'node app.js',
workingDir: '/var/www/app',
version: '2.1.0',
user: 'www-data', // Run as www-data user
group: 'www-data' // Run under www-data group
});
await webService.enable();
await webService.start();
```
### Adding a Service
To manage a daemon, you first need to define it as a service. A service in `smartdaemon` is essentially a description of the daemon process you want to manage — including how it should be started, where it should run, and other operational metadata.
## 🔐 Smart Privilege Escalation
Here's an example of adding a simple service that executes a shell command:
SmartDaemon intelligently handles privilege escalation based on your execution context:
### Automatic Root Detection
```typescript
async function addSampleService() {
const myService = await myDaemon.addService({
name: 'mySampleService',
description: 'A sample service running a long-lived process',
command: 'node path/to/your/script.js',
workingDir: '/absolute/path/to/working/directory',
version: '1.0.0'
// SmartDaemon automatically detects if running as root
const daemon = new SmartDaemon();
// If running as regular user, sudo will be used automatically
// If running as root, commands execute directly
```
### With Sudo Password
If you're not running as root and don't have passwordless sudo configured:
```typescript
const daemon = new SmartDaemon({
sudoPassword: 'your-sudo-password' // Will be used for privilege escalation
});
// All privileged operations will now use the provided password
const service = await daemon.addService({
name: 'privileged-service',
description: 'Service requiring root privileges',
command: 'node admin-task.js',
workingDir: '/opt/admin',
version: '1.0.0'
});
await service.enable(); // Automatically uses sudo with password
```
### Passwordless Sudo (Recommended for Production)
For production environments, configure passwordless sudo for specific systemctl commands:
1. Create a sudoers file: `/etc/sudoers.d/smartdaemon`
2. Add the following content:
```bash
# Allow smartdaemon to manage services without password
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl start smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl daemon-reload
```
## 📚 Complete API
### SmartDaemon Class
```typescript
interface ISmartDaemonOptions {
sudoPassword?: string; // Optional sudo password for privilege escalation
}
class SmartDaemon {
constructor(options?: ISmartDaemonOptions);
// Add a new service or update existing one
async addService(options: ISmartDaemonServiceOptions): Promise<SmartDaemonService>;
// Direct access to managers (advanced usage)
systemdManager: SmartDaemonSystemdManager;
templateManager: SmartDaemonTemplateManager;
}
```
### SmartDaemonService Class
```typescript
interface ISmartDaemonServiceOptions {
name: string; // Unique service identifier
description: string; // Human-readable description
command: string; // Command to execute
workingDir: string; // Working directory (absolute path)
version: string; // Service version
user?: string; // User to run service as (optional)
group?: string; // Group to run service as (optional)
}
class SmartDaemonService {
// Lifecycle management
async enable(): Promise<void>; // Install and enable service
async disable(): Promise<void>; // Disable service
async start(): Promise<void>; // Start service
async stop(): Promise<void>; // Stop service
// Service management
async save(): Promise<void>; // Save service configuration
async delete(): Promise<void>; // Remove service
async reload(): Promise<void>; // Reload systemd daemon
}
```
## 🎭 Real-World Examples
### Microservice Deployment
```typescript
import { SmartDaemon } from '@push.rocks/smartdaemon';
async function deployMicroservices() {
const daemon = new SmartDaemon();
// API Gateway
const apiGateway = await daemon.addService({
name: 'api-gateway',
description: 'API Gateway Service',
command: 'node --max-old-space-size=2048 gateway.js',
workingDir: '/opt/services/gateway',
version: '3.2.1',
user: 'apiuser',
group: 'apigroup'
});
await myService.enable();
}
addSampleService();
```
In this example:
- `name`: Unique identifier for the service.
- `description`: A brief explanation of the service.
- `command`: The command that starts your daemon (e.g., node application, shell script).
- `workingDir`: The working directory from which the command will be executed.
- `version`: The version of your service. This can be used for your own versioning and tracking.
### Starting and Stopping Services
After adding and enabling a service, you can control it using the start and stop methods.
```typescript
async function controlService() {
const myServiceName = 'mySampleService'; // The name of the service you added before
const myService = await myDaemon.getService(myServiceName);
// Auth Service
const authService = await daemon.addService({
name: 'auth-service',
description: 'Authentication Service',
command: 'node auth-service.js',
workingDir: '/opt/services/auth',
version: '2.1.0',
user: 'authuser',
group: 'authgroup'
});
// To start the service
await myService.start();
// To stop the service
await myService.stop();
// Database Sync Worker
const dbWorker = await daemon.addService({
name: 'db-sync-worker',
description: 'Database Synchronization Worker',
command: 'node workers/db-sync.js',
workingDir: '/opt/services/workers',
version: '1.5.3',
user: 'dbworker',
group: 'dbgroup'
});
// Enable and start all services
const services = [apiGateway, authService, dbWorker];
for (const service of services) {
await service.enable();
await service.start();
console.log(`✅ Service ${service.name} is running`);
}
}
controlService();
deployMicroservices();
```
### Advanced Management
`smartdaemon` also provides advanced functionalities to manage your daemons effectively, including:
- Checking the status of your services
- Automatically restarting services on failure
- Configuring environment variables or execution parameters
### Working with Systemd
If you're operating on a Linux environment, `smartdaemon` can generate and control services using `systemd`. This integration allows for robust management capabilities, utilizing system-level features for daemon management.
### Scheduled Task Runner
```typescript
// Example: Creating a systemd service
await myDaemon.systemdManager.saveService(myService);
async function setupScheduledTasks() {
const daemon = new SmartDaemon();
// Backup service that runs every night
const backupService = await daemon.addService({
name: 'nightly-backup',
description: 'Nightly database backup service',
command: 'node --require dotenv/config backup-runner.js',
workingDir: '/opt/backup',
version: '1.0.0',
user: 'backup',
group: 'backup'
});
await backupService.enable();
await backupService.start();
}
```
### Conclusion
`@push.rocks/smartdaemon` streamlines the management of long-running daemons and background services in Node.js applications. By leveraging its comprehensive API, developers can efficiently control the lifecycle of their services, ensuring their applications run smoothly and reliably.
### Development vs Production Setup
This module opens up possibilities for more structured and system-integrated application architectures, especially in environments where long-running tasks are essential.
```typescript
import { SmartDaemon } from '@push.rocks/smartdaemon';
**Note**: Ensure to check permissions and system compatibility, especially when integrating with system-level service managers like `systemd`.
async function setupService(environment: 'development' | 'production') {
let daemonOptions = {};
if (environment === 'development') {
// In development, you might use sudo password
daemonOptions = {
sudoPassword: process.env.SUDO_PASSWORD
};
}
// In production, rely on passwordless sudo configuration
const daemon = new SmartDaemon(daemonOptions);
const service = await daemon.addService({
name: `app-${environment}`,
description: `Application ${environment} server`,
command: environment === 'production'
? 'node --production app.js'
: 'node --inspect app.js',
workingDir: '/opt/application',
version: '1.0.0',
user: environment === 'production' ? 'appuser' : process.env.USER,
group: environment === 'production' ? 'appgroup' : process.env.USER
});
await service.enable();
await service.start();
}
```
## 🔧 Advanced Usage
### Direct SystemdManager Access
For advanced users who need fine-grained control:
```typescript
const daemon = new SmartDaemon();
// Access the SystemdManager directly
const systemdManager = daemon.systemdManager;
// Check if running on a compatible system
const canRun = await systemdManager.checkElegibility();
// Get all existing SmartDaemon services
const existingServices = await systemdManager.getServices();
// Reload systemd daemon
await systemdManager.reload();
```
### Custom Service Templates
Access the template manager for custom service file generation:
```typescript
const daemon = new SmartDaemon();
const template = daemon.templateManager.generateUnitFileForService(myService);
console.log(template); // View generated systemd unit file
```
## 🏗️ System Requirements
- **Operating System**: Linux with systemd (Ubuntu 16.04+, Debian 9+, CentOS 7+, RHEL 7+, Fedora, Arch, etc.)
- **Node.js**: Version 14.x or higher
- **Permissions**: Either root access or properly configured sudo
## ⚙️ Generated Systemd Service Files
SmartDaemon generates professional systemd unit files with:
- Automatic restart on failure
- Network target dependencies
- Proper working directory configuration
- User/Group assignment
- Resource limits configuration
- Syslog integration for logging
- Environment variable support
Example generated service file:
```ini
[Unit]
Description=My API Server
Requires=network.target
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
Environment=NODE_OPTIONS="--max_old_space_size=100"
ExecStart=/bin/bash -c "cd /opt/myapp && node server.js"
WorkingDirectory=/opt/myapp
Restart=always
RestartSec=10
LimitNOFILE=infinity
LimitCORE=infinity
StandardOutput=syslog
StandardError=syslog
[Install]
WantedBy=multi-user.target
```
## 🐛 Troubleshooting
### Permission Denied Errors
If you encounter "Interactive authentication required" errors:
1. **Option 1**: Run your application with sudo
```bash
sudo node your-app.js
```
2. **Option 2**: Configure passwordless sudo (recommended for production)
3. **Option 3**: Provide sudo password programmatically
```typescript
const daemon = new SmartDaemon({ sudoPassword: 'your-password' });
```
### Service Not Found
If systemctl can't find your service:
- Check that the service was enabled: `await service.enable()`
- Verify the service name: Services are prefixed with `smartdaemon_`
- Reload systemd: `await service.reload()`
### Service Fails to Start
Check logs using:
```bash
journalctl -u smartdaemon_your-service-name -f
```
## 🔒 Security Best Practices
1. **Never run services as root** unless absolutely necessary
2. **Use dedicated service users** with minimal permissions
3. **Configure passwordless sudo** for production instead of storing passwords
4. **Limit sudo permissions** to only the specific systemctl commands needed
5. **Regular security audits** of your service configurations
## 🤝 Support
- 📧 Email: hello@task.vc
- 🐛 Issues: [GitHub Issues](https://code.foss.global/push.rocks/smartdaemon/issues)
- 📖 Documentation: [https://code.foss.global/push.rocks/smartdaemon](https://code.foss.global/push.rocks/smartdaemon)
## License and Legal Information
@@ -109,4 +424,4 @@ Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

85
test/test.smartdaemon.ts Normal file
View File

@@ -0,0 +1,85 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartdaemon from '../ts/index.js';
let testSmartdaemon: smartdaemon.SmartDaemon;
tap.test('should create an instance of smartdaemon', async () => {
testSmartdaemon = new smartdaemon.SmartDaemon();
expect(testSmartdaemon).toBeInstanceOf(smartdaemon.SmartDaemon);
});
tap.test('should detect root status correctly', async () => {
const isRoot = await testSmartdaemon.systemdManager['checkIsRoot']();
console.log(`Running as root: ${isRoot}`);
expect(isRoot).toBeTypeofBoolean();
});
tap.test('should create service with user/group properties', async () => {
const testService = await smartdaemon.SmartDaemonService.createFromOptions(
testSmartdaemon,
{
name: 'test-service',
description: 'A test service',
command: 'node test.js',
workingDir: '/tmp',
version: '1.0.0',
user: 'www-data',
group: 'www-data'
}
);
expect(testService.user).toEqual('www-data');
expect(testService.group).toEqual('www-data');
});
tap.test('should generate systemd unit file with User/Group directives', async () => {
const testService = await smartdaemon.SmartDaemonService.createFromOptions(
testSmartdaemon,
{
name: 'test-service',
description: 'A test service',
command: 'node test.js',
workingDir: '/tmp',
version: '1.0.0',
user: 'www-data',
group: 'www-data'
}
);
const unitFileContent = testSmartdaemon.templateManager.generateUnitFileForService(testService);
console.log('Generated unit file:');
console.log(unitFileContent);
expect(unitFileContent).toInclude('User=www-data');
expect(unitFileContent).toInclude('Group=www-data');
expect(unitFileContent).toInclude('# user: www-data');
expect(unitFileContent).toInclude('# group: www-data');
});
tap.test('should handle services without user/group', async () => {
const testService = await smartdaemon.SmartDaemonService.createFromOptions(
testSmartdaemon,
{
name: 'test-service-no-user',
description: 'A test service without user',
command: 'node test.js',
workingDir: '/tmp',
version: '1.0.0'
}
);
const unitFileContent = testSmartdaemon.templateManager.generateUnitFileForService(testService);
expect(unitFileContent).not.toInclude('User=');
expect(unitFileContent).not.toInclude('Group=');
});
tap.test('should create smartdaemon with sudo password option', async () => {
const daemonWithPassword = new smartdaemon.SmartDaemon({
sudoPassword: 'test-password'
});
expect(daemonWithPassword.systemdManager.sudoPassword).toEqual('test-password');
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartdaemon',
version: '2.0.9',
version: '2.1.0',
description: 'Start scripts as long running daemons and manage them.'
}

View File

@@ -1 +1,2 @@
export * from './smartdaemon.classes.smartdaemon.js';
export * from './smartdaemon.classes.service.js';

View File

@@ -8,6 +8,8 @@ export interface ISmartDaemonServiceConstructorOptions {
command: string;
workingDir: string;
version: string;
user?: string;
group?: string;
}
/**
@@ -33,6 +35,8 @@ export class SmartDaemonService implements ISmartDaemonServiceConstructorOptions
public command: string;
public workingDir: string;
public description: string;
public user?: string;
public group?: string;
public smartdaemonRef: SmartDaemon;

View File

@@ -6,13 +6,17 @@ import {
} from './smartdaemon.classes.service.js';
import { SmartDaemonSystemdManager } from './smartdaemon.classes.systemdmanager.js';
export interface ISmartDaemonOptions {
sudoPassword?: string;
}
export class SmartDaemon {
public templateManager: SmartDaemonTemplateManager;
public systemdManager: SmartDaemonSystemdManager;
constructor() {
constructor(optionsArg?: ISmartDaemonOptions) {
this.templateManager = new SmartDaemonTemplateManager(this);
this.systemdManager = new SmartDaemonSystemdManager(this);
this.systemdManager = new SmartDaemonSystemdManager(this, optionsArg?.sudoPassword);
}
/**

View File

@@ -31,13 +31,29 @@ export class SmartDaemonSystemdManager {
public smartsystem: plugins.smartsystem.Smartsystem;
public shouldExecute: boolean = false;
public isRoot: boolean = false;
public sudoPassword?: string;
constructor(smartdaemonRefArg: SmartDaemon) {
constructor(smartdaemonRefArg: SmartDaemon, sudoPasswordArg?: string) {
this.smartdaemonRef = smartdaemonRefArg;
this.smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash',
});
this.smartsystem = new plugins.smartsystem.Smartsystem();
this.sudoPassword = sudoPasswordArg;
// Check if we're running as root
this.checkIsRoot().then(isRoot => {
this.isRoot = isRoot;
if (!isRoot) {
console.log('Not running as root. Sudo will be used for privileged operations.');
}
});
}
private async checkIsRoot(): Promise<boolean> {
const result = await this.smartshellInstance.exec('id -u');
return result.stdout.trim() === '0';
}
public async checkElegibility() {
@@ -52,7 +68,28 @@ export class SmartDaemonSystemdManager {
public async execute(commandArg: string) {
if (await this.checkElegibility()) {
await this.smartshellInstance.exec(commandArg);
// Only use sudo if we're not root and command doesn't already have sudo
if (!this.isRoot && !commandArg.startsWith('sudo')) {
commandArg = `sudo ${commandArg}`;
if (this.sudoPassword) {
// Use interactive PTY mode for password input
const interactiveExec = await this.smartshellInstance.execInteractiveControlPty(commandArg);
await interactiveExec.sendLine(this.sudoPassword);
const result = await interactiveExec.finalPromise;
return result;
}
}
// Execute command (with or without sudo)
try {
await this.smartshellInstance.exec(commandArg);
} catch (error) {
if (!this.isRoot && error.message && error.message.includes('authentication')) {
throw new Error('Sudo authentication failed. Please configure passwordless sudo or provide a sudo password.');
}
throw error;
}
}
}
@@ -101,18 +138,31 @@ export class SmartDaemonSystemdManager {
public async saveService(serviceArg: SmartDaemonService) {
if (await this.checkElegibility()) {
await plugins.smartfile.memory.toFs(
this.smartdaemonRef.templateManager.generateUnitFileForService(serviceArg),
SmartDaemonSystemdManager.createFilePathFromServiceName(serviceArg.name)
);
const content = this.smartdaemonRef.templateManager.generateUnitFileForService(serviceArg);
const targetPath = SmartDaemonSystemdManager.createFilePathFromServiceName(serviceArg.name);
if (this.isRoot) {
// Direct write when running as root
await plugins.smartfile.memory.toFs(content, targetPath);
} else {
// Use sudo to write when not root
const tempPath = `/tmp/smartdaemon_${serviceArg.name}.service`;
await plugins.smartfile.memory.toFs(content, tempPath);
await this.execute(`mv ${tempPath} ${targetPath}`); // execute() will add sudo
await this.execute(`chmod 644 ${targetPath}`); // execute() will add sudo
}
}
}
public async deleteService(serviceArg: SmartDaemonService) {
if (await this.checkElegibility()) {
await plugins.smartfile.fs.remove(
SmartDaemonSystemdManager.createServiceNameFromServiceName(serviceArg.name)
);
const filePath = SmartDaemonSystemdManager.createFilePathFromServiceName(serviceArg.name);
if (this.isRoot) {
await plugins.smartfile.fs.remove(filePath);
} else {
await this.execute(`rm ${filePath}`); // execute() will add sudo
}
}
}

View File

@@ -1,4 +1,3 @@
import * as plugins from './smartdaemon.plugins.js';
import { SmartDaemon } from './smartdaemon.classes.smartdaemon.js';
import { SmartDaemonService } from './smartdaemon.classes.service.js';
@@ -15,7 +14,9 @@ export class SmartDaemonTemplateManager {
# version: ${serviceArg.version}
# description: ${serviceArg.description}
# command: ${serviceArg.command}
# workingDir: ${serviceArg.workingDir}
# workingDir: ${serviceArg.workingDir}${serviceArg.user ? `
# user: ${serviceArg.user}` : ''}${serviceArg.group ? `
# group: ${serviceArg.group}` : ''}
# ---
[Unit]
Description=${serviceArg.description}
@@ -23,7 +24,9 @@ Requires=network.target
After=network.target
[Service]
Type=simple
Type=simple${serviceArg.user ? `
User=${serviceArg.user}` : ''}${serviceArg.group ? `
Group=${serviceArg.group}` : ''}
Environment=NODE_OPTIONS="--max_old_space_size=100"
ExecStart=/bin/bash -c "cd ${serviceArg.workingDir} && ${serviceArg.command}"
WorkingDirectory=${serviceArg.workingDir}