├── README.md ├── README ├── docker.png ├── molo_info.png ├── molo_info2.png ├── molo_login.png ├── molo_wechat_suc.png └── xmolo-zx.png ├── auto_install.py └── molohub ├── __init__.py ├── const.py ├── local_session.py ├── molo_client_app.py ├── molo_client_config.py ├── molo_hub_client.py ├── molo_hub_main.py ├── molo_socket_helper.py ├── molo_tcp_pack.py ├── notify_state.py ├── remote_sesstion.py └── utils.py /README.md: -------------------------------------------------------------------------------- 1 | ## **【中文简介】** 2 | 3 | ![img](README/xmolo-zx.png) 4 | 5 | 这是一个将本地的HA控制网页反向代理到公网, 这样公网就可以轻松访问到HA控制台页面, 并控制家里已经连上HA的硬件. 基于安全方面的考虑, 该组件需要经过Google, GitHub或微信小程序的授权才能正常使用. 6 | 7 | 由于Home Assistant运行于局域网下, 想要通过外网远程访问HA, 首先HA部署环境所在网络下的路由器支持端口映射(port mapping), 映射后在公网通过ip:port直接访问,同时为了方便访问还需要一个ddns服务来把wan ip和动态域名绑定。但是由于网络供应商的网络环境复杂性, 以及用户自身内网环境复杂性, 很难系统性地总结一套通用有效的方法来实现. 上述技术实施起来比较繁琐, 对普通用户来说门槛较高, 本组件旨在简化用户进行远程访问本地HA控制网络. 8 | 9 | **【一键安装】** 10 | 11 | 在终端直接执行下面命令一键安装molohub: 12 | 13 | ```shell 14 | curl --silent --show-error --retry 5 https://raw.githubusercontent.com/haoctopus/molohub/master/auto_install.py | sudo python 15 | ``` 16 | 17 | 等待提示安装成功后手动重启Home Assistant即可。 18 | 19 | 若此方法安装失败,请用下面的方法手动安装。有`curl`组件的Windows用户也可以通过`cmd`执行一键安装(需要去掉命令中的`sudo`)。 20 | 21 | **【安装软件】** 22 | 23 | - [molohub组件](https://github.com/haoctopus/molohub) 24 | 25 | 下载`molohub`文件夹,保存在`/custom_components/`目录中,若`custom_components`目录不存在则自行创建。 26 | 27 | - homeassistant配置目录在哪? 28 | 29 | **Windows用户:** `%APPDATA%\.homeassistant` 30 | 31 | **Linux-based用户:** 可以通过执行`locate .homeassistant/configuration.yaml`命令,查找到的`.homeassistant`文件夹就是配置目录。 32 | 33 | **群晖Docker用户:** 进入Docker - 映像 - homeassistant - 高级设置 - 卷, `/config`对应的路径就是配置目录 34 | 35 | ![img](README/docker.png) 36 | 37 | **【HA中配置实例】** 38 | 39 | ```yaml 40 | molohub: 41 | dismissable: true # 默认状态下忽略按钮不可用,添加此行来激活忽略按钮. 42 | ``` 43 | 44 | **【多开教程】** 45 | 46 | 如果你需要绑定多个molohub, 与家人一起控制HA, 可以通过以下步骤实现molohub多开: 47 | 48 | > 1. 进入到homeassistant配置目录的`custom_components`文件夹 49 | > 2. 复制粘贴`molohub`文件取名为`molohub0` 50 | > 3. 修改homeassistant配置目录下的`configuration.yaml`, 添加如下一行代码 51 | > 52 | >```yaml 53 | >molohub0: 54 | >``` 55 | > 56 | > 4. 手动重启Home Assistant, 完成. 同理可以添加任意多个molohub. 57 | 58 | **【相关链接】** 59 | 60 | 平台入口网站: 61 | 62 | molohub组件: 63 | 64 | **【效果展现】** 65 | 66 | ![img](README/molo_info.png) 67 | **** 68 | ![img](README/molo_login.png) 69 | **** 70 | ![img](README/molo_info2.png) 71 | **** 72 | ![img](README/molo_wechat_suc.png) 73 | 74 | **【联系我们】** 75 | 76 | 如果安装和使用过程中遇到任何问题,可以通过以下方式联系我们,我们将在看到反馈后第一时间作出回应: 77 | 78 | Email: octopus201806@gmail.com 79 | 80 | QQ群: 598514359 81 | 82 | **** 83 | **** 84 | 85 | ## **【Description in English】** 86 | 87 | ![img](README/xmolo-zx.png) 88 | 89 | This is a component forwards the local HA control web page to the public network, so that the public network can be easily accessed, and interact with the hardwares at home that has connected to the HA. For security reasons, this component needs to be authorized by Google, GitHub or Wechat Mini Program to work properly. 90 | 91 | when Home Assistant runs under the LAN, if you want to access the HA remotely through the WAN, the router under the network where the HA deployed must supports port mapping , and will be directly accessible on the public network after mapping. also generally ddns is also needed to solve ip change problem. But due to the network provider's The complexity of the network environment, and the complexity of the user's own internet environment, it is difficult to systematically summarize a set of general and effective methods to achieve the target. The above technology is more complicated to implement, and the threshold for ordinary users is higher. This project aims to simplify users to remotely access the local HA control network. 92 | 93 | **【One-key install】** 94 | 95 | If you are Linux-based user, run the command below to install molohub automatically: 96 | 97 | ```shell 98 | curl --silent --show-error --retry 5 https://raw.githubusercontent.com/haoctopus/molohub/master/auto_install.py | sudo python 99 | ``` 100 | 101 | Wait untill installation success, and restart your Home Assistant. 102 | 103 | If this not working, please install molohub manually according to the next section. For Windows user with `curl` component, remove `sudo` in command and run it in `cmd`. 104 | 105 | **【Installation】** 106 | 107 | - [molohub component](https://github.com/haoctopus/molohub) 108 | 109 | Download `molohub` folder and put it under `homeassistant configuration directory/custom_components/`. If `custom_components` doesn't exist, create one. 110 | 111 | - Where is homeassistant configuration directory? 112 | 113 | **Windows user:** `%APPDATA%\.homeassistant` 114 | 115 | **Linux-based user:** Run command line `locate .homeassistant/configuration.yaml`. The `.homeassistant` folder in the returning result is the configuration directory. 116 | 117 | **Synology NAS Docker user:** Go to Docker - Images - Homeassistant - Advanced settings - Volumes, the path corresponding to `/config` is the configuration directory. 118 | 119 | ![img](README/docker.png) 120 | 121 | **【Configuration】** 122 | 123 | ```yaml 124 | molohub: 125 | dismissable: true # Dismiss button is disable by default, add this line to enable. 126 | ``` 127 | 128 | **【Mulit-client】** 129 | 130 | If you need to bind multiple molohubs to control HA with your family, you can open more molohub by following the steps below: 131 | 132 | > 1. goto homeassistant configuration directory's `custom_components` folder 133 | > 2. copy paste `molohub` folder named `molohub0` 134 | > 3. modify homeassistant configuration file `configuration.yaml`, add a line as below: 135 | > 136 | >```yaml 137 | >molohub0: 138 | >``` 139 | > 140 | > 4. manully restart Home Assistant, done. You can add as more as you want by using this method 141 | 142 | **【Reference link】** 143 | 144 | Platform entrance link: 145 | 146 | molohub component: 147 | 148 | **【Demonstration】** 149 | 150 | ![img](README/molo_info.png) 151 | **** 152 | ![img](README/molo_login.png) 153 | **** 154 | ![img](README/molo_info2.png) 155 | **** 156 | ![img](README/molo_wechat_suc.png) 157 | 158 | **【Contact us】** 159 | 160 | Please contact us if you have any questions about installation and using molohub. We will respond as soon as we see the feedback. 161 | 162 | Email: octopus201806@gmail.com 163 | 164 | QQGroup: 598514359 -------------------------------------------------------------------------------- /README/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haoctopus/molohub/7699ef2b8b92bfddd2726b966b927648aa44395f/README/docker.png -------------------------------------------------------------------------------- /README/molo_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haoctopus/molohub/7699ef2b8b92bfddd2726b966b927648aa44395f/README/molo_info.png -------------------------------------------------------------------------------- /README/molo_info2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haoctopus/molohub/7699ef2b8b92bfddd2726b966b927648aa44395f/README/molo_info2.png -------------------------------------------------------------------------------- /README/molo_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haoctopus/molohub/7699ef2b8b92bfddd2726b966b927648aa44395f/README/molo_login.png -------------------------------------------------------------------------------- /README/molo_wechat_suc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haoctopus/molohub/7699ef2b8b92bfddd2726b966b927648aa44395f/README/molo_wechat_suc.png -------------------------------------------------------------------------------- /README/xmolo-zx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haoctopus/molohub/7699ef2b8b92bfddd2726b966b927648aa44395f/README/xmolo-zx.png -------------------------------------------------------------------------------- /auto_install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zipfile 3 | import shutil 4 | 5 | 6 | def find(name, path): 7 | for root, dirs, files in os.walk(path): 8 | if name in files: 9 | return os.path.join(root, name) 10 | return None 11 | 12 | 13 | def get_config_path(): 14 | path = find('.HA_VERSION', '/') 15 | if not path: 16 | return None 17 | path = path[:len(path) - 11] 18 | print("Home Assistant configuration path found: %s" % (path)) 19 | return path 20 | 21 | 22 | def uninstall_old(path): 23 | print("Uninstall molohub old version...") 24 | path += '/custom_components/molohub' 25 | try: 26 | shutil.rmtree(path) 27 | except Exception: 28 | pass 29 | 30 | 31 | def download_file(): 32 | global start_time 33 | print("Downloading file...") 34 | curl = 'curl --show-error --retry 5 https://codeload.github.com/haoctopus/molohub/zip/master >> molohub-master.zip' 35 | os.system(curl) 36 | 37 | 38 | def extract_file(): 39 | print("Extracting file...") 40 | try: 41 | shutil.rmtree('molo_install_temp/') 42 | except Exception: 43 | pass 44 | with zipfile.ZipFile("molohub-master.zip", 'r') as f: 45 | for file in f.namelist(): 46 | f.extract(file, "molo_install_temp/") 47 | 48 | 49 | def copy_file(path): 50 | print("Copying file...") 51 | path += '/custom_components/molohub' 52 | frompath = 'molo_install_temp/molohub-master/molohub' 53 | shutil.copytree(frompath, path) 54 | 55 | 56 | def configurate(path): 57 | print("Configurating...") 58 | path += '/configuration.yaml' 59 | shutil.copy(path, path + '.bak') 60 | file_str = open(path, 'r').read() 61 | if '\nmolohub:' in file_str: 62 | return 63 | with open(path, 'a') as f: 64 | f.write('\nmolohub:\n') 65 | 66 | 67 | def delete_file(): 68 | delete_list = ['auto_install.py', 'molohub-master.zip', 'molo_install_temp/'] 69 | for item in delete_list: 70 | try: 71 | shutil.rmtree(item) 72 | except Exception: 73 | pass 74 | try: 75 | os.remove(item) 76 | except Exception: 77 | pass 78 | 79 | 80 | if __name__ == '__main__': 81 | path = get_config_path() 82 | if not path: 83 | print("Error finding Home Assistant configuration path!") 84 | print("Install failed.") 85 | exit(0) 86 | 87 | uninstall_old(path) 88 | download_file() 89 | extract_file() 90 | copy_file(path) 91 | configurate(path) 92 | delete_file() 93 | 94 | print("Successfully installed.") 95 | print("configuration.yaml has backed up to configuration.yaml.bak") 96 | print("For any questions, please contact us:") 97 | print(" - Email: octopus201806@gmail.com") 98 | print(" - QQGroup: 598514359") 99 | -------------------------------------------------------------------------------- /molohub/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Molohub. 3 | 4 | For more details about this component, please refer to the documentation at 5 | https://home-assistant.io/components/molohub/ 6 | """ 7 | 8 | import os 9 | 10 | from homeassistant.const import (EVENT_HOMEASSISTANT_START, 11 | EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED) 12 | 13 | from .molo_client_config import MOLO_CONFIGS 14 | from .notify_state import NOTIFY_STATE 15 | from .utils import LOGGER 16 | 17 | DOMAIN = 'molohub' 18 | NOTIFYID = 'molo_notify_' 19 | DISMISSABLE = False 20 | 21 | def setup(hass, config): 22 | global DOMAIN 23 | global NOTIFYID 24 | global DISMISSABLE 25 | """Set up molohub component.""" 26 | LOGGER.info("Begin setup molohub!") 27 | 28 | dir_path = os.path.dirname(os.path.realpath(__file__)) 29 | path_list = None 30 | if '/' in dir_path: 31 | path_list = dir_path.split('/') 32 | elif '\\' in dir_path: 33 | path_list = dir_path.split('\\') 34 | DOMAIN = path_list[len(path_list) - 1] 35 | NOTIFYID += DOMAIN 36 | 37 | # Load config mode from configuration.yaml. 38 | cfg = config[DOMAIN] 39 | if 'mode' in cfg: 40 | MOLO_CONFIGS.load(cfg['mode']) 41 | else: 42 | MOLO_CONFIGS.load('release') 43 | tmp_haweb = MOLO_CONFIGS.get_config_object()['server']['haweb'] 44 | NOTIFY_STATE.set_context(hass, tmp_haweb) 45 | 46 | DISMISSABLE = cfg.get('dismissable', False) 47 | if type(DISMISSABLE) != bool: 48 | DISMISSABLE = False 49 | MOLO_CONFIGS.get_config_object()['domain'] = DOMAIN 50 | 51 | if 'http' in config and 'server_host' in config['http']: 52 | tmp_host = config['http']['server_host'] 53 | MOLO_CONFIGS.get_config_object()['ha']['host'] = tmp_host 54 | if 'http' in config and 'server_port' in config['http']: 55 | tmp_port = config['http']['server_port'] 56 | MOLO_CONFIGS.get_config_object()['ha']['port'] = tmp_port 57 | 58 | def send_notify(notify_str): 59 | """Update UI.""" 60 | global NOTIFYID 61 | LOGGER.debug("Send notify: %s", notify_str) 62 | hass.components.persistent_notification.async_create( 63 | notify_str, "Molo Hub Infomation", NOTIFYID) 64 | 65 | async def stop_molohub(event): 66 | """Stop Molohub while closing ha.""" 67 | LOGGER.info("Begin stop molohub!") 68 | from .molo_hub_main import stop_proxy 69 | stop_proxy() 70 | 71 | async def start_molohub(event): 72 | """Start Molohub while starting ha.""" 73 | LOGGER.debug("molohub started!") 74 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_molohub) 75 | 76 | async def handle_event(event): 77 | """Handle Molohub event.""" 78 | send_notify(NOTIFY_STATE.get_notify_str()) 79 | 80 | async def on_state_changed(event): 81 | """Disable the dismiss button if needed.""" 82 | global NOTIFYID 83 | if DISMISSABLE: 84 | return 85 | state = event.data.get('new_state') 86 | entity_id = event.data.get('entity_id') 87 | if not state and entity_id and entity_id.find(NOTIFYID) != -1: 88 | send_notify(NOTIFY_STATE.get_notify_str()) 89 | 90 | from .molo_hub_main import run_proxy 91 | run_proxy(hass) 92 | 93 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_molohub) 94 | hass.bus.async_listen(EVENT_STATE_CHANGED, on_state_changed) 95 | hass.bus.async_listen('molohub_event', handle_event) 96 | send_notify(NOTIFY_STATE.get_notify_str()) 97 | 98 | return True 99 | -------------------------------------------------------------------------------- /molohub/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Molohub.""" 2 | 3 | BIND_AUTH_STR_TEMPLATE_DEFAULT = """ 4 | 5 | 6 | 9 | 12 | 13 | 16 | 17 |
7 | %s 8 | 10 | 11 | %s 14 | Disconnect 15 |
18 | """.strip().replace('>\n', '>') 19 | 20 | BUFFER_SIZE = 1024 21 | 22 | CLIENT_VERSION = '0.14' 23 | CONNECTED = 1 24 | 25 | HTTP_502_BODY = """ 26 | 27 |
33 |

Tunnel %s unavailable

34 |

Unable to initiate connection to %s. 35 | This port is not yet available for web server.

36 | """ 37 | 38 | HTTP_502_HEADER = """ 39 | HTTP/1.0 502 Bad Gateway\r 40 | Content-Type: text/html\r 41 | Content-Length: %d\r\n\r\n%s 42 | """ 43 | 44 | PING_INTERVAL_DEFAULT = 10 45 | 46 | RECONNECT_INTERVAL = 5 47 | 48 | SERVER_CONNECTING_STR_TEMPLATE_DEFAULT = "Connecting server..." 49 | STAGE_SERVER_UNCONNECTED = 'server_unconnected' 50 | STAGE_SERVER_CONNECTED = 'server_connected' 51 | STAGE_AUTH_BINDED = 'auth_binded' 52 | 53 | TCP_PACK_HEADER_LEN = 16 54 | TOKEN_KEY_NAME = 'slavertoken' 55 | 56 | CLIENT_STATUS_UNBINDED = "unbinded" 57 | CLIENT_STATUS_BINDED = "binded" 58 | 59 | CONFIG_FILE_NAME = "molo_client_config" 60 | 61 | WAIT_FOR_AUTH_STR_TEMPLATE_DEFAULT = """ 62 | Choose platform below to connect: 63 | 64 | - [google](http://%s/login/google?token=%s) 65 | """ 66 | 67 | PROXY_TCP_CONNECTION_ACTIVATE_TIME = 60 68 | -------------------------------------------------------------------------------- /molohub/local_session.py: -------------------------------------------------------------------------------- 1 | """Local proxy session class for Molohub.""" 2 | import asyncore 3 | import socket 4 | 5 | from .const import BUFFER_SIZE 6 | from .molo_client_app import MOLO_CLIENT_APP 7 | from .utils import LOGGER, dns_open 8 | 9 | 10 | class LocalSession(asyncore.dispatcher): 11 | """Local proxy session class.""" 12 | 13 | def __init__(self, host, port, map): 14 | """Initialize local proxy session arguments.""" 15 | asyncore.dispatcher.__init__(self, map=map) 16 | self.host = host 17 | self.port = port 18 | self.append_send_buffer = None 19 | self.append_connect = None 20 | self.clear() 21 | 22 | def handle_connect(self): 23 | """When connected, this method will be call.""" 24 | LOGGER.debug("local session connected(%d)", id(self)) 25 | self.append_connect = False 26 | 27 | def handle_close(self): 28 | """When closed, this method will be call. clean itself.""" 29 | self.clear() 30 | LOGGER.debug("local session closed(%d)", id(self)) 31 | MOLO_CLIENT_APP.remote_session_dict.pop(id(self), None) 32 | remote_session = MOLO_CLIENT_APP.remote_session_dict.get(id(self)) 33 | if remote_session: 34 | remote_session.handle_close() 35 | self.close() 36 | 37 | def handle_read(self): 38 | """Handle read message.""" 39 | buff = self.recv(BUFFER_SIZE) 40 | if not buff: 41 | return 42 | remotesession = MOLO_CLIENT_APP.remote_session_dict.get(id(self)) 43 | if not remotesession: 44 | LOGGER.error("LocalSession handle_read remove session not found") 45 | self.handle_close() 46 | return 47 | LOGGER.debug("local session handle_read %s", buff) 48 | remotesession.send_raw_pack(buff) 49 | 50 | def writable(self): 51 | """If the socket send buffer writable.""" 52 | return self.append_connect or (self.append_send_buffer) 53 | 54 | def handle_write(self): 55 | """Write socket send buffer.""" 56 | sent = self.send(self.append_send_buffer) 57 | self.append_send_buffer = self.append_send_buffer[sent:] 58 | 59 | # The above are base class methods. 60 | def clear(self): 61 | """Reset local proxy session arguments.""" 62 | self.append_send_buffer = bytes() 63 | self.append_connect = True 64 | 65 | def sock_connect(self): 66 | """Connect to host:port.""" 67 | self.clear() 68 | dns_ip = dns_open(self.host) 69 | if not dns_ip: 70 | return 71 | self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 72 | self.connect((dns_ip, self.port)) 73 | 74 | def send_raw_pack(self, raw_data): 75 | """Write raw data pack to write buffer.""" 76 | self.append_send_buffer += raw_data 77 | LOGGER.debug("local session send_raw_pack %s", raw_data) 78 | if not self.append_connect: 79 | self.handle_write() 80 | -------------------------------------------------------------------------------- /molohub/molo_client_app.py: -------------------------------------------------------------------------------- 1 | """Application class for Molohub.""" 2 | import asyncore 3 | import logging 4 | import threading 5 | import time 6 | 7 | from .const import (PING_INTERVAL_DEFAULT, RECONNECT_INTERVAL, 8 | PROXY_TCP_CONNECTION_ACTIVATE_TIME) 9 | from .utils import LOGGER 10 | 11 | 12 | class MoloClientApp: 13 | """Application class for Molohub.""" 14 | 15 | ping_thread = None 16 | main_thread = None 17 | is_exited = False 18 | ping_interval = PING_INTERVAL_DEFAULT 19 | last_activate_time = None 20 | 21 | def __init__(self): 22 | """Initialize application arguments.""" 23 | self.molo_client = None 24 | self.local_session_dict = {} 25 | self.remote_session_dict = {} 26 | self.lock = threading.Lock() 27 | self.ping_buffer = None 28 | self.hass_context = None 29 | self.reset_activate_time() 30 | self.async_map = {} 31 | 32 | def proxy_loop(self): 33 | """Handle main loop and reconnection.""" 34 | self.molo_client.sock_connect() 35 | while not self.is_exited: 36 | try: 37 | asyncore.loop(map=self.async_map) 38 | except asyncore.ExitNow as exc: 39 | logging.exception(exc) 40 | LOGGER.error("asyncore.loop exception") 41 | 42 | if not self.is_exited: 43 | try: 44 | asyncore.close_all() 45 | self.molo_client.sock_connect() 46 | time.sleep(RECONNECT_INTERVAL) 47 | LOGGER.info("moloserver reconnecting...") 48 | except Exception as exc: 49 | print("proxy_loop(): " + str(exc)) 50 | LOGGER.info("reconnect failed, retry...") 51 | time.sleep(RECONNECT_INTERVAL) 52 | asyncore.close_all() 53 | LOGGER.debug("proxy exited") 54 | 55 | def run_reverse_proxy(self, hass, molo_client): 56 | """Start application main thread and ping thread.""" 57 | self.hass_context = hass 58 | self.molo_client = molo_client 59 | self.ping_thread = threading.Thread(target=self.ping_server) 60 | self.ping_thread.setDaemon(True) 61 | self.ping_thread.start() 62 | 63 | self.main_thread = threading.Thread(target=self.proxy_loop) 64 | self.main_thread.setDaemon(True) 65 | self.main_thread.start() 66 | 67 | def ping_server(self): 68 | """Send ping to server every ping_interval.""" 69 | while not self.is_exited: 70 | try: 71 | if self.molo_client: 72 | self.set_ping_buffer(self.molo_client.ping_server_buffer()) 73 | time.sleep(self.ping_interval) 74 | 75 | time_interval = time.time() - self.last_activate_time 76 | LOGGER.debug("data interval: %f", time_interval) 77 | if time_interval > PROXY_TCP_CONNECTION_ACTIVATE_TIME: 78 | LOGGER.info("connection timeout, reconnecting server") 79 | self.molo_client.handle_close() 80 | self.reset_activate_time() 81 | 82 | except Exception as exc: 83 | print("ping_server(): " + str(exc)) 84 | asyncore.close_all() 85 | self.molo_client.sock_connect() 86 | time.sleep(RECONNECT_INTERVAL) 87 | LOGGER.info("moloserver reconnecting...") 88 | 89 | def reset_activate_time(self): 90 | """Reset last activate time for timeout.""" 91 | self.last_activate_time = time.time() 92 | 93 | def set_ping_buffer(self, buffer): 94 | """Send ping.""" 95 | with self.lock: 96 | self.ping_buffer = buffer 97 | 98 | def get_ping_buffer(self): 99 | """Get ping sending buffer.""" 100 | if not self.ping_buffer: 101 | return None 102 | 103 | with self.lock: 104 | buffer = self.ping_buffer 105 | self.ping_buffer = None 106 | return buffer 107 | 108 | def stop_reverse_proxy(self): 109 | """Stop application, close all sessions.""" 110 | LOGGER.debug("stopping reverse proxy") 111 | self.is_exited = True 112 | asyncore.close_all() 113 | 114 | 115 | MOLO_CLIENT_APP = MoloClientApp() 116 | -------------------------------------------------------------------------------- /molohub/molo_client_config.py: -------------------------------------------------------------------------------- 1 | """Configuration class for Molohub.""" 2 | 3 | 4 | class MoloConfigs: 5 | """Configuration class for Molohub.""" 6 | 7 | config_object = dict() 8 | 9 | config_debug = { 10 | 'server': { 11 | 'haweb': "127.0.0.1", 12 | 'host': "127.0.0.1", 13 | 'port': 4443, 14 | 'bufsize': 1024 15 | }, 16 | 'ha': { 17 | 'host': "127.0.0.1", 18 | 'port': 8123 19 | } 20 | } 21 | 22 | config_release = { 23 | 'server': { 24 | 'haweb': "www.molo.cn", 25 | 'host': "haprx.molo.cn", 26 | 'port': 4443, 27 | 'bufsize': 1024 28 | }, 29 | 'ha': { 30 | 'host': "127.0.0.1", 31 | 'port': 8123 32 | } 33 | } 34 | 35 | def load(self, mode): 36 | """Load configs by reading mode in configuration.yaml.""" 37 | if mode == 'debug': 38 | self.config_object = self.config_debug 39 | else: 40 | self.config_object = self.config_release 41 | 42 | def get_config_object(self): 43 | """Get config_object, reload if not exist.""" 44 | if not self.config_object: 45 | self.load('release') 46 | return self.config_object 47 | 48 | 49 | MOLO_CONFIGS = MoloConfigs() 50 | -------------------------------------------------------------------------------- /molohub/molo_hub_client.py: -------------------------------------------------------------------------------- 1 | """Client protocol class for Molohub.""" 2 | import asyncore 3 | import queue 4 | import socket 5 | 6 | from homeassistant.const import __short_version__ 7 | 8 | from .const import (BUFFER_SIZE, CLIENT_STATUS_BINDED, CLIENT_STATUS_UNBINDED, 9 | CLIENT_VERSION, CONFIG_FILE_NAME, STAGE_AUTH_BINDED, 10 | STAGE_SERVER_CONNECTED, STAGE_SERVER_UNCONNECTED) 11 | from .molo_client_app import MOLO_CLIENT_APP 12 | from .molo_client_config import MOLO_CONFIGS 13 | from .molo_socket_helper import MoloSocketHelper 14 | from .molo_tcp_pack import MoloTcpPack 15 | from .notify_state import NOTIFY_STATE 16 | from .remote_sesstion import RemoteSession 17 | from .utils import LOGGER, dns_open, get_rand_char, save_local_seed 18 | 19 | class MoloHubClient(asyncore.dispatcher): 20 | """Client protocol class for Molohub.""" 21 | 22 | tunnel = {} 23 | tunnel['protocol'] = 'http' 24 | tunnel['hostname'] = '' 25 | tunnel['subdomain'] = '' 26 | tunnel['rport'] = 0 27 | tunnel['lhost'] = MOLO_CONFIGS.get_config_object()['ha']['host'] 28 | tunnel['lport'] = MOLO_CONFIGS.get_config_object()['ha']['port'] 29 | 30 | client_id = '' 31 | client_token = '' 32 | 33 | protocol_func_bind_map = {} 34 | 35 | def __init__(self, host, port, map): 36 | """Initialize protocol arguments.""" 37 | asyncore.dispatcher.__init__(self, map=map) 38 | self.host = host 39 | self.port = port 40 | self.molo_tcp_pack = MoloTcpPack() 41 | self.ping_dequeue = queue.Queue() 42 | self.append_recv_buffer = None 43 | self.append_send_buffer = None 44 | self.append_connect = None 45 | self.client_status = None 46 | self.clear() 47 | self.init_func_bind_map() 48 | 49 | def handle_connect(self): 50 | """When connected, this method will be call.""" 51 | LOGGER.debug("server connected") 52 | self.append_connect = False 53 | domain = MOLO_CONFIGS.get_config_object().get('domain', '') 54 | self.send_dict_pack( 55 | MoloSocketHelper.molo_auth(CLIENT_VERSION, 56 | MOLO_CLIENT_APP.hass_context, 57 | __short_version__, domain),) 58 | 59 | def handle_close(self): 60 | """When closed, this method will be call. clean itself.""" 61 | LOGGER.debug("server closed") 62 | self.clear() 63 | data = {} 64 | self.update_notify_state(data, STAGE_SERVER_UNCONNECTED) 65 | self.close() 66 | 67 | # close all and restart 68 | asyncore.close_all() 69 | 70 | def handle_read(self): 71 | """Handle read message.""" 72 | try: 73 | buff = self.recv(BUFFER_SIZE) 74 | self.append_recv_buffer += buff 75 | self.process_molo_tcp_pack() 76 | except Exception as e: 77 | LOGGER.info("recv error: %s", e) 78 | 79 | def writable(self): 80 | """If the socket send buffer writable.""" 81 | ping_buffer = MOLO_CLIENT_APP.get_ping_buffer() 82 | if ping_buffer: 83 | self.append_send_buffer += ping_buffer 84 | 85 | return self.append_connect or (self.append_send_buffer) 86 | 87 | def handle_write(self): 88 | """Write socket send buffer.""" 89 | sent = self.send(self.append_send_buffer) 90 | self.append_send_buffer = self.append_send_buffer[sent:] 91 | 92 | # The above are base class methods. 93 | def clear(self): 94 | """Reset client protocol arguments.""" 95 | self.molo_tcp_pack.clear() 96 | self.append_recv_buffer = bytes() 97 | self.append_send_buffer = bytes() 98 | self.append_connect = True 99 | self.client_status = None 100 | 101 | def sock_connect(self): 102 | """Connect to host:port.""" 103 | self.clear() 104 | dns_ip = dns_open(self.host) 105 | if not dns_ip: 106 | return 107 | self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 108 | self.connect((dns_ip, self.port)) 109 | 110 | def on_start_proxy(self, jdata): 111 | """Handle on_start_proxy json packet.""" 112 | LOGGER.debug("on_start_proxy %s, %s", self.client_id, str(jdata)) 113 | 114 | def on_bind_status(self, jdata): 115 | """Handle on_bind_status json packet.""" 116 | LOGGER.debug("on_bind_status %s", str(jdata)) 117 | jpayload = jdata['Payload'] 118 | self.client_status = jpayload['Status'] 119 | jpayload['token'] = self.client_token 120 | if self.client_status == CLIENT_STATUS_BINDED: 121 | self.update_notify_state(jpayload, STAGE_AUTH_BINDED) 122 | elif self.client_status == CLIENT_STATUS_UNBINDED: 123 | self.update_notify_state(jpayload, STAGE_SERVER_CONNECTED) 124 | 125 | def on_req_proxy(self, jdata): 126 | """Handle on_req_proxy json packet.""" 127 | LOGGER.debug("on_req_proxy, %s, %s, %s, %s", self.host, self.port, 128 | self.tunnel['lhost'], self.tunnel['lport']) 129 | remotesession = RemoteSession(self.client_id, self.host, self.port, 130 | self.tunnel['lhost'], 131 | self.tunnel['lport'], 132 | MOLO_CLIENT_APP.async_map) 133 | remotesession.sock_connect() 134 | 135 | def on_auth_resp(self, jdata): 136 | """Handle on_auth_resp json packet.""" 137 | LOGGER.debug('on_auth_resp %s', str(jdata)) 138 | self.client_id = jdata['Payload']['ClientId'] 139 | 140 | self.send_dict_pack( 141 | MoloSocketHelper.req_tunnel(self.tunnel['protocol'], 142 | self.tunnel['hostname'], 143 | self.tunnel['subdomain'], 144 | self.tunnel['rport'], self.client_id)) 145 | 146 | def on_new_tunnel(self, jdata): 147 | """Handle on_new_tunnel json packet.""" 148 | LOGGER.debug("on_new_tunnel %s", str(jdata)) 149 | data = jdata['OnlineConfig'] 150 | if 'ping_interval' in jdata['OnlineConfig']: 151 | MOLO_CLIENT_APP.ping_interval = jdata['OnlineConfig'][ 152 | 'ping_interval'] 153 | self.update_notify_state(data) 154 | if jdata['Payload']['Error'] != '': 155 | LOGGER.error('Server failed to allocate tunnel: %s', 156 | jdata['Payload']['Error']) 157 | return 158 | 159 | self.client_token = jdata['Payload']['token'] 160 | self.on_bind_status(jdata) 161 | 162 | def on_unbind_auth(self, jdata): 163 | """Handle on_unbind_auth json packet.""" 164 | LOGGER.debug('on_unbind_auth %s', str(jdata)) 165 | data = jdata['Payload'] 166 | data['token'] = self.client_token 167 | self.update_notify_state(data, STAGE_SERVER_CONNECTED) 168 | 169 | def on_token_expired(self, jdata): 170 | """Handle on_token_expired json packet.""" 171 | LOGGER.debug('on_token_expired %s', str(jdata)) 172 | if 'Payload' not in jdata: 173 | return 174 | data = jdata['Payload'] 175 | self.client_token = data['token'] 176 | self.update_notify_state(data) 177 | 178 | def on_pong(self, jdata): 179 | """Handle on_pong json packet.""" 180 | LOGGER.debug('on_pong %s, self token: %s', str(jdata), 181 | self.client_token) 182 | 183 | def on_reset_clientid(self, jdata): 184 | """Handle on_reset_clientid json packet.""" 185 | local_seed = get_rand_char(32).lower() 186 | config_file_name = MOLO_CONFIGS.get_config_object().get('domain', '') 187 | #keep Compatibility with old version 188 | if config_file_name and config_file_name!='molohub': 189 | config_file_name = CONFIG_FILE_NAME + '_' + config_file_name + '.yaml' 190 | else: 191 | config_file_name = CONFIG_FILE_NAME + '.yaml' 192 | save_local_seed( 193 | MOLO_CLIENT_APP.hass_context.config.path(config_file_name), 194 | local_seed) 195 | LOGGER.debug("reset clientid %s to %s", self.client_id, local_seed) 196 | self.handle_close() 197 | 198 | def process_molo_tcp_pack(self): 199 | """Handle received TCP packet.""" 200 | ret = True 201 | while ret: 202 | ret = self.molo_tcp_pack.recv_buffer(self.append_recv_buffer) 203 | if ret and self.molo_tcp_pack.error_code == MoloTcpPack.ERR_OK: 204 | self.process_json_pack(self.molo_tcp_pack.body_jdata) 205 | self.append_recv_buffer = self.molo_tcp_pack.tmp_buffer 206 | if self.molo_tcp_pack.error_code == MoloTcpPack.ERR_MALFORMED: 207 | LOGGER.error("tcp pack malformed!") 208 | self.handle_close() 209 | 210 | def process_json_pack(self, jdata): 211 | """Handle received json packet.""" 212 | LOGGER.debug("process_json_pack %s", str(jdata)) 213 | if jdata['Type'] in self.protocol_func_bind_map: 214 | MOLO_CLIENT_APP.reset_activate_time() 215 | self.protocol_func_bind_map[jdata['Type']](jdata) 216 | 217 | def process_new_tunnel(self, jdata): 218 | """Handle new tunnel.""" 219 | jpayload = jdata['Payload'] 220 | self.client_id = jpayload['clientid'] 221 | self.client_token = jpayload['token'] 222 | LOGGER.debug("Get client id:%s token:%s", self.client_id, 223 | self.client_token) 224 | data = {} 225 | data['clientid'] = self.client_id 226 | data['token'] = self.client_token 227 | self.update_notify_state(data, STAGE_SERVER_CONNECTED) 228 | 229 | def send_raw_pack(self, raw_data): 230 | """Send raw data packet.""" 231 | if self.append_connect: 232 | return 233 | self.append_send_buffer += raw_data 234 | self.handle_write() 235 | 236 | def send_dict_pack(self, dict_data): 237 | """Convert and send dict packet.""" 238 | if self.append_connect: 239 | return 240 | body = MoloTcpPack.generate_tcp_buffer(dict_data) 241 | self.send_raw_pack(body) 242 | 243 | def ping_server_buffer(self): 244 | """Get ping buffer.""" 245 | if not self.client_status: 246 | return 247 | body = MoloTcpPack.generate_tcp_buffer( 248 | MoloSocketHelper.ping(self.client_token, self.client_status)) 249 | return body 250 | 251 | def update_notify_state(self, data, stage=None): 252 | """Add stage field and inform NOTIFY_STATE to update UI data.""" 253 | LOGGER.debug("Send update nofity state with %s", self.client_status) 254 | if stage: 255 | data['stage'] = stage 256 | NOTIFY_STATE.update_state(data) 257 | 258 | def init_func_bind_map(self): 259 | """Initialize protocol function bind map.""" 260 | self.protocol_func_bind_map = { 261 | "StartProxy": self.on_start_proxy, 262 | "BindStatus": self.on_bind_status, 263 | "ReqProxy": self.on_req_proxy, 264 | "AuthResp": self.on_auth_resp, 265 | "NewTunnel": self.on_new_tunnel, 266 | "TokenExpired": self.on_token_expired, 267 | "Pong": self.on_pong, 268 | "ResetClientid": self.on_reset_clientid 269 | } 270 | -------------------------------------------------------------------------------- /molohub/molo_hub_main.py: -------------------------------------------------------------------------------- 1 | """Main interface for Molohub.""" 2 | from .molo_client_app import MOLO_CLIENT_APP 3 | from .molo_client_config import MOLO_CONFIGS 4 | from .molo_hub_client import MoloHubClient 5 | 6 | 7 | def run_proxy(hass): 8 | """Run Molohub application.""" 9 | molo_client = MoloHubClient( 10 | MOLO_CONFIGS.get_config_object()['server']['host'], 11 | int(MOLO_CONFIGS.get_config_object()['server']['port']), 12 | MOLO_CLIENT_APP.async_map) 13 | MOLO_CLIENT_APP.run_reverse_proxy(hass, molo_client) 14 | 15 | 16 | def stop_proxy(): 17 | """Stop Molohub application.""" 18 | MOLO_CLIENT_APP.stop_reverse_proxy() 19 | -------------------------------------------------------------------------------- /molohub/molo_socket_helper.py: -------------------------------------------------------------------------------- 1 | """Socket helper class for Molohub.""" 2 | import platform 3 | 4 | from .const import CONFIG_FILE_NAME 5 | from .utils import get_local_seed, get_mac_addr, get_rand_char, save_local_seed, load_uuid 6 | 7 | 8 | class MoloSocketHelper: 9 | """Socket helper class for Molohub.""" 10 | 11 | @classmethod 12 | def molo_auth(cls, client_version, hass, ha_version, domain): 13 | """Construct register authorization packet.""" 14 | payload = dict() 15 | payload['ClientId'] = '' 16 | payload['OS'] = platform.platform() 17 | payload['PyVersion'] = platform.python_version() 18 | payload['ClientVersion'] = client_version 19 | payload['HAVersion'] = ha_version 20 | payload['UUID'] = load_uuid(hass) 21 | 22 | if domain and domain!='molohub': 23 | config_file_name = CONFIG_FILE_NAME + '_' + domain + '.yaml' 24 | else: 25 | config_file_name = CONFIG_FILE_NAME + '.yaml' 26 | 27 | payload['MacAddr'] = get_mac_addr() 28 | local_seed = get_rand_char(32).lower() 29 | local_seed_saved = get_local_seed(hass.config.path(config_file_name)) 30 | if local_seed_saved: 31 | local_seed = local_seed_saved 32 | else: 33 | save_local_seed(hass.config.path(config_file_name), local_seed) 34 | payload['LocalSeed'] = local_seed 35 | 36 | body = dict() 37 | body['Type'] = 'Auth' 38 | body['Payload'] = payload 39 | return body 40 | 41 | @classmethod 42 | def req_tunnel(cls, protocol, hostname, subdomain, remote_port, clientid): 43 | """Construct request tunnel packet.""" 44 | payload = dict() 45 | payload['ReqId'] = get_rand_char(8) 46 | payload['Protocol'] = protocol 47 | payload['Hostname'] = hostname 48 | payload['Subdomain'] = subdomain 49 | payload['HttpAuth'] = '' 50 | payload['RemotePort'] = remote_port 51 | payload['MacAddr'] = get_mac_addr() 52 | if clientid: 53 | payload['ClientId'] = clientid 54 | body = dict() 55 | body['Type'] = 'ReqTunnel' 56 | body['Payload'] = payload 57 | return body 58 | 59 | @classmethod 60 | def reg_proxy(cls, client_id): 61 | """Construct register proxy packet.""" 62 | payload = dict() 63 | payload['ClientId'] = client_id 64 | body = dict() 65 | body['Type'] = 'RegProxy' 66 | body['Payload'] = payload 67 | return body 68 | 69 | @classmethod 70 | def ping(cls, token, client_status): 71 | """Construct ping packet.""" 72 | payload = dict() 73 | body = dict() 74 | if token: 75 | payload['Token'] = token 76 | if client_status and client_status: 77 | payload['Status'] = client_status 78 | body['Type'] = 'Ping' 79 | body['Payload'] = payload 80 | return body 81 | -------------------------------------------------------------------------------- /molohub/molo_tcp_pack.py: -------------------------------------------------------------------------------- 1 | """TCP packet header define for Molohub.""" 2 | import json 3 | import logging 4 | 5 | from .utils import LOGGER 6 | 7 | # pack format: 8 | # MAGIC 2BYTES 9 | # HEADER_LEN 4BYTES 10 | # HEADER_JSON_STR HEADER_LEN 11 | # BODY_LEN 4BYTES 12 | # BODY_JSON_STR BODY_LEN 13 | 14 | 15 | def bytetolen(byteval): 16 | """Read length integer from bytes.""" 17 | if len(byteval) == MoloTcpPack.PACK_LEN_SIZE: 18 | return int.from_bytes(byteval, byteorder='little') 19 | return 0 20 | 21 | 22 | def lentobyte(length): 23 | """Write length integer to bytes buffer.""" 24 | return length.to_bytes(MoloTcpPack.PACK_LEN_SIZE, byteorder='little') 25 | 26 | 27 | class MoloTcpPack(): 28 | """TCP packet header define class for Molohub.""" 29 | 30 | HEADER_PREFIX_EN = 34 31 | MAGIC_LEN = 2 32 | MOLO_TCP_MAGIC = b"MP" 33 | PACK_VERSION = 1 34 | PACK_LEN_SIZE = 32 35 | 36 | ERR_OK = 0 37 | ERR_INSUFFICIENT_BUFFER = 1 38 | ERR_MALFORMED = 2 39 | 40 | @classmethod 41 | def generate_tcp_buffer(cls, body_jdata): 42 | """Construct TCP packet from json data.""" 43 | header_jdata = {} 44 | header_jdata["ver"] = MoloTcpPack.PACK_VERSION 45 | header_jdata_str = json.dumps(header_jdata) 46 | header_jdata_bytes = header_jdata_str.encode('utf-8') 47 | tcp_buffer = MoloTcpPack.MOLO_TCP_MAGIC + lentobyte( 48 | len(header_jdata_bytes)) + header_jdata_bytes 49 | 50 | body_jdata_str = json.dumps(body_jdata) 51 | body_jdata_bytes = body_jdata_str.encode('utf-8') 52 | tcp_buffer += lentobyte( 53 | len(body_jdata_bytes)) + body_jdata_bytes 54 | return tcp_buffer 55 | 56 | def __init__(self): 57 | """Initialize TCP packet arguments.""" 58 | self.header_jdata = None 59 | self.header_len = None 60 | self.magic = None 61 | self.tmp_buffer = None 62 | self.error_code = None 63 | self.body_len = None 64 | self.body_jdata = None 65 | self.clear() 66 | 67 | def clear(self): 68 | """Reset TCP packet arguments.""" 69 | self.header_jdata = None 70 | self.header_len = None 71 | self.magic = None 72 | self.tmp_buffer = None 73 | self.error_code = MoloTcpPack.ERR_OK 74 | self.body_len = None 75 | self.body_jdata = None 76 | 77 | def recv_header_prefix(self): 78 | """Read received TCP header prefix.""" 79 | if len(self.tmp_buffer) < MoloTcpPack.HEADER_PREFIX_EN: 80 | return False 81 | self.magic = self.tmp_buffer[:MoloTcpPack.MAGIC_LEN] 82 | if self.magic != MoloTcpPack.MOLO_TCP_MAGIC: 83 | self.error_code = MoloTcpPack.ERR_MALFORMED 84 | LOGGER.error("wrong tcp header magic %s", self.magic) 85 | return False 86 | 87 | self.header_len = bytetolen(self.tmp_buffer[ 88 | MoloTcpPack.MAGIC_LEN:MoloTcpPack.HEADER_PREFIX_EN]) 89 | 90 | self.tmp_buffer = self.tmp_buffer[MoloTcpPack.HEADER_PREFIX_EN:] 91 | return True 92 | 93 | def recv_header(self): 94 | """Read received TCP header.""" 95 | if len(self.tmp_buffer) < self.header_len: 96 | return False 97 | try: 98 | json_buff = self.tmp_buffer[:self.header_len].decode('utf-8') 99 | self.header_jdata = json.loads(json_buff) 100 | except (json.JSONDecodeError, UnicodeDecodeError) as exc: 101 | self.error_code = MoloTcpPack.ERR_MALFORMED 102 | LOGGER.error("MoloTcpPack recv header error %s", 103 | self.tmp_buffer[:self.header_len]) 104 | logging.exception(exc) 105 | return False 106 | 107 | self.tmp_buffer = self.tmp_buffer[self.header_len:] 108 | return True 109 | 110 | def recv_body_len(self): 111 | """Read received TCP body length.""" 112 | if len(self.tmp_buffer) < MoloTcpPack.PACK_LEN_SIZE: 113 | return False 114 | self.body_len = bytetolen( 115 | self.tmp_buffer[:MoloTcpPack.PACK_LEN_SIZE]) 116 | self.tmp_buffer = self.tmp_buffer[MoloTcpPack.PACK_LEN_SIZE:] 117 | return True 118 | 119 | def recv_body(self): 120 | """Read received TCP body.""" 121 | if len(self.tmp_buffer) < self.body_len: 122 | return False 123 | try: 124 | json_buff = self.tmp_buffer[:self.body_len].decode('utf-8') 125 | self.body_jdata = json.loads(json_buff) 126 | except (json.JSONDecodeError, UnicodeDecodeError) as exc: 127 | self.error_code = MoloTcpPack.ERR_MALFORMED 128 | LOGGER.error("MoloTcpPack recv body error %s", 129 | self.tmp_buffer[:self.body_len]) 130 | logging.exception(exc) 131 | return False 132 | self.tmp_buffer = self.tmp_buffer[self.body_len:] 133 | return True 134 | 135 | def has_recved_header_prefix(self): 136 | """If self has received header prefix.""" 137 | return self.header_len is not None and self.magic is not None 138 | 139 | def has_recved_header(self): 140 | """If self has received header.""" 141 | return self.header_jdata is not None 142 | 143 | def has_recved_body_len(self): 144 | """If self has received body length.""" 145 | return self.body_len is not None 146 | 147 | def has_recved_body(self): 148 | """If self has received body.""" 149 | return self.body_jdata is not None 150 | 151 | def recv_buffer(self, buffer): 152 | """Handle received.""" 153 | if not buffer: 154 | return False 155 | 156 | ret = False 157 | if self.error_code == MoloTcpPack.ERR_OK: 158 | self.clear() 159 | self.error_code = MoloTcpPack.ERR_INSUFFICIENT_BUFFER 160 | 161 | self.tmp_buffer = buffer 162 | 163 | if not self.has_recved_header_prefix(): 164 | ret = self.recv_header_prefix() 165 | if not ret: 166 | return ret 167 | 168 | if not self.has_recved_header(): 169 | ret = self.recv_header() 170 | if not ret: 171 | return ret 172 | 173 | if not self.has_recved_body_len(): 174 | ret = self.recv_body_len() 175 | if not ret: 176 | return ret 177 | 178 | if not self.has_recved_body(): 179 | ret = self.recv_body() 180 | if not ret: 181 | return ret 182 | 183 | self.error_code = MoloTcpPack.ERR_OK 184 | return True 185 | -------------------------------------------------------------------------------- /molohub/notify_state.py: -------------------------------------------------------------------------------- 1 | """UI class for Molohub.""" 2 | import html 3 | import urllib.parse 4 | 5 | from .const import (BIND_AUTH_STR_TEMPLATE_DEFAULT, 6 | SERVER_CONNECTING_STR_TEMPLATE_DEFAULT, STAGE_AUTH_BINDED, 7 | STAGE_SERVER_CONNECTED, STAGE_SERVER_UNCONNECTED, 8 | WAIT_FOR_AUTH_STR_TEMPLATE_DEFAULT) 9 | from .utils import LOGGER, fire_molohub_event 10 | 11 | 12 | class NotifyState: 13 | """UI class for Molohub.""" 14 | 15 | ha_context = None 16 | molo_server_host_str = '' 17 | 18 | cur_notify_str = SERVER_CONNECTING_STR_TEMPLATE_DEFAULT 19 | cur_data = { 20 | 'stage': STAGE_SERVER_UNCONNECTED, 21 | 'uncnn_templ': SERVER_CONNECTING_STR_TEMPLATE_DEFAULT, 22 | 'cnn_templ': BIND_AUTH_STR_TEMPLATE_DEFAULT, 23 | 'link_templ': WAIT_FOR_AUTH_STR_TEMPLATE_DEFAULT, 24 | 'platform_icon': { 25 | 'default': '''icon_error''' 26 | } 27 | } 28 | 29 | generate_str_func_bind_map = {} 30 | 31 | def __init__(self): 32 | """Initialize NotifyState class.""" 33 | self.init_func_bind_map() 34 | 35 | def set_context(self, hass, host_str): 36 | """Set HA context and server host string.""" 37 | self.ha_context = hass 38 | self.molo_server_host_str = host_str 39 | 40 | def update_state(self, data): 41 | """Update UI state.""" 42 | last_stage = self.cur_data.get('stage') 43 | last_notify_str = self.get_notify_str() 44 | 45 | # Update data 46 | self.cur_data.update(data) 47 | cur_stage = self.cur_data.get('stage') 48 | LOGGER.debug("cur_data %s", str(self.cur_data)) 49 | 50 | # Generate notify string according to stage 51 | if cur_stage in self.generate_str_func_bind_map: 52 | self.generate_str_func_bind_map[cur_stage]() 53 | 54 | # If notify string changed, inform UI to update 55 | if last_notify_str == self.get_notify_str(): 56 | return 57 | 58 | # If stage changed, log new stage 59 | if cur_stage != last_stage: 60 | LOGGER.info(self.state_log_str[cur_stage]) 61 | 62 | # Inform UI to update 63 | fire_molohub_event(self.ha_context, None) 64 | 65 | def get_notify_str(self): 66 | """Get current UI state.""" 67 | return self.cur_notify_str 68 | 69 | def generate_str_server_unconnected(self): 70 | """Handle hass event: on_stage_server_unconnected.""" 71 | self.cur_notify_str = self.cur_data.get('uncnn_templ') 72 | 73 | def generate_str_serverconnected(self): 74 | """Handle hass event: on_stage_serverconnected.""" 75 | token = urllib.parse.quote(self.cur_data.get('token')) 76 | token_list = [] 77 | opentype_count = len(self.cur_data.get('platform_icon')) 78 | i = 0 79 | while i < opentype_count: 80 | token_list.append(self.molo_server_host_str) 81 | token_list.append(token) 82 | i += 1 83 | self.cur_notify_str = ( 84 | self.cur_data.get('link_templ') % tuple(token_list)) 85 | LOGGER.debug("Update nofiy str token %s", token) 86 | 87 | def generate_str_auth_binded(self): 88 | """Handle hass event: on_stage_auth_binded.""" 89 | token = urllib.parse.quote(self.cur_data.get('token')) 90 | opentype = self.cur_data.get('opentype') 91 | openid = self.cur_data.get('openid') 92 | uname = self.cur_data.get('uname') 93 | uname = html.escape(uname) 94 | upicture = self.cur_data.get('upicture') 95 | LOGGER.debug("Update nofiy str opentype: %s, openid: %s, token: %s", 96 | opentype, openid, token) 97 | if opentype not in self.cur_data.get('platform_icon'): 98 | opentype = 'default' 99 | self.cur_notify_str = (self.cur_data.get('cnn_templ') % 100 | (self.cur_data.get('platform_icon')[opentype] % 101 | (self.molo_server_host_str), upicture, uname, 102 | self.molo_server_host_str, token)) 103 | 104 | def init_func_bind_map(self): 105 | """Initialize function bind map and state log string map.""" 106 | self.generate_str_func_bind_map = { 107 | STAGE_SERVER_UNCONNECTED: self.generate_str_server_unconnected, 108 | STAGE_SERVER_CONNECTED: self.generate_str_serverconnected, 109 | STAGE_AUTH_BINDED: self.generate_str_auth_binded 110 | } 111 | self.state_log_str = { 112 | STAGE_SERVER_UNCONNECTED: 'server offline', 113 | STAGE_SERVER_CONNECTED: 'server online, wait for authorize', 114 | STAGE_AUTH_BINDED: 'server online, successfully authorized' 115 | } 116 | 117 | 118 | NOTIFY_STATE = NotifyState() 119 | -------------------------------------------------------------------------------- /molohub/remote_sesstion.py: -------------------------------------------------------------------------------- 1 | """Remote proxy session class for Molohub.""" 2 | import asyncore 3 | import socket 4 | 5 | from .const import BUFFER_SIZE 6 | from .local_session import LocalSession 7 | from .molo_client_app import MOLO_CLIENT_APP 8 | from .molo_client_config import MOLO_CONFIGS 9 | from .molo_socket_helper import MoloSocketHelper 10 | from .molo_tcp_pack import MoloTcpPack 11 | from .utils import LOGGER, dns_open 12 | 13 | 14 | class RemoteSession(asyncore.dispatcher): 15 | """Remote proxy session class.""" 16 | 17 | tunnel = {} 18 | tunnel['protocol'] = 'http' 19 | tunnel['hostname'] = '' 20 | tunnel['subdomain'] = '' 21 | tunnel['rport'] = 0 22 | tunnel['lhost'] = MOLO_CONFIGS.get_config_object()['ha']['host'] 23 | tunnel['lport'] = MOLO_CONFIGS.get_config_object()['ha']['port'] 24 | 25 | def __init__(self, client_id, rhost, rport, lhost, lport, map): 26 | """Initialize remote session arguments.""" 27 | asyncore.dispatcher.__init__(self, map=map) 28 | self.client_id = client_id 29 | self.lhost = lhost 30 | self.lport = lport 31 | self.rhost = rhost 32 | self.rport = rport 33 | self.molo_tcp_pack = MoloTcpPack() 34 | self.tranparency = None 35 | self.append_recv_buffer = None 36 | self.append_send_buffer = None 37 | self.append_connect = None 38 | self.client_token = None 39 | self.clear() 40 | 41 | def handle_connect(self): 42 | """When connected, this method will be call.""" 43 | LOGGER.debug("server connected(%d)", id(self)) 44 | self.append_connect = False 45 | self.send_dict_pack(MoloSocketHelper.reg_proxy(self.client_id)) 46 | 47 | def handle_close(self): 48 | """When closed, this method will be call. clean itself.""" 49 | LOGGER.debug("server closed(%d)", id(self)) 50 | self.clear() 51 | MOLO_CLIENT_APP.local_session_dict.pop(id(self), None) 52 | local_session = MOLO_CLIENT_APP.local_session_dict.get(id(self)) 53 | if local_session: 54 | local_session.handle_close() 55 | self.close() 56 | 57 | def handle_read(self): 58 | """Handle read message.""" 59 | buff = self.recv(BUFFER_SIZE) 60 | self.append_recv_buffer += buff 61 | if self.tranparency: 62 | self.process_tranparency_pack() 63 | return 64 | self.process_molo_tcp_pack() 65 | 66 | def writable(self): 67 | """If the socket send buffer writable.""" 68 | return self.append_connect or (self.append_send_buffer) 69 | 70 | def handle_write(self): 71 | """Write socket send buffer.""" 72 | sent = self.send(self.append_send_buffer) 73 | self.append_send_buffer = self.append_send_buffer[sent:] 74 | 75 | # The above are base class methods. 76 | def clear(self): 77 | """Reset remote proxy session arguments.""" 78 | self.molo_tcp_pack.clear() 79 | self.tranparency = False 80 | self.append_recv_buffer = bytes() 81 | self.append_send_buffer = bytes() 82 | self.append_connect = True 83 | 84 | def sock_connect(self): 85 | """Connect to host:port.""" 86 | self.clear() 87 | dns_ip = dns_open(self.rhost) 88 | if not dns_ip: 89 | return 90 | self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 91 | self.connect((dns_ip, self.rport)) 92 | 93 | def on_start_proxy(self, jdata): 94 | """Handle Start Proxy.""" 95 | LOGGER.debug("on_start_proxy %s", str(jdata)) 96 | localsession = LocalSession(self.lhost, self.lport, MOLO_CLIENT_APP.async_map) 97 | MOLO_CLIENT_APP.local_session_dict[id(self)] = localsession 98 | MOLO_CLIENT_APP.remote_session_dict[id(localsession)] = self 99 | LOGGER.debug("remote local (%d)<->(%d)", id(self), id(localsession)) 100 | localsession.sock_connect() 101 | self.tranparency = True 102 | self.process_tranparency_pack() 103 | 104 | def process_tranparency_pack(self): 105 | """Handle transparency packet.""" 106 | localsession = MOLO_CLIENT_APP.local_session_dict.get(id(self)) 107 | if not localsession: 108 | LOGGER.debug( 109 | "process_tranparency_pack() localsession session not found") 110 | self.handle_close() 111 | return 112 | if self.append_recv_buffer: 113 | localsession.send_raw_pack(self.append_recv_buffer) 114 | self.append_recv_buffer = bytes() 115 | 116 | def process_molo_tcp_pack(self): 117 | """Handle TCP packet.""" 118 | ret = self.molo_tcp_pack.recv_buffer(self.append_recv_buffer) 119 | if ret and self.molo_tcp_pack.error_code == MoloTcpPack.ERR_OK: 120 | self.append_recv_buffer = self.molo_tcp_pack.tmp_buffer 121 | LOGGER.debug("RemoteSession process_molo_tcp_pack body:%s", str( 122 | self.molo_tcp_pack.body_jdata)) 123 | self.process_json_pack(self.molo_tcp_pack.body_jdata) 124 | 125 | if not self.tranparency: 126 | if self.molo_tcp_pack.error_code == MoloTcpPack.ERR_MALFORMED: 127 | LOGGER.error("tcp pack malformed!") 128 | self.handle_close() 129 | 130 | def process_json_pack(self, jdata): 131 | """Handle json packet.""" 132 | if jdata['Type'] in self.protocol_func_bind_map: 133 | self.protocol_func_bind_map[jdata['Type']](self, jdata) 134 | 135 | def send_raw_pack(self, raw_data): 136 | """Write raw data pack to write buffer.""" 137 | if self.append_connect: 138 | return 139 | self.append_send_buffer += raw_data 140 | self.handle_write() 141 | 142 | def send_dict_pack(self, dict_data): 143 | """Convert and send dict packet.""" 144 | if self.append_connect: 145 | return 146 | body = MoloTcpPack.generate_tcp_buffer(dict_data) 147 | self.send_raw_pack(body) 148 | 149 | protocol_func_bind_map = {'StartProxy': on_start_proxy} 150 | -------------------------------------------------------------------------------- /molohub/utils.py: -------------------------------------------------------------------------------- 1 | """Utils for Molohub.""" 2 | import logging 3 | import random 4 | import socket 5 | import uuid 6 | import yaml 7 | import json 8 | 9 | from .const import TCP_PACK_HEADER_LEN 10 | 11 | LOGGER = logging.getLogger(__package__) 12 | 13 | 14 | def get_mac_addr(): 15 | """Get local mac address.""" 16 | import uuid 17 | node = uuid.getnode() 18 | mac = uuid.UUID(int=node).hex[-12:] 19 | return mac 20 | 21 | 22 | def dns_open(host): 23 | """Get ip from hostname.""" 24 | try: 25 | ip_host = socket.gethostbyname(host) 26 | except socket.error: 27 | return None 28 | 29 | return ip_host 30 | 31 | 32 | def len_to_byte(length): 33 | """Write length integer to bytes buffer.""" 34 | return length.to_bytes(TCP_PACK_HEADER_LEN, byteorder='little') 35 | 36 | 37 | def byte_to_len(byteval): 38 | """Read length integer from bytes.""" 39 | if len(byteval) == TCP_PACK_HEADER_LEN: 40 | return int.from_bytes(byteval, byteorder='little') 41 | return 0 42 | 43 | 44 | def get_rand_char(length): 45 | """Generate random string by length.""" 46 | _chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz" 47 | return ''.join(random.sample(_chars, length)) 48 | 49 | 50 | def fire_molohub_event(hass, data): 51 | """Send hass Event message.""" 52 | if not hass: 53 | return 54 | hass.bus.fire('molohub_event', data) 55 | 56 | 57 | def get_local_seed(config_file): 58 | """Read seed from local file.""" 59 | local_seed = "" 60 | try: 61 | with open(config_file, 'r') as file_obj: 62 | config_data = yaml.load(file_obj) 63 | if config_data and 'molohub' in config_data: 64 | if 'localseed' in config_data['molohub']: 65 | local_seed = config_data['molohub']['localseed'] 66 | except (EnvironmentError, yaml.YAMLError): 67 | pass 68 | return local_seed 69 | 70 | 71 | def save_local_seed(config_file, local_seed): 72 | """Save seed to local file.""" 73 | config_data = None 74 | try: 75 | with open(config_file, 'r') as rfile: 76 | config_data = yaml.load(rfile) 77 | except (EnvironmentError, yaml.YAMLError): 78 | pass 79 | 80 | if not config_data: 81 | config_data = {} 82 | config_data['molohub'] = {} 83 | try: 84 | with open(config_file, 'w') as wfile: 85 | config_data['molohub']['localseed'] = local_seed 86 | yaml.dump(config_data, wfile, default_flow_style=False) 87 | except (EnvironmentError, yaml.YAMLError): 88 | pass 89 | 90 | 91 | def load_uuid(hass, filename='.uuid'): 92 | """Load UUID from a file or return None.""" 93 | try: 94 | with open(hass.config.path(filename)) as fptr: 95 | jsonf = json.loads(fptr.read()) 96 | return uuid.UUID(jsonf['uuid'], version=4).hex 97 | except (ValueError, AttributeError, FileNotFoundError): 98 | return None 99 | --------------------------------------------------------------------------------