├── .github ├── ISSUE_TEMPLATE ├── flow_zh.png └── workflows │ └── stale.yml ├── .gitignore ├── .travis.yml ├── README.1.md ├── README.md ├── package-lock.json ├── package.json ├── scripts ├── dl_core.js ├── dl_geo.js └── run_linux.sh ├── src ├── assets │ ├── icon-mac.icns │ ├── icon.png │ ├── tray-off-icon-light.png │ ├── tray-off-icon-light@2x.png │ ├── tray-off-icon.png │ ├── tray-off-icon@2x.png │ ├── tray-on-icon-light.png │ ├── tray-on-icon-light@2x.png │ ├── tray-on-icon-win.ico │ ├── tray-on-icon.png │ └── tray-on-icon@2x.png ├── config │ ├── config.js │ └── convert.js ├── helper │ ├── darwin │ │ ├── configure_proxy │ │ ├── install_helper │ │ ├── md5sum │ │ ├── route │ │ └── setdnsservers │ ├── linux │ │ ├── config_route │ │ ├── install_helper │ │ ├── ip │ │ ├── md5sum │ │ └── recover_route │ └── win32 │ │ ├── config_route.bat │ │ ├── configure_proxy.bat │ │ ├── ensure_tap_device.bat │ │ ├── recover_route.bat │ │ └── tap-windows6 │ │ ├── tap-windows-9.24.2-I601-Win10.exe │ │ └── tap-windows-9.24.2-I601-Win7.exe ├── locales │ ├── en │ │ └── translation.json │ └── zh │ │ └── translation.json ├── main.js ├── spec │ ├── append_inbounds.spec.js │ └── conf_to_json.spec.js └── web │ └── sessions.html └── template └── example.conf /.github/ISSUE_TEMPLATE: -------------------------------------------------------------------------------- 1 | ### 使用操作系统类型以及版本 2 | 3 | 4 | 5 | ### 使用 Mellow 版本 6 | 7 | 8 | 9 | ### 问题描述 10 | 11 | 12 | 13 | ### 问题复现步骤 14 | 15 | 16 | 17 | ### 相关配置 18 | 19 | 20 | 21 | ### 相关日志、截图 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.github/flow_zh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/.github/flow_zh.png -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v3 11 | with: 12 | repo-token: ${{ secrets.GITHUB_TOKEN }} 13 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days' 14 | days-before-stale: 60 15 | days-before-close: 7 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/helper/darwin/core 2 | src/helper/linux/core 3 | src/helper/win32/core.exe 4 | geo.mmdb 5 | geosite.dat 6 | fakedns.cache 7 | /dist 8 | 9 | # General 10 | .DS_Store 11 | .AppleDouble 12 | .LSOverride 13 | 14 | # Icon must end with two \r 15 | Icon 16 | 17 | # Thumbnails 18 | ._* 19 | 20 | # Files that might appear in the root of a volume 21 | .DocumentRevisions-V100 22 | .fseventsd 23 | .Spotlight-V100 24 | .TemporaryItems 25 | .Trashes 26 | .VolumeIcon.icns 27 | .com.apple.timemachine.donotpresent 28 | 29 | # Directories potentially created on remote AFP share 30 | .AppleDB 31 | .AppleDesktop 32 | Network Trash Folder 33 | Temporary Items 34 | .apdisk 35 | # Swap 36 | [._]*.s[a-v][a-z] 37 | [._]*.sw[a-p] 38 | [._]s[a-rt-v][a-z] 39 | [._]ss[a-gi-z] 40 | [._]sw[a-p] 41 | 42 | # Session 43 | Session.vim 44 | Sessionx.vim 45 | 46 | # Temporary 47 | .netrwhist 48 | *~ 49 | # Auto-generated tag files 50 | tags 51 | # Persistent undo 52 | [._]*.un~ 53 | # Logs 54 | logs 55 | *.log 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | lerna-debug.log* 60 | 61 | # Diagnostic reports (https://nodejs.org/api/report.html) 62 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 63 | 64 | # Runtime data 65 | pids 66 | *.pid 67 | *.seed 68 | *.pid.lock 69 | 70 | # Directory for instrumented libs generated by jscoverage/JSCover 71 | lib-cov 72 | 73 | # Coverage directory used by tools like istanbul 74 | coverage 75 | *.lcov 76 | 77 | # nyc test coverage 78 | .nyc_output 79 | 80 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 81 | .grunt 82 | 83 | # Bower dependency directory (https://bower.io/) 84 | bower_components 85 | 86 | # node-waf configuration 87 | .lock-wscript 88 | 89 | # Compiled binary addons (https://nodejs.org/api/addons.html) 90 | build/Release 91 | 92 | # Dependency directories 93 | node_modules/ 94 | jspm_packages/ 95 | 96 | # TypeScript v1 declaration files 97 | typings/ 98 | 99 | # TypeScript cache 100 | *.tsbuildinfo 101 | 102 | # Optional npm cache directory 103 | .npm 104 | 105 | # Optional eslint cache 106 | .eslintcache 107 | 108 | # Optional REPL history 109 | .node_repl_history 110 | 111 | # Output of 'npm pack' 112 | *.tgz 113 | 114 | # Yarn Integrity file 115 | .yarn-integrity 116 | 117 | # dotenv environment variables file 118 | .env 119 | .env.test 120 | 121 | # parcel-bundler cache (https://parceljs.org/) 122 | .cache 123 | 124 | # next.js build output 125 | .next 126 | 127 | # nuxt.js build output 128 | .nuxt 129 | 130 | # vuepress build output 131 | .vuepress/dist 132 | 133 | # Serverless directories 134 | .serverless/ 135 | 136 | # FuseBox cache 137 | .fusebox/ 138 | 139 | # DynamoDB Local files 140 | .dynamodb/ 141 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "12.7.0" 5 | 6 | os: 7 | - osx 8 | 9 | osx_image: xcode11.3 10 | 11 | install: 12 | - npm i 13 | 14 | script: 15 | - npm run dlgeo 16 | - npm run dlcore -- --all 17 | - npm test 18 | - npm run distmac 19 | - USE_HARD_LINKS=false npm run distwin 20 | - npm run distlinux 21 | - ls -la dist 22 | 23 | deploy: 24 | provider: releases 25 | api_key: 26 | secure: TkUl9FEUPwwSlyn5cHoBl6/k7iELyUJ2LXxjeE3FILVbF17y155rZ8tQVixnQwZQP5cptQ6tTf2/DdEqbWfF1Mc0k45bvTl5Aj4/FCXS+V6iSIkj/jNcdqR7dv5XU1+L0Xy9RnAy/7NAwcknhpLeR5kEV8jKNjPKwfDYHeDtnkb6Lx8Z3lo1MDXlV6/umY6OGH0kUH7WH7KxD3iHGy/yn+GrdzYFH6y0sKNhSa+7rKS1o/axiAkTjDfTyfHroRtaF5eZZbw6CwCI1RTvtJfr885hbKGLTa5kF/WVW8wMWJ++eykv3w8I0LiLi2/F7lG7Okdnzrh91SQ3v0zqRfY80WLm3nvD2CW9kvveDlUOW06YYk/fYmChxUGPeEwC2e2HTsJ31jNGXTNx/tKWCi63jZ4KoWZ1WmZLkQvVtKgwGl3oCsZqJgGHDcsIru+Brj9paBlVpocKM8NK7bMrLBgT/LvQdxlRK9clUkjjLbOmdeYbd/rOrdUc9EJ/3bkVyCuy9CQN/UnYjPUqD4aO35762EN5929C4U3JXWCphi2l4e4rp7nFd/+pGyRUNR9APsmT+A80vi5/CkLh3IoLYG5BYr1iHFYHJWiFyysq/EDYbE9QaQvdk/oYxo85v9nW177nE0YGRtHexcngnUI/1C6D4RSlv417EOaSNnT81ayQ7f4= 27 | file_glob: true 28 | file: 29 | - "dist/Mellow Setup*.exe" 30 | - "dist/Mellow*.dmg" 31 | - "dist/Mellow*.AppImage" 32 | overwrite: true 33 | skip_cleanup: true 34 | on: 35 | tags: true 36 | -------------------------------------------------------------------------------- /README.1.md: -------------------------------------------------------------------------------- 1 | # Mellow 2 | 3 | [![Build Status](https://travis-ci.com/mellow-io/mellow.svg?branch=master)](https://travis-ci.com/mellow-io/mellow) 4 | 5 | Mellow 是一个基于规则的全局透明代理工具,可以运行在 Windows、macOS 和 Linux 上,也可以配置成路由器透明代理或代理网关,支持 SOCKS、HTTP、Shadowsocks、VMess 等多种代理协议。 6 | 7 | ## 下载安装 8 | 9 | ### Windows、macOS 和 Linux 安装文件下载 10 | 11 | https://github.com/mellow-io/mellow/releases 12 | 13 | ### Homebrew 安装 (适用于 macOS) 14 | 15 | ``` 16 | brew cask install mellow 17 | ``` 18 | 19 | ## 配置 20 | 21 | ### 全局代理配置 22 | 23 | ```ini 24 | [Endpoint] 25 | MyProxyServer, ss, ss://aes-128-gcm:pass@192.168.100.1:8888 26 | Dns-Out, builtin, dns 27 | 28 | [RoutingRule] 29 | FINAL, MyProxyServer 30 | 31 | [Dns] 32 | hijack = Dns-Out 33 | 34 | [DnsServer] 35 | 8.8.8.8 36 | 8.8.4.4 37 | ``` 38 | 39 | ### 简单配置 40 | 41 | 绕过 cn 和 private。 42 | 43 | ```ini 44 | [Endpoint] 45 | MyProxyServer, ss, ss://aes-128-gcm:pass@192.168.100.1:8888 46 | Direct, builtin, freedom, domainStrategy=UseIP 47 | Dns-Out, builtin, dns 48 | 49 | [RoutingRule] 50 | DOMAIN-KEYWORD, geosite:cn, Direct 51 | GEOIP, cn, Direct 52 | GEOIP, private, Direct 53 | FINAL, MyProxyServer 54 | 55 | [Dns] 56 | hijack = Dns-Out 57 | 58 | [DnsServer] 59 | localhost 60 | 8.8.8.8 61 | ``` 62 | 63 | ### 更多配置 64 | 65 | ```ini 66 | [Endpoint] 67 | ; tag, parser, parser-specific params... 68 | Direct, builtin, freedom, domainStrategy=UseIP 69 | Reject, builtin, blackhole 70 | Dns-Out, builtin, dns 71 | Http-Out, builtin, http, address=192.168.100.1, port=1087, user=myuser, pass=mypass 72 | Socks-Out, builtin, socks, address=192.168.100.1, port=1080, user=myuser, pass=mypass 73 | Proxy-1, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:443/path?network=ws&tls=true&ws.host=example.com 74 | Proxy-2, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:10025?network=tcp 75 | Proxy-3, ss, ss://aes-128-gcm:pass@192.168.100.1:8888 76 | Proxy-4, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:443/path?network=http&http.host=example.com%2Cexample1.com&tls=true&tls.allowinsecure=true 77 | Proxy-7, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:10025?network=kcp&kcp.mtu=1350&kcp.tti=20&kcp.uplinkcapacity=1&kcp.downlinkcapacity=2&kcp.congestion=false&header=none&sockopt.tos=184 78 | Proxy-8, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:10025?network=quic&quic.security=none&quic.key=&header=none&tls=false&sockopt.tos=184 79 | 80 | [EndpointGroup] 81 | ; tag, colon-seperated list of selectors or endpoint tags, strategy, strategy-specific params... 82 | Group-1, Proxy-1:Proxy-2:Proxy-3, latency, interval=300, timeout=6 83 | 84 | [Routing] 85 | domainStrategy = IPIfNonMatch 86 | 87 | [RoutingRule] 88 | ; type, filter, endpoint tag or enpoint group tag 89 | DOMAIN-KEYWORD, geosite:category-ads-all, Reject 90 | IP-CIDR, 223.5.5.5/32, Direct 91 | IP-CIDR, 8.8.8.8/32, Group-1 92 | IP-CIDR, 8.8.4.4/32, Group-1 93 | PROCESS-NAME, cloudmusic.exe, Direct 94 | PROCESS-NAME, NeteaseMusic, Direct 95 | GEOIP, cn, Direct 96 | GEOIP, private, Direct 97 | PORT, 123, Direct 98 | DOMAIN-KEYWORD, geosite:cn, Direct 99 | DOMAIN, www.google.com, Group-1 100 | DOMAIN-FULL, www.google.com, Group-1 101 | DOMAIN-SUFFIX, google.com, Group-1 102 | FINAL, Group-1 103 | 104 | [Dns] 105 | ; hijack = dns endpoint tag 106 | hijack = Dns-Out 107 | ; cliengIp = ip 108 | clientIp = 114.114.114.114 109 | 110 | [DnsServer] 111 | ; address, port, tag 112 | localhost 113 | 223.5.5.5 114 | 8.8.8.8, 53, Remote 115 | 8.8.4.4 116 | 117 | [DnsRule] 118 | ; type, filter, dns server tag 119 | DOMAIN-KEYWORD, geosite:geolocation-!cn, Remote 120 | DOMAIN-SUFFIX, google.com, Remote 121 | 122 | [DnsHost] 123 | ; domain = ip 124 | doubleclick.net = 127.0.0.1 125 | 126 | [Log] 127 | loglevel = warning 128 | ``` 129 | 130 | 更详细的 conf 配置,以及所对应的 JSON 配置可以[查看这里](https://github.com/mellow-io/mellow/blob/master/src/spec/conf_to_json.spec.js)。 131 | 132 | ## 开发运行和构建 133 | 134 | ```sh 135 | git clone https://github.com/mellow-io/mellow.git 136 | cd mellow 137 | 138 | # 安装依赖 139 | npm i 140 | 141 | # 下载数据文件 142 | npm run dlgeo 143 | 144 | # 下载核心 145 | # 默认只下载本系统对应的核心文件,如果要为其它系统构建,加 `-- --all` 下载其它系统对应的文件 146 | npm run dlcore [-- --all] 147 | 148 | # 运行 149 | npm start 150 | 151 | # 构建 macOS 安装文件 152 | npm run distmac 153 | 154 | # 构建 Windows 安装文件 155 | npm run distwin 156 | 157 | # 构建 Linux 安装文件 158 | npm run distlinux 159 | ``` 160 | 161 | ## 一些说明 162 | 163 | ### 要不要使用 Mellow? 164 | Mellow 是一个透明代理客户端,如果不理解,那说得实际点,就是不仅可以代理浏览器的请求,还可以代理微信、QQ、Telegram 客户端、Instagram 客户端、网易云音乐、各种命令行工具、Docker 容器、虚拟机、WSL、各种 IDE、各种游戏等等的网络请求,不需要任何额外的代理设置。 165 | 166 | 所以也很清楚的是,如果仅需要代理浏览器的请求,或者也不嫌麻烦为个别程序单独设置代理的话,是没必要使用 Mellow 的。 167 | 168 | ### 关于配置和启动 169 | 支持两种配置文件格式,一个是类 ini 的 conf 格式,另一个是 V2Ray JSON 格式,两种格式的配置可以同时存在。 170 | 171 | 可以在 Tray 菜单 Config Template 中创建对应的配置模板,创建后就是一个纯文本文件,自行打开目录去编辑完善配置,编辑好后保存,即可启动代理,待图标变色后,就表示代理已经启动。 172 | 173 | macOS 客户端在初次和每次升级后启动时,都可能会弹框请求管理权限。 174 | 175 | Windows 客户端在每次启动时都会弹框请求管理权限,如果不希望看到弹框,可以改变 UAC(用户帐户控制设置) 的通知等级。 176 | 177 | 如果启动代理后有任何问题,比如弹出错误,比如无法连接,要反馈的话,请附上必要的截图(错误弹框等)、配置和日志。 178 | 179 | 任何配置改动都需要重连生效。 180 | 181 | ### 关于 DNS 182 | 因为系统 DNS 很不好控制,推荐 Freedom Outbound 使用 UseIP 策略,再配置好内建 DNS 服务器,这样可以避免一些奇怪问题,也增加 DNS 缓存的利用效率。 183 | 184 | **macOS 和 Linux 用户可能需要检查下系统 DNS 配置,勿用路由器网关地址或私有地址作 DNS**,因为那样流量就不会被路由到 TUN 接口,从而完全摆脱了 Mellow 控制,然后会导致一些 DNS 解析异常以及导致 DNS 分流完全失效。 185 | 186 | DNS 的处理方面基本上和 [这篇文章](https://medium.com/@TachyonDevel/%E6%BC%AB%E8%B0%88%E5%90%84%E7%A7%8D%E9%BB%91%E7%A7%91%E6%8A%80%E5%BC%8F-dns-%E6%8A%80%E6%9C%AF%E5%9C%A8%E4%BB%A3%E7%90%86%E7%8E%AF%E5%A2%83%E4%B8%AD%E7%9A%84%E5%BA%94%E7%94%A8-62c50e58cbd0) 中介绍的没什么出入,默认使用 Sniffing 来处理 DNS 染污,建议再配置一下 DNS 分流,就是 conf 配置中的 DNS Hijack + DNS Outbound(Endpoint) + DNS Server + DNS Rule。 187 | 188 | ### 关于 NAT 类型 189 | 使用 SOCKS 或 Shadowsocks 协议的话支持 Full Cone NAT,注意服务器也要是支持 Full Cone NAT 的,如果要代理游戏,服务端可以考虑用 shadowsocks-libev、go-shadowsocks2 等。 190 | 191 | ### 关于 JSON 配置的 Inbound 192 | JSON 配置文件中不需要有 Inbound,但也可以自行配置 Inbound 作其它用途,例如可以像其它非透明代理客户端一样,配置文件中写上 SOCKS/HTTP Inbound,再手动配置到系统或浏览器的代理设置中,那样浏览器的请求就会从 SOCKS/HTTP Inbound 过来,而不经过 TUN 接口,相当于 “系统代理” 模式(现在默认开启)。注意目前所有类型 Inbound 的 UDP 都不能用,因为我没见过哪个操作系统或浏览器真正地使用 SOCKS 的 UDP,所以也不会去修复它。 193 | 194 | ### 关于日志 195 | 日志有两份,一份是 Mellow 的日志,一份是 V2Ray 的日志,V2Ray 日志如果输出到 stdout/stderr,那 V2Ray 的日志会被打印到 Mellow 的日志里。 196 | 197 | ### 关于 GUI 198 | 目前没有任何计划做成 UI 配置的方式。 199 | 200 | ## FAQ 201 | 202 | ### 为什么在 Sessions 中有些请求显示进程名称为 `unknown process`? 203 | 204 | 1. 某些 UDP 会话如果持续时间过短,则会无法获取其发送进程。 205 | 2. 在 Windows 上,会看到较多的 `unknown process`,这是因为 Mellow 没有权限访问系统进程的信息,特别是 DNS 请求,因为发送 DNS 请求的通常是一个名为 svchost.exe 的系统进程。 206 | 207 | ### “系统代理” 选项的作用是什么? 208 | 209 | Mellow 目前提供两种流量接管方式,一是 TUN 模式(某些软件中所指的 Enhanced Mode 就是这个),二是系统代理(常见的代理软件都用这个,不严谨地说就是只代理浏览器请求那类),与其它软件不同,Mellow 默认强制开启 TUN 模式,可选开启 “系统代理” 模式。 210 | 211 | TUN 模式已经可以接管全部流量了,为什么还需要 “系统代理” 模式? 212 | 213 | 1. TUN 模式有性能瓶颈。 214 | 2. “系统代理” 模式不会有太多 DNS 相关的问题,代理浏览器请求也快。 215 | 216 | 开启 “系统代理” 模式后,两种模式是并存的,可以走系统代理的请求优先走系统代理,剩下的走 TUN。 217 | 218 | ### 可以在 Linux 上以命令行方式运行吗? 219 | 220 | 可以的,只需要把 [这](https://github.com/mellow-io/mellow-core/releases/latest/download/core-linux-amd64) [四](https://github.com/v2ray/domain-list-community/releases/latest/download/dlc.dat) [个](https://web.archive.org/web/20191227182412/https://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz) [文件](https://github.com/mellow-io/mellow/blob/master/scripts/run_linux.sh) 下载到同一个目录,把 `dlc.dat` 改名为 `geosite.dat`,把 `GeoLite2-Country.tar.gz` 解压后改名为 `geo.mmdb`,再自行创建一个叫 `cfg.json` 的 V2Ray 配置文件,然后运行 `run_linux.sh` 脚本(需要 root 权限)。注意,如果用 ssh 连的 Linux,你可能需要打开一个持续 `ping` 目标 Linux 的窗口,不然在运行后 ssh 有可能失去连接或连接不上。 221 | 222 | ### 可以用作网关吗? 223 | 224 | 在 macOS 上,开启 Mellow 后,再开启 IP Forwarding 就行: 225 | 226 | ```sh 227 | sudo sysctl -w net.inet.ip.forwarding=1 228 | ``` 229 | 230 | 同样在 Linux 上,开启 IP Forwarding: 231 | 232 | ```sh 233 | sudo sysctl -w net.ipv4.ip_forward=1 234 | ``` 235 | 236 | 注意这种网关形式上有别于一般的路由器透明代理设置,这里的 “网关” 是局域网里另一台普通的局域网设备,它本身需要路由器作网关。 237 | 238 | ### 怎么把 conf 配置转换成 JSON 配置? 239 | 240 | 把 conf 配置运行起来,然后打开 Running Config 就是了,其实配置最终都是转成 JSON 再运行的。 241 | 242 | 不依赖 UI 的话可以用这条命令来转换: 243 | 244 | ```sh 245 | cat config.conf | node src/config/convert.js > config.json 246 | ``` 247 | 248 | ### 可以运行在路由器上做透明代理吗? 249 | 可以的: 250 | 251 | 1. 首先保证路由器处于一个正常状态,它本身也可以正常访问网络。(在路由器的 ssh shell 里可以 ping 通外网) 252 | 2. 把所需文件下载下来放到路由器上某一个目录里面,有些文件需要改下名字,具体参考上面的 “在 Linux 上运行”。(你需要到 [Releases](https://github.com/mellow-io/mellow-core/releases) 页面找对应系统架构的 core) 253 | 3. 同一目录里,创建一个叫 `cfg.json` 的 V2Ray 配置文件,不需要有 Inbound,其它配置按正常来,但建议参考 Mellow 所推荐的配置方式。 254 | 4. 检查路由器的系统 DNS,保证不是 127.0.0.1 或任何私有地址,如果有必要,自己填两个上去。(/etc/resolv.conf) 255 | 5. 然后运行 `run_linux.sh`(不是后台运行,你需要保留这个窗口)。 256 | 6. 然后用 `ip addr show` 查看 TUN 接口的名字,比如是 `tun1`,那么运行下面这条 iptables 命令就 OK 啦: 257 | 258 | ```sh 259 | iptables -I FORWARD -o tun1 -j ACCEPT 260 | ``` 261 | 262 | 在典型 OpenWrt 系统上流量**大概可能**是这么走的: 263 | ``` 264 | // 局域网其它设备的流量 265 | wlan0/eth0 -> br-lan -> FORWARD (iptables) -> tun1 -> tun2socks (Mellow) -> ROUTING -> pppoe-wan 266 | ``` 267 | 268 | ``` 269 | // 运行在路由器上的进程的流量(比如 dnsmasq) 270 | local process -> ROUTING -> tun1 -> tun2socks (Mellow) -> ROUTING -> pppoe-wan 271 | ``` 272 | 273 | 上面提到系统 DNS (/etc/resolv.conf) 中不能是 127.0.0.1、search lan、localhost 之类的原因是,假如路由器本地跑的是 dnsmasq 作 DNS 服务器,那么如果系统 DNS 是 127.0.0.1,Mellow 发的某些 DNS 请求就会给到 dnsmasq,而从上面第二个过程来看,dnsmasq (local process) 发出去的流量又会再次给到 Mellow,这其中如果处理不好就可能会出现死循环,这种情况一般都比较难处理,所以最直接的方法就是不让 DNS 流量经过本地的 dnsmasq。 274 | 275 | 因为 `Sessions` 的地址是 127.0.0.1,所以如果想查看请求记录,可以做下 SSH Port Forwarding: 276 | 277 | ```sh 278 | # 在本地机器上运行,192.168.1.1 是路由器地址, 279 | # 然后访问:http://localhost:6002/stats/session/plain 280 | ssh -NL 6002:localhost:6001 root@192.168.1.1 281 | ``` 282 | 283 | ### 如何配合其它代理软件使用? 284 | 285 | 参考 https://github.com/mellow-io/mellow/issues/3 和 https://github.com/mellow-io/mellow/issues/52 286 | 287 | 总的来说,需要处理好两点,一是把相应的代理软件流量用 PROCESS-NAME 规则排除掉,二是(有必要的话)对用到伪装域名的代理协议做额外处理。 288 | 289 | ## Mellow 工作流程图 290 | 291 | ![Mellow](https://raw.githubusercontent.com/mellow-io/mellow/master/.github/flow_zh.png) 292 | 293 | ## JSON 配置的扩展功能说明 294 | 295 | ### 自动选择最优线路 296 | 就是 conf 配置中的 Endpoint Group 使用 latency 作策略,可根据代理请求的 RTT(即实际向 outbound 发送一个代理请求,记录返回非空数据所使用的时间),自动选择负载均衡组中最优线路来转发请求。 297 | 298 | ```json 299 | "routing": { 300 | "balancers": [ 301 | { 302 | "tag": "server_lb", 303 | "selector": [ 304 | "server_1", 305 | "server_2" 306 | ], 307 | "strategy": "latency", 308 | "interval": 60, // 秒,每次测速之间的最少时间间隔 309 | "totalMeasures": 2, // 每次测速中对每个 outbound 所做的请求次数 310 | "delay": 1, // 秒,每个测速请求之间的时间间隔 311 | "timeout": 6, // 秒,测速请求的超时时间 312 | "tolerance": 300, // 毫秒,可接受的延迟波动范围,切换最佳节点会将此波动范围考虑进去 313 | "probeTarget": "tls:www.google.com:443", // 测速请求发送的目的地 314 | "probeContent": "HEAD / HTTP/1.1\r\n\r\n" // 测速请求内容 315 | } 316 | ] 317 | } 318 | ``` 319 | 320 | ### 应用进程规则 321 | 就是 conf 配置中的 PROCESS-NAME 规则,支持 `*` 和 `?` 通配符匹配,匹配内容为进程名称,包括所有直接或非直接的父进程。 322 | 323 | 在 Windows 上,进程名称通常为 `xxx.exe`,例如 `chrome.exe`,在 Mellow 的 `Sessions` 中可方便查看。 324 | 325 | 在 macOS 上也可以通过 Mellow 的 `Sessions` 查看,也可以通过 `ps` 命令查看进程。 326 | 327 | ```json 328 | "routing": { 329 | "rules": [ 330 | { 331 | "app": [ 332 | "git*", 333 | "chrome.exe" 334 | ], 335 | "type": "field", 336 | "outboundTag": "proxy" 337 | } 338 | ] 339 | } 340 | ``` 341 | 342 | ### QoS 设置 343 | 344 | 在 Outbound 的 streamSettings 中设置,仅支持 macOS 和 Linux。 345 | 346 | ```json 347 | "streamSettings": { 348 | "sockopt": { 349 | "tos": 184 350 | } 351 | } 352 | ``` 353 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mellow 2 | 3 | # 此项目已不维护。Windows 用户全局流量代理推荐尝试:https://github.com/YtFlow/Maple 4 | 5 | [![Build Status](https://travis-ci.com/mellow-io/mellow.svg?branch=master)](https://travis-ci.com/mellow-io/mellow) 6 | 7 | 一个代理功能上类似于 Proxifier 的,可以代理所有流量的,可以使用域名、IP、GEOIP、进程等规则分流的,可以同时有多个协议和出口的工具。 8 | 9 | [详细说明](https://github.com/mellow-io/mellow/blob/master/README.1.md) 10 | 11 | [核心源码](https://github.com/mellow-io/go-tun2socks) 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mellow", 3 | "version": "0.1.22", 4 | "main": "src/main.js", 5 | "scripts": { 6 | "test": "jest", 7 | "start": "electron .", 8 | "pack": "electron-builder --dir", 9 | "distmac": "CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder -m", 10 | "distwin": "electron-builder -w", 11 | "distlinux": "electron-builder -l", 12 | "dlcore": "node scripts/dl_core.js", 13 | "dlgeo": "node scripts/dl_geo.js" 14 | }, 15 | "devDependencies": { 16 | "axios": "^0.19.2", 17 | "electron": "^8.4.0", 18 | "electron-builder": "^22.7.0", 19 | "jest": "^26.1.0", 20 | "targz": "^1.0.1" 21 | }, 22 | "dependencies": { 23 | "auto-launch": "^5.0.5", 24 | "base64url": "^3.0.1", 25 | "default-gateway": "^5.0.4", 26 | "electron-log": "^3.0.7", 27 | "electron-prompt": "^1.3.1", 28 | "electron-store": "^4.0.0", 29 | "i18next": "^19.6.2", 30 | "i18next-node-fs-backend": "^2.1.3", 31 | "module-alias": "github:eycorsican/module-alias", 32 | "netmask": "^1.0.6", 33 | "semver": "^6.3.0", 34 | "sudo-prompt": "github:eycorsican/sudo-prompt" 35 | }, 36 | "jest": { 37 | "moduleNameMapper": { 38 | "^@mellow(.*)$": "/src/$1" 39 | } 40 | }, 41 | "moduleAliases": { 42 | "@mellow": "src" 43 | }, 44 | "build": { 45 | "appId": "org.mellow.mellow", 46 | "mac": { 47 | "icon": "src/assets/icon-mac.icns", 48 | "extraResources": [ 49 | { 50 | "from": "src/helper/darwin", 51 | "to": "src/helper", 52 | "filter": [ 53 | "core", 54 | "install_helper", 55 | "md5sum", 56 | "route", 57 | "configure_proxy", 58 | "setdnsservers" 59 | ] 60 | }, 61 | "src/helper/geosite.dat", 62 | "src/helper/geo.mmdb", 63 | "src/locales/en/translation.json", 64 | "src/locales/zh/translation.json" 65 | ] 66 | }, 67 | "win": { 68 | "icon": "src/assets/icon.png", 69 | "requestedExecutionLevel": "requireAdministrator", 70 | "extraResources": [ 71 | { 72 | "from": "src/helper/win32", 73 | "to": "src/helper", 74 | "filter": [ 75 | "core.exe", 76 | "ensure_tap_device.bat", 77 | "config_route.bat", 78 | "recover_route.bat", 79 | "tap-windows6", 80 | "configure_proxy.bat" 81 | ] 82 | }, 83 | { 84 | "from": "src/helper/win32/tap-windows6", 85 | "to": "src/helper/tap-windows6", 86 | "filter": [ 87 | "*" 88 | ] 89 | }, 90 | "src/helper/geosite.dat", 91 | "src/helper/geo.mmdb", 92 | "src/locales/en/translation.json", 93 | "src/locales/zh/translation.json" 94 | ] 95 | }, 96 | "linux": { 97 | "target": "AppImage", 98 | "icon": "src/assets/icon.png", 99 | "extraResources": [ 100 | { 101 | "from": "src/helper/linux", 102 | "to": "src/helper", 103 | "filter": [ 104 | "config_route", 105 | "recover_route", 106 | "core", 107 | "install_helper", 108 | "md5sum", 109 | "ip" 110 | ] 111 | }, 112 | "src/helper/geosite.dat", 113 | "src/helper/geo.mmdb", 114 | "src/locales/en/translation.json", 115 | "src/locales/zh/translation.json" 116 | ] 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /scripts/dl_core.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default 2 | const fs = require('fs') 3 | const util = require('util') 4 | const path = require('path') 5 | 6 | const version = 'v1.0.10' 7 | const linkPrefix = 'https://github.com/mellow-io/go-tun2socks/releases/download' 8 | 9 | const links = { 10 | darwin: util.format('%s/%s/core-darwin-10.6-amd64', linkPrefix, version), 11 | linux: util.format('%s/%s/core-linux-amd64', linkPrefix, version), 12 | win32: util.format('%s/%s/core-windows-4.0-amd64.exe', linkPrefix, version) 13 | } 14 | const dsts = { 15 | darwin: path.join(__dirname, '../src/helper/darwin/core'), 16 | linux: path.join(__dirname, '../src/helper/linux/core'), 17 | win32: path.join(__dirname, '../src/helper/win32/core.exe') 18 | } 19 | 20 | async function download(url, filePath) { 21 | const writer = fs.createWriteStream(filePath) 22 | console.log('Downloading', url) 23 | const resp = await axios({ 24 | url, 25 | method: 'GET', 26 | responseType: 'stream', 27 | onDownloadProgress: (e) => { 28 | console.log(e) 29 | } 30 | }) 31 | resp.data.pipe(writer) 32 | writer.on('finish', () => { 33 | console.log('Saved file', filePath) 34 | }) 35 | writer.on('error', (err) => { 36 | console.log('Download failed.', err) 37 | }) 38 | } 39 | 40 | if (process.argv.length > 2) { 41 | if (process.argv[2] == '--all') { 42 | download(links.darwin, dsts.darwin) 43 | download(links.linux, dsts.linux) 44 | download(links.win32, dsts.win32) 45 | } 46 | if (process.argv[2] == '--darwin') { 47 | download(links.darwin, dsts.darwin) 48 | } 49 | if (process.argv[2] == '--linux') { 50 | download(links.linux, dsts.linux) 51 | } 52 | if (process.argv[2] == '--win' || process.argv[2] == '--win32') { 53 | download(links.win32, dsts.win32) 54 | } 55 | } else { 56 | switch (process.platform) { 57 | case 'darwin': 58 | download(links.darwin, dsts.darwin) 59 | break 60 | case 'linux': 61 | download(links.linux, dsts.linux) 62 | break 63 | case 'win32': 64 | download(links.win32, dsts.win32) 65 | break 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /scripts/dl_geo.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default 2 | const path = require('path') 3 | const os = require('os') 4 | const fs = require('fs') 5 | const targz = require('targz') 6 | 7 | const geositeUrl = 'https://github.com/v2ray/domain-list-community/releases/latest/download/dlc.dat' 8 | const mmdbUrl = 'https://web.archive.org/web/20191227182412/https://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz' 9 | 10 | async function downloadGeosite() { 11 | const dstPath = path.join(__dirname, '../src/helper', 'geosite.dat') 12 | const writer = fs.createWriteStream(dstPath) 13 | console.log('Downloading', geositeUrl) 14 | const resp = await axios({ 15 | url: geositeUrl, 16 | method: 'GET', 17 | responseType: 'stream', 18 | onDownloadProgress: (e) => { 19 | console.log(e) 20 | } 21 | }) 22 | resp.data.pipe(writer) 23 | writer.on('finish', () => { 24 | console.log('Saved file', dstPath) 25 | }) 26 | writer.on('error', (err) => { 27 | console.log('Download geosite.dat failed.', err) 28 | }) 29 | } 30 | 31 | function findInDir (dir, filter, fileList = []) { 32 | const files = fs.readdirSync(dir); 33 | 34 | files.forEach((file) => { 35 | const filePath = path.join(dir, file); 36 | const fileStat = fs.lstatSync(filePath); 37 | 38 | if (fileStat.isDirectory()) { 39 | findInDir(filePath, filter, fileList); 40 | } else if (filter.test(filePath)) { 41 | fileList.push(filePath); 42 | } 43 | }); 44 | 45 | return fileList; 46 | } 47 | 48 | async function downloadGeommdb() { 49 | const tmpFolder = path.join(os.tmpdir(), 'mellow') 50 | if (!fs.existsSync(tmpFolder)) { 51 | fs.mkdirSync(tmpFolder) 52 | } 53 | const dlname = 'mmdb.tar.gz' 54 | const tarPath = path.join(tmpFolder, dlname) 55 | const writer = fs.createWriteStream(tarPath) 56 | console.log('Downloading', mmdbUrl) 57 | const resp = await axios({ 58 | url: mmdbUrl, 59 | method: 'GET', 60 | responseType: 'stream', 61 | onDownloadProgress: (e) => { 62 | console.log(e) 63 | } 64 | }) 65 | resp.data.pipe(writer) 66 | writer.on('finish', () => { 67 | console.log('Saved file', tarPath) 68 | targz.decompress({ 69 | src: tarPath, 70 | dest: tmpFolder 71 | }, (err) => { 72 | if (err) { 73 | console.log('Failed to extract mmdb.', err) 74 | process.exit(1) 75 | } else { 76 | const dstPath = path.join(__dirname, '../src/helper', 'geo.mmdb') 77 | const fileName = 'GeoLite2-Country.mmdb' 78 | const files = findInDir(tmpFolder, /\.mmdb/) 79 | if (files.length != 1) { 80 | console.log('mmdb file not found.') 81 | process.exit(1) 82 | } 83 | fs.renameSync(files[0], dstPath) 84 | console.log('Saved file', dstPath) 85 | } 86 | }) 87 | 88 | }) 89 | writer.on('error', (err) => { 90 | console.log('Download Geo mmdb failed.', err) 91 | }) 92 | } 93 | 94 | downloadGeosite() 95 | downloadGeommdb() 96 | -------------------------------------------------------------------------------- /scripts/run_linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | EXE_FILE=core 4 | CONFIG_FILE=cfg.json 5 | GEOSITE_FILE=geosite.dat 6 | GEOMMDB_FILE=geo.mmdb 7 | DNS1=8.8.8.8 8 | DNS2=8.8.4.4 9 | TUN_GW=10.255.0.1 10 | TUN_ADDR=10.255.0.2 11 | TUN_MASK=255.255.255.0 12 | ORIG_GW=`ip route get 1 | awk '{print $3;exit}'` 13 | ORIG_ST_SCOPE=`ip route get 1 | awk '{print $5;exit}'` 14 | ORIG_ST=`ip route get 1 | awk '{print $7;exit}'` 15 | 16 | if [ ! -z "$1" ]; then 17 | CONFIG_FILE=$1 18 | fi 19 | 20 | if [ `id -u` -ne 0 ]; then 21 | echo 'Must run as root!' 22 | exit 1 23 | fi 24 | 25 | if [ ! -f "$EXE_FILE" ]; then 26 | echo "Executable file not found!" 27 | exit 1 28 | fi 29 | 30 | if [ ! -f "$CONFIG_FILE" ]; then 31 | echo "Config file not found!" 32 | exit 1 33 | fi 34 | 35 | if [ ! -x "$EXE_FILE" ]; then 36 | echo "Run 'chmod +x $EXE_FILE' first!" 37 | exit 1 38 | fi 39 | 40 | function ask_data_file { 41 | while true; do 42 | read -p "$1 not found! Feel free to skip if you are sure your config does not depend on this file. Do you wish to proceed this program?" yn 43 | case $yn in 44 | [Yy]* ) break;; 45 | [Nn]* ) exit 1;; 46 | * ) echo "Please answer yes or no.";; 47 | esac 48 | done 49 | } 50 | 51 | if [ ! -f "$GEOSITE_FILE" ]; then 52 | ask_data_file $GEOSITE_FILE 53 | fi 54 | 55 | if [ ! -f "$GEOMMDB_FILE" ]; then 56 | ask_data_file $GEOMMDB_FILE 57 | fi 58 | 59 | echo -e "Detected send through address: \e[92m$ORIG_ST\e[0m <------------- \e[91mMake sure this is an IP address, otherwise you're in trouble.\e[0m" 60 | echo -e "Detected send through scope: \e[92m$ORIG_ST_SCOPE\e[0m <--------- \e[91mThis should be a network interface name.\e[0m" 61 | echo -e "Detected original gateway: \e[92m$ORIG_GW\e[0m <----------------- \e[91mThis should be an IP address.\e[0m" 62 | 63 | if [ -f `which realpath` ]; then 64 | CONFIG_FULL_PATH=`realpath $CONFIG_FILE` 65 | echo -e "Using config file \e[92m$CONFIG_FULL_PATH\e[0m" 66 | fi 67 | 68 | config_dns() { 69 | if [ ! -d "/tmp/mellow" ]; then 70 | mkdir /tmp/mellow 71 | fi 72 | cp /etc/resolv.conf /tmp/mellow/resolv.conf 73 | echo "nameserver $DNS1" > /etc/resolv.conf 74 | echo "nameserver $DNS2" >> /etc/resolv.conf 75 | echo -e "Using DNS \e[92m$DNS1, $DNS2\e[0m" 76 | } 77 | 78 | recover_dns() { 79 | cp /tmp/mellow/resolv.conf /etc/resolv.conf 80 | echo "DNS recovered." 81 | } 82 | 83 | config_route() { 84 | # Give some time for Mellow to open the TUN device. 85 | sleep 3 86 | ip route del default table main 87 | ip route add default via $TUN_GW table main 88 | ip route add default via $ORIG_GW dev $ORIG_ST_SCOPE table default 89 | ip rule add from $ORIG_ST table default 90 | echo "Routing table is ready." 91 | } 92 | 93 | recover_route() { 94 | ip rule del from $ORIG_ST table default 95 | ip route del default table default 96 | ip route del default table main 97 | ip route add default via $ORIG_GW table main 98 | echo "Routing table recovered." 99 | } 100 | 101 | config_dns 102 | 103 | # Configure the routing table in the background. 104 | config_route & 105 | 106 | 107 | EXE_FULL_PATH=`realpath $EXE_FILE` 108 | 109 | # Run Mellow in blocking mode. 110 | $EXE_FULL_PATH \ 111 | -tunAddr $TUN_ADDR \ 112 | -tunGw $TUN_GW \ 113 | -tunMask $TUN_MASK \ 114 | -sendThrough $ORIG_ST \ 115 | -vconfig $CONFIG_FILE \ 116 | -proxyType v2ray \ 117 | -udpTimeout 1m0s \ 118 | -relayICMP \ 119 | -loglevel info 120 | 121 | # Recover the routing table after Mellow exits. 122 | recover_route 123 | 124 | recover_dns 125 | -------------------------------------------------------------------------------- /src/assets/icon-mac.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/icon-mac.icns -------------------------------------------------------------------------------- /src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/icon.png -------------------------------------------------------------------------------- /src/assets/tray-off-icon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-off-icon-light.png -------------------------------------------------------------------------------- /src/assets/tray-off-icon-light@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-off-icon-light@2x.png -------------------------------------------------------------------------------- /src/assets/tray-off-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-off-icon.png -------------------------------------------------------------------------------- /src/assets/tray-off-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-off-icon@2x.png -------------------------------------------------------------------------------- /src/assets/tray-on-icon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-on-icon-light.png -------------------------------------------------------------------------------- /src/assets/tray-on-icon-light@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-on-icon-light@2x.png -------------------------------------------------------------------------------- /src/assets/tray-on-icon-win.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-on-icon-win.ico -------------------------------------------------------------------------------- /src/assets/tray-on-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-on-icon.png -------------------------------------------------------------------------------- /src/assets/tray-on-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/assets/tray-on-icon@2x.png -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | exports.confTemplate = `[Endpoint] 2 | Proxy-1, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:443/path?network=ws&tls=true&ws.host=example.com 3 | Direct, builtin, freedom, domainStrategy=UseIP 4 | Dns-Out, builtin, dns 5 | 6 | [RoutingRule] 7 | DOMAIN-KEYWORD, geosite:cn, Direct 8 | GEOIP, cn, Direct 9 | GEOIP, private, Direct 10 | FINAL, Proxy-1 11 | 12 | [Dns] 13 | hijack = Dns-Out 14 | 15 | [DnsServer] 16 | localhost 17 | 8.8.8.8` 18 | 19 | exports.jsonTemplate = `{ 20 | "dns": { 21 | "servers": [ 22 | "localhost", 23 | "8.8.8.8" 24 | ] 25 | }, 26 | "outbounds": [ 27 | { 28 | "protocol": "vmess", 29 | "tag": "Proxy-1", 30 | "settings": { 31 | "vnext": [ 32 | { 33 | "users": [ 34 | { 35 | "id": "75da2e14-4d08-480b-b3cb-0079a0c51275" 36 | } 37 | ], 38 | "address": "example.com", 39 | "port": 443 40 | } 41 | ] 42 | }, 43 | "streamSettings": { 44 | "network": "ws", 45 | "security": "tls", 46 | "wsSettings": { 47 | "headers": { 48 | "Host": "example.com" 49 | }, 50 | "path": "/path" 51 | } 52 | } 53 | }, 54 | { 55 | "protocol": "freedom", 56 | "tag": "Direct", 57 | "settings": { 58 | "domainStrategy": "UseIP" 59 | } 60 | }, 61 | { 62 | "protocol": "dns", 63 | "tag": "Dns-Out", 64 | "settings": {} 65 | } 66 | ], 67 | "routing": { 68 | "rules": [ 69 | { 70 | "type": "field", 71 | "outboundTag": "Dns-Out", 72 | "inboundTag": [ 73 | "tun2socks" 74 | ], 75 | "network": "udp", 76 | "port": 53 77 | }, 78 | { 79 | "type": "field", 80 | "outboundTag": "Direct", 81 | "domain": [ 82 | "geosite:cn" 83 | ] 84 | }, 85 | { 86 | "type": "field", 87 | "outboundTag": "Direct", 88 | "ip": [ 89 | "geoip:cn", 90 | "geoip:private" 91 | ] 92 | }, 93 | { 94 | "type": "field", 95 | "outboundTag": "Proxy-1", 96 | "network": "tcp,udp" 97 | } 98 | ] 99 | } 100 | }` 101 | -------------------------------------------------------------------------------- /src/config/convert.js: -------------------------------------------------------------------------------- 1 | const util = require('util') 2 | const log = require('electron-log') 3 | const base64url = require('base64url') 4 | 5 | 6 | const readSubConfigBySection = (config, sect) => { 7 | var subConfigNames = [] 8 | var currSect = '' 9 | config.match(/[^\r\n]+/g).forEach((line) => { 10 | line = removeLineComments(line) 11 | const s = getSection(line) 12 | if (s.length != 0) { 13 | currSect = s 14 | return // next 15 | } 16 | if (equalSection(currSect, sect)) { 17 | line = line.trim() 18 | if (line.includes('INCLUDE')) { 19 | const parts = line.split(',') 20 | subConfigNames.push(parts[1].trim()) 21 | } 22 | } 23 | }) 24 | return subConfigNames 25 | } 26 | 27 | const sectionAlias = [ 28 | ['RoutingRule', 'Rule'], 29 | ] 30 | 31 | const getSection = (line) => { 32 | const re = /^\s*\[\s*([^\]]*)\s*\]\s*$/ 33 | if (re.test(line)) { 34 | return line.match(re)[1] 35 | } else { 36 | return "" 37 | } 38 | } 39 | 40 | const equalSection = (s1, s2) => { 41 | s1 = s1.trim() 42 | s2 = s2.trim() 43 | for (var i = 0; i < sectionAlias.length; i++) { 44 | if (sectionAlias[i].includes(s1) && sectionAlias[i].includes(s2)) { 45 | return true 46 | } 47 | } 48 | return (s1 == s2) 49 | } 50 | 51 | const removeLineComments = (s) => { 52 | return s.replace(/;[^*]*/g, '') 53 | } 54 | 55 | const removeJsonLineComments = (s) => { 56 | s = s.replace(/#[^*]*/g, '') 57 | s = s.replace(/\/\/[^*]*/g, '') 58 | return s 59 | } 60 | 61 | const removeJsonComments = (s) => { 62 | let lines = [] 63 | s.match(/[^\r\n]+/g).forEach((line) => { 64 | lines.push(removeJsonLineComments(line)) 65 | }) 66 | return lines.join('\n') 67 | } 68 | 69 | const getLinesBySection = (conf, sect) => { 70 | var lines = [] 71 | var currSect = '' 72 | conf.match(/[^\r\n]+/g).forEach((line) => { 73 | line = removeLineComments(line) 74 | const s = getSection(line) 75 | if (s.length != 0) { 76 | currSect = s 77 | return // next 78 | } 79 | if (equalSection(currSect, sect)) { 80 | line = line.trim() 81 | if (line.length != 0) { 82 | lines.push(line) 83 | } 84 | } 85 | }) 86 | return lines 87 | } 88 | 89 | const equalRuleType = (t1, t2) => { 90 | if (t1.includes('DOMAIN') && t2.includes('DOMAIN')) { 91 | return true 92 | } 93 | if (t1.includes('IP') && t2.includes('IP')) { 94 | return true 95 | } 96 | if (t1 == t2) { 97 | return true 98 | } 99 | return false 100 | } 101 | 102 | const ruleName = (type) => { 103 | if (type.includes('DOMAIN')) { 104 | return 'domain' 105 | } else if (type.includes('IP')) { 106 | return 'ip' 107 | } else if (type.includes('PROCESS')) { 108 | return 'app' 109 | } else if (type.includes('PORT')) { 110 | return 'port' 111 | } else if (type.includes('NETWORK')) { 112 | return 'network' 113 | } else { 114 | return new Error('invalid rule type') 115 | } 116 | } 117 | 118 | const nonListType = (type) => { 119 | if (type.includes('PORT') || type.includes('NETWORK') || type.includes('FINAL')) { 120 | return true 121 | } 122 | return false 123 | } 124 | 125 | const ruleFilter = (type, filters) => { 126 | if (nonListType(type)) { 127 | if (filters.length > 1) { 128 | return new Error('more that 1 filter in non-list type rule') 129 | } 130 | return filters[0] 131 | } else { 132 | return filters 133 | } 134 | } 135 | 136 | const isBalancerTag = (tag, balancers) => { 137 | for (var i = 0; i < balancers.length; i++) { 138 | if (balancers[i].tag == tag) { 139 | return true 140 | } 141 | } 142 | return false 143 | } 144 | 145 | const constructRoutingRules = (rule, routing, subconfig, overrideTarget=undefined) => { 146 | var lastType = '' 147 | var lastTarget = '' 148 | var filters = [] 149 | var routingRules = [] 150 | rule.forEach((line) => { 151 | const parts = line.trim().split(',') 152 | if (parts.length < 2) { 153 | return // next 154 | } 155 | const type = parts[0].trim() 156 | // override target 157 | const target = overrideTarget ? overrideTarget : parts[parts.length-1].trim() 158 | if (filters.length > 0 && (nonListType(type) || !equalRuleType(type, lastType) || target !== lastTarget)) { 159 | var r = { 160 | type: 'field', 161 | } 162 | if (isBalancerTag(lastTarget, routing.balancers)) { 163 | r['balancerTag'] = lastTarget 164 | } else { 165 | r['outboundTag'] = lastTarget 166 | } 167 | r[ruleName(lastType)] = ruleFilter(lastType, filters) 168 | routingRules.push(r) 169 | 170 | lastType = '' 171 | lastTarget = '' 172 | filters = [] 173 | } 174 | 175 | if (type === 'INCLUDE') { 176 | if (parts.length <= 3) { 177 | const content = subconfig['RoutingRule'][parts[1].trim()] 178 | const routingRule = getLinesBySection(content, 'RoutingRule') 179 | const subRules = constructRoutingRules(routingRule, routing, subconfig, parts.length === 3 ? parts[2].trim() : undefined) 180 | routingRules = routingRules.concat(subRules) 181 | } else { 182 | return // next 183 | } 184 | } 185 | 186 | if (type !== 'FINAL') { 187 | if (parts.length != 3 && !overrideTarget) { 188 | return // next 189 | } 190 | } else { 191 | if (parts.length != 2) { 192 | return // next 193 | } 194 | } 195 | 196 | lastType = type 197 | lastTarget = target 198 | var filter = parts[1].trim() 199 | switch (type) { 200 | case 'DOMAIN-KEYWORD': 201 | filters.push(filter) 202 | break 203 | case 'DOMAIN-SUFFIX': 204 | filters.push(util.format('domain:%s', filter)) 205 | break 206 | case 'DOMAIN': 207 | case 'DOMAIN-FULL': 208 | filters.push(util.format('full:%s', filter)) 209 | break 210 | case 'IP-CIDR': 211 | filters.push(filter) 212 | break 213 | case 'GEOIP': 214 | filters.push(util.format('geoip:%s', filter)) 215 | break 216 | case 'PORT': 217 | filters.push(filter) 218 | break 219 | case 'PROCESS-NAME': 220 | filters.push(filter) 221 | break 222 | case 'NETWORK': 223 | filters.push(filter.split(':').join(',')) 224 | break 225 | case 'FINAL': 226 | if (routing.domainStrategy == 'IPIfNonMatch' || routing.domainStrategy == 'IPOnDemand') { 227 | filters.push('0.0.0.0/0') 228 | filters.push('::/0') 229 | lastType = 'IP-CIDR' 230 | } else { 231 | filters.push('tcp,udp') 232 | lastType = 'NETWORK' 233 | } 234 | break 235 | } 236 | }) 237 | if (filters.length > 0) { 238 | var r = { 239 | type: 'field', 240 | } 241 | if (isBalancerTag(lastTarget, routing.balancers)) { 242 | r['balancerTag'] = lastTarget 243 | } else { 244 | r['outboundTag'] = lastTarget 245 | } 246 | r[ruleName(lastType)] = ruleFilter(lastType, filters) 247 | routingRules.push(r) 248 | } 249 | return routingRules 250 | } 251 | 252 | const constructRouting = (routingConf, strategy, balancer, rule, dns, subconfig) => { 253 | var routing = { balancers: [], rules: [] } 254 | 255 | routingConf.forEach((line) => { 256 | const parts = line.trim().split('=') 257 | if (parts.length != 2) { 258 | return // next 259 | } 260 | const k = parts[0].trim() 261 | const v = parts[1].trim() 262 | switch (k) { 263 | case 'domainStrategy': 264 | if (v.length > 0) { 265 | routing.domainStrategy = v 266 | } 267 | break 268 | } 269 | }) 270 | 271 | // Deprecated 272 | strategy.forEach((line) => { 273 | line = line.trim() 274 | if (line.length != 0) { 275 | routing.domainStrategy = line 276 | } 277 | }) 278 | 279 | balancer.forEach((line) => { 280 | const parts = line.trim().split(',') 281 | if (parts.length < 2) { 282 | return // next 283 | } 284 | const tag = parts[0].trim() 285 | const selectors = parts[1].trim().split(':').map(x => x.trim()) 286 | var bnc = { 287 | tag: tag, 288 | selector: selectors 289 | } 290 | if (parts.length > 2) { 291 | bnc.strategy = parts[2].trim() 292 | } 293 | switch (bnc.strategy) { 294 | case 'latency': 295 | const params = parts.slice(3, parts.length) 296 | params.forEach((p) => { 297 | const ps = p.trim().split('=') 298 | if (ps.length != 2) { 299 | return // next 300 | } 301 | key = ps[0].trim() 302 | val = ps[1].trim() 303 | switch (key) { 304 | case 'timeout': 305 | case 'interval': 306 | case 'delay': 307 | case 'tolerance': 308 | case 'totalMeasures': 309 | bnc[key] = parseInt(val) 310 | break 311 | default: 312 | bnc[key] = val 313 | } 314 | }) 315 | break 316 | } 317 | routing.balancers.push(bnc) 318 | }) 319 | 320 | routing.rules = constructRoutingRules(rule, routing, subconfig) 321 | 322 | dns.forEach((line) => { 323 | const parts = line.trim().split('=') 324 | if (parts.length != 2) { 325 | return // next 326 | } 327 | const k = parts[0].trim() 328 | const v = parts[1].trim() 329 | if (k == 'hijack' && v.length > 0) { 330 | routing.rules.unshift({ 331 | type: 'field', 332 | outboundTag: v, 333 | inboundTag: ['tun2socks'], 334 | network: 'udp', 335 | port: 53 336 | }) 337 | } 338 | }) 339 | 340 | if (routing.balancers.length == 0) { 341 | delete routing.balancers 342 | } 343 | if (routing.rules.length == 0) { 344 | delete routing.rules 345 | } 346 | return routing 347 | } 348 | 349 | const constructDns = (dnsConf, server, rule, host, clientIp) => { 350 | var dns = { servers: [] } 351 | 352 | dnsConf.forEach((line) => { 353 | const parts = line.trim().split('=') 354 | if (parts.length != 2) { 355 | return // next 356 | } 357 | const k = parts[0].trim() 358 | const v = parts[1].trim() 359 | if (k == 'clientIp' && v.length > 0) { 360 | dns.clientIp = v 361 | } 362 | }) 363 | 364 | // Deprecated 365 | if (clientIp.length == 1) { 366 | const ip = clientIp[0].trim() 367 | if (ip.length > 0) { 368 | dns.clientIp = ip 369 | } 370 | } 371 | 372 | host.forEach((line) => { 373 | const parts = line.trim().split('=') 374 | if (parts.length != 2) { 375 | return // next 376 | } 377 | const domain = parts[0].trim() 378 | const ip = parts[1].trim() 379 | if (domain.length == 0 || ip.length == 0) { 380 | return // next 381 | } 382 | if (!dns.hasOwnProperty('hosts')) { 383 | dns.hosts = {} 384 | } 385 | dns.hosts[domain] = ip 386 | }) 387 | 388 | var servers = [] 389 | var rules = [] 390 | 391 | rule.forEach((line) => { 392 | const parts = line.trim().split(',') 393 | if (parts.length != 3) { 394 | return // next 395 | } 396 | const type = parts[0].trim() 397 | var filter = parts[1].trim() 398 | const tag = parts[2].trim() 399 | switch (type) { 400 | case 'DOMAIN-SUFFIX': 401 | filter = util.format('domain:%s', filter) 402 | break 403 | case 'DOMAIN': 404 | case 'DOMAIN-FULL': 405 | filter = util.format('full:%s', filter) 406 | break 407 | } 408 | rules.push({ 409 | filter: filter, 410 | tag: tag 411 | }) 412 | }) 413 | 414 | server.forEach((line) => { 415 | const parts = line.trim().split(',') 416 | if (parts.length == 1) { 417 | if (process.platform == 'win32' && parts[0].trim() == 'localhost') { 418 | const sysDnsServers = require('dns').getServers() 419 | sysDnsServers.forEach((sysDns) => { 420 | servers.push({ 421 | address: sysDns 422 | }) 423 | }) 424 | } else { 425 | servers.push({ 426 | address: parts[0].trim() 427 | }) 428 | } 429 | } else if (parts.length == 3) { 430 | var filters = [] 431 | rules.forEach((r) => { 432 | if (r.tag == parts[2].trim()) { 433 | filters.push(r.filter) 434 | } 435 | }) 436 | servers.push({ 437 | address: parts[0].trim(), 438 | port: parseInt(parts[1].trim()), 439 | filters: filters 440 | }) 441 | } 442 | }) 443 | 444 | servers.forEach((s) => { 445 | if (s.hasOwnProperty('port') && s.hasOwnProperty('filters')) { 446 | dns.servers.push({ 447 | address: s.address, 448 | port: parseInt(s.port), 449 | domains: s.filters 450 | }) 451 | } else { 452 | dns.servers.push(s.address) 453 | } 454 | }) 455 | if (dns.servers.length == 0) { 456 | delete dns.servers 457 | } 458 | return dns 459 | } 460 | 461 | const constructLog = (logLines) => { 462 | var log = {} 463 | logLines.forEach((line) => { 464 | const parts = line.trim().split('=') 465 | if (parts.length != 2) { 466 | return // next 467 | } 468 | switch (parts[0].trim()) { 469 | case 'loglevel': 470 | log.loglevel = parts[1].trim() 471 | } 472 | }) 473 | return log 474 | } 475 | 476 | const freedomOutboundParser = (tag, params) => { 477 | var ob = { 478 | "protocol": "freedom", 479 | "tag": tag 480 | } 481 | let settings = {} 482 | params.forEach((param) => { 483 | const kv = param.trim().split('=') 484 | if (kv.length != 2) { 485 | return 486 | } 487 | switch (kv[0].trim()) { 488 | case 'domainStrategy': 489 | settings.domainStrategy = kv[1].trim() 490 | break 491 | } 492 | }) 493 | if (Object.keys(settings).length != 0) { 494 | ob.settings = settings 495 | } 496 | return ob 497 | } 498 | 499 | const blackholeOutboundParser = (tag, params) => { 500 | let ob = { 501 | "protocol": "blackhole", 502 | "tag": tag 503 | } 504 | let settings = {} 505 | params.forEach((param) => { 506 | const kv = param.trim().split('=') 507 | if (kv.length != 2) { 508 | return 509 | } 510 | switch (kv[0].trim()) { 511 | case 'type': 512 | settings.response = { type: kv[1].trim() } 513 | break 514 | } 515 | }) 516 | if (Object.keys(settings).length != 0) { 517 | ob.settings = settings 518 | } 519 | return ob 520 | } 521 | 522 | const httpAndSocksOutboundParser = (protocol, tag, params) => { 523 | var ob = { 524 | "protocol": protocol, 525 | "tag": tag 526 | } 527 | var address = '' 528 | var port = 0 529 | var user = '' 530 | var pass = '' 531 | params.forEach((param) => { 532 | const parts = param.trim().split('=') 533 | if (parts.length != 2) { 534 | return 535 | } 536 | switch (parts[0].trim()) { 537 | case 'address': 538 | address = parts[1].trim() 539 | break 540 | case 'port': 541 | port = parseInt(parts[1].trim()) 542 | break 543 | case 'user': 544 | user = parts[1].trim() 545 | break 546 | case 'pass': 547 | pass = parts[1].trim() 548 | break 549 | } 550 | }) 551 | if (!ob.hasOwnProperty('settings')) { 552 | ob.settings = { 553 | servers: [] 554 | } 555 | } 556 | var server = { 557 | address: address, 558 | port: port, 559 | users: [] 560 | } 561 | if (user.length > 0 && pass.length > 0) { 562 | server.users.push({ 563 | user: user, 564 | pass: pass 565 | }) 566 | } 567 | if (server.users.length == 0) { 568 | delete server.users 569 | } 570 | ob.settings.servers.push(server) 571 | return ob 572 | } 573 | 574 | const dnsOutboundParser = (tag, params) => { 575 | let ob = { 576 | "protocol": "dns", 577 | "tag": tag, 578 | "settings": {} 579 | } 580 | let settings = {} 581 | params.forEach((param) => { 582 | const kv = param.trim().split('=') 583 | if (kv.length != 2) { 584 | return 585 | } 586 | switch (kv[0].trim()) { 587 | case 'network': 588 | ob.settings.network = kv[1].trim() 589 | break 590 | case 'address': 591 | ob.settings.address = kv[1].trim() 592 | break 593 | case 'port': 594 | ob.settings.port = parseInt(kv[1].trim()) 595 | break 596 | } 597 | }) 598 | if (Object.keys(settings).length != 0) { 599 | ob.settings = settings 600 | } 601 | return ob 602 | } 603 | 604 | const vmess1Parser = (tag, params) => { 605 | var ob = { 606 | "protocol": "vmess", 607 | "tag": tag, 608 | "settings": { 609 | "vnext": [] 610 | }, 611 | "streamSettings": {} 612 | } 613 | if (params.length > 1) { 614 | return new Error('invalid vmess1 parameters') 615 | } 616 | const url = new URL(params[0].trim()) 617 | const uuid = url.username 618 | const address = url.hostname 619 | const port = url.port 620 | const path = url.pathname 621 | const query = decodeURIComponent(url.search.substr(1)) 622 | const tlsSettings = {} 623 | const wsSettings = {} 624 | const httpSettings = {} 625 | const kcpSettings = {} 626 | const quicSettings = {} 627 | const mux = {} 628 | const sockopt = {} 629 | let header = null 630 | ob.settings.vnext.push({ 631 | users: [{ 632 | "id": uuid 633 | }], 634 | address: address, 635 | port: parseInt(port), 636 | }) 637 | const parts = query.split('&') 638 | parts.forEach((q) => { 639 | const kv = q.split('=') 640 | switch (kv[0]) { 641 | case 'network': 642 | ob.streamSettings.network = kv[1] 643 | break 644 | case 'tls': 645 | if (kv[1] == 'true') { 646 | ob.streamSettings.security = 'tls' 647 | } else { 648 | ob.streamSettings.security = 'none' 649 | } 650 | break 651 | case 'tls.allowinsecure': 652 | if (kv[1] == 'true') { 653 | tlsSettings.allowInsecure = true 654 | } else { 655 | tlsSettings.allowInsecure = false 656 | } 657 | break 658 | case 'tls.servername': 659 | tlsSettings.serverName = kv[1] 660 | break 661 | case 'ws.host': 662 | let host = kv[1].trim() 663 | if (host.length != 0) { 664 | wsSettings.headers = { Host: host } 665 | } 666 | break 667 | case 'http.host': 668 | let hosts = [] 669 | kv[1].trim().split(',').forEach((h) => { 670 | if (h.trim().length != 0) { 671 | hosts.push(h.trim()) 672 | } 673 | }) 674 | if (hosts.length != 0) { 675 | httpSettings.host = hosts 676 | } 677 | break 678 | case 'mux': 679 | var v = parseInt(kv[1].trim()) 680 | if (v > 0) { 681 | mux.enabled = true 682 | mux.concurrency = v 683 | } 684 | break 685 | case 'sockopt.tos': 686 | var v = parseInt(kv[1].trim()) 687 | if (v > 0) { 688 | sockopt.tos = v 689 | } 690 | break 691 | case 'sockopt.tcpfastopen': 692 | if (kv[1] == 'true') { 693 | sockopt.tcpFastOpen = true 694 | } else { 695 | sockopt.tcpFastOpen = false 696 | } 697 | break 698 | // header type for both kcp and quic (maybe tcp later, not planed) 699 | case 'header': 700 | header = kv[1] 701 | break 702 | case 'kcp.mtu': 703 | kcpSettings.mtu = parseInt(kv[1].trim()) 704 | break 705 | case 'kcp.tti': 706 | kcpSettings.tti = parseInt(kv[1].trim()) 707 | break 708 | case 'kcp.uplinkcapacity': 709 | kcpSettings.uplinkCapacity = parseInt(kv[1].trim()) 710 | break 711 | case 'kcp.downlinkcapacity': 712 | kcpSettings.downlinkCapacity = parseInt(kv[1].trim()) 713 | break 714 | case 'kcp.congestion': 715 | if (kv[1] == 'true') { 716 | kcpSettings.congestion = true 717 | } else { 718 | kcpSettings.congestion = false 719 | } 720 | break 721 | case 'quic.security': 722 | quicSettings.security = kv[1].trim() 723 | break 724 | case 'quic.key': 725 | quicSettings.key = kv[1] 726 | break 727 | } 728 | }) 729 | if (Object.keys(tlsSettings).length != 0) { 730 | if (ob.streamSettings.security == 'tls') { 731 | ob.streamSettings.tlsSettings = tlsSettings 732 | } 733 | } 734 | if (Object.keys(sockopt).length != 0) { 735 | ob.streamSettings.sockopt = sockopt 736 | } 737 | switch (ob.streamSettings.network) { 738 | case 'ws': 739 | if (path.length != 0) { 740 | wsSettings.path = path 741 | } 742 | if (Object.keys(wsSettings).length != 0) { 743 | ob.streamSettings.wsSettings = wsSettings 744 | } 745 | break 746 | case 'http': 747 | case 'h2': 748 | if (path.length != 0) { 749 | httpSettings.path = path 750 | } 751 | if (Object.keys(httpSettings).length != 0) { 752 | ob.streamSettings.httpSettings = httpSettings 753 | } 754 | break 755 | case 'kcp': 756 | case 'mkcp': 757 | if (header) { 758 | kcpSettings.header = { type: header } 759 | } 760 | if (Object.keys(kcpSettings).length != 0) { 761 | ob.streamSettings.kcpSettings = kcpSettings 762 | } 763 | break 764 | case 'quic': 765 | if (header) { 766 | quicSettings.header = { type: header } 767 | } 768 | if (Object.keys(quicSettings).length != 0) { 769 | ob.streamSettings.quicSettings = quicSettings 770 | } 771 | break 772 | } 773 | if (Object.keys(mux).length != 0) { 774 | ob.mux = mux 775 | } 776 | return ob 777 | } 778 | 779 | const ssParser = (tag, params) => { 780 | var ob = { 781 | "protocol": "shadowsocks", 782 | "tag": tag, 783 | "settings": { 784 | "servers": [] 785 | } 786 | } 787 | if (params.length > 1) { 788 | return new Error('invalid shadowsocks parameters') 789 | } 790 | const url = new URL(params[0].trim()) 791 | const userInfo = url.username 792 | const address = url.hostname 793 | const port = url.port 794 | var method 795 | var password 796 | if (url.password.length == 0) { 797 | const parts = base64url.decode(decodeURIComponent(userInfo)).split(':') 798 | if (parts.length != 2) { 799 | return new Error('invalid user info') 800 | } 801 | method = parts[0] 802 | password = parts[1] 803 | } else { 804 | method = url.username 805 | password = url.password 806 | } 807 | ob.settings.servers.push({ 808 | method: method, 809 | password: password, 810 | address: address, 811 | port: parseInt(port) 812 | }) 813 | return ob 814 | } 815 | 816 | const builtinParser = (tag, params) => { 817 | switch (protocol = params[0].trim()) { 818 | case 'freedom': 819 | return freedomOutboundParser(tag, params.slice(1, params.length)) 820 | case 'blackhole': 821 | return blackholeOutboundParser(tag, params.slice(1, params.length)) 822 | case 'dns': 823 | return dnsOutboundParser(tag, params.slice(1, params.length)) 824 | case 'http': 825 | case 'socks': 826 | return httpAndSocksOutboundParser(protocol, tag, params.slice(1, params.length)) 827 | } 828 | } 829 | 830 | const parsers = { 831 | builtin: builtinParser, 832 | vmess1: vmess1Parser, 833 | ss: ssParser, 834 | } 835 | 836 | const constructOutbounds = (endpoint) => { 837 | var outbounds = [] 838 | endpoint.forEach((line) => { 839 | const parts = line.trim().split(',') 840 | if (parts.length < 2) { 841 | return // next 842 | } 843 | const tag = parts[0].trim() 844 | const parser = parts[1].trim() 845 | const params = parts.slice(2, parts.length) 846 | const p = parsers[parser] 847 | if (p instanceof Function) { 848 | let outbound = p(tag, params) 849 | outbounds.push(outbound) 850 | } else { 851 | log.warn('parser not found: ', parser) 852 | } 853 | }) 854 | return outbounds 855 | } 856 | 857 | const constructSystemInbounds = (opts) => { 858 | var inbounds = [] 859 | if (opts && opts.enabled) { 860 | inbounds = [ 861 | { 862 | "port": opts.socksPort, 863 | "protocol": "socks", 864 | "listen": "127.0.0.1", 865 | "settings": { 866 | "auth": "noauth", 867 | "udp": false 868 | } 869 | }, 870 | { 871 | "port": opts.httpPort, 872 | "protocol": "http", 873 | "listen": "127.0.0.1", 874 | "settings": {} 875 | } 876 | ] 877 | } 878 | return inbounds 879 | } 880 | 881 | const appendInbounds = (config, inbounds) => { 882 | if (inbounds.length > 0) { 883 | if (config.inbounds) { 884 | const newPorts = inbounds.map(nib => nib.port) 885 | config.inbounds = config.inbounds.filter((ib) => { 886 | return !newPorts.includes(ib.port) 887 | }) 888 | config.inbounds.push(...inbounds) 889 | } else { 890 | config['inbounds'] = inbounds 891 | } 892 | } 893 | return config 894 | } 895 | 896 | const constructJson = (conf, subConf) => { 897 | const routingDomainStrategy = getLinesBySection(conf, 'RoutingDomainStrategy') 898 | const routingConf = getLinesBySection(conf, 'Routing') 899 | const balancerRule = getLinesBySection(conf, 'EndpointGroup') 900 | const routingRule = getLinesBySection(conf, 'RoutingRule') 901 | const dnsConf = getLinesBySection(conf, 'Dns') 902 | const routing = constructRouting(routingConf, routingDomainStrategy, balancerRule, routingRule, dnsConf, subConf) 903 | 904 | const dnsServer = getLinesBySection(conf, 'DnsServer') 905 | const dnsRule = getLinesBySection(conf, 'DnsRule') 906 | const dnsHost = getLinesBySection(conf, 'DnsHost') 907 | const dnsClientIp = getLinesBySection(conf, 'DnsClientIp') 908 | const dns = constructDns(dnsConf, dnsServer, dnsRule, dnsHost, dnsClientIp) 909 | 910 | const logLines = getLinesBySection(conf, 'Log') 911 | const log = constructLog(logLines) 912 | 913 | const endpoint = getLinesBySection(conf, 'Endpoint') 914 | const outbounds = constructOutbounds(endpoint) 915 | 916 | var o = { 917 | log: log, 918 | dns: dns, 919 | outbounds: outbounds, 920 | routing: routing 921 | } 922 | 923 | for (var prop in o) { 924 | if (Object.entries(o[prop]).length === 0) { 925 | delete o[prop] 926 | } 927 | } 928 | 929 | return o 930 | } 931 | 932 | module.exports = { 933 | getLinesBySection, 934 | constructRouting, 935 | constructDns, 936 | constructLog, 937 | constructOutbounds, 938 | constructJson, 939 | constructSystemInbounds, 940 | appendInbounds, 941 | removeJsonComments, 942 | readSubConfigBySection 943 | } 944 | 945 | if (typeof require !== 'undefined' && require.main === module) { 946 | const readline = require('readline') 947 | const rl = readline.createInterface({ 948 | input: process.stdin, 949 | output: process.stdout, 950 | terminal: false 951 | }) 952 | var lines = [] 953 | rl.on('line', (line) => { 954 | lines.push(line) 955 | }) 956 | rl.on('close', () => { 957 | console.log(JSON.stringify(constructJson(lines.join('\n')), null, 2)) 958 | }) 959 | } 960 | -------------------------------------------------------------------------------- /src/helper/darwin/configure_proxy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | state=$1 4 | http_port=$2 5 | socks_port=$3 6 | 7 | services=$(networksetup -listnetworkserviceorder | grep 'Hardware Port') 8 | 9 | while read line; do 10 | sname=$(echo $line | awk -F "(, )|(: )|[)]" '{print $2}') 11 | sdev=$(echo $line | awk -F "(, )|(: )|[)]" '{print $4}') 12 | #echo "Current service: $sname, $sdev, $currentservice" 13 | if [ -n "$sdev" ]; then 14 | ifout="$(ifconfig $sdev 2>/dev/null)" 15 | echo "$ifout" | grep 'status: active' > /dev/null 2>&1 16 | rc="$?" 17 | if [ "$rc" -eq 0 ]; then 18 | currentservice="$sname" 19 | currentdevice="$sdev" 20 | currentmac=$(echo "$ifout" | awk '/ether/{print $2}') 21 | 22 | # may have multiple active devices, so echo it here 23 | # echo "$currentservice, $currentdevice, $currentmac" 24 | 25 | if [ "$state" = "on" ]; then 26 | networksetup -setwebproxy "$currentservice" "127.0.0.1" $http_port 27 | networksetup -setsecurewebproxy "$currentservice" "127.0.0.1" $http_port 28 | networksetup -setsocksfirewallproxy "$currentservice" "127.0.0.1" $socks_port 29 | elif [ "$state" = "off" ]; then 30 | networksetup -setwebproxystate "$currentservice" off 31 | networksetup -setsecurewebproxystate "$currentservice" off 32 | networksetup -setsocksfirewallproxystate "$currentservice" off 33 | else 34 | echo "invalid argument" 35 | fi 36 | fi 37 | fi 38 | done <<< "$(echo "$services")" 39 | 40 | if [ -z "$currentservice" ]; then 41 | >&2 echo "Could not find current service" 42 | exit 1 43 | fi 44 | -------------------------------------------------------------------------------- /src/helper/darwin/install_helper: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RES_PATH=$1 4 | INSTALL_PATH=$2 5 | 6 | mkdir -p "$INSTALL_PATH" 7 | 8 | cp "$RES_PATH/geosite.dat" "$INSTALL_PATH" 9 | cp "$RES_PATH/geo.mmdb" "$INSTALL_PATH" 10 | cp "$RES_PATH/core" "$INSTALL_PATH" 11 | cp "$RES_PATH/md5sum" "$INSTALL_PATH" 12 | cp "$RES_PATH/route" "$INSTALL_PATH" 13 | 14 | chown root "$INSTALL_PATH/core" 15 | chown root "$INSTALL_PATH/md5sum" 16 | chown root "$INSTALL_PATH/route" 17 | 18 | chmod +s "$INSTALL_PATH/core" 19 | chmod +s "$INSTALL_PATH/md5sum" 20 | chmod +s "$INSTALL_PATH/route" 21 | 22 | chmod +x "$INSTALL_PATH/core" 23 | chmod +x "$INSTALL_PATH/md5sum" 24 | chmod +x "$INSTALL_PATH/route" 25 | -------------------------------------------------------------------------------- /src/helper/darwin/md5sum: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ ! -f `which md5` ]]; then 4 | exit 1 5 | fi 6 | if [[ ! -f `which awk` ]]; then 7 | exit 1 8 | fi 9 | 10 | md5 "$1" | awk '{print $NF}' 11 | -------------------------------------------------------------------------------- /src/helper/darwin/route: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/helper/darwin/route -------------------------------------------------------------------------------- /src/helper/darwin/setdnsservers: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | servers=$1 4 | 5 | services=$(networksetup -listnetworkserviceorder | grep 'Hardware Port') 6 | 7 | while read line; do 8 | sname=$(echo $line | awk -F "(, )|(: )|[)]" '{print $2}') 9 | sdev=$(echo $line | awk -F "(, )|(: )|[)]" '{print $4}') 10 | #echo "Current service: $sname, $sdev, $currentservice" 11 | if [ -n "$sdev" ]; then 12 | ifout="$(ifconfig $sdev 2>/dev/null)" 13 | echo "$ifout" | grep 'status: active' > /dev/null 2>&1 14 | rc="$?" 15 | if [ "$rc" -eq 0 ]; then 16 | currentservice="$sname" 17 | currentdevice="$sdev" 18 | currentmac=$(echo "$ifout" | awk '/ether/{print $2}') 19 | 20 | # may have multiple active devices, so echo it here 21 | # echo "$currentservice, $currentdevice, $currentmac" 22 | 23 | networksetup -setdnsservers "$currentservice" $servers 24 | fi 25 | fi 26 | done <<< "$(echo "$services")" 27 | 28 | if [ -z "$currentservice" ]; then 29 | >&2 echo "Could not find current service" 30 | exit 1 31 | fi 32 | -------------------------------------------------------------------------------- /src/helper/linux/config_route: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | CMD=$1 4 | TUN_GW=$2 5 | ORIG_GW=$3 6 | ORIG_GW_SCOPE=$4 7 | ORIG_ST=$5 8 | 9 | "$CMD" route del default table main 10 | "$CMD" route add default via $TUN_GW table main 11 | "$CMD" route add default via $ORIG_GW dev $ORIG_GW_SCOPE table default 12 | "$CMD" rule add from $ORIG_ST table default 13 | -------------------------------------------------------------------------------- /src/helper/linux/install_helper: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RES_PATH=$1 4 | INSTALL_PATH=$2 5 | 6 | mkdir -p "$INSTALL_PATH" 7 | 8 | cp "$RES_PATH/geosite.dat" "$INSTALL_PATH" 9 | cp "$RES_PATH/geo.mmdb" "$INSTALL_PATH" 10 | cp "$RES_PATH/core" "$INSTALL_PATH" 11 | cp "$RES_PATH/md5sum" "$INSTALL_PATH" 12 | cp "$RES_PATH/ip" "$INSTALL_PATH" 13 | 14 | chown root "$INSTALL_PATH/core" 15 | chown root "$INSTALL_PATH/md5sum" 16 | chown root "$INSTALL_PATH/ip" 17 | 18 | chmod +s "$INSTALL_PATH/core" 19 | chmod +s "$INSTALL_PATH/md5sum" 20 | chmod +s "$INSTALL_PATH/ip" 21 | 22 | chmod +x "$INSTALL_PATH/core" 23 | chmod +x "$INSTALL_PATH/md5sum" 24 | chmod +x "$INSTALL_PATH/ip" 25 | -------------------------------------------------------------------------------- /src/helper/linux/ip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/helper/linux/ip -------------------------------------------------------------------------------- /src/helper/linux/md5sum: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ ! -f `which md5sum` ]; then 4 | exit 1 5 | fi 6 | if [ ! -f `which awk` ]; then 7 | exit 1 8 | fi 9 | 10 | md5sum $1 | awk '{print $1}' 11 | -------------------------------------------------------------------------------- /src/helper/linux/recover_route: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | CMD=$1 4 | ORIG_ST=$2 5 | ORIG_GW=$3 6 | 7 | "$CMD" rule del from $ORIG_ST table default 8 | "$CMD" route del default table default 9 | "$CMD" route del default table main 10 | "$CMD" route add default via $ORIG_GW table main 11 | -------------------------------------------------------------------------------- /src/helper/win32/config_route.bat: -------------------------------------------------------------------------------- 1 | set TUN_GW=%1 2 | set DEVICE_NAME=%2 3 | 4 | for /f "skip=3 tokens=4" %%a in ('netsh interface show interface') do ( 5 | netsh interface ipv6 set interface %%a routerdiscovery=disabled 6 | ) 7 | netsh interface ip add route 0.0.0.0/0 %DEVICE_NAME% %TUN_GW% metric=0 store=active 8 | ipconfig /flushdns 9 | -------------------------------------------------------------------------------- /src/helper/win32/configure_proxy.bat: -------------------------------------------------------------------------------- 1 | set STATE=%1 2 | set PORT=%2 3 | set PAC_URL="http://127.0.0.1:%PORT%/proxy.pac" 4 | 5 | if %STATE% == "on" ( 6 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v AutoConfigURL /t REG_SZ /d %PAC_URL% /f 7 | ) 8 | 9 | if %STATE% == "off" ( 10 | reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v AutoConfigURL /t REG_SZ /d "" /f 11 | ) 12 | -------------------------------------------------------------------------------- /src/helper/win32/ensure_tap_device.bat: -------------------------------------------------------------------------------- 1 | :: Copyright 2018 The Outline Authors 2 | :: 3 | :: Licensed under the Apache License, Version 2.0 (the "License"); 4 | :: you may not use this file except in compliance with the License. 5 | :: You may obtain a copy of the License at 6 | :: 7 | :: http://www.apache.org/licenses/LICENSE-2.0 8 | :: 9 | :: Unless required by applicable law or agreed to in writing, software 10 | :: distributed under the License is distributed on an "AS IS" BASIS, 11 | :: WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | :: See the License for the specific language governing permissions and 13 | :: limitations under the License. 14 | 15 | @echo off 16 | setlocal 17 | 18 | set TAP_WINDOWS_PATH=%1 19 | set DEVICE_NAME=%2 20 | 21 | :: Because we've seen multiple failures due to commands (netsh, etc.) not being 22 | :: found, append some common directories to the PATH. 23 | :: 24 | :: Note: 25 | :: - %SystemRoot% almost always expands to c:\windows. 26 | :: - Do *not* surround with quotes. 27 | set PATH=%PATH%;%SystemRoot%\system32;%SystemRoot%\system32\wbem;%SystemRoot%\system32\WindowsPowerShell/v1.0 28 | 29 | :: Check whether the device already exists. 30 | netsh interface show interface name=%DEVICE_NAME% >nul 31 | if %errorlevel% equ 0 ( 32 | echo TAP network device already exists. 33 | goto :configure 34 | ) 35 | 36 | :: Add the device, recording the names of devices before and after to help 37 | :: us find the name of the new device. 38 | :: 39 | :: Note: 40 | :: - While we could limit the search to devices having ServiceName=%DEVICE_HWID%, 41 | :: that will cause wmic to output just "no instances available" when there 42 | :: are no other TAP devices present, messing up the diff. 43 | :: - We do not use findstr, etc., to strip blank lines because those ancient tools 44 | :: typically don't understand/output non-Latin characters (wmic *does*, even on 45 | :: Windows 7). 46 | set BEFORE_DEVICES=%tmp%\outlineinstaller-tap-devices-before.txt 47 | set AFTER_DEVICES=%tmp%\outlineinstaller-tap-devices-after.txt 48 | 49 | echo Storing current network device list... 50 | wmic nic where "netconnectionid is not null" get netconnectionid > "%BEFORE_DEVICES%" 51 | if %errorlevel% neq 0 ( 52 | echo Could not store network device list. >&2 53 | exit /b 1 54 | ) 55 | type "%BEFORE_DEVICES%" 56 | 57 | echo Creating TAP network device... 58 | for /f "tokens=4 delims=[.] " %%i in ('ver') do ( 59 | if %%i==10 %TAP_WINDOWS_PATH%\tap-windows-9.24.2-I601-Win10.exe /S 60 | if %%i==6 %TAP_WINDOWS_PATH%\tap-windows-9.24.2-I601-Win7.exe /S 61 | ) 62 | if %errorlevel% neq 0 ( 63 | echo Could not create TAP network device. >&2 64 | exit /b 1 65 | ) 66 | 67 | echo Storing new network device list... 68 | wmic nic where "netconnectionid is not null" get netconnectionid > "%AFTER_DEVICES%" 69 | if %errorlevel% neq 0 ( 70 | echo Could not store network device list. >&2 71 | exit /b 1 72 | ) 73 | type "%AFTER_DEVICES%" 74 | 75 | :: Find the name of the new device and rename it. 76 | :: 77 | :: Obviously, this command is a beast; roughly what it does, in this order, is: 78 | :: - perform a diff on the *trimmed* (in case wmic uses different column widths) before and after 79 | :: text files 80 | :: - remove leading/trailing space and blank lines with trim() 81 | :: - store the result in NEW_DEVICE 82 | :: - print NEW_DEVICE, for debugging (though non-Latin characters may appear as ?) 83 | :: - invoke netsh 84 | :: 85 | :: Running all this in one go helps reduce the need to deal with temporary 86 | :: files and the character encoding headaches that follow. 87 | :: 88 | :: Note that we pipe input from /dev/null to prevent Powershell hanging forever 89 | :: waiting on EOF. 90 | echo Searching for new TAP network device name... 91 | powershell "(compare-object (cat \"%BEFORE_DEVICES%\" | foreach-object {$_.trim()}) (cat \"%AFTER_DEVICES%\" | foreach-object {$_.trim()}) | format-wide -autosize | out-string).trim() | set-variable NEW_DEVICE; write-host \"New TAP device name: ${NEW_DEVICE}\"; netsh interface set interface name = \"${NEW_DEVICE}\" newname = \"%DEVICE_NAME%\"" &2 94 | exit /b 1 95 | ) 96 | 97 | :: We've occasionally seen delays before netsh will "see" the new device, at least for 98 | :: purposes of configuring IP and DNS ("netsh interface show interface name=xxx" does not 99 | :: seem to be affected). 100 | echo Testing that the new TAP network device is visible to netsh... 101 | netsh interface ip show interfaces | find "%DEVICE_NAME%" >nul 102 | if %errorlevel% equ 0 goto :configure 103 | 104 | :loop 105 | echo waiting... 106 | :: timeout doesn't like the environment created by nsExec::ExecToStack and exits with: 107 | :: "ERROR: Input redirection is not supported, exiting the process immediately." 108 | waitfor /t 10 thisisnotarealsignalname >nul 2>&1 109 | netsh interface ip show interfaces | find "%DEVICE_NAME%" >nul 110 | if %errorlevel% neq 0 goto :loop 111 | 112 | :configure 113 | 114 | :: Try to enable the device, in case it's somehow been disabled. 115 | :: 116 | :: Annoyingly, this returns an error and outputs a confusing message if the device exists and is 117 | :: already enabled: 118 | :: This network connection does not exist. 119 | :: 120 | :: So, continue even if this command fails - and always include its output. 121 | echo (Re-)enabling TAP network device... 122 | netsh interface set interface "%DEVICE_NAME%" admin=disabled 123 | netsh interface set interface "%DEVICE_NAME%" admin=enabled 124 | 125 | :: Give the device an IP address. 126 | :: 10.0.85.x is a guess which we hope will work for most users (Docker for 127 | :: Windows uses 10.0.75.x by default): if the address is already in use the 128 | :: script will fail and the installer will show an error message to the user. 129 | :: TODO: Actually search the system for an unused subnet or make the subnet 130 | :: configurable in the Outline client. 131 | :: echo Configuring TAP device subnet... 132 | :: netsh interface ip set address %DEVICE_NAME% static 10.0.85.2 255.255.255.0 133 | :: if %errorlevel% neq 0 ( 134 | :: echo Could not set TAP network device subnet. >&2 135 | :: exit /b 1 136 | :: ) 137 | 138 | :: Windows has no system-wide DNS server; each network device can have its 139 | :: "own" set of DNS servers. Windows seems to use the DNS server(s) of the 140 | :: network device associated with the default gateway. This is good for us 141 | :: as it means we do not have to modify the DNS settings of any other network 142 | :: device in the system. Configure with OpenDNS and Dyn resolvers. 143 | :: echo Configuring primary DNS... 144 | :: netsh interface ip set dnsservers %DEVICE_NAME% static address=208.67.222.222 145 | :: if %errorlevel% neq 0 ( 146 | :: echo Could not configure TAP device primary DNS. >&2 147 | :: exit /b 1 148 | :: ) 149 | :: echo Configuring secondary DNS... 150 | :: netsh interface ip add dnsservers %DEVICE_NAME% 216.146.35.35 index=2 151 | :: if %errorlevel% neq 0 ( 152 | :: echo Could not configure TAP device secondary DNS. >&2 153 | :: exit /b 1 154 | :: ) 155 | 156 | echo Set all adapters metric to auto. 157 | for /f "skip=3 tokens=4" %%a in ('netsh interface show interface') do ( 158 | netsh interface ip set interface %%a metric=automatic 159 | netsh interface ipv6 set interface %%a metric=automatic 160 | ) 161 | 162 | echo Set TAP adapter metric to 0. 163 | netsh interface ip set interface %DEVICE_NAME% metric=0 164 | netsh interface ipv6 set interface %DEVICE_NAME% metric=0 165 | 166 | echo TAP network device added successfully. 167 | exit /b 0 168 | -------------------------------------------------------------------------------- /src/helper/win32/recover_route.bat: -------------------------------------------------------------------------------- 1 | set DEVICE_NAME=%1 2 | 3 | for /f "skip=3 tokens=4" %%a in ('netsh interface show interface') do ( 4 | netsh interface ipv6 set interface %%a routerdiscovery=enabled 5 | ) 6 | netsh interface ip delete route 0.0.0.0/0 %DEVICE_NAME% 7 | ipconfig /flushdns 8 | -------------------------------------------------------------------------------- /src/helper/win32/tap-windows6/tap-windows-9.24.2-I601-Win10.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/helper/win32/tap-windows6/tap-windows-9.24.2-I601-Win10.exe -------------------------------------------------------------------------------- /src/helper/win32/tap-windows6/tap-windows-9.24.2-I601-Win7.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mellow-io/mellow/e294bf078a475a35def1de93a481b45b797f933f/src/helper/win32/tap-windows6/tap-windows-9.24.2-I601-Win7.exe -------------------------------------------------------------------------------- /src/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "Connect": "Connect", 3 | "Disconnect": "Disconnect", 4 | "Reconnect": "Reconnect", 5 | "Edit Selected": "Edit Selected", 6 | "Edit Include Config": "Edit Include Config", 7 | "No selected config.": "No selected config.", 8 | "Create Config": "Create Config", 9 | "Create Conf Template": "Create Conf Template", 10 | "Create JSON Template": "Create JSON Template", 11 | "Create From URL": "Create From URL", 12 | "Download Config": "Download Config", 13 | "Config URL:": "Config URL:", 14 | "Config added as %s": "Config added as %s", 15 | "Config Folder": "Config Folder", 16 | "Preferences": "Preferences", 17 | "Auto Launch": "Auto Launch", 18 | "Auto Connect": "Auto Connect", 19 | "Check Updates": "Check Updates", 20 | "Log Level": "Log Level", 21 | "Advanced": "Advanced", 22 | "Set System DNS": "Set System DNS", 23 | "Set System DNS Resolvers": "Set System DNS Resolvers", 24 | "Comma-separated list:": "Comma-separated list:", 25 | "Domain Sniffing": "Domain Sniffing", 26 | "Fake DNS": "Fake DNS", 27 | "Reset": "Reset", 28 | "Running Config": "Running Config", 29 | "Sessions": "Sessions", 30 | "Proxy is not running.": "Proxy is not running.", 31 | "Log": "Log", 32 | "Check For Updates": "Check For Updates", 33 | "Help": "Help", 34 | "About": "About", 35 | "Quit": "Quit", 36 | "You are up-to-date!": "You are up-to-date!", 37 | "updateMessage": "A new version (%s) is available.\n\n\nRelease Notes:\n\n%s\n\n\nDownload:\n\n%s", 38 | "Please select a config.": "Please select a config.", 39 | "System Proxy": "System Proxy", 40 | "File name must end with .conf or .json": "File name must end with .conf or .json", 41 | "Rename Selected": "Rename Selected", 42 | "Rename Config": "Rename Config", 43 | "New File Name:": "New File Name:", 44 | "Set UDP Timeout": "Set UDP Timeout", 45 | "Set UDP session timeout": "Set UDP session timeout", 46 | "Duration (e.g. 5m10s):": "Duration (e.g. 5m10s):", 47 | "Hide Dock Icon": "Hide Dock Icon", 48 | "Fake DNS Excludes": "Fake DNS Excludes", 49 | "Beginning Port": "Beginning Port", 50 | "A port number": "A port number:" 51 | } 52 | -------------------------------------------------------------------------------- /src/locales/zh/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "Connect": "连接", 3 | "Disconnect": "断开连接", 4 | "Reconnect": "重新连接", 5 | "Edit Selected": "编辑选中配置", 6 | "Edit Include Config": "编辑导入配置", 7 | "No selected config.": "没有选中配置。", 8 | "Create Config": "新建配置", 9 | "Create Conf Template": "新建 Conf 配置模板", 10 | "Create JSON Template": "新建 JSON 配置模板", 11 | "Create From URL": "从 URL 链接下载配置", 12 | "Download Config": "下载配置", 13 | "Config URL:": "配置下载链接:", 14 | "Config added as %s": "已添加配置 %s", 15 | "Config Folder": "打开配置目录", 16 | "Preferences": "偏好设置", 17 | "Auto Launch": "开机启动", 18 | "Auto Connect": "启动后自动连接", 19 | "Check Updates": "自动检测更新", 20 | "Log Level": "日志级别", 21 | "Advanced": "高级设置", 22 | "Set System DNS": "自定义系统 DNS", 23 | "Set System DNS Resolvers": "设置系统 DNS 服务器地址", 24 | "Comma-separated list:": "以英文逗号分隔的列表:", 25 | "Domain Sniffing": "域名嗅探", 26 | "Fake DNS": "Fake DNS", 27 | "Reset": "重置", 28 | "Running Config": "运行配置", 29 | "Sessions": "连接记录", 30 | "Proxy is not running.": "代理没有运行。", 31 | "Log": "日志", 32 | "Check For Updates": "检测更新", 33 | "Help": "查看帮助", 34 | "About": "关于软件", 35 | "Quit": "退出", 36 | "You are up-to-date!": "已经是最新版本!", 37 | "updateMessage": "有新版本 (%s)。\n\n\n新增功能:\n\n%s\n\n\n下载:\n\n%s", 38 | "Please select a config.": "请先选择(或创建)一个配置。", 39 | "System Proxy": "系统代理", 40 | "File name must end with .conf or .json": "文件名必需以 .conf 或 .json 结尾", 41 | "Rename Selected": "重命名选中配置", 42 | "Rename Config": "重命名配置", 43 | "New File Name:": "新文件名:", 44 | "Set UDP Timeout": "设置 UDP 超时", 45 | "Set UDP session timeout": "设置 UDP 会话超时时间", 46 | "Duration (e.g. 5m10s):": "时间 (例如 5m10s):", 47 | "Hide Dock Icon": "隐藏程序坞图标", 48 | "Fake DNS Excludes": "Fake DNS 域名排除", 49 | "Beginning Port": "起始端口", 50 | "A port number": "端口号:" 51 | } 52 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // ============================= 2 | require('module-alias/register') 3 | // ============================= 4 | 5 | const electron = require('electron') 6 | const { app, systemPreferences, Menu, Tray, BrowserWindow, dialog, shell } = electron 7 | const path = require('path') 8 | const { spawn, execSync } = require('child_process') 9 | const log = require('electron-log') 10 | const sudo = require('sudo-prompt') 11 | const defaultGateway = require('default-gateway') 12 | const os = require('os') 13 | const fs = require('fs') 14 | const net = require('net') 15 | const util = require('util') 16 | const Netmask = require('netmask').Netmask 17 | const Store = require('electron-store') 18 | const AutoLaunch = require('auto-launch') 19 | const prompt = require('electron-prompt') 20 | const https = require('https') 21 | const semver = require('semver') 22 | const i18n = require('i18next') 23 | const i18nextBackend = require('i18next-node-fs-backend') 24 | 25 | const config = require('@mellow/config/config') 26 | const convert = require('@mellow/config/convert') 27 | 28 | const isDarwin = process.platform == 'darwin' 29 | const isLinux = process.platform == 'linux' 30 | const isWin32 = process.platform == 'win32' 31 | 32 | let win = null 33 | let running = false 34 | let helperVerified = false 35 | let coreNeedResume = false 36 | let tray = null 37 | let trayMenu = null 38 | let core = null 39 | let coreInterrupt = false 40 | let origGw = null 41 | let origGwScope = null 42 | let sendThrough = null 43 | var pacServer = null 44 | let originalDnsServers = null 45 | let defaultFakeDnsExcludes = (() => { 46 | switch (process.platform) { 47 | case 'win32': 48 | const domains = [ 49 | 'dns.msftncsi.com', 50 | 'msftconnecttest.com' 51 | ] 52 | return domains.join(',') 53 | default: 54 | return '' 55 | } 56 | })() 57 | 58 | var tunName 59 | switch (process.platform) { 60 | case 'darwin': 61 | tunName = 'utun233' 62 | break 63 | case 'win32': 64 | tunName = 'mellow-tap0' 65 | break 66 | case 'linux': 67 | tunName = 'tun1' 68 | break 69 | } 70 | 71 | let tunAddr = '10.255.0.2' 72 | let tunMask = '255.255.255.0' 73 | let tunGw = '10.255.0.1' 74 | var tunAddrBlock = new Netmask(tunAddr, tunMask) 75 | 76 | var localesPath 77 | if (app.isPackaged) { 78 | localesPath = path.join(process.resourcesPath, 'src/locales/{{lng}}/{{ns}}.json') 79 | } else { 80 | localesPath = path.join(__dirname, 'locales/{{lng}}/{{ns}}.json') 81 | } 82 | const i18nextOptions = { 83 | debug: true, 84 | backend: { 85 | loadPath: localesPath 86 | }, 87 | fallbackLng: 'en' 88 | } 89 | i18n.use(i18nextBackend) 90 | i18n.init(i18nextOptions) 91 | 92 | const autoLauncher = new AutoLaunch({name: 'Mellow'}) 93 | 94 | const schema = { 95 | autoLaunch: { 96 | type: 'boolean', 97 | default: false 98 | }, 99 | autoConnect: { 100 | type: 'boolean', 101 | default: false 102 | }, 103 | checkUpdates: { 104 | type: 'boolean', 105 | default: true 106 | }, 107 | loglevel: { 108 | type: 'string', 109 | default: 'info' 110 | }, 111 | configUrl: { 112 | type: 'string', 113 | default: 'https://raw.githubusercontent.com/mellow-io/mellow/master/template/example.conf' 114 | }, 115 | selectedConfig: { 116 | type: 'string', 117 | default: '' 118 | }, 119 | sniffing: { 120 | type: 'boolean', 121 | default: true 122 | }, 123 | fakeDns: { 124 | type: 'boolean', 125 | default: false 126 | }, 127 | systemDns: { 128 | type: 'string', 129 | default: '114.114.114.114,8.8.8.8' 130 | }, 131 | systemProxy: { 132 | type: 'boolean', 133 | default: true 134 | }, 135 | udpTimeout: { 136 | type: 'string', 137 | default: '1m0s' 138 | }, 139 | hideDockIcon: { 140 | type: 'boolean', 141 | default: false 142 | }, 143 | fakeDnsExcludes: { 144 | type: 'string', 145 | default: defaultFakeDnsExcludes 146 | }, 147 | beginningPort: { 148 | type: 'integer', 149 | default: 2884 150 | }, 151 | } 152 | const store = new Store({name: 'preference', schema: schema}) 153 | 154 | let beginningPort = store.get('beginningPort') 155 | let coreRpcPort = beginningPort + 0 156 | let systemProxyHttpPort = beginningPort + 1 157 | let systemProxySocksPort = beginningPort + 2 158 | let pacServerPort = beginningPort + 3 159 | 160 | function resetAutoLaunch() { 161 | if (store.get('autoLaunch')) { 162 | autoLauncher.isEnabled().then((isEnabled) => { 163 | if (!isEnabled) { 164 | // Enabled in Mellow but found disabled in system preferences. 165 | autoLauncher.enable() 166 | } 167 | }).catch((err) => { 168 | dialog.showErrorBox('Error', 'Failed to check auto launcher status.') 169 | }) 170 | } else { 171 | autoLauncher.isEnabled().then((isEnabled) => { 172 | if (isEnabled) { 173 | // Disabled in Mellow but found enabled in system preferences. 174 | autoLauncher.disable() 175 | } 176 | }).catch((err) => { 177 | dialog.showErrorBox('Error', 'Failed to check auto launcher status.') 178 | }) 179 | } 180 | } 181 | 182 | resetAutoLaunch() 183 | 184 | var helperResourcePath 185 | if (app.isPackaged) { 186 | helperResourcePath = path.join(process.resourcesPath, 'src/helper') 187 | } else { 188 | helperResourcePath = path.join(path.join(__dirname, 'helper'), process.platform) 189 | for (let f of ['geo.mmdb', 'geosite.dat']) { 190 | src = path.join(path.join(__dirname, 'helper'), f) 191 | dst = path.join(helperResourcePath, f) 192 | if (!fs.existsSync(dst)) { 193 | fs.copyFileSync(src, dst) 194 | } 195 | } 196 | } 197 | 198 | var helperInstallPath 199 | var helperFiles 200 | var executableHelperFiles 201 | switch (process.platform) { 202 | case 'darwin': 203 | helperInstallPath = "/Library/Application Support/Mellow" 204 | helperFiles = [ 205 | 'geo.mmdb', 206 | 'geosite.dat', 207 | 'core', 208 | 'md5sum', 209 | 'route' 210 | ] 211 | executableHelperFiles = [ 212 | 'core', 213 | 'md5sum', 214 | 'route' 215 | ] 216 | break 217 | case 'linux': 218 | helperInstallPath = '/usr/local/mellow' 219 | helperFiles = [ 220 | 'geo.mmdb', 221 | 'geosite.dat', 222 | 'core', 223 | 'md5sum', 224 | 'ip' 225 | ] 226 | executableHelperFiles = [ 227 | 'core', 228 | 'md5sum', 229 | 'ip' 230 | ] 231 | break 232 | } 233 | 234 | let logPath = log.transports.file.findLogPath('Mellow') 235 | let configFolder = path.join(app.getPath('userData'), 'config') 236 | let lagecyConfigFile = path.join(app.getPath('userData'), 'cfg.json') 237 | let runningConfig = path.join(app.getPath('userData'), 'running-config.json') 238 | 239 | const createConfigFolderIfNotExists = () => { 240 | if (!fs.existsSync(configFolder)) { 241 | fs.mkdirSync(configFolder, { recursive: true }) 242 | log.info(util.format('Created config folder %s', configFolder)) 243 | } 244 | } 245 | createConfigFolderIfNotExists() 246 | 247 | const handleLagecyConfigFile = () => { 248 | if (fs.existsSync(lagecyConfigFile)) { 249 | const newPath = path.join(configFolder, 'cfg.json') 250 | fs.renameSync(lagecyConfigFile, newPath) 251 | log.info(util.format('Renamed lagecy config file %s to %s', lagecyConfigFile, newPath)) 252 | } 253 | } 254 | handleLagecyConfigFile() 255 | 256 | var md5Cmd 257 | var routeCmd 258 | var coreCmd 259 | var setDnsCmd 260 | switch(process.platform) { 261 | case 'linux': 262 | md5Cmd = path.join(helperInstallPath, 'md5sum') 263 | coreCmd = path.join(helperInstallPath, 'core') 264 | routeCmd = path.join(helperInstallPath, 'ip') 265 | break 266 | case 'darwin': 267 | md5Cmd = path.join(helperInstallPath, 'md5sum') 268 | coreCmd = path.join(helperInstallPath, 'core') 269 | routeCmd = path.join(helperInstallPath, 'route') 270 | setDnsCmd = path.join(helperResourcePath, 'setdnsservers') 271 | break 272 | case 'win32': 273 | coreCmd = path.join(helperResourcePath, 'core.exe') 274 | break 275 | } 276 | 277 | function isDarkMode() { 278 | return (systemPreferences.getUserDefault('AppleInterfaceStyle', 'string') == 'Dark') 279 | } 280 | 281 | const trayIcon = { 282 | get on() { 283 | switch (process.platform) { 284 | case 'linux': 285 | return path.join(__dirname, 'assets/tray-on-icon.png') 286 | case 'darwin': 287 | if (isDarkMode()) { 288 | return path.join(__dirname, 'assets/tray-on-icon-light.png') 289 | } else { 290 | return path.join(__dirname, 'assets/tray-on-icon.png') 291 | } 292 | case 'win32': 293 | return path.join(__dirname, 'assets/tray-on-icon-win.ico') 294 | } 295 | }, 296 | get off() { 297 | switch (process.platform) { 298 | case 'linux': 299 | return path.join(__dirname, 'assets/tray-off-icon.png') 300 | case 'darwin': 301 | if (isDarkMode()) { 302 | return path.join(__dirname, 'assets/tray-off-icon-light.png') 303 | } else { 304 | return path.join(__dirname, 'assets/tray-off-icon.png') 305 | } 306 | case 'win32': 307 | return path.join(__dirname, 'assets/tray-off-icon.png') 308 | } 309 | } 310 | } 311 | 312 | const state = { 313 | Disconnected: 'Disconnected', 314 | Connecting: 'Connecting', 315 | Connected: 'Connected' 316 | } 317 | 318 | var currentState = state.Disconnected 319 | 320 | function isConnected() { 321 | return (currentState == state.Connected) 322 | } 323 | 324 | function setState(s) { 325 | switch (s) { 326 | case state.Disconnected: 327 | tray.setImage(trayIcon.off) 328 | break 329 | case state.Connecting: 330 | tray.setImage(trayIcon.off) 331 | break 332 | case state.Connected: 333 | tray.setImage(trayIcon.on) 334 | break 335 | default: 336 | throw 'Invalid State' 337 | } 338 | 339 | currentState = s 340 | } 341 | 342 | let themeChangedNotifier = null 343 | switch (process.platform) { 344 | case 'darwin': 345 | themeChangedNotifier = systemPreferences.subscribeNotification('AppleInterfaceThemeChangedNotification', (e, i) => { 346 | setState(currentState) 347 | }) 348 | break 349 | } 350 | 351 | function monitorPowerEvent() { 352 | electron.powerMonitor.on('lock-screen', () => { 353 | log.info('Screen locked.') 354 | }) 355 | electron.powerMonitor.on('unlock-screen', () => { 356 | log.info('Screen unlocked.') 357 | }) 358 | electron.powerMonitor.on('suspend', () => { 359 | log.info('Device suspended.') 360 | if (isWin32) { 361 | coreNeedResume = true 362 | down() 363 | } 364 | }) 365 | electron.powerMonitor.on('resume', async () => { 366 | log.info('Device resumed.') 367 | await delay(2000) 368 | up() 369 | }) 370 | } 371 | 372 | function checkHelper() { 373 | log.info('Checking helper files.') 374 | for (let f of helperFiles) { 375 | try { 376 | resourceFile = path.join(helperResourcePath, f) 377 | installedFile = path.join(helperInstallPath, f) 378 | resourceSum = execSync(util.format('"%s" "%s"', md5Cmd, resourceFile)) 379 | installedSum = execSync(util.format('"%s" "%s"', md5Cmd, installedFile)) 380 | if (resourceSum.toString() != installedSum.toString()) { 381 | log.info('md5 checksum not match:') 382 | log.info(util.format('[%s "%s"] not match [%s "%s"]', resourceFile, resourceSum, installedFile, installedSum)) 383 | return false 384 | } 385 | } catch (err) { 386 | if (err.status == 1) { 387 | dialog.showErrorBox('Error', 'Failed checksum helper files, it seems md5/md5sum or awk command is missing.') 388 | } else { 389 | log.info(err) 390 | return false 391 | } 392 | } 393 | } 394 | for (let f of executableHelperFiles) { 395 | installedFile = path.join(helperInstallPath, f) 396 | try { 397 | execSync(util.format('sh -c "[ -x \'%s\' ]"', installedFile)) 398 | } catch (err) { 399 | log.info('File requires execute permission', installedFile) 400 | return false 401 | } 402 | } 403 | return true 404 | } 405 | 406 | function startPacServer() { 407 | const requestListener = (req, res) => { 408 | const script = util.format('function FindProxyForURL(url, host) { return "SOCKS5 127.0.0.1:%s; SOCKS 127.0.0.1:%s" }', systemProxySocksPort, systemProxySocksPort) 409 | console.log(req.url) 410 | res.writeHead(200, { 411 | 'Content-Type': 'application/x-ns-proxy-autoconfig' 412 | }) 413 | res.write(script) 414 | res.end() 415 | } 416 | const http = require('http') 417 | pacServer = http.createServer(requestListener) 418 | pacServer.listen(pacServerPort, '127.0.0.1') 419 | } 420 | 421 | function stopPacServer() { 422 | if (pacServer) { 423 | pacServer.close() 424 | } 425 | } 426 | 427 | function configureSystemProxy(enabled) { 428 | switch (process.platform) { 429 | case 'darwin': 430 | var configureProxy = path.join(helperResourcePath, 'configure_proxy') 431 | var configureProxyCmd = util.format('"%s" "%s"', configureProxy, enabled ? 'on' : 'off', systemProxyHttpPort, systemProxySocksPort) 432 | log.info(util.format('Set system proxy with command: %s', configureProxyCmd)) 433 | execSync(configureProxyCmd) 434 | break 435 | case 'win32': 436 | if (enabled) { 437 | startPacServer() 438 | } else { 439 | stopPacServer() 440 | } 441 | var configureProxy = path.join(helperResourcePath, 'configure_proxy.bat') 442 | var configureProxyCmd = util.format('"%s" "%s" %s', configureProxy, enabled ? 'on' : 'off', pacServerPort) 443 | log.info(util.format('Set system proxy with command: %s', configureProxyCmd)) 444 | execSync(configureProxyCmd) 445 | break 446 | } 447 | } 448 | 449 | async function startCore(callback) { 450 | coreInterrupt = false 451 | 452 | var v2json 453 | 454 | const selectedConfig = store.get('selectedConfig') 455 | if (selectedConfig.length == 0) { 456 | dialog.showMessageBox({ message: i18n.t('Please select a config.') }) 457 | return 458 | } 459 | if (selectedConfig.includes('.conf')) { 460 | try { 461 | const content = fs.readFileSync(selectedConfig, 'utf-8') 462 | let subConfig = {} 463 | 464 | routingRuleSubConfig = convert.readSubConfigBySection(content, 'RoutingRule') 465 | if (routingRuleSubConfig.length != 0) { 466 | subConfig['RoutingRule'] = {} 467 | routingRuleSubConfig.forEach((filename) => { 468 | subConfig['RoutingRule'][filename] = fs.readFileSync(path.join(configFolder, filename), 'utf-8') 469 | }) 470 | } 471 | 472 | v2json = convert.constructJson(content, subConfig) 473 | } catch(err) { 474 | dialog.showErrorBox('Error', 'Config error: ' + err) 475 | return 476 | } 477 | } else if (selectedConfig.includes('.json')) { 478 | try { 479 | var content = fs.readFileSync(selectedConfig, 'utf-8') 480 | content = convert.removeJsonComments(content) 481 | v2json = JSON.parse(content) 482 | } catch (err) { 483 | dialog.showErrorBox('Error', 'Config error: ' + err) 484 | return 485 | } 486 | } else { 487 | dialog.showErrorBox('Config Error', 'Unknown config suffix') 488 | return 489 | } 490 | if (store.get('systemProxy')) { 491 | const systemProxyOpts = { 492 | enabled: store.get('systemProxy'), 493 | httpPort: systemProxyHttpPort, 494 | socksPort: systemProxySocksPort 495 | } 496 | const inbounds = convert.constructSystemInbounds(systemProxyOpts) 497 | v2json = convert.appendInbounds(v2json, inbounds) 498 | } 499 | 500 | const parsedConfig = JSON.stringify(v2json, null, 2) 501 | 502 | if (parsedConfig) { 503 | f = fs.openSync(runningConfig, 'w') 504 | fs.writeFileSync(f, parsedConfig) 505 | fs.closeSync(f) 506 | } else { 507 | dialog.showErrorBox('Config Error', 'Parsing config failed') 508 | return 509 | } 510 | 511 | if (isWin32) { 512 | log.info('Ensuring tap device sets up correctly.') 513 | try { 514 | out = await sudoExec(util.format('"%s" "%s" %s', path.join(helperResourcePath, 'ensure_tap_device.bat'), path.join(helperResourcePath, 'tap-windows6'), tunName)) 515 | log.info(out) 516 | } catch (err) { 517 | dialog.showErrorBox('Error', 'TAP device not ready: ' + err) 518 | return 519 | } 520 | } 521 | 522 | if (isDarwin || isWin32) { 523 | configureSystemProxy(store.get('systemProxy')) 524 | } 525 | 526 | var params 527 | var cmd 528 | switch (process.platform) { 529 | case 'linux': 530 | case 'darwin': 531 | params = [ 532 | '-tunName', tunName, 533 | '-tunAddr', tunAddr, 534 | '-tunMask', tunMask, 535 | '-tunGw', tunGw, 536 | '-sendThrough', sendThrough, 537 | '-vconfig', runningConfig, 538 | '-proxyType', 'v2ray', 539 | '-udpTimeout', store.get('udpTimeout'), 540 | '-relayICMP', 541 | '-loglevel', store.get('loglevel') 542 | ] 543 | break 544 | case 'win32': 545 | // The flag order is important, some flags won't work in specific 546 | // flag order, and I don't known exactly why is it. 547 | params = [ 548 | '-tunName', tunName, 549 | '-tunAddr', tunAddr, 550 | '-tunMask', tunMask, 551 | '-tunGw', tunGw, 552 | '-tunDns', store.get('systemDns'), 553 | '-rpcPort', coreRpcPort.toString(), 554 | '-sendThrough', sendThrough, 555 | '-proxyType', 'v2ray', 556 | '-udpTimeout', store.get('udpTimeout'), 557 | '-relayICMP', 558 | '-loglevel', store.get('loglevel'), 559 | '-vconfig', runningConfig 560 | ] 561 | break 562 | } 563 | 564 | if (store.get('sniffing')) { 565 | params.push(...['-sniffingType', 'http,tls']) 566 | } else { 567 | params.push(...['-sniffingType', 'none']) 568 | } 569 | 570 | if (store.get('fakeDns')) { 571 | params.push('-fakeDns') 572 | params.push(...['-fakeDnsExcludes', store.get('fakeDnsExcludes')]) 573 | } 574 | 575 | let env = Object.create(process.env) 576 | 577 | switch (process.platform) { 578 | case 'linux': 579 | case 'darwin': 580 | env.LANG = 'en_US.UTF-8' 581 | break 582 | case 'win32': 583 | break 584 | } 585 | 586 | core = spawn(coreCmd, params, { env: env }) 587 | core.stdout.on('data', (data) => { 588 | log.info(data.toString()) 589 | }) 590 | core.stderr.on('data', (data) => { 591 | log.info(data.toString()) 592 | }) 593 | core.on('close', (code, signal) => { 594 | log.info('Core stopped, code', code, 'signal' , signal) 595 | 596 | if (coreNeedResume) { 597 | // Change status and wait for the resume event callback to be called so the core will be restarted. 598 | log.info('Core will restart upon device resume.') 599 | coreNeedResume = false 600 | core = null 601 | return 602 | } 603 | 604 | if (code && code != 0) { 605 | log.info('Core fails to startup, interrupt the starting procedure.') 606 | coreInterrupt = true 607 | core = null 608 | dialog.showErrorBox('Error', util.format('Failed to start the Core, see "%s" for more details.', logPath)) 609 | } 610 | 611 | setState(state.Disconnected) 612 | }) 613 | core.on('error', (err) => { 614 | log.info('Core errored.') 615 | coreInterrupt = true 616 | core = null 617 | if ((isDarwin || isWin32) && store.get('systemProxy')) { 618 | configureSystemProxy(false) 619 | } 620 | setState(state.Disconnected) 621 | log.info(err) 622 | dialog.showErrorBox('Error', util.format('Failed to start the Core, see "%s" for more details.', logPath)) 623 | }) 624 | log.info('Core started.') 625 | if (callback !== null) { 626 | callback() 627 | } 628 | } 629 | 630 | function isPrivateIP(ip) { 631 | return /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(ip) || 632 | /^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(ip) || 633 | /^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(ip) || 634 | /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(ip) || 635 | /^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(ip) || 636 | /^f[cd][0-9a-f]{2}:/i.test(ip) || 637 | /^fe80:/i.test(ip) || 638 | /^::1$/.test(ip) || 639 | /^::$/.test(ip) 640 | } 641 | 642 | async function configRoute() { 643 | if (coreInterrupt) { 644 | log.info('Start interrupted.') 645 | coreInterrupt = false 646 | return 647 | } 648 | 649 | switch (process.platform) { 650 | case 'linux': 651 | case 'darwin': 652 | if (tunGw === null || origGw === null || origGwScope === null) { 653 | return 654 | } 655 | break 656 | case 'win32': 657 | if (tunGw === null || origGw === null) { 658 | return 659 | } 660 | break 661 | default: 662 | dialog.showErrorBox('Error', 'Unsupported platform: ' + process.platform) 663 | } 664 | 665 | gw = null 666 | for (i = 0; i < 5; i++) { 667 | gw = getDefaultGateway() 668 | if (gw === null) { 669 | await delay(2 * 1000) 670 | log.info('Retrying to get the default gateway.') 671 | continue 672 | } 673 | break 674 | } 675 | log.info('The default gateway before configuring routes:') 676 | log.info(gw) 677 | if (gw === null) { 678 | dialog.showErrorBox('Error', util.format('Failed to find the default gateway, see "%s" for more details.', logPath)) 679 | } 680 | 681 | // Try to find the TUN interface, it must exists and up before we can 682 | // add routes to it. We now wait for the core to open the device. 683 | tunIface = null 684 | for (i = 0; i < 10; i++) { 685 | tunIface = findTunInterface() 686 | if (tunIface === null) { 687 | await delay(2 * 1000) 688 | log.info('Retrying to find the TUN interface.') 689 | continue 690 | } 691 | break 692 | } 693 | if (tunIface === null) { 694 | dialog.showErrorBox('Error', util.format('Failed to find the TUN interface, see "%s" for more details.', logPath)) 695 | return 696 | } 697 | log.info('The TUN interface before configuring routes:') 698 | log.info(tunIface) 699 | 700 | try { 701 | switch (process.platform) { 702 | case 'darwin': 703 | execSync(util.format('"%s" delete default', routeCmd)) 704 | execSync(util.format('"%s" delete default -ifscope %s', routeCmd, origGwScope)) 705 | execSync(util.format('"%s" add default %s', routeCmd, tunGw)) 706 | execSync(util.format('"%s" add default %s -ifscope %s', routeCmd, origGw, origGwScope)) 707 | 708 | const dnsServers = require('dns').getServers() 709 | if ((dnsServers.length == 0) || (isPrivateIP(dnsServers[0]))) { 710 | execSync(util.format('"%s" "%s"', setDnsCmd, store.get('systemDns').split(',').join(' '))) 711 | originalDnsServers = dnsServers 712 | log.info('Set system DNS', store.get('systemDns')) 713 | } 714 | break 715 | case 'win32': 716 | await sudoExec(util.format('"%s" %s %s', path.join(helperResourcePath, 'config_route.bat'), tunGw, tunName)) 717 | break 718 | case 'linux': 719 | execSync(util.format('"%s" %s %s %s %s %s', path.join(helperResourcePath, 'config_route'), routeCmd, tunGw, origGw, origGwScope, sendThrough)) 720 | break 721 | } 722 | log.info('Set ' + tunGw + ' as the default gateway.') 723 | } catch (err) { 724 | log.info(err) 725 | log.info(err) 726 | dialog.showErrorBox('Error', util.format('Failed to configure routes, see "%s" for more details.', logPath)) 727 | } 728 | 729 | setState(state.Connected) 730 | trayMenu.items[0].enabled = false 731 | trayMenu.items[1].enabled = true 732 | trayMenu.items[2].enabled = true 733 | tray.setContextMenu(trayMenu) 734 | } 735 | 736 | async function recoverRoute() { 737 | if (origGw !== null) { 738 | log.info('Restore ' + origGw + ' as the default gateway.') 739 | try { 740 | switch (process.platform) { 741 | case 'darwin': 742 | execSync(util.format('"%s" delete default', routeCmd)) 743 | execSync(util.format('"%s" delete default -ifscope %s', routeCmd, origGwScope)) 744 | execSync(util.format('"%s" add default %s', routeCmd, origGw)) 745 | 746 | if (originalDnsServers) { 747 | execSync(util.format('"%s" "%s"', setDnsCmd, originalDnsServers.join(' '))) 748 | log.info('Recover system DNS servers to', originalDnsServers.join(' ')) 749 | } 750 | break 751 | case 'win32': 752 | await sudoExec(util.format('"%s" %s', path.join(helperResourcePath, 'recover_route.bat'), tunName)) 753 | break 754 | case 'linux': 755 | execSync(util.format('"%s" %s %s', path.join(helperResourcePath, 'recover_route'), routeCmd, sendThrough, origGw)) 756 | break 757 | } 758 | } catch (error) { 759 | log.info(error.stdout) 760 | log.info(error.stderr) 761 | dialog.showErrorBox('Error', util.format('Failed to configure routes, see "%s" for more details.', logPath)) 762 | } 763 | } else { 764 | dialog.showErrorBox('Error', 'Failed to recover original network, original gateway is missing.') 765 | } 766 | } 767 | 768 | function stopCoreWindows() { 769 | return new Promise((resolve, reject) => { 770 | // We want a graceful shutdown for the core, but sending signals 771 | // not work on Windows, use TCP instead. 772 | c = new net.Socket() 773 | c.connect(coreRpcPort, '127.0.0.1', () => { 774 | c.write('SIGINT') 775 | }) 776 | c.on('data', (data) => { 777 | if (data.toString() == 'OK') { 778 | c.destroy() 779 | core = null 780 | resolve() 781 | } 782 | }) 783 | c.on('error', (err) => { 784 | log.info('management RPC error:') 785 | log.info(err) 786 | reject() 787 | }) 788 | }) 789 | } 790 | 791 | async function stopCore() { 792 | if (core !== null) { 793 | if (isWin32) { 794 | await stopCoreWindows() 795 | } else { 796 | core.kill('SIGTERM') 797 | core = null 798 | } 799 | } 800 | if ((isDarwin || isWin32) && store.get('systemProxy')) { 801 | configureSystemProxy(false) 802 | } 803 | setState(state.Disconnected) 804 | } 805 | 806 | const delay = ms => new Promise(res => setTimeout(res, ms)) 807 | 808 | async function up() { 809 | switch (process.platform) { 810 | case 'darwin': 811 | case 'linux': 812 | if (!helperVerified) { 813 | if (!checkHelper()) { 814 | success = await installHelper() 815 | if (!success) { 816 | return 817 | } 818 | } 819 | helperVerified = true 820 | } 821 | break 822 | } 823 | 824 | gw = null 825 | for (i = 0; i < 5; i++) { 826 | gw = getDefaultGateway() 827 | if (gw === null) { 828 | await delay(1000) 829 | log.info('Retrying to get the default gateway.') 830 | continue 831 | } 832 | break 833 | } 834 | if (gw === null) { 835 | // Default gateway is missing, already tried 5 times to get it and 836 | // all failed, better to show an error to user and stop the core 837 | // for the moment. 838 | stopCore() 839 | dialog.showErrorBox('Error', 'Failed to find the default gateway, please ensure your network is reachable. You may try to restart/reconnect the network/wifi.') 840 | return 841 | } else if (tunAddrBlock.contains(gw['gateway'])) { 842 | // Routing seems ready, check if the core should restart. 843 | if (core === null) { 844 | startCore(null) 845 | running = true 846 | return 847 | } 848 | return 849 | } else { 850 | // This is the original gateway. 851 | origGw = gw['gateway'] 852 | log.info('Original gateway is ' + origGw) 853 | st = null 854 | for (i = 0; i < 5; i++) { 855 | st = findOriginalSendThrough(gw) 856 | if (st === null) { 857 | await delay(1000) 858 | log.info('Retrying to find the original send through address.') 859 | continue 860 | } 861 | break 862 | } 863 | if (st !== null) { 864 | log.info('Original send through ' + st['address'] + ' ' + st['interface']) 865 | sendThrough = st['address'] 866 | origGwScope = st['interface'] 867 | } else { 868 | log.info('Can not find original send through.') 869 | sendThrough = null 870 | origGwScope = null 871 | stopCore() 872 | return 873 | } 874 | 875 | // Original gateway and original send through were found, start the core 876 | // if necessary. 877 | if (core === null) { 878 | startCore(configRoute) 879 | running = true 880 | } else { 881 | // Core is running but the default gateway is not the tun interface, 882 | // it's very likely network has been reset due to network changes. 883 | // And the original gateway is also very likely point to a different 884 | // IP, we must restart the core and pass the correct send through address. 885 | await stopCore() 886 | startCore(configRoute) 887 | running = true 888 | } 889 | } 890 | } 891 | 892 | async function down() { 893 | log.info('Shutting down the core.') 894 | 895 | // Get the gateway first since stopping the core may causes 896 | // the route to be deleted. 897 | gw = getDefaultGateway() 898 | 899 | if (core) { 900 | await stopCore() 901 | } 902 | 903 | // Recover default route only if current route is to tunGw. 904 | if (gw !== null && tunAddrBlock.contains(gw['gateway'])) { 905 | recoverRoute() 906 | } 907 | 908 | running = false 909 | 910 | setState(state.Disconnected) 911 | 912 | log.info('Core downed.') 913 | 914 | trayMenu.items[0].enabled = true 915 | trayMenu.items[1].enabled = false 916 | trayMenu.items[2].enabled = false 917 | tray.setContextMenu(trayMenu) 918 | } 919 | 920 | // {gateway: '1.2.3.4', interface: 'en1'} 921 | function getDefaultGateway() { 922 | try { 923 | return defaultGateway.v4.sync() 924 | } catch(error) { 925 | return null 926 | } 927 | } 928 | 929 | // {address: '192.168.1.1', interface: 'en0'} 930 | function findOriginalSendThrough(gw) { 931 | if (gw === null) { 932 | return null 933 | } 934 | 935 | ifaces = os.networkInterfaces() 936 | for (name in ifaces) { 937 | if (name == gw['interface']) { 938 | for (let info of ifaces[name]) { 939 | if (info['family'] == 'IPv4' && !info['internal'] && info['cidr'] !== undefined) { 940 | block = new Netmask(info['cidr']) 941 | if (block.contains(gw['gateway'])) { 942 | return {address: info['address'], interface: name} 943 | } 944 | } 945 | } 946 | } 947 | } 948 | 949 | return null 950 | } 951 | 952 | function findTunInterface() { 953 | ifaces = os.networkInterfaces() 954 | for (k in ifaces) { 955 | for (let addrObj of ifaces[k]) { 956 | cidr = addrObj['cidr'] 957 | if (addrObj['family'] == 'IPv4' && !addrObj['internal'] && cidr !== undefined) { 958 | block = new Netmask(cidr) 959 | if (block.contains(tunGw)) { 960 | return {address: addrObj['address'], interface: k} 961 | } 962 | } 963 | } 964 | } 965 | 966 | return null 967 | } 968 | 969 | async function sudoExec(cmd) { 970 | return new Promise((resolve, reject) => { 971 | var options = { name: 'Mellow' } 972 | sudo.exec(cmd, options, (err, stdout, stderr) => { 973 | if (err) { 974 | log.info(stderr) 975 | log.info(stdout) 976 | reject(err) 977 | } 978 | resolve(stdout) 979 | }) 980 | }) 981 | } 982 | 983 | async function installHelper() { 984 | log.info('Installing helper.') 985 | 986 | var installer 987 | var cmd 988 | 989 | if (isLinux) { 990 | let tmpResDir = '/tmp/mellow_helper_res' 991 | execSync(util.format('cp -r "%s" "%s"', helperResourcePath, tmpResDir)) 992 | installer = path.join(tmpResDir, 'install_helper') 993 | cmd = util.format('"%s" "%s" "%s"', installer, tmpResDir, helperInstallPath) 994 | } else { 995 | installer = path.join(helperResourcePath, 'install_helper') 996 | cmd = util.format('"%s" "%s" "%s"', installer, helperResourcePath, helperInstallPath) 997 | } 998 | 999 | log.info('Executing:', cmd) 1000 | 1001 | try { 1002 | await sudoExec(cmd) 1003 | log.info('Helper installed.') 1004 | return true 1005 | } catch (err) { 1006 | dialog.showErrorBox('Error', 'Failed to install helper: ' + err) 1007 | return false 1008 | } 1009 | } 1010 | 1011 | function createConfigFileIfNotExists() { 1012 | if (!fs.existsSync(configFile)) { 1013 | if (!fs.existsSync(configFolder)) { 1014 | fs.mkdirSync(configFolder, { recursive: true }) 1015 | } 1016 | fd = fs.openSync(configFile, 'w') 1017 | fs.writeSync(fd, config.jsonTemplate) 1018 | fs.closeSync(fd) 1019 | } 1020 | } 1021 | 1022 | function checkForUpdates(silent) { 1023 | opt = { 1024 | headers: { 1025 | 'User-Agent': 'Mellow' 1026 | } 1027 | } 1028 | https.get('https://api.github.com/repos/mellow-io/mellow/releases/latest', opt, (res) => { 1029 | if (res.statusCode != 200) { 1030 | if (!silent) { 1031 | dialog.showErrorBox('Error', 'HTTP GET failed, status: ' + res.statusCode) 1032 | } 1033 | return 1034 | } 1035 | var body = '' 1036 | res.on('data', (data) => { 1037 | body += data 1038 | }) 1039 | res.on('end', () => { 1040 | obj = JSON.parse(body) 1041 | latestVer = semver.clean(obj['tag_name']) 1042 | ver = app.getVersion() 1043 | if (ver != latestVer) { 1044 | dialog.showMessageBox({ message: util.format(i18n.t('updateMessage'), latestVer, obj['body'], obj['html_url']) }) 1045 | } else { 1046 | if (!silent) { 1047 | dialog.showMessageBox({ message: i18n.t('You are up-to-date!') }) 1048 | } 1049 | } 1050 | }) 1051 | }).on('error', (err) => { 1052 | if (!silent) { 1053 | dialog.showErrorBox('Error', 'HTTP GET failed: ' + err) 1054 | } 1055 | }) 1056 | } 1057 | 1058 | async function reconnect() { 1059 | await down() 1060 | up() 1061 | } 1062 | 1063 | function getFormattedTime() { 1064 | var today = new Date(); 1065 | var y = today.getFullYear(); 1066 | // JavaScript months are 0-based. 1067 | var m = today.getMonth() + 1; 1068 | var d = today.getDate(); 1069 | var h = today.getHours(); 1070 | var mi = today.getMinutes(); 1071 | var s = today.getSeconds(); 1072 | return y + "-" + m + "-" + d + "-" + h + "-" + mi + "-" + s; 1073 | } 1074 | 1075 | function buildTrayMenu() { 1076 | var mainMenus = [] 1077 | mainMenus = [ 1078 | ...mainMenus, 1079 | { label: i18n.t('Connect'), type: 'normal', enabled: !isConnected(), click: function() { 1080 | up() 1081 | } 1082 | }, 1083 | { label: i18n.t('Disconnect'), type: 'normal', enabled: isConnected(), click: function() { 1084 | down() 1085 | } 1086 | }, 1087 | { label: i18n.t('Reconnect'), type: 'normal', enabled: isConnected(), click: function() { 1088 | reconnect() 1089 | } 1090 | } 1091 | ] 1092 | 1093 | mainMenus.push({ type: 'separator' }) 1094 | 1095 | mainMenus.push({ 1096 | label: i18n.t('System Proxy'), 1097 | type: 'checkbox', 1098 | click: (item) => { 1099 | store.set('systemProxy', item.checked) 1100 | if (isConnected()) { 1101 | reconnect() 1102 | } 1103 | }, 1104 | checked: store.get('systemProxy'), 1105 | visible: isDarwin || isWin32 1106 | }, { 1107 | type: 'separator', 1108 | visible: isDarwin || isWin32 1109 | }) 1110 | 1111 | const configs = fs.readdirSync(configFolder).filter(x => (x.match(/^[^.].*(\.conf|\.json)$/g))) 1112 | configs.forEach((config) => { 1113 | mainMenus.push({ 1114 | label: config, 1115 | type: 'radio', 1116 | checked: ((store.get('selectedConfig').length > 0) && (config == store.get('selectedConfig').replace(/^.*[\\\/]/, ''))), 1117 | click: function() { 1118 | const fullpath = path.join(configFolder, config) 1119 | store.set('selectedConfig', fullpath) 1120 | reloadTray() 1121 | if (isConnected()) { 1122 | reconnect() 1123 | } 1124 | } 1125 | }) 1126 | }) 1127 | if (configs.length > 0) { 1128 | mainMenus.push({ type: 'separator' }) 1129 | } 1130 | const subConfigs = fs.readdirSync(configFolder).filter(x => (x.match(/^[^.].*(\.list)$/g))) 1131 | 1132 | mainMenus.push({ 1133 | label: i18n.t('Edit Selected'), 1134 | type: 'normal', 1135 | click: function() { 1136 | const config = store.get('selectedConfig') 1137 | if (config.length > 0) { 1138 | shell.openItem(config) 1139 | } else { 1140 | dialog.showMessageBox({message: i18n.t('No selected config.')}) 1141 | return 1142 | } 1143 | } 1144 | }, { 1145 | label: i18n.t('Rename Selected'), 1146 | type: 'normal', 1147 | click: function() { 1148 | prompt({ 1149 | title: i18n.t('Rename Config'), 1150 | label: i18n.t('New File Name:'), 1151 | value: path.basename(store.get('selectedConfig')), 1152 | inputAttrs: { 1153 | type: 'text', 1154 | required: true 1155 | } 1156 | }) 1157 | .then((r) => { 1158 | if (!r) { 1159 | return 1160 | } 1161 | if (!r.match(/^[^.].*(\.conf|\.json)$/g)) { 1162 | dialog.showErrorBox('Error', i18n.t('File name must end with .conf or .json')) 1163 | return 1164 | } 1165 | let newFile = path.join(configFolder, r) 1166 | fs.rename(store.get('selectedConfig'), newFile, (err) => { 1167 | if (err) { 1168 | dialog.showErrorBox('Error', 'Rename file failed: ' + err) 1169 | } else { 1170 | store.set('selectedConfig', newFile) 1171 | } 1172 | }) 1173 | }) 1174 | .catch((err) => { 1175 | dialog.showErrorBox('Error', 'Failed to rename config: ' + err) 1176 | }) 1177 | } 1178 | }, { 1179 | label: i18n.t('Create Config'), 1180 | type: 'submenu', 1181 | submenu: Menu.buildFromTemplate([{ 1182 | label: i18n.t('Create Conf Template'), 1183 | type: 'normal', 1184 | click: () => { 1185 | f = fs.openSync(path.join(configFolder, getFormattedTime() + '.conf'), 'w+') 1186 | fs.writeFileSync(f, config.confTemplate) 1187 | fs.closeSync(f) 1188 | reloadTray() 1189 | } 1190 | }, { 1191 | label: i18n.t('Create JSON Template'), 1192 | type: 'normal', 1193 | click: () => { 1194 | f = fs.openSync(path.join(configFolder, getFormattedTime() + '.json'), 'w+') 1195 | fs.writeFileSync(f, config.jsonTemplate) 1196 | fs.closeSync(f) 1197 | reloadTray() 1198 | } 1199 | }, { 1200 | type: 'separator' 1201 | }, { 1202 | label: i18n.t('Create From URL'), 1203 | type: 'normal', 1204 | click: () => { 1205 | prompt({ 1206 | title: i18n.t('Download Config'), 1207 | label: i18n.t('Config URL:'), 1208 | value: store.get('configUrl'), 1209 | inputAttrs: { 1210 | type: 'url' 1211 | } 1212 | }) 1213 | .then((r) => { 1214 | if (r) { 1215 | opt = { 1216 | timeout: 15 * 1000 1217 | } 1218 | https.get(r, opt, (res) => { 1219 | if (res.statusCode != 200) { 1220 | dialog.showErrorBox('Error', 'HTTP GET failed, status: ' + res.statusCode) 1221 | return 1222 | } 1223 | var body = '' 1224 | res.on('data', (data) => { 1225 | body += data 1226 | }) 1227 | res.on('end', () => { 1228 | var filename 1229 | if (body.toString().trim().startsWith('{')) { 1230 | filename = getFormattedTime() + '.json' 1231 | } else { 1232 | filename = getFormattedTime() + '.conf' 1233 | } 1234 | fd = fs.openSync(path.join(configFolder, filename), 'w') 1235 | fs.writeSync(fd, body) 1236 | fs.closeSync(fd) 1237 | store.set('configUrl', r) 1238 | dialog.showMessageBox({ message: util.format(i18n.t('Config added as %s'), filename) }) 1239 | reloadTray() 1240 | }) 1241 | res.on('timeout', ()=> { 1242 | dialog.showErrorBox('Error', 'HTTP GET timeout') 1243 | }) 1244 | }).on('error', (err) => { 1245 | dialog.showErrorBox('Error', 'HTTP GET failed: ' + err) 1246 | }) 1247 | } 1248 | }) 1249 | .catch((err) => { 1250 | dialog.showErrorBox('Error', 'Failed to download config: ' + err) 1251 | }) 1252 | } 1253 | }]) 1254 | }, { 1255 | label: i18n.t('Edit Include Config'), 1256 | type: 'submenu', 1257 | submenu: Menu.buildFromTemplate(subConfigs.map((config) => { 1258 | return { 1259 | label: config, 1260 | type: 'normal', 1261 | click: function() { 1262 | const fullpath = path.join(configFolder, config) 1263 | shell.openItem(fullpath) 1264 | } 1265 | } 1266 | })) 1267 | }) 1268 | mainMenus.push({ 1269 | label: i18n.t('Config Folder'), 1270 | type: 'normal', 1271 | click: () => { shell.openItem(configFolder) } 1272 | }) 1273 | 1274 | mainMenus.push({ type: 'separator' }) 1275 | 1276 | var otherMenus = [{ 1277 | label: i18n.t('Preferences'), 1278 | type: 'submenu', 1279 | submenu: Menu.buildFromTemplate([ 1280 | { 1281 | label: i18n.t('Auto Launch'), 1282 | type: 'checkbox', 1283 | click: (item) => { 1284 | store.set('autoLaunch', item.checked) 1285 | resetAutoLaunch() 1286 | }, 1287 | checked: store.get('autoLaunch'), 1288 | visible: !isWin32 1289 | }, 1290 | { 1291 | label: i18n.t('Auto Connect'), 1292 | type: 'checkbox', 1293 | click: (item) => { store.set('autoConnect', item.checked) }, 1294 | checked: store.get('autoConnect') 1295 | }, 1296 | { 1297 | label: i18n.t('Check Updates'), 1298 | type: 'checkbox', 1299 | click: (item) => { store.set('checkUpdates', item.checked) }, 1300 | checked: store.get('checkUpdates') 1301 | }, 1302 | { 1303 | label: i18n.t('Hide Dock Icon'), 1304 | type: 'checkbox', 1305 | click: (item) => { 1306 | if (item.checked) { 1307 | app.dock.hide() 1308 | } else { 1309 | app.dock.show() 1310 | } 1311 | store.set('hideDockIcon', item.checked) 1312 | }, 1313 | checked: store.get('hideDockIcon'), 1314 | visible: isDarwin 1315 | }, 1316 | { 1317 | label: i18n.t('Log Level'), 1318 | type: 'submenu', 1319 | submenu: Menu.buildFromTemplate([ 1320 | { 1321 | label: 'debug', 1322 | type: 'radio', 1323 | click: () => { store.set('loglevel', 'debug') }, 1324 | checked: store.get('loglevel') == 'debug' 1325 | }, 1326 | { 1327 | label: 'info', 1328 | type: 'radio', 1329 | click: () => { store.set('loglevel', 'info') }, 1330 | checked: store.get('loglevel') == 'info' 1331 | }, 1332 | { 1333 | label: 'warn', 1334 | type: 'radio', 1335 | click: () => { store.set('loglevel', 'warn') }, 1336 | checked: store.get('loglevel') == 'warn' 1337 | }, 1338 | { 1339 | label: 'error', 1340 | type: 'radio', 1341 | click: () => { store.set('loglevel', 'error') }, 1342 | checked: store.get('loglevel') == 'error' 1343 | }, 1344 | { 1345 | label: 'none', 1346 | type: 'radio', 1347 | click: () => { store.set('loglevel', 'none') }, 1348 | checked: store.get('loglevel') == 'none' 1349 | } 1350 | ]) 1351 | }, 1352 | { type: 'separator' }, 1353 | { 1354 | label: i18n.t('Advanced'), 1355 | type: 'submenu', 1356 | submenu: Menu.buildFromTemplate([ 1357 | { 1358 | label: i18n.t('Set System DNS'), 1359 | type: 'normal', 1360 | click: (item) => { 1361 | prompt({ 1362 | title: i18n.t('Set System DNS Resolvers'), 1363 | label: i18n.t('Comma-separated list:'), 1364 | value: store.get('systemDns'), 1365 | inputAttrs: { 1366 | type: 'text' 1367 | } 1368 | }) 1369 | .then((r) => { 1370 | if (r) { 1371 | const dnsServers = r.replace(/\s/g,'').split(',') 1372 | if (dnsServers.length == 0 || isPrivateIP(dnsServers[0])) { 1373 | dialog.showMessageBox({message: 'invalid input'}) 1374 | return 1375 | } 1376 | store.set('systemDns', dnsServers.join(',')) 1377 | } 1378 | }) 1379 | }, 1380 | visible: isWin32 || isDarwin 1381 | }, 1382 | { 1383 | label: i18n.t('Set UDP Timeout'), 1384 | type: 'normal', 1385 | click: (item) => { 1386 | prompt({ 1387 | title: i18n.t('Set UDP session timeout'), 1388 | label: i18n.t('Duration (e.g. 5m10s):'), 1389 | value: store.get('udpTimeout'), 1390 | inputAttrs: { 1391 | type: 'text' 1392 | } 1393 | }) 1394 | .then((r) => { 1395 | if (r) { 1396 | // remove all whitespaces before store 1397 | store.set('udpTimeout', r.replace(/\s/g,'')) 1398 | } 1399 | }) 1400 | } 1401 | }, 1402 | { 1403 | label: i18n.t('Fake DNS Excludes'), 1404 | type: 'normal', 1405 | click: (item) => { 1406 | prompt({ 1407 | title: i18n.t('Exclude domains'), 1408 | label: i18n.t('Seperated by comma:'), 1409 | value: store.get('fakeDnsExcludes'), 1410 | inputAttrs: { 1411 | type: 'text' 1412 | } 1413 | }) 1414 | .then((r) => { 1415 | if (r === null) { 1416 | // cancel 1417 | return 1418 | } 1419 | if (r) { 1420 | let domains = r.replace(/\s/g,'').split(',') 1421 | store.set('fakeDnsExcludes', domains.join(',')) 1422 | } else { 1423 | // empty input 1424 | store.set('fakeDnsExcludes', '') 1425 | } 1426 | }) 1427 | } 1428 | }, 1429 | { 1430 | label: i18n.t('Beginning Port'), 1431 | type: 'normal', 1432 | click: (item) => { 1433 | prompt({ 1434 | title: i18n.t('Beginning Port'), 1435 | label: i18n.t('A port number:'), 1436 | value: store.get('beginningPort'), 1437 | inputAttrs: { 1438 | type: 'text' 1439 | } 1440 | }) 1441 | .then((r) => { 1442 | if (r === null) { 1443 | // cancel 1444 | return 1445 | } 1446 | if (r) { 1447 | let port = parseInt(r.replace(/\s/g,'')) 1448 | store.set('beginningPort', port) 1449 | } 1450 | }) 1451 | } 1452 | }, 1453 | { type: 'separator' }, 1454 | { 1455 | label: i18n.t('Domain Sniffing'), 1456 | type: 'checkbox', 1457 | click: (item) => { store.set('sniffing', item.checked) }, 1458 | checked: store.get('sniffing') 1459 | }, 1460 | { 1461 | label: i18n.t('Fake DNS'), 1462 | type: 'checkbox', 1463 | click: (item) => { store.set('fakeDns', item.checked) }, 1464 | checked: store.get('fakeDns') 1465 | }, 1466 | ]) 1467 | }, 1468 | { type: 'separator' }, 1469 | { 1470 | label: i18n.t('Reset'), 1471 | type: 'normal', 1472 | click: (item) => { 1473 | store.clear() 1474 | reloadTray() 1475 | } 1476 | }, 1477 | ]) 1478 | }, 1479 | { type: 'separator' }, 1480 | { 1481 | label: i18n.t('Running Config'), 1482 | type: 'normal', 1483 | click: () => { shell.openItem(runningConfig) } 1484 | }, 1485 | { label: i18n.t('Sessions'), type: 'normal', click: function() { 1486 | if (core === null) { 1487 | dialog.showMessageBox({message: i18n.t('Proxy is not running.')}) 1488 | } else { 1489 | // shell.openExternal('http://localhost:6001/stats/session/plain') 1490 | if (win && !win.isDestroyed()) { 1491 | if (win.isMinimized()) { 1492 | win.restore() 1493 | } 1494 | win.show() 1495 | win.focus() 1496 | } else { 1497 | win = new BrowserWindow({ width: 800, height: 600 }) 1498 | win.loadURL(`file://${__dirname}/web/sessions.html`) 1499 | win.maximize() 1500 | } 1501 | } 1502 | } 1503 | }, 1504 | { 1505 | label: i18n.t('Log'), 1506 | type: 'normal', 1507 | click: () => { shell.openItem(logPath) } 1508 | }, 1509 | { type: 'separator' }, 1510 | { label: i18n.t('Check For Updates'), type: 'normal', click: function() { 1511 | checkForUpdates(false) 1512 | } 1513 | }, 1514 | { label: i18n.t('Help'), type: 'normal', click: function() { 1515 | shell.openExternal('https://github.com/mellow-io/mellow') 1516 | } 1517 | }, 1518 | { label: i18n.t('About'), type: 'normal', click: function() { 1519 | dialog.showMessageBox({ message: util.format('Mellow (v%s)\n\n%s', app.getVersion(), 'https://github.com/mellow-io/mellow') }) 1520 | } 1521 | }, 1522 | { type: 'separator' }, 1523 | { label: i18n.t('Quit'), type: 'normal', click: function() { 1524 | down() 1525 | app.quit() 1526 | } 1527 | }] 1528 | 1529 | mainMenus.push(...otherMenus) 1530 | 1531 | return mainMenus 1532 | } 1533 | 1534 | function createTray() { 1535 | tray = new Tray(trayIcon.off) 1536 | trayMenu = Menu.buildFromTemplate(buildTrayMenu()) 1537 | tray.setToolTip('Mellow') 1538 | tray.setContextMenu(trayMenu) 1539 | setState(currentState) 1540 | } 1541 | 1542 | function reloadTray() { 1543 | trayMenu = Menu.buildFromTemplate(buildTrayMenu()) 1544 | tray.setContextMenu(trayMenu) 1545 | } 1546 | 1547 | function monitorRunningStatus() { 1548 | setInterval(() => { 1549 | if (running) { 1550 | up() 1551 | } 1552 | }, 60 * 1000) 1553 | } 1554 | 1555 | function monitorConfigs() { 1556 | fs.watch(configFolder, (e, f) => { 1557 | if (e == 'rename') { 1558 | reloadTray() 1559 | } 1560 | }) 1561 | } 1562 | 1563 | function init() { 1564 | switch (process.platform) { 1565 | case 'darwin': 1566 | if (store.get('hideDockIcon')) { 1567 | app.dock.hide() 1568 | } 1569 | break 1570 | case 'linux': 1571 | case 'win32': 1572 | break 1573 | } 1574 | 1575 | createTray() 1576 | monitorConfigs() 1577 | monitorPowerEvent() 1578 | monitorRunningStatus() 1579 | if (store.get('autoConnect')) { 1580 | up() 1581 | } 1582 | log.info(util.format('Mellow (%s) started.', app.getVersion())) 1583 | if (store.get('checkUpdates')) { 1584 | checkForUpdates(true) 1585 | } 1586 | } 1587 | 1588 | app.on('ready', init) 1589 | 1590 | app.on('window-all-closed', function () { 1591 | switch (process.platform) { 1592 | case 'darwin': 1593 | if (store.get('hideDockIcon')) { 1594 | app.dock.hide() 1595 | } 1596 | break 1597 | case 'linux': 1598 | case 'win32': 1599 | break 1600 | } 1601 | }) 1602 | 1603 | app.on('browser-window-created', () => { 1604 | switch (process.platform) { 1605 | case 'darwin': 1606 | if (store.get('hideDockIcon')) { 1607 | app.dock.show() 1608 | } 1609 | break 1610 | case 'linux': 1611 | case 'win32': 1612 | break 1613 | } 1614 | }) 1615 | 1616 | app.on('quit', () => { 1617 | switch (process.platform) { 1618 | case 'darwin': 1619 | if (themeChangedNotifier !== null) { 1620 | systemPreferences.unsubscribeNotification(themeChangedNotifier) 1621 | } 1622 | break 1623 | } 1624 | }) 1625 | 1626 | i18n.on('loaded', (loaded) => { 1627 | const locale = app.getLocale() 1628 | if (locale.includes('zh')) { 1629 | i18n.changeLanguage('zh') 1630 | } else { 1631 | i18n.changeLanguage('en') 1632 | } 1633 | if (tray !== null) { 1634 | reloadTray() 1635 | } 1636 | }) 1637 | -------------------------------------------------------------------------------- /src/spec/append_inbounds.spec.js: -------------------------------------------------------------------------------- 1 | const convert = require('@mellow/config/convert') 2 | 3 | const existingInbounds = [ 4 | { 5 | "port": 1086, 6 | "protocol": "socks", 7 | "listen": "127.0.0.1", 8 | "settings": { 9 | "auth": "noauth", 10 | "udp": false 11 | } 12 | }, 13 | { 14 | "port": 1088, 15 | "protocol": "http", 16 | "listen": "127.0.0.1", 17 | "settings": {} 18 | } 19 | ] 20 | 21 | const newInbounds = [ 22 | { 23 | "port": 1086, 24 | "protocol": "socks", 25 | "listen": "127.0.0.1", 26 | "settings": { 27 | "auth": "noauth", 28 | "udp": false 29 | } 30 | }, 31 | { 32 | "port": 1087, 33 | "protocol": "http", 34 | "listen": "127.0.0.1", 35 | "settings": {} 36 | } 37 | ] 38 | 39 | const expectedInbounds = [ 40 | { 41 | "port": 1088, 42 | "protocol": "http", 43 | "listen": "127.0.0.1", 44 | "settings": {} 45 | }, 46 | { 47 | "port": 1086, 48 | "protocol": "socks", 49 | "listen": "127.0.0.1", 50 | "settings": { 51 | "auth": "noauth", 52 | "udp": false 53 | } 54 | }, 55 | { 56 | "port": 1087, 57 | "protocol": "http", 58 | "listen": "127.0.0.1", 59 | "settings": {} 60 | } 61 | ] 62 | 63 | describe('Append inbounds', () => { 64 | test('Removing comments', () => { 65 | const commentedJsonString = `{ 66 | "inbounds": [{ 67 | // comment 68 | "port": 1087, 69 | # comment 70 | "protocol": "http", 71 | "listen": "127.0.0.1", # inline comment 72 | "settings": {} \/\/ inline comment 73 | }] 74 | }` 75 | const jsonString = convert.removeJsonComments(commentedJsonString) 76 | const expectedJson = { 77 | inbounds: [{ 78 | "port": 1087, 79 | "protocol": "http", 80 | "listen": "127.0.0.1", 81 | "settings": {} 82 | }] 83 | } 84 | expect(JSON.parse(jsonString)).toEqual(expectedJson) 85 | }) 86 | 87 | test('Merging inbounds', () => { 88 | const config = { 89 | inbounds: existingInbounds 90 | } 91 | const newConfig = convert.appendInbounds(config, newInbounds) 92 | expect(newConfig.inbounds).toEqual(expectedInbounds) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /src/spec/conf_to_json.spec.js: -------------------------------------------------------------------------------- 1 | const convert = require('@mellow/config/convert') 2 | 3 | const conf = ` 4 | [Endpoint] 5 | ; tag, parser, parser-specific params... 6 | 7 | Direct, builtin, freedom, domainStrategy=UseIP 8 | 9 | Reject, builtin, blackhole, type=http 10 | 11 | Dns-Out, builtin, dns, network=tcp, address=1.1.1.1, port=53 12 | 13 | ; http 14 | Http-Out, builtin, http, address=192.168.100.1, port=1087, user=myuser, pass=mypass 15 | 16 | ; socks5 17 | Socks-Out, builtin, socks, address=127.0.0.1, port=1080, user=myuser, pass=mypass 18 | 19 | ; ws + tls 20 | Proxy-1, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:443/v2?network=ws&tls=true&ws.host=example.com 21 | 22 | ; tcp, mux enabled, concurrency 8 23 | Proxy-2, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:10025?network=tcp&mux=8 24 | 25 | ; shadowsocks SIP002 26 | Proxy-3, ss, ss://YWVzLTEyOC1nY206dGVzdA==@192.168.100.1:8888 27 | 28 | ; shadowsocks SIP002-without-base64-encode 29 | Proxy-4, ss, ss://aes-128-gcm:pass@192.168.100.1:8888 30 | 31 | ; h2, multiple hosts 32 | Proxy-5, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:443/h2?network=http&http.host=example.com%2Cexample1.com&tls=true&tls.allowinsecure=true 33 | 34 | ; tcp + tls 35 | Proxy-6, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:10025?network=tcp&tls=true&tls.servername=example.com&tls.allowinsecure=true&sockopt.tcpfastopen=true 36 | 37 | ; kcp 38 | Proxy-7, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:10025?network=kcp&kcp.mtu=1350&kcp.tti=20&kcp.uplinkcapacity=1&kcp.downlinkcapacity=2&kcp.congestion=false&header=none&sockopt.tos=184 39 | 40 | ; quic 41 | Proxy-8, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:10025?network=quic&quic.security=none&quic.key=&header=none&tls=false&sockopt.tos=184 42 | 43 | [EndpointGroup] 44 | ; tag, colon-seperated list of selectors or endpoint tags, strategy, strategy-specific params... 45 | MyGroup, Proxy-1:Proxy-2:Proxy-3, latency, interval=300, timeout=6 46 | 47 | [Routing] 48 | domainStrategy = IPIfNonMatch 49 | 50 | [RoutingRule] 51 | ; type, filter, endpoint tag or enpoint group tag 52 | DOMAIN-KEYWORD, geosite:category-ads-all, Reject 53 | IP-CIDR, 8.8.8.8/32, MyGroup 54 | GEOIP, cn, Direct 55 | GEOIP, private, Direct 56 | PORT, 123, Direct 57 | DOMAIN-FULL, a.google.com, MyGroup 58 | DOMAIN, b.google.com, MyGroup 59 | DOMAIN-SUFFIX, c.google.com, MyGroup 60 | DOMAIN-KEYWORD, geosite:cn, Direct 61 | DOMAIN-KEYWORD, bilibili, Direct 62 | PROCESS-NAME, git, Proxy-2 63 | INCLUDE, subconf1.list 64 | INCLUDE, subconf2.list, MyGroup 65 | INCLUDE, subconf3.list, MyGroup 66 | NETWORK, udp:tcp, Direct 67 | FINAL, Direct 68 | 69 | [Dns] 70 | ; hijack = dns endpoint tag 71 | hijack = Dns-Out 72 | clientIp = 114.114.114.114 73 | 74 | [DnsServer] 75 | ; address, port, tag 76 | 223.5.5.5 77 | 8.8.8.8, 53, Remote 78 | 8.8.4.4 79 | 80 | [DnsRule] 81 | ; type, filter, dns server tag 82 | DOMAIN-KEYWORD, geosite:geolocation-!cn, Remote 83 | DOMAIN, www.google.com, Remote 84 | DOMAIN-FULL, www.twitter.com, Remote 85 | DOMAIN-SUFFIX, google.com, Remote 86 | 87 | [DnsHost] 88 | ; domain = ip 89 | localhost = 127.0.0.1 90 | 91 | [Log] 92 | loglevel = warning 93 | ` 94 | 95 | const subconf1 = ` 96 | [RoutingRule] 97 | PROCESS-NAME, storedownloadd, Direct 98 | PROCESS-NAME, com.apple.WeatherKitService, Direct 99 | ` 100 | 101 | const subconf2 = ` 102 | [RoutingRule] 103 | PROCESS-NAME, trustd 104 | DOMAIN-SUFFIX, apple.com 105 | ` 106 | 107 | const subconf3 = ` 108 | [RoutingRule] 109 | DOMAIN-SUFFIX, icloud.com, Direct 110 | DOMAIN-SUFFIX, me.com, Direct 111 | ` 112 | 113 | const subConf = { 114 | 'RoutingRule': { 115 | 'subconf1.list': subconf1, 116 | 'subconf2.list': subconf2, 117 | 'subconf3.list': subconf3, 118 | } 119 | } 120 | 121 | const json = ` 122 | { 123 | "log": { 124 | "loglevel": "warning" 125 | }, 126 | "outbounds": [ 127 | { 128 | "tag": "Direct", 129 | "protocol": "freedom", 130 | "settings": { 131 | "domainStrategy": "UseIP" 132 | } 133 | }, 134 | { 135 | "tag": "Reject", 136 | "protocol": "blackhole", 137 | "settings": { 138 | "response": { 139 | "type": "http" 140 | } 141 | } 142 | }, 143 | { 144 | "tag": "Dns-Out", 145 | "protocol": "dns", 146 | "settings": { 147 | "network": "tcp", 148 | "address": "1.1.1.1", 149 | "port": 53 150 | } 151 | }, 152 | { 153 | "tag": "Http-Out", 154 | "protocol": "http", 155 | "settings": { 156 | "servers": [ 157 | { 158 | "address": "192.168.100.1", 159 | "port": 1087, 160 | "users": [ 161 | { 162 | "user": "myuser", 163 | "pass": "mypass" 164 | } 165 | ] 166 | } 167 | ] 168 | } 169 | }, 170 | { 171 | "tag": "Socks-Out", 172 | "protocol": "socks", 173 | "settings": { 174 | "servers": [ 175 | { 176 | "address": "127.0.0.1", 177 | "port": 1080, 178 | "users": [ 179 | { 180 | "user": "myuser", 181 | "pass": "mypass" 182 | } 183 | ] 184 | } 185 | ] 186 | } 187 | }, 188 | { 189 | "tag": "Proxy-1", 190 | "protocol": "vmess", 191 | "settings": { 192 | "vnext": [ 193 | { 194 | "users": [ 195 | { 196 | "id": "75da2e14-4d08-480b-b3cb-0079a0c51275" 197 | } 198 | ], 199 | "address": "example.com", 200 | "port": 443 201 | } 202 | ] 203 | }, 204 | "streamSettings": { 205 | "network": "ws", 206 | "security": "tls", 207 | "wsSettings": { 208 | "path": "\/v2", 209 | "headers": { 210 | "Host": "example.com" 211 | } 212 | } 213 | } 214 | }, 215 | { 216 | "tag": "Proxy-2", 217 | "protocol": "vmess", 218 | "settings": { 219 | "vnext": [ 220 | { 221 | "users": [ 222 | { 223 | "id": "75da2e14-4d08-480b-b3cb-0079a0c51275" 224 | } 225 | ], 226 | "address": "example.com", 227 | "port": 10025 228 | } 229 | ] 230 | }, 231 | "streamSettings": { 232 | "network": "tcp" 233 | }, 234 | "mux": { 235 | "enabled": true, 236 | "concurrency": 8 237 | } 238 | }, 239 | { 240 | "tag": "Proxy-3", 241 | "protocol": "shadowsocks", 242 | "settings": { 243 | "servers": [ 244 | { 245 | "method": "aes-128-gcm", 246 | "password": "test", 247 | "address": "192.168.100.1", 248 | "port": 8888 249 | } 250 | ] 251 | } 252 | }, 253 | { 254 | "tag": "Proxy-4", 255 | "protocol": "shadowsocks", 256 | "settings": { 257 | "servers": [ 258 | { 259 | "method": "aes-128-gcm", 260 | "password": "pass", 261 | "address": "192.168.100.1", 262 | "port": 8888 263 | } 264 | ] 265 | } 266 | }, 267 | { 268 | "tag": "Proxy-5", 269 | "protocol": "vmess", 270 | "settings": { 271 | "vnext": [ 272 | { 273 | "users": [ 274 | { 275 | "id": "75da2e14-4d08-480b-b3cb-0079a0c51275" 276 | } 277 | ], 278 | "address": "example.com", 279 | "port": 443 280 | } 281 | ] 282 | }, 283 | "streamSettings": { 284 | "network": "http", 285 | "security": "tls", 286 | "tlsSettings": { 287 | "allowInsecure": true 288 | }, 289 | "httpSettings": { 290 | "path": "/h2", 291 | "host": [ 292 | "example.com", 293 | "example1.com" 294 | ] 295 | } 296 | } 297 | }, 298 | { 299 | "tag": "Proxy-6", 300 | "protocol": "vmess", 301 | "settings": { 302 | "vnext": [ 303 | { 304 | "users": [ 305 | { 306 | "id": "75da2e14-4d08-480b-b3cb-0079a0c51275" 307 | } 308 | ], 309 | "address": "example.com", 310 | "port": 10025 311 | } 312 | ] 313 | }, 314 | "streamSettings": { 315 | "network": "tcp", 316 | "security": "tls", 317 | "tlsSettings": { 318 | "serverName": "example.com", 319 | "allowInsecure": true 320 | }, 321 | "sockopt": { 322 | "tcpFastOpen": true 323 | } 324 | } 325 | }, 326 | { 327 | "tag": "Proxy-7", 328 | "protocol": "vmess", 329 | "settings": { 330 | "vnext": [ 331 | { 332 | "users": [ 333 | { 334 | "id": "75da2e14-4d08-480b-b3cb-0079a0c51275" 335 | } 336 | ], 337 | "address": "example.com", 338 | "port": 10025 339 | } 340 | ] 341 | }, 342 | "streamSettings": { 343 | "network": "kcp", 344 | "kcpSettings": { 345 | "mtu": 1350, 346 | "tti": 20, 347 | "uplinkCapacity": 1, 348 | "downlinkCapacity": 2, 349 | "congestion": false, 350 | "header": { 351 | "type": "none" 352 | } 353 | }, 354 | "sockopt": { 355 | "tos": 184 356 | } 357 | } 358 | }, 359 | { 360 | "tag": "Proxy-8", 361 | "protocol": "vmess", 362 | "settings": { 363 | "vnext": [ 364 | { 365 | "users": [ 366 | { 367 | "id": "75da2e14-4d08-480b-b3cb-0079a0c51275" 368 | } 369 | ], 370 | "address": "example.com", 371 | "port": 10025 372 | } 373 | ] 374 | }, 375 | "streamSettings": { 376 | "network": "quic", 377 | "security": "none", 378 | "quicSettings": { 379 | "security": "none", 380 | "key": "", 381 | "header": { 382 | "type": "none" 383 | } 384 | }, 385 | "sockopt": { 386 | "tos": 184 387 | } 388 | } 389 | } 390 | ], 391 | "routing": { 392 | "domainStrategy": "IPIfNonMatch", 393 | "balancers": [ 394 | { 395 | "tag": "MyGroup", 396 | "selector": [ 397 | "Proxy-1", 398 | "Proxy-2", 399 | "Proxy-3" 400 | ], 401 | "strategy": "latency", 402 | "interval": 300, 403 | "timeout": 6 404 | } 405 | ], 406 | "rules": [ 407 | { 408 | "inboundTag": ["tun2socks"], 409 | "network": "udp", 410 | "port": 53, 411 | "outboundTag": "Dns-Out", 412 | "type": "field" 413 | }, 414 | { 415 | "type": "field", 416 | "domain": [ 417 | "geosite:category-ads-all" 418 | ], 419 | "outboundTag": "Reject" 420 | }, 421 | { 422 | "type": "field", 423 | "ip": [ 424 | "8.8.8.8\/32" 425 | ], 426 | "balancerTag": "MyGroup" 427 | }, 428 | { 429 | "type": "field", 430 | "ip": [ 431 | "geoip:cn", 432 | "geoip:private" 433 | ], 434 | "outboundTag": "Direct" 435 | }, 436 | { 437 | "type": "field", 438 | "port": "123", 439 | "outboundTag": "Direct" 440 | }, 441 | { 442 | "type": "field", 443 | "domain": [ 444 | "full:a.google.com", 445 | "full:b.google.com", 446 | "domain:c.google.com" 447 | ], 448 | "balancerTag": "MyGroup" 449 | }, 450 | { 451 | "type": "field", 452 | "domain": [ 453 | "geosite:cn", 454 | "bilibili" 455 | ], 456 | "outboundTag": "Direct" 457 | }, 458 | { 459 | "type": "field", 460 | "app": [ 461 | "git" 462 | ], 463 | "outboundTag": "Proxy-2" 464 | }, 465 | { 466 | "type": "field", 467 | "app": [ 468 | "storedownloadd", 469 | "com.apple.WeatherKitService" 470 | ], 471 | "outboundTag": "Direct" 472 | }, 473 | { 474 | "type": "field", 475 | "app": [ 476 | "trustd" 477 | ], 478 | "balancerTag": "MyGroup" 479 | }, 480 | { 481 | "type": "field", 482 | "domain": [ 483 | "domain:apple.com" 484 | ], 485 | "balancerTag": "MyGroup" 486 | }, 487 | { 488 | "type": "field", 489 | "domain": [ 490 | "domain:icloud.com", 491 | "domain:me.com" 492 | ], 493 | "balancerTag": "MyGroup" 494 | }, 495 | { 496 | "type": "field", 497 | "network": "udp,tcp", 498 | "outboundTag": "Direct" 499 | }, 500 | { 501 | "type": "field", 502 | "ip": [ 503 | "0.0.0.0\/0", 504 | "::\/0" 505 | ], 506 | "outboundTag": "Direct" 507 | } 508 | ] 509 | }, 510 | "dns": { 511 | "servers": [ 512 | "223.5.5.5", 513 | { 514 | "address": "8.8.8.8", 515 | "port": 53, 516 | "domains": [ 517 | "geosite:geolocation-!cn", 518 | "full:www.google.com", 519 | "full:www.twitter.com", 520 | "domain:google.com" 521 | ] 522 | }, 523 | "8.8.4.4" 524 | ], 525 | "hosts": { 526 | "localhost": "127.0.0.1" 527 | }, 528 | "clientIp": "114.114.114.114" 529 | } 530 | } 531 | ` 532 | 533 | describe('Convert conf config to JSON', () => { 534 | test('Get lines by section', () => { 535 | const lines = convert.getLinesBySection(conf, 'DnsServer') 536 | const expectedLines = [ 537 | '223.5.5.5', 538 | '8.8.8.8, 53, Remote', 539 | '8.8.4.4' 540 | ] 541 | expect(lines).toEqual(expectedLines) 542 | }) 543 | 544 | test('Construct routing object', () => { 545 | const routingDomainStrategy = convert.getLinesBySection(conf, 'RoutingDomainStrategy') 546 | const routingConf = convert.getLinesBySection(conf, 'Routing') 547 | const balancerRule = convert.getLinesBySection(conf, 'EndpointGroup') 548 | const routingRule = convert.getLinesBySection(conf, 'RoutingRule') 549 | const dnsConf = convert.getLinesBySection(conf, 'Dns') 550 | const routing = convert.constructRouting(routingConf, routingDomainStrategy, balancerRule, routingRule, dnsConf, subConf) 551 | expect(routing).toEqual(JSON.parse(json).routing) 552 | }) 553 | 554 | test('Construct dns object', () => { 555 | const dnsConf = convert.getLinesBySection(conf, 'Dns') 556 | const dnsServer = convert.getLinesBySection(conf, 'DnsServer') 557 | const dnsRule = convert.getLinesBySection(conf, 'DnsRule') 558 | const dnsHost = convert.getLinesBySection(conf, 'DnsHost') 559 | const dnsClientIp = convert.getLinesBySection(conf, 'DnsClientIp') 560 | const dns = convert.constructDns(dnsConf, dnsServer, dnsRule, dnsHost, dnsClientIp) 561 | expect(dns).toEqual(JSON.parse(json).dns) 562 | }) 563 | 564 | test('Construct log object', () => { 565 | const logLines = convert.getLinesBySection(conf, 'Log') 566 | const log = convert.constructLog(logLines) 567 | expect(log).toEqual(JSON.parse(json).log) 568 | }) 569 | 570 | test('Construct outbounds object', () => { 571 | const endpoint = convert.getLinesBySection(conf, 'Endpoint') 572 | const outbounds = convert.constructOutbounds(endpoint) 573 | expect(outbounds).toEqual(JSON.parse(json).outbounds) 574 | }) 575 | 576 | test('Construct the whole JSON object', () => { 577 | const v2json = convert.constructJson(conf, subConf) 578 | expect(v2json).toEqual(JSON.parse(json)) 579 | }) 580 | }) 581 | -------------------------------------------------------------------------------- /src/web/sessions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | 17 | 111 |
112 | 113 | 114 | -------------------------------------------------------------------------------- /template/example.conf: -------------------------------------------------------------------------------- 1 | [Endpoint] 2 | Proxy-1, vmess1, vmess1://75da2e14-4d08-480b-b3cb-0079a0c51275@example.com:443/path?network=ws&tls=true&ws.host=example.com 3 | Direct, builtin, freedom, domainStrategy=UseIP 4 | Dns-Out, builtin, dns 5 | 6 | [RoutingRule] 7 | DOMAIN-KEYWORD, geosite:cn, Direct 8 | GEOIP, cn, Direct 9 | GEOIP, private, Direct 10 | FINAL, Proxy-1 11 | 12 | [Dns] 13 | hijack = Dns-Out 14 | 15 | [DnsServer] 16 | localhost 17 | 8.8.8.8 18 | --------------------------------------------------------------------------------