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, hostname=host.hostname,
port=host.port, port=host.port,
username=host.username, username=host.username,
command=form.command command=form.command,
pkey=host.private_key,
) )
return json_response(token) return json_response(token)
return json_response(error=error) return json_response(error=error)

View File

@ -14,6 +14,7 @@ class Host(models.Model, ModelMixin):
hostname = models.CharField(max_length=50) hostname = models.CharField(max_length=50)
port = models.IntegerField() port = models.IntegerField()
username = models.CharField(max_length=50) username = models.CharField(max_length=50)
pkey = models.TextField(null=True)
desc = models.CharField(max_length=255, null=True) desc = models.CharField(max_length=255, null=True)
created_at = models.CharField(max_length=20, default=human_datetime) 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_at = models.CharField(max_length=20, null=True)
deleted_by = models.ForeignKey(User, models.PROTECT, related_name='+', 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): 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) return SSH(self.hostname, self.port, self.username, pkey)
def __repr__(self): def __repr__(self):

View File

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

View File

@ -3,6 +3,7 @@
# Released under the AGPL-3.0 License. # Released under the AGPL-3.0 License.
from django.views.generic import View from django.views.generic import View
from django.db.models import F from django.db.models import F
from django.http.response import HttpResponseBadRequest
from libs import json_response, JsonParser, Argument from libs import json_response, JsonParser, Argument
from apps.setting.utils import AppSetting from apps.setting.utils import AppSetting
from apps.host.models import Host from apps.host.models import Host
@ -37,11 +38,13 @@ class HostView(View):
Argument('username', handler=str.strip, help='请输入登录用户名'), Argument('username', handler=str.strip, help='请输入登录用户名'),
Argument('hostname', handler=str.strip, help='请输入主机名或IP'), Argument('hostname', handler=str.strip, help='请输入主机名或IP'),
Argument('port', type=int, help='请输入SSH端口'), Argument('port', type=int, help='请输入SSH端口'),
Argument('pkey', required=False),
Argument('desc', required=False), Argument('desc', required=False),
Argument('password', required=False), Argument('password', required=False),
).parse(request.body) ).parse(request.body)
if error is None: 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') return json_response('auth fail')
if form.id: if form.id:
@ -119,7 +122,8 @@ def post_import(request):
summary['skip'].append(i) summary['skip'].append(i)
continue continue
try: 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) summary['fail'].append(i)
continue continue
except AuthenticationException: except AuthenticationException:
@ -141,7 +145,7 @@ def post_import(request):
return json_response(summary) 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: try:
private_key = AppSetting.get('private_key') private_key = AppSetting.get('private_key')
public_key = AppSetting.get('public_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() private_key, public_key = SSH.generate_key()
AppSetting.set('private_key', private_key, 'ssh private key') AppSetting.set('private_key', private_key, 'ssh private key')
AppSetting.set('public_key', public_key, 'ssh public key') AppSetting.set('public_key', public_key, 'ssh public key')
cli = SSH(hostname, port, username, private_key)
if password: if password:
_cli = SSH(hostname, port, username, password=str(password)) _cli = SSH(hostname, port, username, password=str(password))
_cli.add_public_key(public_key) _cli.add_public_key(public_key)
if pkey:
private_key = pkey
try: try:
cli = SSH(hostname, port, username, private_key)
cli.ping() cli.ping()
except BadAuthenticationType: except BadAuthenticationType:
if with_expect: if with_expect:
@ -164,3 +170,12 @@ def valid_ssh(hostname, port, username, password, with_expect=True):
raise ValueError('密钥认证失败请参考官方文档错误代码E02') raise ValueError('密钥认证失败请参考官方文档错误代码E02')
return False return False
return True 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) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com> # Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License. # Released under the AGPL-3.0 License.
from libs.ssh import SSH
from apps.host.models import Host from apps.host.models import Host
from apps.setting.utils import AppSetting
from socket import socket from socket import socket
import requests import requests
import logging import logging
@ -29,9 +27,9 @@ def port_check(addr, port):
return False, f'异常信息:{e}' return False, f'异常信息:{e}'
def host_executor(host, pkey, command): def host_executor(host, command):
try: try:
cli = SSH(host.hostname, host.port, host.username, pkey=pkey) cli = host.get_ssh()
exit_code, out = cli.exec_command(command) exit_code, out = cli.exec_command(command)
if exit_code == 0: if exit_code == 0:
return True, out or '检测状态正常' return True, out or '检测状态正常'
@ -52,6 +50,5 @@ def dispatch(tp, addr, extra):
command = extra command = extra
else: else:
raise TypeError(f'invalid monitor type: {tp!r}') raise TypeError(f'invalid monitor type: {tp!r}')
pkey = AppSetting.get('private_key')
host = Host.objects.filter(pk=addr).first() 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. # Released under the AGPL-3.0 License.
from queue import Queue from queue import Queue
from threading import Thread from threading import Thread
from libs.ssh import SSH, AuthenticationException from libs.ssh import AuthenticationException
from apps.host.models import Host from apps.host.models import Host
from apps.setting.utils import AppSetting
from django.db import close_old_connections from django.db import close_old_connections
import subprocess import subprocess
import socket import socket
@ -22,10 +21,10 @@ def local_executor(q, command):
q.put(('local', exit_code, round(time.time() - now, 3), out.decode())) 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() exit_code, out, now = -1, None, time.time()
try: try:
cli = SSH(host.hostname, host.port, host.username, pkey=pkey) cli = host.get_ssh()
exit_code, out = cli.exec_command(command) exit_code, out = cli.exec_command(command)
out = out if out else None out = out if out else None
except AuthenticationException: except AuthenticationException:
@ -39,7 +38,7 @@ def host_executor(q, host, pkey, command):
def dispatch(command, targets, in_view=False): def dispatch(command, targets, in_view=False):
if not in_view: if not in_view:
close_old_connections() close_old_connections()
threads, pkey, q = [], AppSetting.get('private_key'), Queue() threads, q = [], Queue()
for t in targets: for t in targets:
if t == 'local': if t == 'local':
threads.append(Thread(target=local_executor, args=(q, command))) 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() host = Host.objects.filter(pk=t).first()
if not host: if not host:
raise ValueError(f'unknown host id: {t!r}') 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: else:
raise ValueError(f'invalid target: {t!r}') raise ValueError(f'invalid target: {t!r}')
for t in threads: for t in threads:

View File

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

View File

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

View File

@ -14,13 +14,14 @@ class Channel:
return uuid.uuid4().hex return uuid.uuid4().hex
@staticmethod @staticmethod
def send_ssh_executor(hostname, port, username, command, token=None): def send_ssh_executor(hostname, port, username, command, pkey, token=None):
message = { message = {
'type': 'exec', 'type': 'exec',
'token': token, 'token': token,
'hostname': hostname, 'hostname': hostname,
'port': port, 'port': port,
'username': username, 'username': username,
'command': command 'command': command,
'pkey': pkey
} }
async_to_sync(layer.send)('ssh_exec', message) 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 { Router } from 'react-router-dom';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import zhCN from 'antd/es/locale/zh_CN'; import zhCN from 'antd/es/locale/zh_CN';
import { history, updatePermissions } from 'libs';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import moment from 'moment'; import moment from 'moment';
import 'moment/locale/zh-cn'; import 'moment/locale/zh-cn';
import * as serviceWorker from './serviceWorker'; import * as serviceWorker from './serviceWorker';
import { history, updatePermissions } from 'libs';
moment.locale('zh-cn'); moment.locale('zh-cn');
updatePermissions() updatePermissions()

View File

@ -5,7 +5,7 @@
*/ */
import React from 'react'; import React from 'react';
import { observer } from 'mobx-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 http from 'libs/http';
import store from './store'; import store from './store';
@ -13,21 +13,37 @@ import store from './store';
class ComForm extends React.Component { class ComForm extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.token = localStorage.getItem('token');
this.state = { this.state = {
loading: false, loading: false,
uploading: false,
password: null, password: null,
addZone: null, addZone: null,
fileList: [],
editZone: store.record.zone, editZone: store.record.zone,
} }
} }
componentDidMount() {
if (store.record.pkey) {
this.setState({
fileList: [{uid: '0', name: '独立密钥', data: store.record.pkey}]
})
}
}
handleSubmit = () => { handleSubmit = () => {
this.setState({loading: true}); this.setState({loading: true});
const formData = this.props.form.getFieldsValue(); const formData = this.props.form.getFieldsValue();
formData['id'] = store.record.id; formData['id'] = store.record.id;
const file = this.state.fileList[0];
if (file && file.data) formData['pkey'] = file.data;
http.post('/api/host/', formData) http.post('/api/host/', formData)
.then(res => { .then(res => {
if (res === 'auth fail') { if (res === 'auth fail') {
if (formData.pkey) {
message.error('独立密钥认证失败')
} else {
this.setState({loading: false}); this.setState({loading: false});
Modal.confirm({ Modal.confirm({
icon: 'exclamation-circle', icon: 'exclamation-circle',
@ -35,6 +51,7 @@ class ComForm extends React.Component {
content: this.confirmForm(formData.username), content: this.confirmForm(formData.username),
onOk: () => this.handleConfirm(formData), onOk: () => this.handleConfirm(formData),
}) })
}
} else { } else {
message.success('操作成功'); message.success('操作成功');
store.formVisible = false; 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() { render() {
const info = store.record; const info = store.record;
const {fileList, loading, uploading} = this.state;
const {getFieldDecorator} = this.props.form; const {getFieldDecorator} = this.props.form;
return ( return (
<Modal <Modal
@ -120,7 +157,7 @@ class ComForm extends React.Component {
title={store.record.id ? '编辑主机' : '新建主机'} title={store.record.id ? '编辑主机' : '新建主机'}
okText="验证" okText="验证"
onCancel={() => store.formVisible = false} onCancel={() => store.formVisible = false}
confirmLoading={this.state.loading} confirmLoading={loading}
onOk={this.handleSubmit}> onOk={this.handleSubmit}>
<Form labelCol={{span: 6}} wrapperCol={{span: 14}}> <Form labelCol={{span: 6}} wrapperCol={{span: 14}}>
<Form.Item required label="主机类别"> <Form.Item required label="主机类别">
@ -162,6 +199,12 @@ class ComForm extends React.Component {
)} )}
</Form.Item> </Form.Item>
</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="备注信息"> <Form.Item label="备注信息">
{getFieldDecorator('desc', {initialValue: info['desc']})( {getFieldDecorator('desc', {initialValue: info['desc']})(
<Input.TextArea placeholder="请输入主机备注信息"/> <Input.TextArea placeholder="请输入主机备注信息"/>