PyQt5端口映射TCP/UDP工具

造轮子的初衷

  • 之前在项目上采集数据跨网段,专业软件不支持改端口,又不想在上位机上直接装采集软件,网上找了几个网络映射数据转发工具,感觉都挺不错的,多是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 ) #端口复用的关键点
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值