造轮子的初衷
- 之前在项目上采集数据跨网段,专业软件不支持改端口,又不想在上位机上直接装采集软件,网上找了几个网络映射数据转发工具,感觉都挺不错的,多是C/C++,C#写的,小巧,功能够用.
- 有一点功能没有,也许是我没找到这种工具,就是打开软件不能自动启动配置或没有这功能,要手动点启动.
- 目的是做成开机启动软件自动运行配置,省得人工忘记点启动,另一个目的一直没写过网络编程,正好练练,以下附上,源码,也有打包二进制,说真的,打包这块真心不爽,别的编程语言几百K,几M的,到Python都是几十上百M,虽然对于现在电脑配置是一点压力都没有,但就一个功能单一的小工具,你几十上百M真心不爽,又不想在使用机器上装PYQT5和PYTHON。
- 哎,半路出家,全是自学的,水平有限,凑合着看,欢迎交流经验.
软件界面
-
程序主界面
-
配置,双击/编辑打开
-
托盘
源文件
- main_ui.ui QT设计师画的程序主UI文件
- main_ui.py PYQT5转换的PY文件
- config_ui.ui QT设计师画的配置UI文件
- config_ui.py PYQT5转换的PY文件
- icon.qrc QT资源文件
- icon_rc.py PYQT5转换的PY文件
- log.py 封装的标准日志模块文件
- port_map.py 主要映射转发逻辑
- main_window.py 窗口交互处理
- PortMapping.py 程序入口主文件
- config.dat 配置文件,自动生成
- 源码文件打包, 地址
- pyinstaller 打包二进制, 地址
主要文件
- 核心中转代码 port_map.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 软件版本 3.7.3
import logging
import socket
import sys
import threading
from PyQt5.QtCore import pyqtSignal, QObject, QThread
from PyQt5.QtWidgets import QApplication
class SocketThread(QThread):
"""docstring for SocketThread"""
transfer_data_signal = pyqtSignal(str, int)
receive_data_signal = pyqtSignal(str, int)
stop_return_Signal = pyqtSignal(str, bool)
def __init__(self, config):
super(SocketThread, self).__init__()
self.config = config
self.name = config.get('name', 'socket_thread')
self.log = logging.getLogger('main.{}'.format(self.name))
self.type = config.get('map_type')
self.server = socket.socket(socket.AF_INET, self.get_map_type())
# 获取映射协议类型
def get_map_type(self):
if self.type == "TCP":
return socket.SOCK_STREAM
else:
return socket.SOCK_DGRAM
# 单向流数据传递
def mapping_worker(self, conn_receiver, conn_sender, direction):
data_size = 0
while self.config['starting']:
try: # 接收
data = conn_receiver.recv(2048)
except Exception as e:
self.log.info('映射:{},连接断开:{}'.format(self.name, e))
break
if not data:
break
try: # 中转发送
conn_sender.sendall(data)
except Exception as e:
self.log.warning('映射:{},中转发送数据失败:{}'.format(self.name, e))
break
data_lenght = len(data)
data_size += data_lenght
if direction == 'send':
self.transfer_data_signal.emit(self.name, data_size)
else:
self.receive_data_signal.emit(self.name, data_size)
recieve_host, receive_port = conn_receiver.getpeername()
send_host, sned_port = conn_sender.getpeername()
self.log.debug('信息: 映射 > %s -> %s > %d bytes.' % (recieve_host, send_host, data_lenght))
conn_receiver.close()
conn_sender.close()
return
# 端口映射请求处理
def mapping_request(self, local_conn, remote_ip, remote_port):
remote_conn = socket.socket(socket.AF_INET, self.get_map_type())
try:
remote_conn.connect((remote_ip, remote_port))
except Exception as e:
local_conn.close()
self.log.warning('映射:{},转出连接,初始失败,请检测地址及端口,异常:{}'.format(self.config.get('name'), e))
return
# 中转发送
threading.Thread(target = self.mapping_worker, args = (local_conn, remote_conn, 'send')).start()
# 中转接收
threading.Thread(target = self.mapping_worker, args = (remote_conn, local_conn, 'receive')).start()
return
# UDP 数据转发
def udp_request(self, conn_receiver, conn_sender, sender_addr):
try:
data_size = 0
while True:
data, addr = conn_receiver.recvfrom(2048)
if data:
rec = conn_sender.sendto(data, sender_addr)
data_size += rec
self.receive_data_signal.emit(self.name, data_size)
except Exception as e:
pass
def run(self):
local_addr = (self.config['local_host'], int(self.config['local_port']))
map_addr = (self.config['map_host'], self.config['map_port'])
if self.type == "TCP":
try:
self.server.bind(local_addr)
self.server.listen(5)
while self.config['starting']:
self.stop_return_Signal.emit(self.config['name'], True)
local_conn, addr = self.server.accept()
self.log.warning('映射:{},接收来自:{},的数据,准备转发数据...'.format(self.config.get('name'), addr))
threading.Thread(target = self.mapping_request, args = (local_conn, *map_addr)).start()
except Exception as e:
self.server.close()
self.log.warning("TCP映射转入连接,初始化失败,请检测地址及端口占用,异常:{}".format(e))
self.stop_return_Signal.emit(self.config['name'], False)
else: # UDP
try:
self.server.bind(local_addr)
send_conn = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
data_size = 0
self.stop_return_Signal.emit(self.config['name'], True)
while True:
send_data, server_addr = self.server.recvfrom(2048)
if not send_data:
self.log.warning("没有数据")
continue
rec = send_conn.sendto(send_data, map_addr)
if not rec:
self.log.warning("转发数据失败")
data_size += rec
self.transfer_data_signal.emit(self.name, data_size)
# 以传入端口为名称检测线程,如果不存在就增加
if str(server_addr[1]) not in [v.name for v in threading.enumerate()]:
th = threading.Thread(target = self.udp_request,
args = (send_conn, self.server, server_addr))
th.setName(server_addr[1])
th.start()
except Exception as e:
self.server.close()
self.log.warning("UDP映射转入连接,初始化失败,请检测地址及端口,异常:{}".format(e))
self.stop_return_Signal.emit(self.config['name'], False)
class MappingServer(QObject):
"""docstring for MapingServer"""
delete_signal = pyqtSignal(str)
transfer_data_signal = pyqtSignal(str, int)
receive_data_signal = pyqtSignal(str, int)
feedback_signal = pyqtSignal(str, bool)
mapping_thread_dict = {}
def __init__(self, config):
super(MappingServer, self).__init__()
self.log = logging.getLogger("main.MapingServer")
self.config = config
self.delete_signal.connect(self.delelet_thread)
self.create_mapping_server()
# 删除连接
def delelet_thread(self, name):
map_thread = self.mapping_thread_dict.get(name)
if map_thread:
map_thread.quit()
self.mapping_thread_dict.pop(name)
# 创建映射服务
def create_mapping_server(self, config = None):
if config:
config_list = [config, ]
else:
config_list = self.config
for row in config_list:
name = row['name']
row['local_port'] = int(row['local_port'])
row['map_port'] = int(row['map_port'])
start = row.get('starting')
socket_thread = SocketThread(row)
socket_thread.transfer_data_signal.connect(self.transfer_data_signal.emit)
socket_thread.receive_data_signal.connect(self.receive_data_signal.emit)
socket_thread.stop_return_Signal.connect(self.feedback_signal.emit)
if start:
socket_thread.start()
self.mapping_thread_dict.update({name: socket_thread})
self.log.warning("创建映射线程:{},线程:{}".format(name, socket_thread.isRunning()))
if __name__ == '__main__':
config = {
'name': 'test', 'map_type': 'UDP', 'local_host': '192.168.31.27', 'local_port': '9000',
'map_host': '192.168.187.1',
'map_port': '8000', 'auto_start': True, 'starting': True
}
app = QApplication(sys.argv)
tcp = MappingServer([config])
sys.exit(app.exec_())
- 参考其他网友文章,具体记不清了
- TCP创建一个线程启动本地TCP 监听,发现传入连接后就创建一条中转连续远程监听,并创建两条线程,一个用于接收,一个用于转发到远程
- UDP 创建一条线程用于接收传入,收到立刻传走,拿到远程端口后创建一条接收远程线程,创建前判断这个端口线程是否创建
- 交互
- UI主窗交互
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 软件版本 : Python3.7.3
# 功能 :
import os
import pickle
import socket
import time
from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt
from PyQt5.QtWidgets import QCheckBox, QDialog, QFileDialog, QGridLayout, QInputDialog, QLineEdit, QMessageBox, \
QTableWidgetItem, QTextBrowser, QWidget
import config_ui
import log
import main_ui
import port_map
class Config(object):
"""docstring for Config"""
def __init__(self):
super(Config, self).__init__()
self.path = './config.dat'
def set_config(self, data):
with open(self.path, 'wb') as f:
rec = pickle.dump(data, f)
return rec
def get_config(self):
if not os.path.exists(self.path):
with open(self.path, 'wb') as f:
pickle.dump({}, f)
return {}
with open(self.path, 'rb') as f:
data = pickle.load(f)
return data
def error_info(func):
def error(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
show_message(e)
return error
def show_message(info):
rec = QMessageBox.information(None, "提示", "{}".format(info), QMessageBox.Yes | QMessageBox.No)
return True if rec == QMessageBox.Yes else False
class MainWindow(QWidget):
"""docstring for MainWindow"""
send_start_signal = pyqtSignal(str, bool)
def __init__(self):
super(MainWindow, self).__init__()
self.log = log.Logger(name = 'main')
self.ui = main_ui.Ui_Form()
self.ui.setupUi(self)
self.config = Config()
self.config_info = self.config.get_config()
self.init_map_table_width()
self.localhost_list = self.get_localhost_list()
self.config_window = QWidget()
self.cui = config_ui.Ui_Form()
self.cui.setupUi(self.config_window)
self.cui.ok.clicked.connect(self.save_map_config)
self.cui.cancel.clicked.connect(self.config_window.close)
self.load_map_config()
self.mapping_server = self.init_mapping()
def init_mapping(self):
if self.config_info:
obj = self.ui.map_table
name_list = list(self.config_info.keys())
# 初始化映射服务
mapping_server = port_map.MappingServer(self.config_info.values())
mapping_server.transfer_data_signal.connect(
lambda k, v: obj.setItem(name_list.index(k), 7, QTableWidgetItem('{:.2f}KB'.format(v / 1024))))
mapping_server.receive_data_signal.connect(
lambda k, v: obj.setItem(name_list.index(k), 8, QTableWidgetItem('{:.2f}KB'.format(v / 1024))))
mapping_server.feedback_signal.connect(self.receiv_feedback)
return mapping_server
# 接收启停信号反馈
def receiv_feedback(self, name, v):
name_list = list(self.config_info.keys())
obj = self.ui.map_table.cellWidget(name_list.index(name), 6)
if not v:
info = "映射:{},无法启动,请检查...\n本地端口是否占用?\n远程地址端口是否可访问?".format(name)
self.log.warning(info)
show_message(info)
obj.setChecked(False)
qss = "QCheckBox {background-color: rgba(255, 0, 0, 120);}"
else:
qss = "QCheckBox {background-color: rgba(0, 170, 0, 120);}"
obj.setStyleSheet(qss)
# 删除映射数据
@pyqtSlot(bool)
def on_del_map_clicked(self):
select_list = [i.row() for i in self.ui.map_table.selectionModel().selection().indexes()]
if len(select_list) == 1:
self.ui.map_table.removeRow(select_list[0])
else:
show_message("请选择一条需要删除的映射数据?")
# 编辑映射数据
@pyqtSlot(bool)
def on_edit_map_clicked(self):
select_list = [i.row() for i in self.ui.map_table.selectionModel().selection().indexes()]
if len(select_list) == 1:
self.on_map_table_itemDoubleClicked(self.ui.map_table.item(select_list[0], 0))
else:
show_message("请选择一条需要编辑的映射数据?")
# 增加映射
@pyqtSlot(bool)
def on_add_map_clicked(self):
self.config_window.show()
self.cui.map_name.setText('test')
self.cui.starting.setChecked(False)
self.cui.local_host_list.clear()
self.cui.local_host_list.addItems(self.localhost_list)
self.cui.local_port.clear()
self.cui.map_host.clear()
self.cui.map_port.clear()
# 获取本机IPv4地址
def get_localhost_list(self):
addrs = socket.getaddrinfo(socket.gethostname(), None)
host_list = ['0.0.0.0', '127.0.0.1']
host_list += [item[4][0] for item in addrs if ':' not in item[4][0]]
return host_list
# 初始映射表格
def init_map_table_width(self):
column = self.ui.map_table.columnCount()
row = self.ui.map_table.rowCount()
for i in [0, 2, 4]:
self.ui.map_table.setColumnWidth(i, 100)
self.ui.map_table.setColumnWidth(7, 80)
self.ui.map_table.setColumnWidth(8, 80)
for i in range(row):
for col in range(column):
item = self.ui.map_table.item(i, col)
if item:
item.setTextAlignment(Qt.AlignVCenter | Qt.AlignHCenter)
# 存储映射配置
def save_map_config(self):
local_port = self.cui.local_port.text()
if not local_port:
return show_message("请输入本地端口...")
map_host = self.cui.map_host.text()
if not map_host:
return show_message("请输入映射IP或域名...")
map_port = self.cui.map_port.text()
if not map_port:
return show_message("请输入映射端口...")
data = {
'name': self.cui.map_name.text(),
'map_type': self.cui.map_type.currentText(),
'local_host': self.cui.local_host_list.currentText(),
'local_port': local_port,
'map_host': map_host,
'map_port': map_port,
'starting': self.cui.starting.isChecked(),
}
self.config_info.update({self.cui.map_name.text(): data})
self.config.set_config(self.config_info)
self.load_map_config()
self.config_window.close()
# 加载映射配置
def load_map_config(self):
obj = self.ui.map_table
obj.setRowCount(0)
obj.setRowCount(len(self.config_info))
for i, config in enumerate(self.config_info.values()):
name = config.get('name', 'test')
obj.setItem(i, 0, QTableWidgetItem(name))
obj.setItem(i, 1, QTableWidgetItem(config.get('map_type')))
obj.setItem(i, 2, QTableWidgetItem(config.get('local_host', '0.0.0.0')))
obj.setItem(i, 3, QTableWidgetItem(str(config.get('local_port'))))
obj.setItem(i, 4, QTableWidgetItem(config.get('map_host')))
obj.setItem(i, 5, QTableWidgetItem(str(config.get('map_port'))))
obj.setItem(i, 7, QTableWidgetItem(" "))
obj.setItem(i, 8, QTableWidgetItem(" "))
start = QCheckBox()
start.setObjectName(name)
start.setChecked(config.get('starting', False))
obj.setCellWidget(i, 6, start)
# 界面点击启停
obj.cellWidget(i, 6).clicked.connect(self.clicked_start)
# 居中
for col in range(obj.columnCount()):
item = obj.item(i, col)
if item:
item.setTextAlignment(Qt.AlignVCenter | Qt.AlignHCenter)
# 点击启停
def clicked_start(self, value):
name = self.sender().objectName()
thread = self.mapping_server.mapping_thread_dict.get(name)
status = thread.isRunning()
thread.config['starting'] = value
# 重新启动线程
if not status and value:
thread.start()
# 设置颜色
name_list = list(self.config_info.keys())
obj = self.ui.map_table.cellWidget(name_list.index(name), 6)
if not value:
qss = "QCheckBox {background-color: none;}"
obj.setStyleSheet(qss)
if value and obj.isChecked():
qss = "QCheckBox {background-color: rgba(0, 170, 0, 120);}"
obj.setStyleSheet(qss)
# 双击单元格
@error_info
@pyqtSlot(bool)
def on_map_table_itemDoubleClicked(self, item):
obj = self.ui.map_table
row = item.row()
self.config_window.show()
self.cui.map_name.setText(obj.item(row, 0).text())
self.cui.map_type.setCurrentText(obj.item(row, 1).text())
host = obj.item(row, 2).text()
self.cui.local_host_list.clear()
if host in self.localhost_list:
self.cui.local_host_list.addItems(self.localhost_list)
self.cui.local_host_list.setCurrentIndex(self.localhost_list.index(host))
else:
self.cui.local_host_list.addItems(self.localhost_list)
self.cui.local_port.setText(obj.item(row, 3).text())
self.cui.map_host.setText(obj.item(row, 4).text())
self.cui.map_port.setText(obj.item(row, 5).text())
self.cui.starting.setChecked(obj.cellWidget(row, 6).isChecked())
# 连接日志行
def concat_log(self, path):
with open(path, 'r') as f:
data = f.readlines()
for line in data:
color = '#999999'
if "INFO" in line:
color = 'DarkRed'
if "DEBUG" in line:
color = 'Silver'
if "WARNING" in line:
color = 'Orange'
if "ERROR" in line:
color = 'red'
yield '<br/><span style=" color:{};">{}</span><br/>'.format(color, line)
# 检测密码
def check_password(self):
password = "{}".format(time.strftime("%m%d", time.localtime(time.time())))
value, ok = QInputDialog.getText(self, '密码验证', '输入密码:', echo = QLineEdit.PasswordEchoOnEdit)
if ok and value == password:
return True
else:
msg = " * 密码不对?\n * 是不可能让你操作的!!!\n\n * 友情提示:每天都会变的月日哦 ^_^ "
QMessageBox.information(self, "What?", msg, QMessageBox.Yes)
# 打开日志文件
def open_log_file(self):
log_view = QTextBrowser()
log_window = QDialog()
log_window.setFixedSize(800, 600)
grid = QGridLayout()
grid.addWidget(log_view)
log_window.setLayout(grid)
log_window.setWindowTitle("日志")
file = QFileDialog.getOpenFileName(self, '日志', './log', 'all file (*.*)')[0]
logs = "\n".join(self.concat_log(file))
log_view.insertHtml(logs)
log_window.exec_()
if __name__ == '__main__':
from PyQt5.QtWidgets import QApplication
import sys
app = QApplication(sys.argv)
ui = MainWindow()
ui.show()
sys.exit(app.exec_())
- 程序入口
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 软件版本 : Python3.7.3
# 功能 :
import os
import sys
import win32api
import win32con
import win32event
from PyQt5.QtWidgets import QApplication,QSystemTrayIcon,QAction,QMenu
from PyQt5.QtGui import QIcon
from winerror import ERROR_ALREADY_EXISTS
from main_window import MainWindow
# 系统托盘图标
class SystemTray(object):
# 程序托盘类
# noinspection PyShadowingNames
def __init__(self, gui):
self.gui = gui
QApplication.setQuitOnLastWindowClosed(False)
self.gui.show() # 不设置显示则为启动最小化到托盘
self.tp = QSystemTrayIcon(self.gui)
# 设置托盘图标
self.tp.setIcon(QIcon(":/icon/icon.ico"))
self.set_menu()
# 图标双击
def act(self, reason):
# 主界面显示方法
# 鼠标点击icon传递的信号会带有一个整形的值,1是表示单击右键,2是双击,3是单击左键,4是用鼠标中键点击
if reason == 2:
if self.gui.isHidden():
self.gui.showNormal()
else:
self.gui.hide()
# 先隐藏图标后退出
def quit(self):
if not self.gui.check_password():
return
self.tp.hide()
os._exit(0)
def set_menu(self):
a1 = QAction(self.gui)
a1.setText("最小化")
a1.triggered.connect(self.gui.hide)
a2 = QAction(self.gui)
a2.setText("最大化")
a2.triggered.connect(self.gui.showMaximized)
a3 = QAction(self.gui)
a3.setText("还原大小")
a3.triggered.connect(self.gui.showNormal)
a4 = QAction(self.gui)
a4.setText("打开日志")
a4.triggered.connect(self.gui.open_log_file)
a5 = QAction(self.gui)
a5.setText("退出程序")
a5.triggered.connect(self.quit)
tpMenu = QMenu()
tpMenu.addAction(a1)
tpMenu.addSeparator()
tpMenu.addAction(a2)
tpMenu.addSeparator()
tpMenu.addAction(a3)
tpMenu.addSeparator()
tpMenu.addAction(a4)
tpMenu.addSeparator()
tpMenu.addAction(a5)
self.tp.setContextMenu(tpMenu)
self.tp.setToolTip(self.gui.windowTitle())
self.tp.show() # 不调用show不会显示系统托盘消息,图标隐藏无法调用
# 信息提示
# 参数1:标题
# 参数2:内容
# 参数3:图标(0没有图标 1信息图标 2警告图标 3错误图标),0还是有一个小图标
# noinspection PyTypeChecker
# self.tp.show_message('Hello', '我藏好了', icon = 0)
# 绑定托盘菜单点击事件
self.tp.activated.connect(self.act)
class APP(object):
"""docstring for APP"""
def __init__(self):
super(APP, self).__init__()
self.app = QApplication(sys.argv)
self.gui = MainWindow()
self.SystemTray = SystemTray(self.gui)
self.gui.show()
self.app.setQuitOnLastWindowClosed(False)
sys.exit(self.app.exec_())
if __name__ == '__main__':
# 程序互斥防多启
mutexname = "端口映射工具" # 互斥体命名
mutex = win32event.CreateMutex(None, False, mutexname)
if win32api.GetLastError() == ERROR_ALREADY_EXISTS:
win32api.MessageBox(0, u'程序已运行,禁止重复启动!!!', u'警告', win32con.MB_OK)
os._exit(0)
app = APP() ```
- 主要加托盘,密码,及程序互斥,防多启,退出
- 最近使用发现有端口占用的情况,处理方法是,在实例socket 后面加上一句复用端口就能解决
实例名.setsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 ) #端口复用的关键点