369 lines
9.2 KiB
JavaScript
369 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Vaultwarden CLI Tool (交互版)
|
|
* - 交互式输入密码
|
|
* - 支持查看密码
|
|
* - OAuth 自动认证
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const https = require('https');
|
|
const readline = require('readline');
|
|
|
|
// Configuration
|
|
const CONFIG_PATH = process.env.VAULTWARDEN_CONFIG_PATH ||
|
|
path.join(process.env.HOME || process.env.USERPROFILE, '.config', 'vaultwarden', 'config.json');
|
|
|
|
const ENV = {
|
|
url: process.env.VAULTWARDEN_URL || 'https://bit.180356.xyz',
|
|
clientId: process.env.VAULTWARDEN_CLIENT_ID || '',
|
|
clientSecret: process.env.VAULTWARDEN_CLIENT_SECRET || ''
|
|
};
|
|
|
|
// Load config file
|
|
let config = { url: ENV.url, client_id: '', client_secret: '' };
|
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
try {
|
|
config = { ...config, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
|
|
} catch (e) {
|
|
console.error('Error reading config:', e.message);
|
|
}
|
|
}
|
|
|
|
const VAULT_URL = ENV.url || config.url;
|
|
const CLIENT_ID = ENV.clientId || config.client_id;
|
|
const CLIENT_SECRET = ENV.clientSecret || config.client_secret;
|
|
|
|
// 交互式输入
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout
|
|
});
|
|
|
|
function ask(question) {
|
|
return new Promise(resolve => {
|
|
rl.question(question, answer => resolve(answer));
|
|
});
|
|
}
|
|
|
|
class VaultwardenAPI {
|
|
constructor() {
|
|
this.token = null;
|
|
this.tokenExpiry = null;
|
|
this.deviceId = 'openclaw-cli-' + require('crypto').randomUUID();
|
|
this.deviceType = '14';
|
|
this.deviceName = 'OpenClaw CLI';
|
|
}
|
|
|
|
async getToken() {
|
|
if (this.token && this.tokenExpiry && Date.now() < this.tokenExpiry - 300000) {
|
|
return this.token;
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
grant_type: 'client_credentials',
|
|
scope: 'api',
|
|
client_id: CLIENT_ID,
|
|
client_secret: CLIENT_SECRET,
|
|
device_identifier: this.deviceId,
|
|
device_type: this.deviceType,
|
|
device_name: this.deviceName
|
|
});
|
|
|
|
const response = await this.request(
|
|
'/identity/connect/token',
|
|
'POST',
|
|
params.toString(),
|
|
false,
|
|
'application/x-www-form-urlencoded'
|
|
);
|
|
|
|
if (!response.access_token) {
|
|
throw new Error('认证失败');
|
|
}
|
|
|
|
this.token = response.access_token;
|
|
this.tokenExpiry = Date.now() + ((response.expires_in || 7200) - 300) * 1000;
|
|
return this.token;
|
|
}
|
|
|
|
async request(endpoint, method = 'GET', data = null, useAuth = true, contentType = 'application/json') {
|
|
const url = new URL(VAULT_URL + endpoint);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const options = {
|
|
hostname: url.hostname,
|
|
port: url.port || 443,
|
|
path: url.pathname + url.search,
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': contentType,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 30000
|
|
};
|
|
|
|
if (useAuth && this.token) {
|
|
options.headers['Authorization'] = `Bearer ${this.token}`;
|
|
}
|
|
|
|
const req = https.request(options, (res) => {
|
|
let body = '';
|
|
res.on('data', chunk => body += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
const json = body ? JSON.parse(body) : {};
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
resolve(json);
|
|
} else {
|
|
reject(new Error(`HTTP ${res.statusCode}: ${json.message || body}`));
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('超时')); });
|
|
|
|
if (data) req.write(data);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async listItems() {
|
|
await this.getToken();
|
|
const response = await this.request('/api/ciphers');
|
|
return response.data || [];
|
|
}
|
|
|
|
async getItem(id) {
|
|
await this.getToken();
|
|
return await this.request(`/api/ciphers/${id}`);
|
|
}
|
|
|
|
async getItemByName(name) {
|
|
const items = await this.listItems();
|
|
return items.find(item => item.name?.toLowerCase() === name.toLowerCase());
|
|
}
|
|
|
|
async createItem(name, password, username = '', notes = '') {
|
|
await this.getToken();
|
|
const itemData = {
|
|
name: name,
|
|
notes: notes,
|
|
type: 1,
|
|
favorite: false,
|
|
login: {
|
|
username: username,
|
|
password: password,
|
|
totp: null
|
|
},
|
|
fields: [],
|
|
collectionIds: []
|
|
};
|
|
return await this.request('/api/ciphers', 'POST', JSON.stringify(itemData));
|
|
}
|
|
|
|
async deleteItem(id) {
|
|
await this.getToken();
|
|
return await this.request(`/api/ciphers/${id}?permanent=true`, 'DELETE');
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
const command = args[0] || 'list';
|
|
|
|
if (!CLIENT_ID || !CLIENT_SECRET) {
|
|
console.error('❌ 未配置! 请设置环境变量或配置文件');
|
|
console.log('\n💡 运行: npm run setup');
|
|
process.exit(1);
|
|
}
|
|
|
|
const api = new VaultwardenAPI();
|
|
|
|
try {
|
|
switch (command) {
|
|
case 'save':
|
|
case 'add':
|
|
await handleSave(args.slice(1), api);
|
|
break;
|
|
case 'list':
|
|
await handleList(api);
|
|
break;
|
|
case 'get':
|
|
await handleGet(args.slice(1), api);
|
|
break;
|
|
case 'search':
|
|
await handleSearch(args.slice(1), api);
|
|
break;
|
|
case 'delete':
|
|
await handleDelete(args.slice(1), api);
|
|
break;
|
|
case 'setup':
|
|
await handleSetup();
|
|
break;
|
|
case 'help':
|
|
default:
|
|
showHelp();
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
console.error('\n❌ 错误:', error.message);
|
|
process.exit(1);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
async function handleSave(args, api) {
|
|
console.log('\n📝 保存新项目\n');
|
|
|
|
const name = await ask('名称: ');
|
|
if (!name) {
|
|
console.error('❌ 名称不能为空');
|
|
return;
|
|
}
|
|
|
|
const username = await ask('用户名 (可选): ');
|
|
|
|
const password = await ask('密码: ');
|
|
if (!password) {
|
|
console.error('❌ 密码不能为空');
|
|
return;
|
|
}
|
|
|
|
const notes = await ask('备注 (可选): ');
|
|
|
|
await api.createItem(name, password, username, notes);
|
|
console.log(`\n✅ 已保存: ${name}\n`);
|
|
}
|
|
|
|
async function handleList(api) {
|
|
const items = await api.listItems();
|
|
console.log(`\n📋 项目列表 (${items.length})\n`);
|
|
items.forEach(item => {
|
|
const name = item.name || 'Unknown';
|
|
const id = item.id?.slice(0, 8) || 'N/A';
|
|
console.log(` • ${name} [${id}]`);
|
|
});
|
|
console.log('');
|
|
}
|
|
|
|
async function handleGet(args, api) {
|
|
const name = args[0];
|
|
if (!name) {
|
|
console.error('\n用法: get <名称>\n');
|
|
return;
|
|
}
|
|
|
|
const item = await api.getItemByName(name);
|
|
if (!item) {
|
|
console.error(`\n❌ 未找到: ${name}\n`);
|
|
return;
|
|
}
|
|
|
|
console.log(`\n📄 ${item.name}\n`);
|
|
console.log(` ID: ${item.id}`);
|
|
console.log(` 用户名: ${item.login?.username || '无'}`);
|
|
console.log(` 密码: ${item.login?.password || '无'}`);
|
|
console.log(` 备注: ${item.notes || '无'}`);
|
|
console.log('');
|
|
}
|
|
|
|
async function handleSearch(args, api) {
|
|
const query = args[0];
|
|
if (!query) {
|
|
console.error('\n用法: search <关键词>\n');
|
|
return;
|
|
}
|
|
|
|
const items = await api.listItems();
|
|
const results = items.filter(item =>
|
|
item.name?.toLowerCase().includes(query.toLowerCase()) ||
|
|
item.login?.username?.toLowerCase().includes(query.toLowerCase()) ||
|
|
item.notes?.toLowerCase().includes(query.toLowerCase())
|
|
);
|
|
|
|
console.log(`\n🔍 搜索 "${query}" (${results.length} 个结果)\n`);
|
|
results.forEach(item => {
|
|
console.log(` • ${item.name}`);
|
|
});
|
|
console.log('');
|
|
}
|
|
|
|
async function handleDelete(args, api) {
|
|
const name = args[0];
|
|
if (!name) {
|
|
console.error('\n用法: delete <名称>\n');
|
|
return;
|
|
}
|
|
|
|
const item = await api.getItemByName(name);
|
|
if (!item) {
|
|
console.error(`\n❌ 未找到: ${name}\n`);
|
|
return;
|
|
}
|
|
|
|
const answer = await ask(`🗑️ 删除 "${item.name}"? (y/N): `);
|
|
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
|
|
console.log('已取消\n');
|
|
return;
|
|
}
|
|
|
|
await api.deleteItem(item.id);
|
|
console.log(`\n✅ 已删除: ${item.name}\n`);
|
|
}
|
|
|
|
async function handleSetup() {
|
|
console.log('\n🔐 Vaultwarden 配置\n');
|
|
|
|
const url = await ask('服务器地址 (默认 https://bit.180356.xyz): ');
|
|
const clientId = await ask('Client ID: ');
|
|
const clientSecret = await ask('Client Secret: ');
|
|
|
|
const configData = {
|
|
url: url || 'https://bit.180356.xyz',
|
|
client_id: clientId,
|
|
client_secret: clientSecret
|
|
};
|
|
|
|
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(configData, null, 2));
|
|
|
|
console.log('\n✅ 配置已保存!\n');
|
|
}
|
|
|
|
function showHelp() {
|
|
console.log(`
|
|
🔐 Vaultwarden CLI (交互版)
|
|
|
|
用法:
|
|
save 保存新项目 (交互式)
|
|
list 列出所有项目
|
|
get <名称> 获取项目详情
|
|
search <关键词> 搜索项目
|
|
delete <名称> 删除项目
|
|
setup 配置
|
|
help 显示帮助
|
|
|
|
示例:
|
|
save # 交互式保存
|
|
list # 列出所有
|
|
get GitHub # 获取 GitHub
|
|
search GitHub # 搜索
|
|
delete GitHub # 删除
|
|
|
|
注意: 密码会在命令行中显示
|
|
`);
|
|
}
|
|
|
|
module.exports = {};
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|