initial commit

This commit is contained in:
2026-02-20 17:14:35 +11:00
commit aef20b9d35
32 changed files with 8212 additions and 0 deletions

250
bin/sftp-proxy.ts Normal file
View File

@@ -0,0 +1,250 @@
#!/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()