A 主机可以设置独立密钥 #170

pull/191/head
vapao 2020-08-27 08:57:50 +08:00
parent 367f21a640
commit 1fcd818583
11 changed files with 94 additions and 35 deletions

View File

@ -56,7 +56,8 @@ def do_task(request):
hostname=host.hostname,
port=host.port,
username=host.username,
command=form.command
command=form.command,
pkey=host.private_key,
)
return json_response(token)
return json_response(error=error)

View File

@ -14,6 +14,7 @@ class Host(models.Model, ModelMixin):
hostname = models.CharField(max_length=50)
port = models.IntegerField()
username = models.CharField(max_length=50)
pkey = models.TextField(null=True)
desc = models.CharField(max_length=255, null=True)
created_at = models.CharField(max_length=20, default=human_datetime)
@ -21,8 +22,12 @@ class Host(models.Model, ModelMixin):
deleted_at = models.CharField(max_length=20, null=True)
deleted_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True)
@property
def private_key(self):
return self.pkey or AppSetting.get('private_key')
def get_ssh(self, pkey=None):
pkey = pkey or AppSetting.get('private_key')
pkey = pkey or self.private_key
return SSH(self.hostname, self.port, self.username, pkey)
def __repr__(self):

View File

@ -8,4 +8,5 @@ from .views import *
urlpatterns = [
path('', HostView.as_view()),
path('import/', post_import),
path('parse/', post_parse),
]

View File

@ -3,6 +3,7 @@
# Released under the AGPL-3.0 License.
from django.views.generic import View
from django.db.models import F
from django.http.response import HttpResponseBadRequest
from libs import json_response, JsonParser, Argument
from apps.setting.utils import AppSetting
from apps.host.models import Host
@ -37,11 +38,13 @@ class HostView(View):
Argument('username', handler=str.strip, help='请输入登录用户名'),
Argument('hostname', handler=str.strip, help='请输入主机名或IP'),
Argument('port', type=int, help='请输入SSH端口'),
Argument('pkey', required=False),
Argument('desc', required=False),
Argument('password', required=False),
).parse(request.body)
if error is None:
if valid_ssh(form.hostname, form.port, form.username, form.pop('password')) is False:
if valid_ssh(form.hostname, form.port, form.username, password=form.pop('password'),
pkey=form.pkey) is False:
return json_response('auth fail')
if form.id:
@ -119,7 +122,8 @@ def post_import(request):
summary['skip'].append(i)
continue
try:
if valid_ssh(data.hostname, data.port, data.username, data.pop('password') or password, False) is False:
if valid_ssh(data.hostname, data.port, data.username, data.pop('password') or password, None,
False) is False:
summary['fail'].append(i)
continue
except AuthenticationException:
@ -141,7 +145,7 @@ def post_import(request):
return json_response(summary)
def valid_ssh(hostname, port, username, password, with_expect=True):
def valid_ssh(hostname, port, username, password=None, pkey=None, with_expect=True):
try:
private_key = AppSetting.get('private_key')
public_key = AppSetting.get('public_key')
@ -149,11 +153,13 @@ def valid_ssh(hostname, port, username, password, with_expect=True):
private_key, public_key = SSH.generate_key()
AppSetting.set('private_key', private_key, 'ssh private key')
AppSetting.set('public_key', public_key, 'ssh public key')
cli = SSH(hostname, port, username, private_key)
if password:
_cli = SSH(hostname, port, username, password=str(password))
_cli.add_public_key(public_key)
if pkey:
private_key = pkey
try:
cli = SSH(hostname, port, username, private_key)
cli.ping()
except BadAuthenticationType:
if with_expect:
@ -164,3 +170,12 @@ def valid_ssh(hostname, port, username, password, with_expect=True):
raise ValueError('密钥认证失败请参考官方文档错误代码E02')
return False
return True
def post_parse(request):
file = request.FILES['file']
if file:
data = file.read()
return json_response(data.decode())
else:
return HttpResponseBadRequest()

View File

@ -1,9 +1,7 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
from libs.ssh import SSH
from apps.host.models import Host
from apps.setting.utils import AppSetting
from socket import socket
import requests
import logging
@ -29,9 +27,9 @@ def port_check(addr, port):
return False, f'异常信息:{e}'
def host_executor(host, pkey, command):
def host_executor(host, command):
try:
cli = SSH(host.hostname, host.port, host.username, pkey=pkey)
cli = host.get_ssh()
exit_code, out = cli.exec_command(command)
if exit_code == 0:
return True, out or '检测状态正常'
@ -52,6 +50,5 @@ def dispatch(tp, addr, extra):
command = extra
else:
raise TypeError(f'invalid monitor type: {tp!r}')
pkey = AppSetting.get('private_key')
host = Host.objects.filter(pk=addr).first()
return host_executor(host, pkey, command)
return host_executor(host, command)

View File

@ -3,9 +3,8 @@
# Released under the AGPL-3.0 License.
from queue import Queue
from threading import Thread
from libs.ssh import SSH, AuthenticationException
from libs.ssh import AuthenticationException
from apps.host.models import Host
from apps.setting.utils import AppSetting
from django.db import close_old_connections
import subprocess
import socket
@ -22,10 +21,10 @@ def local_executor(q, command):
q.put(('local', exit_code, round(time.time() - now, 3), out.decode()))
def host_executor(q, host, pkey, command):
def host_executor(q, host, command):
exit_code, out, now = -1, None, time.time()
try:
cli = SSH(host.hostname, host.port, host.username, pkey=pkey)
cli = host.get_ssh()
exit_code, out = cli.exec_command(command)
out = out if out else None
except AuthenticationException:
@ -39,7 +38,7 @@ def host_executor(q, host, pkey, command):
def dispatch(command, targets, in_view=False):
if not in_view:
close_old_connections()
threads, pkey, q = [], AppSetting.get('private_key'), Queue()
threads, q = [], Queue()
for t in targets:
if t == 'local':
threads.append(Thread(target=local_executor, args=(q, command)))
@ -47,7 +46,7 @@ def dispatch(command, targets, in_view=False):
host = Host.objects.filter(pk=t).first()
if not host:
raise ValueError(f'unknown host id: {t!r}')
threads.append(Thread(target=host_executor, args=(q, host, pkey, command)))
threads.append(Thread(target=host_executor, args=(q, host, command)))
else:
raise ValueError(f'invalid target: {t!r}')
for t in threads:

View File

@ -3,7 +3,6 @@
# Released under the AGPL-3.0 License.
from channels.generic.websocket import WebsocketConsumer
from django_redis import get_redis_connection
from apps.setting.utils import AppSetting
from apps.account.models import User
from apps.host.models import Host
from threading import Thread
@ -85,7 +84,7 @@ class SSHConsumer(WebsocketConsumer):
self.send(text_data='Unknown host\r\n')
self.close()
try:
self.ssh = host.get_ssh(AppSetting.get('private_key')).get_client()
self.ssh = host.get_ssh().get_client()
except Exception as e:
self.send(bytes_data=f'Exception: {e}\r\n'.encode())
self.close()

View File

@ -2,7 +2,6 @@
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
from channels.consumer import SyncConsumer
from apps.setting.utils import AppSetting
from django_redis import get_redis_connection
from libs.ssh import SSH
import threading
@ -12,8 +11,7 @@ import json
class SSHExecutor(SyncConsumer):
def exec(self, job):
pkey = AppSetting.get('private_key')
job = Job(pkey=pkey, **job)
job = Job(**job)
threading.Thread(target=job.run).start()

View File

@ -14,13 +14,14 @@ class Channel:
return uuid.uuid4().hex
@staticmethod
def send_ssh_executor(hostname, port, username, command, token=None):
def send_ssh_executor(hostname, port, username, command, pkey, token=None):
message = {
'type': 'exec',
'token': token,
'hostname': hostname,
'port': port,
'username': username,
'command': command
'command': command,
'pkey': pkey
}
async_to_sync(layer.send)('ssh_exec', message)

View File

@ -8,12 +8,12 @@ import ReactDOM from 'react-dom';
import { Router } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/es/locale/zh_CN';
import { history, updatePermissions } from 'libs';
import './index.css';
import App from './App';
import moment from 'moment';
import 'moment/locale/zh-cn';
import * as serviceWorker from './serviceWorker';
import { history, updatePermissions } from 'libs';
moment.locale('zh-cn');
updatePermissions()

View File

@ -5,7 +5,7 @@
*/
import React from 'react';
import { observer } from 'mobx-react';
import { Modal, Form, Input, Select, Col, Button, message } from 'antd';
import { Modal, Form, Input, Select, Col, Button, Upload, message } from 'antd';
import http from 'libs/http';
import store from './store';
@ -13,28 +13,45 @@ import store from './store';
class ComForm extends React.Component {
constructor(props) {
super(props);
this.token = localStorage.getItem('token');
this.state = {
loading: false,
uploading: false,
password: null,
addZone: null,
fileList: [],
editZone: store.record.zone,
}
}
componentDidMount() {
if (store.record.pkey) {
this.setState({
fileList: [{uid: '0', name: '独立密钥', data: store.record.pkey}]
})
}
}
handleSubmit = () => {
this.setState({loading: true});
const formData = this.props.form.getFieldsValue();
formData['id'] = store.record.id;
const file = this.state.fileList[0];
if (file && file.data) formData['pkey'] = file.data;
http.post('/api/host/', formData)
.then(res => {
if (res === 'auth fail') {
this.setState({loading: false});
Modal.confirm({
icon: 'exclamation-circle',
title: '首次验证请输入密码',
content: this.confirmForm(formData.username),
onOk: () => this.handleConfirm(formData),
})
if (formData.pkey) {
message.error('独立密钥认证失败')
} else {
this.setState({loading: false});
Modal.confirm({
icon: 'exclamation-circle',
title: '首次验证请输入密码',
content: this.confirmForm(formData.username),
onOk: () => this.handleConfirm(formData),
})
}
} else {
message.success('操作成功');
store.formVisible = false;
@ -109,8 +126,28 @@ class ComForm extends React.Component {
});
};
handleUploadChange = (v) => {
if (v.fileList.length === 0) {
this.setState({fileList: []})
}
};
handleUpload = (file, fileList) => {
this.setState({uploading: true});
const formData = new FormData();
formData.append('file', file);
http.post('/api/host/parse/', formData)
.then(res => {
file.data = res;
this.setState({fileList: [file]})
})
.finally(() => this.setState({uploading: false}))
return false
};
render() {
const info = store.record;
const {fileList, loading, uploading} = this.state;
const {getFieldDecorator} = this.props.form;
return (
<Modal
@ -120,7 +157,7 @@ class ComForm extends React.Component {
title={store.record.id ? '编辑主机' : '新建主机'}
okText="验证"
onCancel={() => store.formVisible = false}
confirmLoading={this.state.loading}
confirmLoading={loading}
onOk={this.handleSubmit}>
<Form labelCol={{span: 6}} wrapperCol={{span: 14}}>
<Form.Item required label="主机类别">
@ -162,6 +199,12 @@ class ComForm extends React.Component {
)}
</Form.Item>
</Form.Item>
<Form.Item label="独立密钥" extra="默认使用全局密钥,如果上传了独立密钥则优先使用该密钥。">
<Upload name="file" fileList={fileList} headers={{'X-Token': this.token}} beforeUpload={this.handleUpload}
onChange={this.handleUploadChange}>
{fileList.length === 0 ? <Button loading={uploading} icon="upload">点击上传</Button> : null}
</Upload>
</Form.Item>
<Form.Item label="备注信息">
{getFieldDecorator('desc', {initialValue: info['desc']})(
<Input.TextArea placeholder="请输入主机备注信息"/>