mirror of https://github.com/openspug/spug
A 主机可以设置独立密钥 #170
parent
367f21a640
commit
1fcd818583
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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="请输入主机备注信息"/>
|
||||||
|
|
Loading…
Reference in New Issue