mirror of https://github.com/openspug/spug
A 主机可以设置独立密钥 #170
parent
367f21a640
commit
1fcd818583
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -8,4 +8,5 @@ from .views import *
|
|||
urlpatterns = [
|
||||
path('', HostView.as_view()),
|
||||
path('import/', post_import),
|
||||
path('parse/', post_parse),
|
||||
]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,21 +13,37 @@ 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') {
|
||||
if (formData.pkey) {
|
||||
message.error('独立密钥认证失败')
|
||||
} else {
|
||||
this.setState({loading: false});
|
||||
Modal.confirm({
|
||||
icon: 'exclamation-circle',
|
||||
|
@ -35,6 +51,7 @@ class ComForm extends React.Component {
|
|||
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="请输入主机备注信息"/>
|
||||
|
|
Loading…
Reference in New Issue