├── .github └── workflows │ ├── goreleaser-release.yml │ ├── goreleaser-validate.yml │ └── renvoate-config-validation.yml ├── .gitignore ├── .idea ├── .gitignore ├── alwaysonline.iml ├── modules.xml └── vcs.xml ├── Dockerfile.goreleaser ├── LICENSE ├── README.md ├── dns_server.go ├── dns_server_a.go ├── dns_server_aaaa.go ├── dns_server_fallback.go ├── dns_server_soa.go ├── dns_server_txt.go ├── doc └── assets │ └── windows10_20h2_ncsi.png ├── docker-compose.yml ├── exceptions.go ├── go.mod ├── go.sum ├── goreleaser.yaml ├── http_log.go ├── http_server.go ├── main.go ├── port.go ├── renovate.json └── version.go /.github/workflows/goreleaser-release.yml: -------------------------------------------------------------------------------- 1 | name: "GoReleaser Release" 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | env: 9 | GOPATH: /tmp/go 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | packages: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - uses: actions/setup-go@v5 22 | - uses: crazy-max/ghaction-upx@v3 23 | with: 24 | install-only: true 25 | - uses: docker/login-action@v3 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | - uses: goreleaser/goreleaser-action@v6 30 | with: 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser-validate.yml: -------------------------------------------------------------------------------- 1 | name: "GoReleaser Validation" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | env: 12 | GOPATH: /tmp/go 13 | 14 | jobs: 15 | goreleaser: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - uses: actions/setup-go@v5 22 | - uses: crazy-max/ghaction-upx@v3 23 | with: 24 | install-only: true 25 | - uses: goreleaser/goreleaser-action@v6 26 | with: 27 | distribution: goreleaser 28 | args: build --snapshot --clean 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/renvoate-config-validation.yml: -------------------------------------------------------------------------------- 1 | name: "Renovate Config Validation" 2 | 3 | on: 4 | push: 5 | paths: 6 | - renovate.json 7 | pull_request: 8 | branches: 9 | - master 10 | paths: 11 | - renovate.json 12 | jobs: 13 | renovate-config-validation: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - uses: actions/setup-node@v4 20 | - run: npx --yes --package renovate -- renovate-config-validator --strict 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,windows,macos,go,intellij 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,windows,macos,go,intellij 5 | 6 | ### Go ### 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | ### Go Patch ### 24 | /vendor/ 25 | /Godeps/ 26 | 27 | ### Intellij ### 28 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 29 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 30 | 31 | # User-specific stuff 32 | .idea/**/workspace.xml 33 | .idea/**/tasks.xml 34 | .idea/**/usage.statistics.xml 35 | .idea/**/dictionaries 36 | .idea/**/shelf 37 | 38 | # Generated files 39 | .idea/**/contentModel.xml 40 | 41 | # Sensitive or high-churn files 42 | .idea/**/dataSources/ 43 | .idea/**/dataSources.ids 44 | .idea/**/dataSources.local.xml 45 | .idea/**/sqlDataSources.xml 46 | .idea/**/dynamic.xml 47 | .idea/**/uiDesigner.xml 48 | .idea/**/dbnavigator.xml 49 | 50 | # Gradle 51 | .idea/**/gradle.xml 52 | .idea/**/libraries 53 | 54 | # Gradle and Maven with auto-import 55 | # When using Gradle or Maven with auto-import, you should exclude module files, 56 | # since they will be recreated, and may cause churn. Uncomment if using 57 | # auto-import. 58 | # .idea/artifacts 59 | # .idea/compiler.xml 60 | # .idea/jarRepositories.xml 61 | # .idea/modules.xml 62 | # .idea/*.iml 63 | # .idea/modules 64 | # *.iml 65 | # *.ipr 66 | 67 | # CMake 68 | cmake-build-*/ 69 | 70 | # Mongo Explorer plugin 71 | .idea/**/mongoSettings.xml 72 | 73 | # File-based project format 74 | *.iws 75 | 76 | # IntelliJ 77 | out/ 78 | 79 | # mpeltonen/sbt-idea plugin 80 | .idea_modules/ 81 | 82 | # JIRA plugin 83 | atlassian-ide-plugin.xml 84 | 85 | # Cursive Clojure plugin 86 | .idea/replstate.xml 87 | 88 | # Crashlytics plugin (for Android Studio and IntelliJ) 89 | com_crashlytics_export_strings.xml 90 | crashlytics.properties 91 | crashlytics-build.properties 92 | fabric.properties 93 | 94 | # Editor-based Rest Client 95 | .idea/httpRequests 96 | 97 | # Android studio 3.1+ serialized cache file 98 | .idea/caches/build_file_checksums.ser 99 | 100 | ### Intellij Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 110 | .idea/**/sonarlint/ 111 | 112 | # SonarQube Plugin 113 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 114 | .idea/**/sonarIssues.xml 115 | 116 | # Markdown Navigator plugin 117 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 118 | .idea/**/markdown-navigator.xml 119 | .idea/**/markdown-navigator-enh.xml 120 | .idea/**/markdown-navigator/ 121 | 122 | # Cache file creation bug 123 | # See https://youtrack.jetbrains.com/issue/JBR-2257 124 | .idea/$CACHE_FILE$ 125 | 126 | # CodeStream plugin 127 | # https://plugins.jetbrains.com/plugin/12206-codestream 128 | .idea/codestream.xml 129 | 130 | ### macOS ### 131 | # General 132 | .DS_Store 133 | .AppleDouble 134 | .LSOverride 135 | 136 | # Icon must end with two \r 137 | Icon 138 | 139 | # Thumbnails 140 | ._* 141 | 142 | # Files that might appear in the root of a volume 143 | .DocumentRevisions-V100 144 | .fseventsd 145 | .Spotlight-V100 146 | .TemporaryItems 147 | .Trashes 148 | .VolumeIcon.icns 149 | .com.apple.timemachine.donotpresent 150 | 151 | # Directories potentially created on remote AFP share 152 | .AppleDB 153 | .AppleDesktop 154 | Network Trash Folder 155 | Temporary Items 156 | .apdisk 157 | 158 | ### VisualStudioCode ### 159 | .vscode/* 160 | !.vscode/settings.json 161 | !.vscode/tasks.json 162 | !.vscode/launch.json 163 | !.vscode/extensions.json 164 | *.code-workspace 165 | 166 | ### VisualStudioCode Patch ### 167 | # Ignore all local history of files 168 | .history 169 | .ionide 170 | 171 | ### Windows ### 172 | # Windows thumbnail cache files 173 | Thumbs.db 174 | Thumbs.db:encryptable 175 | ehthumbs.db 176 | ehthumbs_vista.db 177 | 178 | # Dump file 179 | *.stackdump 180 | 181 | # Folder config file 182 | [Dd]esktop.ini 183 | 184 | # Recycle Bin used on file shares 185 | $RECYCLE.BIN/ 186 | 187 | # Windows Installer files 188 | *.cab 189 | *.msi 190 | *.msix 191 | *.msm 192 | *.msp 193 | 194 | # Windows shortcuts 195 | *.lnk 196 | 197 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,windows,macos,go,intellij 198 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /../../../../../../../:\Users\jamesits\Documents\code\alwaysonline\.idea/dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/alwaysonline.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile.goreleaser: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY alwaysonline / 3 | ENTRYPOINT ["/alwaysonline"] 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GLWTS(Good Luck With That Shit) Public License 2 | Copyright (c) Every-fucking-one, except the Author 3 | 4 | Everyone is permitted to copy, distribute, modify, merge, sell, publish, 5 | sublicense or whatever the fuck they want with this software but at their 6 | OWN RISK. 7 | 8 | Preamble 9 | 10 | The author has absolutely no fucking clue what the code in this project 11 | does. It might just fucking work or not, there is no third option. 12 | 13 | 14 | GOOD LUCK WITH THAT SHIT PUBLIC LICENSE 15 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION, AND MODIFICATION 16 | 17 | 0. You just DO WHATEVER THE FUCK YOU WANT TO as long as you NEVER LEAVE 18 | A FUCKING TRACE TO TRACK THE AUTHOR of the original product to blame for 19 | or held responsible. 20 | 21 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 24 | DEALINGS IN THE SOFTWARE. 25 | 26 | Good luck and Godspeed. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AlwaysOnline 2 | 3 | AlwaysOnline is a HTTP and DNS server which mocks a lot network/internet/portal detection servers. 4 | 5 | ![Works - On My Machine](https://img.shields.io/badge/Works-On_My_Machine-2ea44f) 6 | ![Project Status - Feature Complete](https://img.shields.io/badge/Project_Status-Feature_Complete-2ea44f) 7 | [![Docker Image Version](https://img.shields.io/docker/v/jamesits/alwaysonline?label=Docker%20Hub)](http://hub.docker.com/r/jamesits/alwaysonline) 8 | 9 | ## Usage 10 | 11 | Ports required: tcp/80, tcp/53, udp/53. 12 | 13 | Start the server: 14 | 15 | ```shell script 16 | # use docker 17 | docker run \ 18 | -d --restart unless-stopped \ 19 | --cap-drop ALL --cap-add NET_BIND_SERVICE \ 20 | -p 80:80 -p 53:53 -p 53:53/udp \ 21 | jamesits/alwaysonline:latest --ipv4 192.168.1.2 --ipv6 fd00::2 22 | 23 | # or download and run the executable 24 | chmod +x ./alwaysonline 25 | setcap cap_net_bind_service=+ep ./alwaysonline 26 | ./alwaysonline --ipv4 192.168.1.2 --ipv6 fd00::2 27 | ``` 28 | 29 | (Pass the IP address end users use to connect to the server. See [Which IP Address Should I Use](#which-ip-address-should-i-use) for more information.) 30 | 31 | Hijack (delegate) the following domains (including subdomains) on your DNS server to the AlwaysOnline server IP address (and disable DNSSEC verification if possible): 32 | 33 | ``` 34 | // Windows 35 | msftncsi.com 36 | msftconnecttest.com 37 | resolver1.opendns.com (if you use WindowsSpyBlocker to change NCSI config) 38 | 39 | // iOS, macOS 40 | captive.apple.com 41 | 42 | // Android 43 | clients3.google.com 44 | connectivitycheck.gstatic.com 45 | connectivitycheck.android.com 46 | connect.rom.miui.com 47 | connectivitycheck.platform.hicloud.com 48 | // www.googleapis.cn (for MCC=460 sim cards only; might impact other services; not recommended; see https://cs.android.com/android/platform/superproject/+/master:packages/modules/NetworkStack/res/values-mcc460/config.xml?q=values-mcc460) 49 | 50 | // Linux 51 | network-test.debian.org 52 | nmcheck.gnome.org 53 | www.archlinux.org (DO NOT hijack archlinux.org or other subdomains) 54 | capnet.elementary.io 55 | ``` 56 | 57 | ## Development 58 | 59 | Building locally: 60 | 61 | ```shell 62 | goreleaser build --snapshot --clean --single-target 63 | ``` 64 | 65 | ## FAQ / Technical Details 66 | 67 | ### Why? 68 | 69 | Microsoft Store refuses to work when NCSI reports "no Internet", even if it can load everything. Also some UWP games (e.g. ones authorized by Xbox Game Pass) refuse to work without a "Internet". But NCSI is so unreliable in my area that 99% of the time I have the disconnected globe icon on my taskbar. This is why I wrote this piece of software in the first place. 70 | 71 | By terminating portal detection servers in your LAN, you also benefit from: 72 | 73 | * Less wait when connecting to a network (mainly on Windows 10) 74 | * Less wired/wireless switching issues on Windows 10 75 | * More privacy (since your ISP and the OS vendor can no longer log these requests) 76 | 77 | ### Which IP Address Should I Use 78 | 79 | Whatever IP addresses you pass to the 2 parameters 80 | 81 | * `--ipv4` 82 | * `--ipv6` 83 | 84 | will be sent to the client in every DNS response, so that the following HTTP requests will come back to the IP addresses you specified. Generally speaking: 85 | 86 | * Use the IP addresses on the public-facing interface of your server 87 | * If the server is behind any destination NAT, reverse proxy or load balancer, use the IP address provided by the NAT/proxy/balancer service 88 | * If you don't have either IPv4 or IPv6 deployed, omit that 89 | 90 | The server will always listen on `0.0.0.0`/`[::]` regardless of the IP addresses you specified. 91 | 92 | ### Use the HTTP Server without DNS Delegation 93 | 94 | It is possible to use only the HTTP server provided by AlwaysOnline. Just make sure you fake every DNS responses right on your own DNS server. 95 | 96 | ### Security 97 | 98 | AlwaysOnline need `cap_net_bind_service=+ep` to listen on ports 53 and 80 as a non-root user. It is recommended to run AlwaysOnline in a confined, readonly environment. 99 | 100 | Exposing AlwaysOnline directly to the internet is not recommended, mainly because DNS/UDP can passively anticipate in an amplification/reflection DDoS attack. AlwaysOnline does not implement any rate limiting or abnormal client blocking mechanism. 101 | 102 | ### High Availability 103 | 104 | AlwaysOnline is stateless, so it is possible to run multiple instances to accommodate your high availability needs. Failover is realized by a 1-second DNS cache TTL and your own DNS server's failover capability. 105 | 106 | I acknowledge this is not the best way to implement HA, but it is simple and enough for casual deployments. If you use AlwaysOnline in a mission-critical environment, then external load balancer with upstream health check (e.g. Nginx or HAProxy) and/or distributed container orchestration tools (e.g. Docker Swarm Mode or Kubernetes) are strongly recommended. 107 | 108 | ### Extensibility 109 | 110 | AlwaysOnline replies to any URL segments it recognizes, regardless of which domain it comes from. If your client speaks a protocol AlwaysOnline implements but on a different domain, simply add another delegation on your DNS server and everything should work. 111 | 112 | ### About Windows 10 NCSI Service 113 | 114 | Service `NlaSvc` controls NCSI -- Network Connectivity Status Indicator, i.e. the tray icon on your taskbar showing whether you have Internet access. The service, when it goes wrong, is very annoying, as it will cause Microsoft Store to be unusable and all your UWP games unplayable even if you *actually* have Internet access. 115 | 116 | NCSI use a set of DNS and HTTP tests to detect if the device is connected to the Internet. The tests can be customized at `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\NlaSvc\Parameters\Internet`. AlwaysOnline implements the default config. (BTW, Windows 10 CMGE have the same NCSI config as every other Windows 10. ) 117 | 118 | For a network to trigger the NCSI tests, you need an address, network mask and DNS server to be set. For IPv6 networks, the IP address need to be a global one (in the range `2000::/3`). Sometimes you need a default gateway, but not always. 119 | 120 | ![Screenshot showing Windows 10 network connection details: IPv4 address, default gateway, DNS server set to 10.0.0.1, subnet mask 255.255.255.0; IPv6 address and DNS server set to 2000::, subnet length 64](doc/assets/windows10_20h2_ncsi.png) 121 | 122 | [NCSI will cache negative results for a network](https://web.archive.org/web/20230815184404/https://learn.microsoft.com/en-us/answers/questions/400385/network-location-awareness-not-detecting-domain-ne), so if a network is detected to be non-Internet, NCSI will not test it for a long period, even if the network adapter is disabled then re-enabled. 123 | 124 | If you don't want to deploy a server or you don't have a suitable LAN environment, [NCSIOverride](https://github.com/dantmnf/NCSIOverride) can be used to fake NCSI results on a single machine. 125 | 126 | ### About Android 127 | 128 | Stock Android 7.1.1 or later will connect to both HTTP and HTTPS endpoints for internet detection. AlwaysOnline does not implement the HTTPS server due to obvious reasons. 129 | 130 | If you have privacy concerns, you can change the endpoint URLs manually with ADB. See: https://www.noisyfox.io/android-captive-portal.html 131 | -------------------------------------------------------------------------------- /dns_server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/miekg/dns" 6 | "strings" 7 | ) 8 | 9 | const DNSDefaultTTL = 1 10 | 11 | type dnsRequestHandler struct{} 12 | 13 | func newDNSReplyMsg() *dns.Msg { 14 | msg := dns.Msg{} 15 | 16 | msg.Compress = true 17 | 18 | // this is an authoritative DNS server 19 | msg.Authoritative = true 20 | msg.RecursionAvailable = false 21 | 22 | // DNSSEC disabled for now 23 | // TODO: fix DNSSEC 24 | msg.AuthenticatedData = false 25 | msg.CheckingDisabled = true 26 | 27 | return &msg 28 | } 29 | 30 | // send out the generated answer, and if the answer is not correct, send out a SERVFAIL 31 | func finishAnswer(w *dns.ResponseWriter, r *dns.Msg) { 32 | err := (*w).WriteMsg(r) 33 | if err != nil { 34 | softFailIf(err) 35 | 36 | // if answer sanity check (miekg/dns automatically does this) fails, reply with SERVFAIL 37 | msg := newDNSReplyMsg() 38 | msg.SetReply(r) 39 | msg.Rcode = dns.RcodeServerFailure 40 | err = (*w).WriteMsg(msg) 41 | softFailIf(err) 42 | } 43 | 44 | // access log to stdout 45 | fmt.Printf("[DNS] C=%d Q=%d R=%d DOMAIN=%s %s => %s\n", r.Question[0].Qclass, r.Question[0].Qtype, r.Rcode, r.Question[0].Name, (*w).RemoteAddr(), (*w).LocalAddr()) 46 | } 47 | 48 | // TODO: force TCP for 1) clients which requests too fast; 2) non-existent answers 49 | // See: https://labs.apnic.net/?p=382 50 | func (this *dnsRequestHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { 51 | msg := newDNSReplyMsg() 52 | msg.SetReply(r) 53 | 54 | // on function return, we send out the current answer 55 | defer finishAnswer(&w, msg) 56 | 57 | // sanity check 58 | if len(r.Question) != 1 { 59 | msg.Rcode = dns.RcodeRefused 60 | return 61 | } 62 | 63 | switch r.Question[0].Qclass { 64 | case dns.ClassINET: 65 | switch r.Question[0].Qtype { 66 | case dns.TypeA: 67 | handleA(this, r, msg) 68 | return 69 | 70 | case dns.TypeAAAA: 71 | handleAAAA(this, r, msg) 72 | return 73 | 74 | case dns.TypeSOA: 75 | handleSOA(this, r, msg) 76 | return 77 | 78 | default: 79 | handleDefault(this, r, msg) 80 | return 81 | } 82 | case dns.ClassCHAOS: 83 | switch r.Question[0].Qtype { 84 | case dns.TypeTXT: 85 | if strings.EqualFold(r.Question[0].Name, "version.bind.") { 86 | // we need to reply our software version 87 | // https://serverfault.com/questions/517087/dns-how-to-find-out-which-software-a-remote-dns-server-is-running 88 | handleTXTVersionRequest(this, r, msg) 89 | } else { 90 | handleDefault(this, r, msg) 91 | } 92 | return 93 | 94 | default: 95 | handleDefault(this, r, msg) 96 | return 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /dns_server_a.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/miekg/dns" 5 | "net" 6 | "strings" 7 | ) 8 | 9 | func handleA(this *dnsRequestHandler, r, msg *dns.Msg) { 10 | switch strings.ToLower(msg.Question[0].Name) { 11 | case "dns.msftncsi.com.": 12 | msg.Answer = append(msg.Answer, &dns.A{ 13 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 14 | A: net.IPv4(131, 107, 255, 255), 15 | }) 16 | return 17 | 18 | case "resolver1.opendns.com.": 19 | // for https://github.com/crazy-max/WindowsSpyBlocker/blob/0e48685cf8c2b3f263f4ada9065188d6c9966cac/app/settings.json#L119 20 | msg.Answer = append(msg.Answer, &dns.A{ 21 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 22 | A: net.IPv4(208, 67, 222, 222), 23 | }) 24 | return 25 | 26 | default: 27 | if localResolveIp4Enabled { 28 | // for everything else, resolve to our own IP address 29 | msg.Answer = append(msg.Answer, &dns.A{ 30 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 31 | A: localResolveIp4Address, 32 | }) 33 | } else { 34 | // IPv4 not configured, reply empty answer 35 | msg.Answer = append(msg.Answer, &dns.A{ 36 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 37 | }) 38 | } 39 | return 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /dns_server_aaaa.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/miekg/dns" 5 | "net" 6 | "strings" 7 | ) 8 | 9 | func handleAAAA(this *dnsRequestHandler, r, msg *dns.Msg) { 10 | switch strings.ToLower(msg.Question[0].Name) { 11 | case "dns.msftncsi.com.": 12 | msg.Answer = append(msg.Answer, &dns.AAAA{ 13 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 14 | AAAA: net.ParseIP("fd3e:4f5a:5b81::1"), 15 | }) 16 | return 17 | 18 | case "resolver1.opendns.com.": 19 | // for https://github.com/crazy-max/WindowsSpyBlocker/blob/0e48685cf8c2b3f263f4ada9065188d6c9966cac/app/settings.json#L119 20 | msg.Answer = append(msg.Answer, &dns.AAAA{ 21 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 22 | AAAA: net.ParseIP("2620:119:35::35"), 23 | }) 24 | return 25 | 26 | default: 27 | if localResolveIp6Enabled { 28 | // for everything else, resolve to our own IP address 29 | msg.Answer = append(msg.Answer, &dns.AAAA{ 30 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 31 | AAAA: localResolveIp6Address, 32 | }) 33 | } else { 34 | // IPv6 not configured, reply empty answer 35 | msg.Answer = append(msg.Answer, &dns.AAAA{ 36 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 37 | }) 38 | } 39 | return 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /dns_server_fallback.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/miekg/dns" 5 | "log" 6 | ) 7 | 8 | // all unknown DNS requests are processed here 9 | func handleDefault(this *dnsRequestHandler, r, msg *dns.Msg) { 10 | if msg.RecursionDesired { 11 | // Refused 12 | msg.RecursionAvailable = false 13 | msg.Rcode = dns.RcodeRefused 14 | 15 | log.Printf("[DNS] %d %s refused: recursion requested but not available\n", msg.Question[0].Qtype, msg.Question[0].Name) 16 | } else { 17 | // NotImp 18 | msg.Rcode = dns.RcodeNotImplemented 19 | 20 | log.Printf("[DNS] %d %s refused: not implemented\n", msg.Question[0].Qtype, msg.Question[0].Name) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /dns_server_soa.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/miekg/dns" 5 | ) 6 | 7 | func handleSOA(_ *dnsRequestHandler, r, msg *dns.Msg) { 8 | // When adding an upstream in Windows Server's DNS server, a SOA question to `.` (or the specific domain, if adding 9 | // as a conditional forwarder) will be generated to probe if the upstream is alive. 10 | // Reply this hardcoded answer to pass the test. 11 | msg.Answer = append(msg.Answer, &dns.SOA{ 12 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 13 | Ns: "a.root-servers.net.", 14 | Mbox: "nstld.verisign-grs.com.", 15 | Serial: 114514, 16 | Refresh: 60, 17 | Retry: 10, 18 | Expire: 3600000, 19 | Minttl: DNSDefaultTTL, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /dns_server_txt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/miekg/dns" 5 | ) 6 | 7 | // replies a TXT record containing server name and version 8 | func handleTXTVersionRequest(this *dnsRequestHandler, r, msg *dns.Msg) { 9 | msg.Answer = append(msg.Answer, &dns.TXT{ 10 | Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: r.Question[0].Qtype, Class: r.Question[0].Qclass, Ttl: DNSDefaultTTL}, 11 | Txt: []string{"bind-⑨"}, 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /doc/assets/windows10_20h2_ncsi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jamesits/alwaysonline/f0919fec06253c59d48150e7c4d68001bc1e6475/doc/assets/windows10_20h2_ncsi.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | alwaysonline: 3 | build: "." 4 | image: "jamesits/alwaysonline:latest" 5 | restart: "unless-stopped" 6 | ports: 7 | - "80:80/tcp" 8 | - "53:53/tcp" 9 | - "53:53/udp" 10 | cap_add: 11 | - "NET_BIND_SERVICE" 12 | cap_drop: 13 | - "ALL" 14 | command: "--ipv4 192.168.1.2 --ipv6 fd00::2" # replace with your host's IP address 15 | -------------------------------------------------------------------------------- /exceptions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "log" 4 | 5 | var softErrorCount uint64 6 | 7 | // if QuitOnError is true, then panic; 8 | // else go on 9 | func softFailIf(e error) { 10 | if e != nil { 11 | softErrorCount++ 12 | log.Printf("[ERROR] %s", e) 13 | } 14 | } 15 | 16 | func hardFailIf(e error) { 17 | if e != nil { 18 | panic(e) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jamesits/alwaysonline/v2 2 | 3 | go 1.24.1 4 | 5 | require github.com/miekg/dns v1.1.66 6 | 7 | require ( 8 | golang.org/x/mod v0.24.0 // indirect 9 | golang.org/x/net v0.39.0 // indirect 10 | golang.org/x/sync v0.13.0 // indirect 11 | golang.org/x/sys v0.32.0 // indirect 12 | golang.org/x/tools v0.32.0 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 2 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 | github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= 4 | github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= 5 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 6 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 7 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 8 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 9 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 10 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 11 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 12 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 13 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 14 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 15 | -------------------------------------------------------------------------------- /goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # IDEA auto formatter is causing trouble 2 | # @formatter:off 3 | version: 2 4 | 5 | before: 6 | hooks: 7 | - "go mod verify" 8 | 9 | env: 10 | - "GO111MODULE=on" 11 | - "CGO_ENABLED=0" 12 | 13 | builds: 14 | - id: "alwaysonline" 15 | main: "." 16 | binary: "alwaysonline" 17 | mod_timestamp: "{{ .CommitTimestamp }}" 18 | goos: ["linux", "darwin", "windows"] 19 | goarch: ["amd64", "arm64"] 20 | goamd64: ["v3"] 21 | flags: 22 | - "-v" 23 | - "-trimpath" 24 | - "-buildvcs=true" 25 | asmflags: 26 | - "all=-trimpath={{ .Env.GOPATH }}" 27 | gcflags: 28 | - "all=-trimpath={{ .Env.GOPATH }}" 29 | ldflags: 30 | - "-s" 31 | - "-w" 32 | - "-X \"main.versionMajor={{ .Major }}\"" 33 | - "-X \"main.versionMinor={{ .Minor }}\"" 34 | - "-X \"main.versionRevision={{ .Patch }}\"" 35 | - "-X \"main.versionGitCommitHash={{ .Commit }}\"" 36 | - "-X \"main.versionCompileTime={{ .CommitTimestamp }}\"" 37 | hooks: 38 | post: 39 | - "sh -c 'upx \"{{ .Path }}\" || true'" 40 | - "sudo setcap 'cap_net_bind_service=+ep' \"{{ .Path }}\"" 41 | 42 | snapshot: 43 | version_template: "{{ incpatch .Version }}-next" 44 | 45 | archives: 46 | - id: "release" 47 | formats: ["tar.xz"] 48 | wrap_in_directory: true 49 | 50 | dockers: 51 | - dockerfile: "Dockerfile.goreleaser" 52 | goarch: amd64 53 | goamd64: v3 54 | image_templates: 55 | - jamesits/alwaysonline:{{ .Version }}-amd64 56 | build_flag_templates: 57 | - "--platform=linux/amd64" 58 | - dockerfile: "Dockerfile.goreleaser" 59 | goarch: arm64 60 | image_templates: 61 | - jamesits/alwaysonline:{{ .Version }}-arm64 62 | build_flag_templates: 63 | - "--platform=linux/arm64" 64 | docker_manifests: 65 | - name_template: "jamesits/alwaysonline:{{ .Version }}" 66 | image_templates: 67 | - jamesits/alwaysonline:{{ .Version }}-amd64 68 | - jamesits/alwaysonline:{{ .Version }}-arm64 69 | 70 | checksum: 71 | name_template: "checksums.txt" 72 | algorithm: "sha256" 73 | 74 | changelog: 75 | sort: "asc" 76 | filters: 77 | exclude: 78 | - "^doc:" 79 | - "^docs:" 80 | - "^test:" 81 | - "^cleanup:" 82 | - "^ci:" 83 | - "typo" 84 | - "readme" 85 | - "README" 86 | - "comment" 87 | -------------------------------------------------------------------------------- /http_log.go: -------------------------------------------------------------------------------- 1 | // source: https://gist.github.com/cespare/3985516 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type ApacheLogRecord struct { 13 | http.ResponseWriter 14 | 15 | ip string 16 | time time.Time 17 | method, uri, protocol string 18 | status int 19 | responseBytes int64 20 | elapsedTime time.Duration 21 | } 22 | 23 | func (r *ApacheLogRecord) Log(out io.Writer) { 24 | timeFormatted := r.time.Format("02/Jan/2006 03:04:05") 25 | fmt.Fprintf(out, "[HTTP] %s - - [%s] \"%s %s %s %d %d\" %f\n", r.ip, timeFormatted, r.method, r.uri, r.protocol, r.status, r.responseBytes, r.elapsedTime.Seconds()) 26 | } 27 | 28 | func (r *ApacheLogRecord) Write(p []byte) (int, error) { 29 | written, err := r.ResponseWriter.Write(p) 30 | r.responseBytes += int64(written) 31 | return written, err 32 | } 33 | 34 | func (r *ApacheLogRecord) WriteHeader(status int) { 35 | r.status = status 36 | r.ResponseWriter.WriteHeader(status) 37 | } 38 | 39 | type ApacheLoggingHandler struct { 40 | handler http.Handler 41 | out io.Writer 42 | } 43 | 44 | func NewApacheLoggingHandler(handler http.Handler, out io.Writer) http.Handler { 45 | return &ApacheLoggingHandler{ 46 | handler: handler, 47 | out: out, 48 | } 49 | } 50 | 51 | func (h *ApacheLoggingHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 52 | clientIP := r.RemoteAddr 53 | if colon := strings.LastIndex(clientIP, ":"); colon != -1 { 54 | clientIP = clientIP[:colon] 55 | } 56 | 57 | record := &ApacheLogRecord{ 58 | ResponseWriter: rw, 59 | ip: clientIP, 60 | time: time.Time{}, 61 | method: r.Method, 62 | uri: r.RequestURI, 63 | protocol: r.Proto, 64 | status: http.StatusOK, 65 | elapsedTime: time.Duration(0), 66 | } 67 | 68 | startTime := time.Now() 69 | h.handler.ServeHTTP(record, r) 70 | finishTime := time.Now() 71 | 72 | record.time = finishTime.UTC() 73 | record.elapsedTime = finishTime.Sub(startTime) 74 | 75 | record.Log(h.out) 76 | } 77 | -------------------------------------------------------------------------------- /http_server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | func http_server_fallback(w http.ResponseWriter, req *http.Request) { 11 | switch strings.ToLower(req.Host) { 12 | case "captive.apple.com": 13 | hotspot_detect_html(w, req) 14 | return 15 | 16 | case "capnet.elementary.io": 17 | capnet(w, req) 18 | return 19 | 20 | case "www.archlinux.org": 21 | // mock the redirect 22 | w.Header().Add("Location", "https://archlinux.org"+req.RequestURI) 23 | w.WriteHeader(http.StatusMovedPermanently) 24 | fmt.Fprint(w, "\n301 Moved Permanently\n\n

301 Moved Permanently

\n
nginx
\n\n\n") 25 | return 26 | 27 | case "networkcheck.kde.org": 28 | ok_upcase(w, req) 29 | return 30 | 31 | case "connectivitycheck.platform.hicloud.com": 32 | // Huawei phones generate requests like "http://connectivitycheck.platform.hicloud.com/generate_204_1ee9b362-b226-4c81-bffe-6708fa241ab8" 33 | // and the UUID is different every time 34 | generate_204(w, req) 35 | return 36 | 37 | default: 38 | // fallback to a 404 page 39 | log.Printf("[HTTP] not implemented: %s %s => \"%s%s\"\n", req.Method, req.RemoteAddr, req.Host, req.RequestURI) 40 | w.WriteHeader(http.StatusNotFound) 41 | } 42 | } 43 | 44 | // /robots.txt 45 | func robots_txt(w http.ResponseWriter, req *http.Request) { 46 | w.Header().Add("Content-Type", "text/plain; charset=utf-8") 47 | w.WriteHeader(http.StatusOK) 48 | fmt.Fprint(w, "User-agent: *\nDisallow: /\n") 49 | } 50 | 51 | // http://www.msftncsi.com/ncsi.txt 52 | func ncsi_txt(w http.ResponseWriter, req *http.Request) { 53 | w.Header().Add("Content-Type", "text/plain") 54 | w.WriteHeader(http.StatusOK) 55 | fmt.Fprint(w, "Microsoft NCSI") 56 | } 57 | 58 | // http://www.msftconnecttest.com/connecttest.txt 59 | // http://ipv6.msftconnecttest.com/connecttest.txt 60 | func connecttest(w http.ResponseWriter, req *http.Request) { 61 | w.Header().Add("Content-Type", "text/plain; charset=utf-8") 62 | w.WriteHeader(http.StatusOK) 63 | fmt.Fprint(w, "Microsoft Connect Test") 64 | } 65 | 66 | // http://www.msftconnecttest.com/redirect 67 | func redirect(w http.ResponseWriter, req *http.Request) { 68 | w.Header().Add("Location", "http://go.microsoft.com/fwlink/?LinkID=219472&clcid=0x409") 69 | w.Header().Add("Server", "Microsoft-IIS/114.514") 70 | w.Header().Add("Content-Length", "0") 71 | w.WriteHeader(http.StatusFound) 72 | w.Write([]byte{}) 73 | } 74 | 75 | // http://captive.apple.com/hotspot-detect.html 76 | func hotspot_detect_html(w http.ResponseWriter, req *http.Request) { 77 | w.Header().Add("Content-Type", "text/html") 78 | w.WriteHeader(http.StatusOK) 79 | fmt.Fprint(w, "SuccessSuccess") 80 | } 81 | 82 | // http://clients3.google.com/generate_204 83 | // http://connectivitycheck.gstatic.com/generate_204 84 | // http://connectivitycheck.android.com/generate_204 85 | // http://connect.rom.miui.com/generate_204 86 | func generate_204(w http.ResponseWriter, req *http.Request) { 87 | w.Header().Add("Content-Length", "0") 88 | w.WriteHeader(http.StatusNoContent) 89 | w.Write([]byte{}) 90 | } 91 | 92 | // http://network-test.debian.org/nm 93 | // http://ping.archlinux.org/nm-check.txt 94 | // http://nmcheck.gnome.org/check_network_status.txt 95 | // http://www.archlinux.org/check_network_status.txt 96 | func nm(w http.ResponseWriter, req *http.Request) { 97 | w.Header().Add("X-NetworkManager-Status", "online") 98 | w.WriteHeader(http.StatusOK) 99 | fmt.Fprint(w, "NetworkManager is online\n") 100 | } 101 | 102 | // http://networkcheck.kde.org 103 | func ok_upcase(w http.ResponseWriter, req *http.Request) { 104 | w.Header().Add("X-NetworkManager-Status", "online") 105 | w.WriteHeader(http.StatusOK) 106 | fmt.Fprint(w, "OK\n") 107 | } 108 | 109 | // http://detectportal.firefox.com/success.txt 110 | func success_txt(w http.ResponseWriter, req *http.Request) { 111 | w.WriteHeader(http.StatusOK) 112 | fmt.Fprint(w, "success\n") 113 | } 114 | 115 | // http://start.ubuntu.com/connectivity-check.html 116 | func connectivity_check_html(w http.ResponseWriter, req *http.Request) { 117 | w.WriteHeader(http.StatusOK) 118 | fmt.Fprint(w, "\n\n\nLorem Ipsum\n\n\n

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

\n\n\n") 119 | } 120 | 121 | // http://capnet.elementary.io 122 | // warning: relative resources exist 123 | func capnet(w http.ResponseWriter, req *http.Request) { 124 | w.WriteHeader(http.StatusOK) 125 | fmt.Fprint(w, "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nYou're connected! ⋅ elementary\n\n\n\n\n\n\n\n\n\n\n\n
\n
\n

You're connected!

\n

Your Internet connection appears to be working. You can safely close this window and continue using your device.

\n

Why is this window appearing?

\n

elementary OS automatically checks your Internet connection when you connect to a new Wi-Fi network. If it detects there is not an Internet connection (i.e. if you are connecting to a captive portal at a coffee shop or other public location), this window will appear and display the login page.

\n

Some networks can appear to be a captive portal at first, triggering this window, then actually end up connecting. In those cases, you'll see this message and can safely close the window.

\n
\n
\n\n\n\n") 126 | } 127 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/miekg/dns" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | "sync" 12 | ) 13 | 14 | var mainThreadWaitGroup = &sync.WaitGroup{} 15 | var disableDNSServer bool 16 | var localDNSPortString *string 17 | var localDNSPort int 18 | var localDNSPortInvaild bool 19 | var localResolveIp4AddressString *string 20 | var localResolveIp4Address net.IP 21 | var localResolveIp4Enabled bool 22 | var localResolveIp6AddressString *string 23 | var localResolveIp6Address net.IP 24 | var localResolveIp6Enabled bool 25 | var localResolvePortString *string 26 | var localResolvePort int 27 | var localResolvePortInvaild bool 28 | var showVersionOnly *bool 29 | 30 | func main() { 31 | 32 | // arguments parsing 33 | flag.BoolVar(&disableDNSServer, "disable-dns-server", false, "disable DNS server") 34 | localDNSPortString = flag.String("dns-port", "", "listening port of the DNS server") 35 | localResolveIp4AddressString = flag.String("ipv4", "", "the IPv4 address to this server") 36 | localResolveIp6AddressString = flag.String("ipv6", "", "the IPv6 address to this server") 37 | localResolvePortString = flag.String("port", "", "listening port of server") 38 | showVersionOnly = flag.Bool("version", false, "show version and quit") 39 | flag.Parse() 40 | 41 | if *showVersionOnly { 42 | fmt.Println(getVersionFullString()) 43 | return 44 | } else { 45 | log.Println(getVersionFullString()) 46 | } 47 | 48 | if len(*localResolveIp4AddressString) == 0 { 49 | localResolveIp4Enabled = false 50 | localResolveIp4Address = net.ParseIP("0.0.0.0") 51 | log.Println("[CONFIG] IPv4 resolution disabled") 52 | } else { 53 | localResolveIp4Enabled = true 54 | localResolveIp4Address = net.ParseIP(*localResolveIp4AddressString) 55 | log.Printf("[CONFIG] Local server IPv4 address: %s\n", localResolveIp4Address) 56 | } 57 | 58 | if len(*localResolveIp6AddressString) == 0 { 59 | localResolveIp6Enabled = false 60 | localResolveIp6Address = net.ParseIP("::") 61 | log.Println("[CONFIG] IPv6 resolution disabled") 62 | } else { 63 | localResolveIp6Enabled = true 64 | localResolveIp6Address = net.ParseIP(*localResolveIp6AddressString) 65 | log.Printf("[CONFIG] Local server IPv6 address: %s\n", localResolveIp6Address) 66 | } 67 | 68 | if len(*localResolvePortString) == 0 { 69 | localResolvePort = 80 70 | log.Println("[CONFIG] Listen port: 80") 71 | } else { 72 | localResolvePort, localResolvePortInvaild = parsePort(*localResolvePortString) 73 | if localResolvePortInvaild || localResolvePort == 0 { 74 | localResolvePort = 80 75 | } 76 | log.Printf("[CONFIG] Listen port: %d\n", localResolvePort) 77 | } 78 | 79 | if len(*localDNSPortString) == 0 { 80 | localDNSPort = 53 81 | log.Println("[CONFIG] DNS port: 53") 82 | } else { 83 | localDNSPort, localDNSPortInvaild = parsePort(*localDNSPortString) 84 | if localDNSPortInvaild || localDNSPort == 0 { 85 | localDNSPort = 53 86 | } 87 | log.Printf("[CONFIG] DNS port: %d\n", localDNSPort) 88 | } 89 | 90 | // HTTP router setup 91 | mux := http.DefaultServeMux 92 | mux.HandleFunc("/robots.txt", robots_txt) 93 | mux.HandleFunc("/ncsi.txt", ncsi_txt) 94 | mux.HandleFunc("/redirect", redirect) 95 | mux.HandleFunc("/hotspot-detect.html", hotspot_detect_html) 96 | mux.HandleFunc("/generate_204", generate_204) 97 | mux.HandleFunc("/gen_204", generate_204) 98 | mux.HandleFunc("/nm", nm) 99 | mux.HandleFunc("/nm-check.txt", nm) 100 | mux.HandleFunc("/check_network_status.txt", nm) 101 | mux.HandleFunc("/success.txt", success_txt) 102 | mux.HandleFunc("/connecttest.txt", connecttest) 103 | mux.HandleFunc("/connectivity-check.html", connectivity_check_html) 104 | mux.HandleFunc("/", http_server_fallback) // catch all 105 | 106 | // HTTP logger setup 107 | loggingHandler := NewApacheLoggingHandler(mux, os.Stdout) // HTTP access log is sent to stdout for now 108 | 109 | // HTTP server setup 110 | if !(localResolveIp4Enabled || localResolveIp6Enabled) { 111 | openPlainHttpServer(fmt.Sprintf(":%d", localResolvePort), loggingHandler) 112 | } 113 | if localResolveIp4Enabled { 114 | openPlainHttpServer(fmt.Sprintf("%s:%d", localResolveIp4Address.String(), localResolvePort), loggingHandler) 115 | } 116 | if localResolveIp6Enabled { 117 | openPlainHttpServer(fmt.Sprintf("[%s]:%d", localResolveIp6Address.String(), localResolvePort), loggingHandler) 118 | } 119 | 120 | // HTTPS server setup 121 | // tlsHttpServer := &http.Server{ 122 | // Addr: ":443", 123 | // Handler: loggingHandler, 124 | // TLSConfig: &tls.Config{ 125 | // GetCertificate: , 126 | // // ... 127 | // } 128 | // } 129 | // go tlsHttpServer.ListenAndServe() 130 | 131 | if !disableDNSServer { 132 | // DNS TCP server setup 133 | dnsTcp1 := &dns.Server{Addr: fmt.Sprintf(":%d", localDNSPort), Net: "tcp"} 134 | dnsTcp1.Handler = &dnsRequestHandler{} 135 | go dnsTcp1.ListenAndServe() 136 | 137 | // DNS UDP server setup 138 | dnsUdp1 := &dns.Server{Addr: fmt.Sprintf(":%d", localDNSPort), Net: "udp"} 139 | dnsUdp1.Handler = &dnsRequestHandler{} 140 | go dnsUdp1.ListenAndServe() 141 | } else { 142 | log.Println("[MAIN] not starting DNS server") 143 | } 144 | 145 | // done 146 | log.Println("[MAIN] Server started.") 147 | 148 | // just a normal while(1) 149 | mainThreadWaitGroup.Add(1) 150 | mainThreadWaitGroup.Wait() 151 | } 152 | 153 | func openPlainHttpServer(addr string, handler http.Handler) { 154 | plainHttpServer := &http.Server{ 155 | Addr: addr, 156 | Handler: handler, 157 | } 158 | go plainHttpServer.ListenAndServe() 159 | } 160 | -------------------------------------------------------------------------------- /port.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | func parsePort(service string) (port int, error bool) { 8 | prt, err := strconv.ParseUint(service, 10, 16) 9 | if err != nil { 10 | return 0, true 11 | } 12 | return int(prt), false 13 | } 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "github>jamesits/.github//renovate/default", 6 | "github>jamesits/.github//renovate/group-monorepo" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | var versionMajor = "0" 6 | var versionMinor = "0" 7 | var versionRevision = "0" 8 | var versionGitCommitHash string 9 | var versionCompileTime string 10 | 11 | func getVersionNumberString() string { 12 | return fmt.Sprintf("%s.%s.%s", versionMajor, versionMinor, versionRevision) 13 | } 14 | 15 | func getVersionFullString() string { 16 | if len(versionGitCommitHash) == 0 { 17 | versionGitCommitHash = "UNKNOWN" 18 | } 19 | 20 | return fmt.Sprintf("AlwaysOnline/%s (+https://github.com/Jamesits/alwaysonline; Commit %s/%s)", getVersionNumberString(), versionGitCommitHash, versionCompileTime) 21 | } 22 | --------------------------------------------------------------------------------