|
|
@@ -12,10 +12,11 @@ const root='/api/app/vault', state=ref<View>(), busy=ref(false), error=ref(''),
|
|
|
const master=ref(''), confirmation=ref(''), replacement=ref(''), secret=ref(''), passphrase=ref('')
|
|
|
const name=ref(''), username=ref(''), kind=ref<'website'|'ssh'>('website'), origin=ref(''), host=ref(''), port=ref(22), hostKey=ref('')
|
|
|
const editing=ref<CredentialSummary>(), showEditor=ref(false), taskId=ref(''), credentialId=ref(''), operation=ref<CredentialOperation>('website.login')
|
|
|
-const sshAuth=ref<'privateKey'|'password'>('privateKey')
|
|
|
+const sshAuth=ref<'privateKey'|'password'>('password')
|
|
|
const authenticationChanged=computed(()=>editing.value?.target.kind==='ssh'&&kind.value==='ssh'&&sshAuth.value!==(editing.value.target.auth??'privateKey'))
|
|
|
const secretRequired=computed(()=>!editing.value||authenticationChanged.value)
|
|
|
const selectedCredential=computed(()=>state.value?.entries.find(entry=>entry.id===credentialId.value))
|
|
|
+const firstSshUse=computed(()=>selectedCredential.value?.target.kind==='ssh'&&!selectedCredential.value.target.hostKey)
|
|
|
const configureUse=ref(false),useKind=ref<'ssh.exec'|'sftp.read'|'sftp.write'>('ssh.exec'),command=ref('/usr/bin/uptime'),remotePath=ref(''),contents=ref(''),maxBytes=ref(65536),clearPassphrase=ref(false)
|
|
|
const loginPath=ref('/login'),submitPath=ref('/login'),successPath=ref('/account'),logoutPath=ref(''),formId=ref('login'),usernameName=ref('username'),passwordName=ref('password'),successSelector=ref('#login-success')
|
|
|
let timer:ReturnType<typeof setInterval>|undefined,alive=true
|
|
|
@@ -23,7 +24,7 @@ const inflight=new Set<AbortController>()
|
|
|
function clearSecrets(){master.value='';confirmation.value='';replacement.value='';secret.value='';passphrase.value='';contents.value='';clearPassphrase.value=false}
|
|
|
function failure(cause:unknown){
|
|
|
const code=cause&&typeof cause==='object'&&'code' in cause?String(cause.code):''
|
|
|
- return ({vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'填写内容不符合要求:主密码至少 12 位;网站须为不含路径的 HTTPS 地址;SSH 须提供 SHA256 主机指纹。',vault_auth_secret_required:'更换 SSH 认证方式后,请重新填写密码或私钥。',vault_executor_not_enabled:'凭据执行器暂时不可用,请刷新状态或检查服务。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
|
|
|
+ return ({vault_offline:'密码保险箱连接不可用,请刷新状态或检查数据目录权限。',vault_locked:'密码保险箱已锁定,请重新手动解锁后添加凭据。',vault_unlock_failed:'主密码错误或密码库已损坏,请核对主密码。',vault_invalid_request:'请检查填写内容:主密码至少 12 位,网站使用 HTTPS 地址,SSH 地址和端口需有效。手填指纹时请使用 SHA256 格式。',vault_auth_secret_required:'更换 SSH 认证方式后,请重新填写密码或私钥。',vault_executor_not_enabled:'凭据执行器暂时不可用,请刷新状态或检查服务。',vault_host_probe_failed:'无法连接 SSH 服务器;已保存的密码不受影响,请检查主机地址和端口后重试授权。',vault_host_key_required:'首次使用时请选择确认服务器并授权任务。',vault_host_key_rejected:'服务器身份已改变,已停止连接。请核对服务器后再修改高级设置中的指纹。'} as Record<string,string>)[code]??'操作未完成,请检查连接和主密码;秘密不会回显。'
|
|
|
}
|
|
|
async function request<T>(path:string,method:'GET'|'POST'|'PUT'|'DELETE'='GET',body?:Record<string,JsonValue>){
|
|
|
const c=new AbortController();inflight.add(c)
|
|
|
@@ -43,8 +44,8 @@ async function lock(){clearSecrets();showEditor.value=false;await action(async()
|
|
|
function edit(entry?:CredentialSummary){
|
|
|
clearSecrets();editing.value=entry;name.value=entry?.name??'';username.value=entry?.username??'';kind.value=entry?.target.kind??'website'
|
|
|
origin.value=entry?.target.kind==='website'?entry.target.origin:'';host.value=entry?.target.kind==='ssh'?entry.target.host:''
|
|
|
- port.value=entry?.target.kind==='ssh'?entry.target.port:22;hostKey.value=entry?.target.kind==='ssh'?entry.target.hostKey:''
|
|
|
- sshAuth.value=entry?.target.kind==='ssh'?entry.target.auth??'privateKey':'privateKey'
|
|
|
+ port.value=entry?.target.kind==='ssh'?entry.target.port:22;hostKey.value=entry?.target.kind==='ssh'?entry.target.hostKey??'':''
|
|
|
+ sshAuth.value=entry?.target.kind==='ssh'?entry.target.auth??'privateKey':'password'
|
|
|
configureUse.value=!!entry?.usage;useKind.value=entry?.usage&&entry.usage.kind!=='website.form'?entry.usage.kind:'ssh.exec'
|
|
|
command.value=entry?.usage?.kind==='ssh.exec'?entry.usage.command:'/usr/bin/uptime';remotePath.value=entry?.usage&&(entry.usage.kind==='sftp.read'||entry.usage.kind==='sftp.write')?entry.usage.remotePath:''
|
|
|
maxBytes.value=entry?.usage?.kind==='sftp.read'?entry.usage.maxBytes:65536
|
|
|
@@ -55,7 +56,7 @@ function edit(entry?:CredentialSummary){
|
|
|
function changeSshAuth(){secret.value='';passphrase.value='';clearPassphrase.value=false}
|
|
|
async function save(){
|
|
|
if(secretRequired.value&&!secret.value){error.value=authenticationChanged.value?'更换 SSH 认证方式后,请重新填写密码或私钥。':'请填写密码或私钥。';return}
|
|
|
- const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,hostKey:hostKey.value,...(sshAuth.value==='password'?{auth:'password'}:{})}
|
|
|
+ const target:Record<string,JsonValue>=kind.value==='website'?{kind:'website',origin:origin.value}:{kind:'ssh',host:host.value,port:port.value,...(hostKey.value.trim()?{hostKey:hostKey.value.trim()}:{}),...(sshAuth.value==='password'?{auth:'password'}:{})}
|
|
|
const usage:Record<string,JsonValue>|undefined=!configureUse.value?undefined:kind.value==='website'?{kind:'website.form',loginPath:loginPath.value,submitPath:submitPath.value,successPath:successPath.value,...(logoutPath.value?{logoutPath:logoutPath.value}:{}),formId:formId.value,usernameName:usernameName.value,passwordName:passwordName.value,successSelector:successSelector.value}:useKind.value==='ssh.exec'?{kind:'ssh.exec',command:command.value}:useKind.value==='sftp.read'?{kind:'sftp.read',remotePath:remotePath.value,maxBytes:maxBytes.value}:{kind:'sftp.write',remotePath:remotePath.value,...(contents.value||!editing.value||editing.value.usage?.kind!=='sftp.write'?{contents:contents.value}:{})}
|
|
|
const body={name:name.value,username:username.value,target,...(usage?{usage}:{}),...(secret.value?{secret:secret.value}:{}),...(kind.value==='ssh'&&sshAuth.value==='privateKey'?(clearPassphrase.value?{passphrase:''}:passphrase.value?{passphrase:passphrase.value}:{}):{}),...(editing.value?{revision:editing.value.revision}:{})}
|
|
|
await action(async()=>{await request(editing.value?'/entries/'+editing.value.id:'/entries',editing.value?'PUT':'POST',body);showEditor.value=false},'凭据已加密保存,密码与私钥不回显')
|
|
|
@@ -128,11 +129,16 @@ onBeforeUnmount(()=>{
|
|
|
<label>用户名<input v-model="username" maxlength="256" autocomplete="off" required :disabled="busy" /></label>
|
|
|
<label>用途<select v-model="kind" :disabled="busy||!!editing" @change="changeSshAuth"><option value="website">HTTPS 网站</option><option value="ssh">SSH / SFTP</option></select></label>
|
|
|
<label v-if="kind==='website'">准确网站 origin<input v-model="origin" type="url" placeholder="https://example.com" required :disabled="busy" /></label>
|
|
|
- <template v-else><label>SSH 主机<input v-model="host" required :disabled="busy" /></label><label>端口<input v-model.number="port" type="number" min="1" max="65535" required :disabled="busy" /></label><label>已人工确认的 host key 指纹<input v-model="hostKey" placeholder="SHA256:…" required :disabled="busy" /></label></template>
|
|
|
+ <label v-else>SSH 主机<input v-model="host" placeholder="IP 地址或域名" required :disabled="busy" /></label>
|
|
|
<label v-if="kind==='ssh'">SSH 认证方式<select v-model="sshAuth" :disabled="busy" @change="changeSshAuth"><option value="privateKey">私钥登录</option><option value="password">密码登录</option></select></label>
|
|
|
<label>{{kind==='website'?'密码':sshAuth==='password'?'SSH 登录密码':'私钥'}}<input v-if="kind==='website'||sshAuth==='password'" v-model="secret" type="password" autocomplete="new-password" :required="secretRequired" maxlength="32768" :disabled="busy" /><textarea v-else v-model="secret" autocomplete="off" :required="secretRequired" maxlength="32768" :disabled="busy" /><small v-if="authenticationChanged">认证方式已更换,需要填写新的{{sshAuth==='password'?'登录密码':'私钥'}}。</small><small v-else-if="editing">留空保留原值;没有秘密读取或回显接口。</small></label>
|
|
|
<label v-if="kind==='ssh'&&sshAuth==='privateKey'">私钥口令(可选)<input v-model="passphrase" type="password" autocomplete="new-password" :disabled="busy" /></label>
|
|
|
<label v-if="kind==='ssh'&&sshAuth==='privateKey'&&editing" class="checkbox-field"><input v-model="clearPassphrase" type="checkbox" :disabled="busy" />清除原私钥口令(留空输入默认保留)</label>
|
|
|
+ <details v-if="kind==='ssh'" class="advanced-connection">
|
|
|
+ <summary>高级连接设置(可选)</summary>
|
|
|
+ <label>端口<input v-model.number="port" type="number" min="1" max="65535" required :disabled="busy" /></label>
|
|
|
+ <label>服务器指纹(可选)<input v-model="hostKey" placeholder="SHA256:…" :disabled="busy" /><small>留空即可保存。首次授权会自动获取并记住服务器指纹;已有指纹会继续校验。</small></label>
|
|
|
+ </details>
|
|
|
<label class="checkbox-field"><input v-model="configureUse" type="checkbox" :disabled="busy||!!editing?.usage" />配置允许 Bot 执行的固定操作</label>
|
|
|
<template v-if="configureUse&&kind==='website'">
|
|
|
<p>仅支持主页面的标准 POST 表单。允许跳转仅限此 origin 的成功页;iframe、跨站 SSO、MFA 或验证码转人工。</p>
|
|
|
@@ -146,7 +152,7 @@ onBeforeUnmount(()=>{
|
|
|
<label v-else>批准的远端绝对路径<input v-model="remotePath" required :disabled="busy" /></label>
|
|
|
<label v-if="useKind==='sftp.read'">最大读取字节数<input v-model.number="maxBytes" type="number" min="1" max="1048576" required :disabled="busy" /></label>
|
|
|
<label v-if="useKind==='sftp.write'">固定写入正文(加密保存;最多 64 KiB)<textarea v-model="contents" maxlength="65536" autocomplete="off" :disabled="busy" /><small v-if="editing?.usage?.kind==='sftp.write'">留空保留原正文,不回显。写入仅创建新文件,不覆盖;中断需人工核对。</small></label>
|
|
|
- <p>使用所选的密码或私钥认证,固定 host key;不转发 Agent/端口。Bot 只收到退出码或字节数和校验,收不到密码、密钥、输出正文或文件正文。</p>
|
|
|
+ <p>首次授权会记住服务器,以后身份改变就停止连接。Bot 只收到退出码或字节数和校验,收不到密码、密钥、输出正文或文件正文。</p>
|
|
|
</template>
|
|
|
<div class="actions"><button :disabled="busy">加密保存</button><button type="button" :disabled="busy" @click="showEditor=false;clearSecrets()">取消</button></div>
|
|
|
</form>
|
|
|
@@ -160,7 +166,8 @@ onBeforeUnmount(()=>{
|
|
|
<label>活动任务<select v-model="taskId" required :disabled="busy"><option disabled value="">请选择</option><option v-for="t in state.tasks" :key="t.workId" :value="t.workId">{{t.name}} · {{t.workId.slice(0,8)}}</option></select></label>
|
|
|
<label>操作<select v-model="operation" :disabled="busy"><option value="website.login">网站登录</option><option value="ssh.exec">SSH 执行</option><option value="sftp.read">SFTP 读取</option><option value="sftp.write">SFTP 写入</option></select></label>
|
|
|
<p>批准的使用计划:{{describeUse(selectedCredential)}}</p>
|
|
|
- <button :disabled="busy||!state.tasks.length||!selectedCredential?.usage">授予本任务 60 秒引用权限</button>
|
|
|
+ <p v-if="firstSshUse">首次连接将自动记住这台服务器,之后身份变化会停止连接;无需手填指纹。</p>
|
|
|
+ <button :disabled="busy||!state.tasks.length||!selectedCredential?.usage">{{firstSshUse?'确认服务器并授权本任务 60 秒':'授予本任务 60 秒引用权限'}}</button>
|
|
|
</form>
|
|
|
<ul><li v-for="lease in state.leases" :key="lease.id">{{lease.workId.slice(0,8)}} · {{lease.operation}} · {{new Date(lease.expiresAt).toLocaleTimeString()}}<button type="button" :disabled="busy" @click="action(async()=>{await request('/leases/'+lease.id,'DELETE')},'任务授权已撤销')">撤销</button></li></ul>
|
|
|
<form @submit.prevent="rotate"><label>新的主密码<input v-model="replacement" type="password" autocomplete="new-password" minlength="12" maxlength="1024" required :disabled="busy" /></label><button :disabled="busy">轮换主密码与加密密钥并锁定</button></form>
|
|
|
@@ -171,6 +178,7 @@ onBeforeUnmount(()=>{
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
+.advanced-connection{display:grid;gap:12px}.advanced-connection summary{min-height:40px;padding:10px 0;box-sizing:border-box;cursor:pointer;color:var(--text-secondary)}.advanced-connection:not([open])>label{display:none}
|
|
|
.credential-vault label.checkbox-field{display:flex;align-items:center;gap:8px;min-height:40px;cursor:pointer}.credential-vault .checkbox-field input{width:18px;min-height:18px;height:18px;flex:0 0 18px;margin:0;padding:0;cursor:inherit}
|
|
|
.credential-vault{display:grid;gap:16px}.credential-vault p{margin:0;color:var(--text-secondary);line-height:1.6}.credential-vault form{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:12px}.credential-vault label{display:grid;gap:6px}.credential-vault input,.credential-vault textarea,.credential-vault select{width:100%;box-sizing:border-box;min-height:40px;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary)}.credential-vault textarea{min-height:120px}.credential-vault button{min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer}.credential-vault button:disabled{opacity:.55;cursor:default}.credential-vault ul{list-style:none;margin:0;padding:0;display:grid;gap:12px}.credential-vault li{padding:12px;border:1px solid var(--line);border-radius:10px}.credential-vault code{overflow-wrap:anywhere}.credential-vault [role=alert]{color:var(--danger)}.actions{display:flex;flex-wrap:wrap;gap:8px}
|
|
|
</style>
|