251 lines
7.3 KiB
JavaScript
251 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { readFileSync, existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { homedir, platform } from 'node:os'
|
|
import { Command } from 'commander'
|
|
import { ConfigSchema, type Config } from '../src/types.js'
|
|
import { BaseCache } from '../src/adapters/cache/base.js'
|
|
import { MemoryCache } from '../src/adapters/cache/memory.js'
|
|
import { SqliteCache } from '../src/adapters/cache/sqlite.js'
|
|
import { BaseClient } from '../src/adapters/client/base.js'
|
|
import { WebDAVClientAdapter } from '../src/adapters/client/webdav.js'
|
|
import { FTPClientAdapter } from '../src/adapters/client/ftp.js'
|
|
import { SFTPClientAdapter } from '../src/adapters/client/sftp.js'
|
|
import { SFTPServer } from '../src/adapters/server/sftp.js'
|
|
import { FTPServer } from '../src/adapters/server/ftp.js'
|
|
import { WebDAVServer } from '../src/adapters/server/webdav.js'
|
|
import { NFSServer } from '../src/adapters/server/nfs.js'
|
|
import { SMBServer } from '../src/adapters/server/smb.js'
|
|
import type { BaseServer } from '../src/adapters/server/base.js'
|
|
import { logger, attachSqliteTransport } from '../src/logger.js'
|
|
import * as launchd from '../src/service/launchd.js'
|
|
import * as systemd from '../src/service/systemd.js'
|
|
|
|
const CONFIG_PATH = join(homedir(), '.config', 'sftp-proxy', 'config.json')
|
|
|
|
/**
|
|
* Loads and validates the config file from ~/.config/sftp-proxy/config.json.
|
|
* Exits with a clear error message if the file is missing or invalid.
|
|
*/
|
|
function loadConfig(): Config {
|
|
if (!existsSync(CONFIG_PATH)) {
|
|
console.error(`Config file not found: ${CONFIG_PATH}`)
|
|
console.error('Create one with your client configuration. See documentation for schema.')
|
|
process.exit(1)
|
|
}
|
|
|
|
let raw: unknown
|
|
try {
|
|
raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
|
|
} catch (err) {
|
|
console.error(`Failed to parse config file: ${(err as Error).message}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const result = ConfigSchema.safeParse(raw)
|
|
if (!result.success) {
|
|
console.error('Invalid config:')
|
|
for (const issue of result.error.issues) {
|
|
console.error(` ${issue.path.join('.')}: ${issue.message}`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
return result.data
|
|
}
|
|
|
|
/**
|
|
* Creates a cache instance based on the client's cache config.
|
|
* If using SQLite, also attaches the winston transport.
|
|
*/
|
|
function createCache(type: 'sqlite' | 'memory'): BaseCache {
|
|
if (type === 'sqlite') {
|
|
const cache = new SqliteCache()
|
|
attachSqliteTransport(cache)
|
|
return cache
|
|
}
|
|
return new MemoryCache()
|
|
}
|
|
|
|
/**
|
|
* Creates a client adapter based on the client type config.
|
|
*/
|
|
function createClient(
|
|
clientConfig: Config['clients'][number],
|
|
cache: BaseCache,
|
|
): BaseClient {
|
|
const baseConfig = {
|
|
url: clientConfig.url,
|
|
basePath: clientConfig.basePath,
|
|
username: clientConfig.username,
|
|
password: clientConfig.password,
|
|
mountPath: clientConfig.mountPath,
|
|
concurrency: clientConfig.concurrency,
|
|
}
|
|
|
|
switch (clientConfig.type) {
|
|
case 'webdav':
|
|
return new WebDAVClientAdapter(baseConfig, cache)
|
|
case 'ftp':
|
|
return new FTPClientAdapter(baseConfig, cache)
|
|
case 'sftp':
|
|
return new SFTPClientAdapter(baseConfig, cache)
|
|
default:
|
|
throw new Error(`Unknown client type: ${clientConfig.type}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects whether we're on macOS (launchd) or Linux (systemd).
|
|
*/
|
|
function getServiceManager() {
|
|
return platform() === 'darwin' ? launchd : systemd
|
|
}
|
|
|
|
const program = new Command()
|
|
|
|
program
|
|
.name('sftp-proxy')
|
|
.description('Local protocol servers that proxy to remote storage')
|
|
.version('1.0.0')
|
|
|
|
program
|
|
.command('start')
|
|
.description('Start all configured servers in the foreground')
|
|
.action(async () => {
|
|
const config = loadConfig()
|
|
|
|
// Build clients
|
|
const clients: BaseClient[] = config.clients.map(clientConf => {
|
|
const cache = createCache(clientConf.cache.type)
|
|
return createClient(clientConf, cache)
|
|
})
|
|
|
|
// Start sync workers for all clients
|
|
for (const client of clients) {
|
|
client.sync.start()
|
|
logger.info(`Sync started for ${client.mountPath}`)
|
|
}
|
|
|
|
// Track all started servers for graceful shutdown
|
|
const servers: BaseServer[] = []
|
|
|
|
// Start SFTP server (always on — it's the primary protocol)
|
|
const sftpPort = config.servers?.sftp?.port ?? config.port
|
|
const sftpServer = new SFTPServer(clients, sftpPort, config.credentials)
|
|
await sftpServer.start()
|
|
servers.push(sftpServer)
|
|
|
|
// Start WebDAV server if configured
|
|
if (config.servers?.webdav) {
|
|
logger.info(`Starting WebDAV server on port ${config.servers.webdav.port}`)
|
|
const webdavServer = new WebDAVServer(clients, config.servers.webdav.port, config.credentials)
|
|
await webdavServer.start()
|
|
servers.push(webdavServer)
|
|
}
|
|
|
|
// Start FTP server if configured
|
|
if (config.servers?.ftp) {
|
|
const ftpConf = config.servers.ftp
|
|
const ftpServer = new FTPServer(clients, {
|
|
port: ftpConf.port,
|
|
pasv_url: ftpConf.pasv_url,
|
|
pasv_min: ftpConf.pasv_min,
|
|
pasv_max: ftpConf.pasv_max,
|
|
}, config.credentials)
|
|
await ftpServer.start()
|
|
servers.push(ftpServer)
|
|
}
|
|
|
|
// Warn about unimplemented servers
|
|
if (config.servers?.nfs) {
|
|
logger.warn('NFS server is configured but not yet implemented. Skipping.')
|
|
}
|
|
if (config.servers?.smb) {
|
|
logger.warn('SMB server is configured but not yet implemented. Skipping.')
|
|
}
|
|
|
|
// Graceful shutdown
|
|
const shutdown = async () => {
|
|
logger.info('Shutting down...')
|
|
for (const client of clients) {
|
|
client.sync.stop()
|
|
}
|
|
for (const server of servers) {
|
|
await server.stop()
|
|
}
|
|
process.exit(0)
|
|
}
|
|
|
|
process.on('SIGINT', shutdown)
|
|
process.on('SIGTERM', shutdown)
|
|
})
|
|
|
|
program
|
|
.command('install')
|
|
.description('Install as background service (auto-detect launchd vs systemd)')
|
|
.action(() => {
|
|
const svc = getServiceManager()
|
|
svc.install()
|
|
})
|
|
|
|
program
|
|
.command('uninstall')
|
|
.description('Remove background service')
|
|
.action(() => {
|
|
const svc = getServiceManager()
|
|
svc.uninstall()
|
|
})
|
|
|
|
program
|
|
.command('status')
|
|
.description('Show service status')
|
|
.action(() => {
|
|
const svc = getServiceManager()
|
|
console.log(svc.status())
|
|
})
|
|
|
|
program
|
|
.command('sync')
|
|
.description('Force re-index all clients')
|
|
.action(async () => {
|
|
const config = loadConfig()
|
|
|
|
const clients: BaseClient[] = config.clients.map(clientConf => {
|
|
const cache = createCache(clientConf.cache.type)
|
|
return createClient(clientConf, cache)
|
|
})
|
|
|
|
logger.info('Force syncing all clients...')
|
|
|
|
const syncPromises = clients.map(async (client) => {
|
|
client.sync.start()
|
|
await client.sync.forceSync('/')
|
|
client.sync.stop()
|
|
logger.info(`Sync complete for ${client.mountPath}`)
|
|
})
|
|
|
|
await Promise.all(syncPromises)
|
|
logger.info('All clients synced')
|
|
process.exit(0)
|
|
})
|
|
|
|
program
|
|
.command('logs')
|
|
.description('Print last 500 log entries')
|
|
.action(() => {
|
|
const cache = new SqliteCache()
|
|
const entries = cache.readLogs(500)
|
|
|
|
// Print in chronological order (readLogs returns newest first)
|
|
for (const entry of entries.reverse()) {
|
|
const ts = new Date(entry.timestamp).toISOString()
|
|
console.log(`${ts} [${entry.level}] ${entry.message}`)
|
|
}
|
|
|
|
cache.close()
|
|
})
|
|
|
|
program.parse()
|