8 Commits

Author SHA1 Message Date
ThisIsDara ec44b3ccab changelog fix 2026-05-14 13:10:09 +03:30
ThisIsDara ff6035305d Update release changelog 2026-05-14 13:00:44 +03:30
ThisIsDara 222af94ca2 V1.4.0 workflow 2026-05-14 12:49:38 +03:30
ThisIsDara afc979ef30 V1.4.0 Update 2026-05-14 12:44:56 +03:30
ThisIsDara 197ee4441f fix: workflow artifact download path 2026-05-08 07:46:32 +03:30
ThisIsDara 8465e329a0 fix: workflow cross-compilation for Windows 2026-05-08 07:43:24 +03:30
ThisIsDara 07ad4415a1 Fix: Accept-Enconding header 2026-05-08 07:41:51 +03:30
dara 08ba77d13e Create LICENSE 2026-05-07 09:49:15 +03:30
9 changed files with 238 additions and 31 deletions
+59 -23
View File
@@ -9,7 +9,28 @@ jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
include:
- os: ubuntu-latest
goos: linux
goarch: amd64
- os: ubuntu-latest
goos: linux
goarch: arm64
- os: macos-latest
goos: darwin
goarch: amd64
- os: macos-latest
goos: darwin
goarch: arm64
- os: windows-latest
goos: windows
goarch: amd64
- os: windows-latest
goos: windows
goarch: arm64
- os: windows-latest
goos: windows
goarch: 386
runs-on: ${{ matrix.os }}
permissions:
contents: write
@@ -21,21 +42,28 @@ jobs:
with:
go-version: '1.22'
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: GOOS=linux go build -ldflags "-s -w" -o mhr-cfw-go-linux ./cmd/mhr-cfw
- name: Build
shell: bash
run: |
export GOOS=${{ matrix.goos }}
export GOARCH=${{ matrix.goarch }}
if [ "$GOOS" = "windows" ] && [ "$GOARCH" = "arm64" ]; then
echo "Skipping unsupported windows/arm64"
exit 0
fi
go build -ldflags "-s -w" -o mhr-cfw-go-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/mhr-cfw
- name: Build Windows
if: matrix.os == 'windows-latest'
run: go build -ldflags "-s -w" -o mhr-cfw-go-windows.exe ./cmd/mhr-cfw
- name: Rename Windows executable
if: matrix.goos == 'windows' && matrix.goarch != 'arm64'
shell: bash
run: mv mhr-cfw-go-windows-${{ matrix.goarch }} mhr-cfw-go-windows-${{ matrix.goarch }}.exe
- name: Upload artifact
if: matrix.goos != 'windows' || matrix.goarch != 'arm64'
uses: actions/upload-artifact@v4
with:
name: mhr-cfw-go-${{ matrix.os }}
path: |
mhr-cfw-go-windows.exe
mhr-cfw-go-linux
name: mhr-cfw-go-${{ matrix.goos }}-${{ matrix.goarch }}
path: mhr-cfw-go-${{ matrix.goos }}-${{ matrix.goarch }}*
release:
needs: build
@@ -46,24 +74,32 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download Linux artifact
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
name: mhr-cfw-go-ubuntu-latest
path: .
path: ./binaries
pattern: mhr-cfw-go-*
merge-multiple: true
- name: Download Windows artifact
uses: actions/download-artifact@v4
with:
name: mhr-cfw-go-windows-latest
path: .
- name: List binaries
run: ls -la ./binaries
- name: Release
uses: softprops/action-gh-release@v2
with:
files: |
mhr-cfw-go-windows.exe
mhr-cfw-go-linux
body: "Built from commit ${{ github.sha }}"
files: ./binaries/*
body: |
# Changelog
## Improvements
- Added startup prewarm to keep Apps Script containers hot for faster first requests.
- Added periodic keepalive ping to reduce idle latency spikes.
- Tuned HTTP/2 transport with idle and ping timeouts for more stable performance.
- Enabled static asset cache fast-path to reduce repeated relay calls.
## Fixes
- Updated version to 1.4.0.
- Improved graceful shutdown by closing active client connections on stop.
🦢
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 dara
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -91,6 +91,7 @@ func main() {
func runMenu(a *args) error {
menu := &tui.Menu{
Title: "mhr-cfw",
Version: constants.Version,
Options: []tui.Option{
{Key: 1, Label: "Start proxy", Handler: func() error { return runProxy(a) }},
{Key: 2, Label: "Setup wizard", Handler: func() error { return setup.RunInteractiveWizard(a.configPath) }},
+5 -5
View File
@@ -1,21 +1,21 @@
{
"auth_key": "changeme",
"chunked_download_chunk_size": 524288,
"script_id": "changeme",
"chunked_download_max_chunks": 256,
"chunked_download_max_parallel": 8,
"chunked_download_min_size": 5242880,
"front_domain": "www.google.com",
"google_ip": "216.239.38.120",
"hosts": {},
"lan_sharing": true,
"listen_host": "0.0.0.0",
"lan_sharing": false,
"listen_host": "127.0.0.1",
"listen_port": 8085,
"log_level": "INFO",
"max_response_body_bytes": 209715200,
"mode": "apps_script",
"relay_timeout": 25,
"script_id": "changeme",
"socks5_enabled": false,
"chunked_download_chunk_size": 524288,
"socks5_enabled": true,
"socks5_port": 1080,
"tcp_connect_timeout": 10,
"tls_connect_timeout": 15,
+5 -1
View File
@@ -1,6 +1,6 @@
package constants
const Version = "1.1.0"
const Version = "1.4.0"
const (
MaxRequestBodyBytes = 100 * 1024 * 1024
@@ -86,6 +86,10 @@ const (
StatsLogTopN = 10
)
const (
KeepaliveInterval = 240.0
)
var GoogleDirectExactExclude = map[string]struct{}{
"gemini.google.com": {},
"aistudio.google.com": {},
+55 -1
View File
@@ -72,6 +72,8 @@ type DomainFronter struct {
coalesce map[string][]chan []byte
statsStop chan struct{}
keepalive *time.Ticker
warmOnce sync.Once
}
type pooledConn struct {
@@ -128,6 +130,8 @@ func New(cfg config.Config) *DomainFronter {
f.h2 = h2.New(f.connectHost, f.sniHosts, f.verifySSL)
go f.statsLoop()
go f.keepaliveLoop()
go f.prewarm()
return f
}
@@ -164,6 +168,9 @@ func buildSNIPool(frontDomain string, overrides []string) []string {
func (f *DomainFronter) Close() error {
close(f.statsStop)
if f.keepalive != nil {
f.keepalive.Stop()
}
if f.h2 != nil {
_ = f.h2.Close()
}
@@ -507,7 +514,13 @@ func (f *DomainFronter) buildPayload(method, urlStr string, headers map[string]s
"r": false,
}
if headers != nil {
p["h"] = headers
filtered := make(map[string]string)
for k, v := range headers {
if strings.ToLower(k) != "accept-encoding" {
filtered[k] = v
}
}
p["h"] = filtered
}
if len(body) > 0 {
p["b"] = base64.StdEncoding.EncodeToString(body)
@@ -709,6 +722,47 @@ func (f *DomainFronter) statsLoop() {
}
}
func (f *DomainFronter) keepaliveLoop() {
f.keepalive = time.NewTicker(time.Duration(constants.KeepaliveInterval) * time.Second)
defer f.keepalive.Stop()
for {
select {
case <-f.statsStop:
return
case <-f.keepalive.C:
if strings.TrimSpace(f.authKey) == "" {
continue
}
payload := map[string]any{
"m": "HEAD",
"u": "http://example.com/",
"r": false,
"k": f.authKey,
}
jsonBody, _ := json.Marshal(payload)
path := f.execPath("example.com")
_, _, _, _ = f.h2.Request(context.Background(), "POST", path, f.httpHost, map[string]string{"content-type": "application/json"}, jsonBody, 20*time.Second)
}
}
}
func (f *DomainFronter) prewarm() {
f.warmOnce.Do(func() {
if strings.TrimSpace(f.authKey) == "" {
return
}
payload := map[string]any{
"m": "HEAD",
"u": "http://example.com/",
"r": false,
"k": f.authKey,
}
jsonBody, _ := json.Marshal(payload)
path := f.execPath("example.com")
_, _, _, _ = f.h2.Request(context.Background(), "POST", path, f.httpHost, map[string]string{"content-type": "application/json"}, jsonBody, 20*time.Second)
})
}
func (f *DomainFronter) logStats() {
if len(f.perSite) == 0 {
return
+2
View File
@@ -52,6 +52,8 @@ func (t *Transport) ensure() {
}
tr := &http2.Transport{
AllowHTTP: false,
ReadIdleTimeout: 90 * time.Second,
PingTimeout: 15 * time.Second,
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
sni := t.nextSNI()
tlsCfg := &tls.Config{
+85
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net"
"net/url"
"net/textproto"
"regexp"
"strconv"
@@ -153,6 +154,8 @@ type Server struct {
mu sync.Mutex
servers []net.Listener
conns map[net.Conn]struct{}
connMu sync.Mutex
wg sync.WaitGroup
ctx context.Context
}
@@ -177,6 +180,7 @@ func NewServer(cfg config.Config) (*Server, error) {
mitm: mitm.NewManager(),
cache: NewResponseCache(constants.CacheMaxMB),
directFailUntil: map[string]time.Time{},
conns: map[net.Conn]struct{}{},
}, nil
}
@@ -214,6 +218,7 @@ func (s *Server) Start(ctx context.Context) error {
for _, l := range s.servers {
_ = l.Close()
}
s.closeAllConns()
_ = s.fronter.Close()
s.wg.Wait()
log.Infof("Server stopped")
@@ -233,11 +238,37 @@ func (s *Server) acceptLoop(ln net.Listener, handler func(net.Conn)) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.trackConn(conn)
defer s.untrackConn(conn)
handler(conn)
}()
}
}
func (s *Server) trackConn(conn net.Conn) {
s.connMu.Lock()
s.conns[conn] = struct{}{}
s.connMu.Unlock()
}
func (s *Server) untrackConn(conn net.Conn) {
s.connMu.Lock()
delete(s.conns, conn)
s.connMu.Unlock()
}
func (s *Server) closeAllConns() {
s.connMu.Lock()
conns := make([]net.Conn, 0, len(s.conns))
for conn := range s.conns {
conns = append(conns, conn)
}
s.connMu.Unlock()
for _, conn := range conns {
_ = conn.Close()
}
}
func (s *Server) handleHTTPConn(conn net.Conn) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(30 * time.Second))
@@ -341,7 +372,23 @@ func (s *Server) relayHTTPStream(host string, port int, conn net.Conn) {
continue
}
if s.cacheAllowed(method, urlStr, headerMap, body) {
if cached := s.cache.Get(urlStr); cached != nil {
if origin != "" {
cached = injectCORSHeaders(cached, origin)
}
_, _ = conn.Write(cached)
continue
}
}
response := s.fronter.Relay(method, urlStr, headerMap, body)
if s.cacheAllowed(method, urlStr, headerMap, body) {
ttl := s.cache.ParseTTL(response, urlStr)
if ttl > 0 {
s.cache.Put(urlStr, response, ttl)
}
}
if origin != "" {
response = injectCORSHeaders(response, origin)
}
@@ -367,7 +414,23 @@ func (s *Server) handlePlainHTTP(conn net.Conn, reader *bufio.Reader, headers []
}
urlStr := path
if s.cacheAllowed(method, urlStr, headerMap, body) {
if cached := s.cache.Get(urlStr); cached != nil {
if origin != "" {
cached = injectCORSHeaders(cached, origin)
}
_, _ = conn.Write(cached)
return
}
}
response := s.fronter.Relay(method, urlStr, headerMap, body)
if s.cacheAllowed(method, urlStr, headerMap, body) {
ttl := s.cache.ParseTTL(response, urlStr)
if ttl > 0 {
s.cache.Put(urlStr, response, ttl)
}
}
if origin != "" {
response = injectCORSHeaders(response, origin)
}
@@ -520,6 +583,28 @@ func headerValue(headers map[string]string, name string) string {
return ""
}
func (s *Server) cacheAllowed(method, urlStr string, headers map[string]string, body []byte) bool {
if strings.ToUpper(method) != "GET" || len(body) > 0 {
return false
}
for _, name := range constants.UncacheableHeaderNames {
if headerValue(headers, name) != "" {
return false
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
return false
}
path := strings.ToLower(parsed.Path)
for _, ext := range constants.StaticExts {
if strings.HasSuffix(path, ext) {
return true
}
}
return false
}
func corsPreflight(origin, acrMethod, acrHeaders string) []byte {
allowOrigin := origin
if allowOrigin == "" {
+5 -1
View File
@@ -16,6 +16,7 @@ type Option struct {
type Menu struct {
Title string
Version string
Options []Option
}
@@ -58,7 +59,10 @@ func (m *Menu) render() {
borderMid := "╠" + strings.Repeat("═", width+2) + "╣"
borderBot := "╚ " + strings.Repeat("═", width) + " ╝"
inner := "║" + strings.Repeat(" ", width+2) + "║"
tag := "Mhr-Cfw-Go V1.0"
tag := "Mhr-Cfw-Go"
if strings.TrimSpace(m.Version) != "" {
tag = fmt.Sprintf("Mhr-Cfw-Go v%s", m.Version)
}
link := "https://github.com/ThisIsDara/"
centerText := func(text string) string {