diff --git a/spug_api/apps/exec/views.py b/spug_api/apps/exec/views.py index cf3ef04..cc6024f 100644 --- a/spug_api/apps/exec/views.py +++ b/spug_api/apps/exec/views.py @@ -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) diff --git a/spug_api/apps/host/models.py b/spug_api/apps/host/models.py index df37d32..e79bb11 100644 --- a/spug_api/apps/host/models.py +++ b/spug_api/apps/host/models.py @@ -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): diff --git a/spug_api/apps/host/urls.py b/spug_api/apps/host/urls.py index 272a002..0cb30ea 100644 --- a/spug_api/apps/host/urls.py +++ b/spug_api/apps/host/urls.py @@ -8,4 +8,5 @@ from .views import * urlpatterns = [ path('', HostView.as_view()), path('import/', post_import), + path('parse/', post_parse), ] diff --git a/spug_api/apps/host/views.py b/spug_api/apps/host/views.py index d2a4d5b..e4cfbc3 100644 --- a/spug_api/apps/host/views.py +++ b/spug_api/apps/host/views.py @@ -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() diff --git a/spug_api/apps/monitor/executors.py b/spug_api/apps/monitor/executors.py index 3487166..14f2eea 100644 --- a/spug_api/apps/monitor/executors.py +++ b/spug_api/apps/monitor/executors.py @@ -1,9 +1,7 @@ # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) # 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) diff --git a/spug_api/apps/schedule/executors.py b/spug_api/apps/schedule/executors.py index 023badf..c78b4b8 100644 --- a/spug_api/apps/schedule/executors.py +++ b/spug_api/apps/schedule/executors.py @@ -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: diff --git a/spug_api/consumer/consumers.py b/spug_api/consumer/consumers.py index 0bdc4e4..abab83d 100644 --- a/spug_api/consumer/consumers.py +++ b/spug_api/consumer/consumers.py @@ -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() diff --git a/spug_api/consumer/executors.py b/spug_api/consumer/executors.py index b69b265..e4bcfa1 100644 --- a/spug_api/consumer/executors.py +++ b/spug_api/consumer/executors.py @@ -2,7 +2,6 @@ # Copyright: (c) # 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() diff --git a/spug_api/libs/channel.py b/spug_api/libs/channel.py index 7836d7c..d04382a 100644 --- a/spug_api/libs/channel.py +++ b/spug_api/libs/channel.py @@ -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) diff --git a/spug_web/src/index.js b/spug_web/src/index.js index 7e5fce5..0332da1 100644 --- a/spug_web/src/index.js +++ b/spug_web/src/index.js @@ -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() diff --git a/spug_web/src/pages/host/Form.js b/spug_web/src/pages/host/Form.js index 7554cde..8a6b13e 100644 --- a/spug_web/src/pages/host/Form.js +++ b/spug_web/src/pages/host/Form.js @@ -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 ( store.formVisible = false} - confirmLoading={this.state.loading} + confirmLoading={loading} onOk={this.handleSubmit}>
@@ -162,6 +199,12 @@ class ComForm extends React.Component { )} + + + {fileList.length === 0 ? : null} + + {getFieldDecorator('desc', {initialValue: info['desc']})(