pull/707/merge
mjzhang95 2025-04-23 19:11:56 +08:00 committed by GitHub
commit 50b5dcd39f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 194 additions and 23 deletions

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
version: "3.6"
services:
db:
image: mariadb:10.8.2
container_name: spug-db
restart: always
command: --port 3306 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- /data/spug/mysql:/var/lib/mysql
environment:
- MYSQL_DATABASE=spug
- MYSQL_USER=spug
- MYSQL_PASSWORD=spug.cc
- MYSQL_ROOT_PASSWORD=spug.cc
spug:
build:
context: .
dockerfile: Dockerfile
container_name: spug
privileged: true
restart: always
volumes:
- /data/spug/service:/data/spug
- /data/spug/repos:/data/repos
ports:
- "80:80"
- "23:23" # telnet端口
environment:
- MYSQL_DATABASE=spug
- MYSQL_USER=spug
- MYSQL_PASSWORD=spug.cc
- MYSQL_HOST=db
- MYSQL_PORT=3306
depends_on:
- db

View File

@ -19,6 +19,7 @@ class Host(models.Model, ModelMixin):
is_verified = models.BooleanField(default=False) is_verified = models.BooleanField(default=False)
created_at = models.CharField(max_length=20, default=human_datetime) created_at = models.CharField(max_length=20, default=human_datetime)
created_by = models.ForeignKey(User, models.PROTECT, related_name='+') created_by = models.ForeignKey(User, models.PROTECT, related_name='+')
connect_type = models.CharField(max_length=20, default='ssh')
@property @property
def private_key(self): def private_key(self):
@ -28,6 +29,12 @@ class Host(models.Model, ModelMixin):
pkey = pkey or self.private_key pkey = pkey or self.private_key
return SSH(self.hostname, self.port, self.username, pkey, default_env=default_env) return SSH(self.hostname, self.port, self.username, pkey, default_env=default_env)
def get_telnet(self, password=None):
"""获取telnet连接实例"""
from libs.telnet import Telnet
port = self.port or 23 # telnet默认端口23
return Telnet(self.hostname, port, self.username, password)
def to_view(self): def to_view(self):
tmp = self.to_dict() tmp = self.to_dict()
if hasattr(self, 'hostextend'): if hasattr(self, 'hostextend'):

View File

@ -13,7 +13,8 @@ from apps.exec.models import ExecTemplate
from apps.app.models import Deploy from apps.app.models import Deploy
from apps.schedule.models import Task from apps.schedule.models import Task
from apps.monitor.models import Detection from apps.monitor.models import Detection
from libs.ssh import SSH, AuthenticationException from libs.ssh import SSH
from libs.telnet import Telnet, AuthenticationException
from paramiko.ssh_exception import BadAuthenticationType from paramiko.ssh_exception import BadAuthenticationType
from openpyxl import load_workbook from openpyxl import load_workbook
from threading import Thread from threading import Thread
@ -193,7 +194,23 @@ def batch_valid(request):
def _do_host_verify(form): def _do_host_verify(form):
password = form.pop('password') password = form.pop('password', None)
connect_type = form.pop('connect_type', 'ssh')
if connect_type == 'telnet':
if not password:
return False
try:
with Telnet(form.hostname, form.port or 23, form.username, password) as tn:
return True
except AuthenticationException:
raise Exception('Telnet认证失败请检查用户名和密码是否正确')
except socket.timeout:
raise Exception('连接主机超时,请检查网络')
except Exception as e:
raise Exception(f'Telnet连接失败: {str(e)}')
# SSH验证逻辑
if form.pkey: if form.pkey:
try: try:
with SSH(form.hostname, form.port, form.username, form.pkey) as ssh: with SSH(form.hostname, form.port, form.username, form.pkey) as ssh:

72
spug_api/libs/telnet.py Normal file
View File

@ -0,0 +1,72 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
import telnetlib
import time
class AuthenticationException(Exception):
pass
class Telnet:
def __init__(self, hostname, port, username, password, timeout=10):
self.hostname = hostname
self.port = port
self.username = username
self.password = password
self.timeout = timeout
self._tn = None
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def connect(self):
try:
self._tn = telnetlib.Telnet(self.hostname, self.port, self.timeout)
# 等待登录提示
index, match, text = self._tn.expect([b"login:", b"username:", b"Username:"], self.timeout)
if index != -1:
self._tn.write(self.username.encode('ascii') + b'\n')
# 等待密码提示
index, match, text = self._tn.expect([b"Password:", b"password:"], self.timeout)
if index != -1:
self._tn.write(self.password.encode('ascii') + b'\n')
# 验证登录结果
index, match, text = self._tn.expect([b"#", b"$", b">"], self.timeout)
if index == -1:
raise AuthenticationException("Authentication failed")
else:
raise AuthenticationException("Password prompt not found")
else:
raise AuthenticationException("Login prompt not found")
except:
if self._tn:
self.close()
raise
def exec_command(self, command):
"""执行命令并返回结果"""
if not self._tn:
raise RuntimeError("Not connected")
try:
self._tn.write(command.encode('ascii') + b'\n')
time.sleep(0.5) # 等待命令执行
# 读取命令输出直到提示符
response = self._tn.read_until(b"#", self.timeout)
return 0, response.decode('ascii')
except:
return 1, None
def close(self):
"""关闭连接"""
if self._tn:
self._tn.close()
self._tn = None

View File

@ -10,3 +10,4 @@ GitPython==3.1.41
python-ldap==3.4.0 python-ldap==3.4.0
openpyxl==3.0.3 openpyxl==3.0.3
user_agents==2.2.0 user_agents==2.2.0
telnetlib3==1.0.4

View File

@ -6,51 +6,75 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { ExclamationCircleOutlined, UploadOutlined } from '@ant-design/icons'; import { ExclamationCircleOutlined, UploadOutlined } from '@ant-design/icons';
import { Modal, Form, Input, TreeSelect, Button, Upload, Alert, message } from 'antd'; import { Modal, Form, Input, TreeSelect, Button, Upload, Alert, message, Select } from 'antd';
import { http, X_TOKEN } from 'libs'; import { http, X_TOKEN } from 'libs';
import store from './store'; import store from './store';
import styles from './index.module.less'; import styles from './index.module.less';
const { Option } = Select;
export default observer(function () { export default observer(function () {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [fileList, setFileList] = useState([]); const [fileList, setFileList] = useState([]);
const [connectType, setConnectType] = useState('ssh');
const [showPasswordField, setShowPasswordField] = useState(false);
function handleConnectTypeChange(value) {
setConnectType(value);
setShowPasswordField(value === 'telnet');
if (value === 'telnet') {
setFileList([]);
}
}
useEffect(() => { useEffect(() => {
if (store.record.pkey) { if (store.record.pkey) {
setFileList([{uid: '0', name: '独立密钥', data: store.record.pkey}]) setFileList([{uid: '0', name: '独立密钥', data: store.record.pkey}])
} }
if (store.record.connect_type) {
setConnectType(store.record.connect_type);
setShowPasswordField(store.record.connect_type === 'telnet');
}
}, []) }, [])
function handleSubmit() { function handleSubmit() {
setLoading(true); setLoading(true);
const formData = form.getFieldsValue(); const formData = form.getFieldsValue();
formData['id'] = store.record.id; formData['id'] = store.record.id;
formData['connect_type'] = connectType;
const file = fileList[0]; const file = fileList[0];
if (file && file.data) formData['pkey'] = file.data; if (file && file.data) formData['pkey'] = file.data;
if (connectType === 'telnet' && !formData.password && !showPasswordField) {
setShowPasswordField(true);
setLoading(false);
return;
}
http.post('/api/host/', formData) http.post('/api/host/', formData)
.then(res => { .then(res => {
if (res === 'auth fail') { if (res === 'auth fail') {
setLoading(false) setLoading(false);
if (formData.pkey) { if (formData.pkey) {
message.error('独立密钥认证失败') message.error(connectType === 'ssh' ? '独立密钥认证失败' : 'Telnet认证失败');
} else { } else {
const onChange = v => formData.password = v; const onChange = v => formData.password = v;
Modal.confirm({ Modal.confirm({
icon: <ExclamationCircleOutlined/>, icon: <ExclamationCircleOutlined/>,
title: '首次验证请输入密码', title: connectType === 'ssh' ? '首次验证请输入密码' : 'Telnet认证',
content: <ConfirmForm username={formData.username} onChange={onChange}/>, content: <ConfirmForm username={formData.username} onChange={onChange}/>,
onOk: () => handleConfirm(formData), onOk: () => handleConfirm(formData),
}) });
} }
} else { } else {
message.success('验证成功'); message.success('验证成功');
store.formVisible = false; store.formVisible = false;
store.fetchRecords(); store.fetchRecords();
store.fetchExtend(res.id) store.fetchExtend(res.id);
} }
}, () => setLoading(false)) }, () => setLoading(false));
} }
function handleConfirm(formData) { function handleConfirm(formData) {
@ -117,27 +141,42 @@ export default observer(function () {
<Input placeholder="请输入主机名称"/> <Input placeholder="请输入主机名称"/>
</Form.Item> </Form.Item>
<Form.Item required label="连接地址" style={{marginBottom: 0}}> <Form.Item required label="连接地址" style={{marginBottom: 0}}>
<Form.Item name="username" className={styles.formAddress1} style={{width: 'calc(30%)'}}> <Form.Item name="connect_type" className={styles.formAddress1} style={{width: 'calc(30%)'}}>
<Input addonBefore="ssh" placeholder="用户名"/> <Select defaultValue="ssh" onChange={handleConnectTypeChange}>
<Option value="ssh">SSH</Option>
<Option value="telnet">Telnet</Option>
</Select>
</Form.Item> </Form.Item>
<Form.Item name="hostname" className={styles.formAddress2} style={{width: 'calc(40%)'}}> <Form.Item name="username" className={styles.formAddress2} style={{width: 'calc(30%)'}}>
<Input addonBefore={connectType === 'ssh' ? 'ssh' : 'telnet'} placeholder="用户名"/>
</Form.Item>
<Form.Item name="hostname" className={styles.formAddress3} style={{width: 'calc(40%)'}}>
<Input addonBefore="@" placeholder="主机名/IP"/> <Input addonBefore="@" placeholder="主机名/IP"/>
</Form.Item> </Form.Item>
<Form.Item name="port" className={styles.formAddress3} style={{width: 'calc(30%)'}}> </Form.Item>
<Input addonBefore="-p" placeholder="端口"/> <Form.Item label="端口号" name="port">
<Input placeholder={connectType === 'ssh' ? '默认22' : '默认23'} style={{width: 200}} />
</Form.Item>
{connectType === 'ssh' && (
<Form.Item label="独立密钥" extra="默认使用全局密钥,如果上传了独立密钥(私钥)则优先使用该密钥。">
<Upload name="file" fileList={fileList} headers={{'X-Token': X_TOKEN}} beforeUpload={handleUpload}
onChange={handleUploadChange}>
{fileList.length === 0 ? <Button loading={uploading} icon={<UploadOutlined/>}>点击上传</Button> : null}
</Upload>
</Form.Item> </Form.Item>
</Form.Item> )}
<Form.Item label="独立密钥" extra="默认使用全局密钥,如果上传了独立密钥(私钥)则优先使用该密钥。"> {connectType === 'telnet' && (
<Upload name="file" fileList={fileList} headers={{'X-Token': X_TOKEN}} beforeUpload={handleUpload} <Form.Item name="password" label="密码" rules={[{required: true, message: '请输入Telnet密码'}]}>
onChange={handleUploadChange}> <Input.Password placeholder="请输入Telnet密码" />
{fileList.length === 0 ? <Button loading={uploading} icon={<UploadOutlined/>}>点击上传</Button> : null} </Form.Item>
</Upload> )}
</Form.Item>
<Form.Item name="desc" label="备注信息"> <Form.Item name="desc" label="备注信息">
<Input.TextArea placeholder="请输入主机备注信息"/> <Input.TextArea placeholder="请输入主机备注信息"/>
</Form.Item> </Form.Item>
<Form.Item wrapperCol={{span: 17, offset: 5}}> <Form.Item wrapperCol={{span: 17, offset: 5}}>
<Alert showIcon type="info" message="首次验证时需要输入登录用户名对应的密码该密码会用于配置SSH密钥认证不会存储该密码。"/> <Alert showIcon type="info" message={connectType === 'ssh' ?
'首次验证时需要输入登录用户名对应的密码该密码会用于配置SSH密钥认证不会存储该密码。' :
'Telnet连接需要输入密码进行认证。'} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>