Skip to content

Zmj spug #707

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: 3.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions spug_api/apps/host/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Host(models.Model, ModelMixin):
is_verified = models.BooleanField(default=False)
created_at = models.CharField(max_length=20, default=human_datetime)
created_by = models.ForeignKey(User, models.PROTECT, related_name='+')
connect_type = models.CharField(max_length=20, default='ssh')

@property
def private_key(self):
Expand All @@ -27,6 +28,12 @@ def private_key(self):
def get_ssh(self, pkey=None, default_env=None):
pkey = pkey or self.private_key
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):
tmp = self.to_dict()
Expand Down
21 changes: 19 additions & 2 deletions spug_api/apps/host/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
from apps.app.models import Deploy
from apps.schedule.models import Task
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 openpyxl import load_workbook
from threading import Thread
Expand Down Expand Up @@ -193,7 +194,23 @@ def batch_valid(request):


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:
try:
with SSH(form.hostname, form.port, form.username, form.pkey) as ssh:
Expand Down
72 changes: 72 additions & 0 deletions spug_api/libs/telnet.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion spug_api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ requests==2.32.0
GitPython==3.1.41
python-ldap==3.4.0
openpyxl==3.0.3
user_agents==2.2.0
user_agents==2.2.0
telnetlib3==1.0.4
77 changes: 58 additions & 19 deletions spug_web/src/pages/host/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,51 +6,75 @@
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react';
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 store from './store';
import styles from './index.module.less';

const { Option } = Select;

export default observer(function () {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
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(() => {
if (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() {
setLoading(true);
const formData = form.getFieldsValue();
formData['id'] = store.record.id;
formData['connect_type'] = connectType;
const file = fileList[0];
if (file && file.data) formData['pkey'] = file.data;

if (connectType === 'telnet' && !formData.password && !showPasswordField) {
setShowPasswordField(true);
setLoading(false);
return;
}

http.post('/api/host/', formData)
.then(res => {
if (res === 'auth fail') {
setLoading(false)
setLoading(false);
if (formData.pkey) {
message.error('独立密钥认证失败')
message.error(connectType === 'ssh' ? '独立密钥认证失败' : 'Telnet认证失败');
} else {
const onChange = v => formData.password = v;
Modal.confirm({
icon: <ExclamationCircleOutlined/>,
title: '首次验证请输入密码',
title: connectType === 'ssh' ? '首次验证请输入密码' : 'Telnet认证',
content: <ConfirmForm username={formData.username} onChange={onChange}/>,
onOk: () => handleConfirm(formData),
})
});
}
} else {
message.success('验证成功');
store.formVisible = false;
store.fetchRecords();
store.fetchExtend(res.id)
store.fetchExtend(res.id);
}
}, () => setLoading(false))
}, () => setLoading(false));
}

function handleConfirm(formData) {
Expand Down Expand Up @@ -117,27 +141,42 @@ export default observer(function () {
<Input placeholder="请输入主机名称"/>
</Form.Item>
<Form.Item required label="连接地址" style={{marginBottom: 0}}>
<Form.Item name="username" className={styles.formAddress1} style={{width: 'calc(30%)'}}>
<Input addonBefore="ssh" placeholder="用户名"/>
<Form.Item name="connect_type" className={styles.formAddress1} style={{width: 'calc(30%)'}}>
<Select defaultValue="ssh" onChange={handleConnectTypeChange}>
<Option value="ssh">SSH</Option>
<Option value="telnet">Telnet</Option>
</Select>
</Form.Item>
<Form.Item name="hostname" className={styles.formAddress2} style={{width: 'calc(40%)'}}>
<Input addonBefore="@" placeholder="主机名/IP"/>
<Form.Item name="username" className={styles.formAddress2} style={{width: 'calc(30%)'}}>
<Input addonBefore={connectType === 'ssh' ? 'ssh' : 'telnet'} placeholder="用户名"/>
</Form.Item>
<Form.Item name="port" className={styles.formAddress3} style={{width: 'calc(30%)'}}>
<Input addonBefore="-p" placeholder="端口"/>
<Form.Item name="hostname" className={styles.formAddress3} style={{width: 'calc(40%)'}}>
<Input addonBefore="@" placeholder="主机名/IP"/>
</Form.Item>
</Form.Item>
<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 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>
)}
{connectType === 'telnet' && (
<Form.Item name="password" label="密码" rules={[{required: true, message: '请输入Telnet密码'}]}>
<Input.Password placeholder="请输入Telnet密码" />
</Form.Item>
)}
<Form.Item name="desc" label="备注信息">
<Input.TextArea placeholder="请输入主机备注信息"/>
</Form.Item>
<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>
</Modal>
Expand Down