diff --git a/.gitignore b/.gitignore index 040e493f1..2f7d15205 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,9 @@ docs/superpowers # release.yml, not git. src-tauri/binaries/ +# Built userspace Tailscale sidecar (go build in codeg-tsnet/) +codeg-tsnet/codeg-tsnet +codeg-tsnet/codeg-tsnet.exe + # pnpm local store (pnpm 11+) .pnpm-store/ diff --git a/codeg-tsnet/go.mod b/codeg-tsnet/go.mod new file mode 100644 index 000000000..707e40a00 --- /dev/null +++ b/codeg-tsnet/go.mod @@ -0,0 +1,51 @@ +module github.com/xintaofei/codeg/codeg-tsnet + +go 1.26.5 + +require tailscale.com v1.102.2 + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/creachadair/msync v0.8.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gaissmai/bart v0.26.1 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.1 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect +) diff --git a/codeg-tsnet/go.sum b/codeg-tsnet/go.sum new file mode 100644 index 000000000..67f4b2ad8 --- /dev/null +++ b/codeg-tsnet/go.sum @@ -0,0 +1,236 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 h1:IOdss+igJDFdic9w3WKwxGCmHqUxydvIhJOm9LJ32Dk= +github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= +github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= +github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/mds v0.25.13 h1:PsSUHV6zsfPd29k4kvm1rMoee1YFia7JyNGeMPmDcPM= +github.com/creachadair/mds v0.25.13/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= +github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM= +github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914 h1:kD8PseueGeYiid/Mmcv17Q0Qqicc4F46jcX22L/e/Hs= +github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= +github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= +github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed h1:uyvHhX1FQada0vVk8CSHa4tJT96EEAkTypaYz8Tq5Nc= +github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU= +github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.102.2 h1:K0TJMOFv0F9aJDSjM/C2uVtrwnLn+ek22c42x61FXeA= +tailscale.com v1.102.2/go.mod h1:ynxKzc9hDxwGLHORQE0qrTH1b8NBRrIsXcnfnug/3dg= diff --git a/codeg-tsnet/main.go b/codeg-tsnet/main.go new file mode 100644 index 000000000..24aae4376 --- /dev/null +++ b/codeg-tsnet/main.go @@ -0,0 +1,149 @@ +// Userspace Tailscale node bundled with Codeg. +// Joins the tailnet inside this process. No Tailscale app on the PC. +// Proxies HTTPS on the tailnet (or Funnel if --public) to a loopback target. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "tailscale.com/ipn/ipnstate" + "tailscale.com/tsnet" +) + +type event struct { + Event string `json:"event"` + URL string `json:"url,omitempty"` + Mode string `json:"mode,omitempty"` + Message string `json:"message,omitempty"` +} + +func emit(e event) { + b, err := json.Marshal(e) + if err != nil { + return + } + fmt.Println(string(b)) +} + +func main() { + target := flag.String("target", "http://127.0.0.1:3080", "loopback Codeg Web Service") + hostname := flag.String("hostname", "codeg", "tailnet hostname") + stateDir := flag.String("state-dir", "", "persistent tsnet state directory") + public := flag.Bool("public", false, "Funnel (public HTTPS). Default is private tailnet only") + flag.Parse() + + parsed, err := url.Parse(*target) + if err != nil || parsed.Scheme != "http" { + emit(event{Event: "error", Message: "target must be http://127.0.0.1:"}) + os.Exit(2) + } + host := parsed.Hostname() + if host != "127.0.0.1" && host != "localhost" { + emit(event{Event: "error", Message: "target must be loopback"}) + os.Exit(2) + } + + dir := strings.TrimSpace(*stateDir) + if dir == "" { + cfg, err := os.UserConfigDir() + if err != nil { + emit(event{Event: "error", Message: "cannot resolve config dir"}) + os.Exit(2) + } + dir = filepath.Join(cfg, "app.codeg", "tsnet") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + emit(event{Event: "error", Message: "cannot create state dir"}) + os.Exit(2) + } + + mode := "private" + if *public { + mode = "public" + } + + srv := &tsnet.Server{ + Hostname: strings.TrimSpace(*hostname), + Dir: dir, + UserLogf: func(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + if u := authURLFromLog(msg); u != "" { + emit(event{Event: "auth_url", URL: u}) + } + }, + Logf: func(string, ...any) {}, + } + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + st, err := srv.Up(ctx) + cancel() + if err != nil { + emit(event{Event: "error", Message: "tailnet login failed"}) + os.Exit(1) + } + + if *public { + funnelLn, err := srv.ListenFunnel("tcp", ":443") + if err != nil { + emit(event{Event: "error", Message: "could not start public Funnel listener"}) + os.Exit(1) + } + go http.Serve(funnelLn, reverseProxy(parsed)) + } else { + tlsLn, err := srv.ListenTLS("tcp", ":443") + if err != nil { + emit(event{Event: "error", Message: "could not start private TLS listener"}) + os.Exit(1) + } + go http.Serve(tlsLn, reverseProxy(parsed)) + } + + emit(event{Event: "ready", URL: httpsURL(st), Mode: mode}) + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop +} + +func reverseProxy(target *url.URL) http.Handler { + proxy := httputil.NewSingleHostReverseProxy(target) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Host = target.Host + proxy.ServeHTTP(w, r) + }) +} + +func httpsURL(st *ipnstate.Status) string { + if st == nil { + return "" + } + if len(st.CertDomains) > 0 { + return "https://" + strings.TrimSuffix(st.CertDomains[0], ".") + } + if st.Self != nil && st.Self.DNSName != "" { + return "https://" + strings.TrimSuffix(st.Self.DNSName, ".") + } + return "" +} + +func authURLFromLog(msg string) string { + for _, part := range strings.Fields(msg) { + if strings.HasPrefix(part, "https://login.tailscale.com/") { + return strings.TrimRight(part, ".,)") + } + } + return "" +} diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 09e74bc16..a849e3a9d 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -39,34 +39,33 @@ fn ensure_sidecar_placeholder() { "" }; let dir = PathBuf::from("binaries"); - let path = dir.join(format!("codeg-mcp-{triple}{ext}")); - - println!("cargo:rerun-if-changed={}", path.display()); - - let needs_placeholder = match fs::metadata(&path) { - Ok(meta) => meta.len() == 0, - Err(_) => true, - }; - - if needs_placeholder { - if let Err(e) = fs::create_dir_all(&dir) { - panic!("failed to create {}: {e}", dir.display()); - } - if let Err(e) = fs::write(&path, b"") { - panic!( - "failed to write sidecar placeholder {}: {e}", + for name in ["codeg-mcp", "codeg-tsnet"] { + let path = dir.join(format!("{name}-{triple}{ext}")); + println!("cargo:rerun-if-changed={}", path.display()); + let needs_placeholder = match fs::metadata(&path) { + Ok(meta) => meta.len() == 0, + Err(_) => true, + }; + if needs_placeholder { + if let Err(e) = fs::create_dir_all(&dir) { + panic!("failed to create {}: {e}", dir.display()); + } + if let Err(e) = fs::write(&path, b"") { + panic!( + "failed to write sidecar placeholder {}: {e}", + path.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); + } + println!( + "cargo:warning={name} sidecar missing at {}; wrote 0-byte placeholder. \ + Run `pnpm tauri:prepare-sidecars` before `tauri build` to ship a working binary.", path.display() ); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); - } - println!( - "cargo:warning=codeg-mcp sidecar missing at {}; wrote 0-byte placeholder. \ - Run `pnpm tauri:prepare-sidecars` before `tauri build` to ship a working binary.", - path.display() - ); } } diff --git a/src-tauri/scripts/prepare-sidecars.mjs b/src-tauri/scripts/prepare-sidecars.mjs index cf255749d..3a0cfa0ba 100644 --- a/src-tauri/scripts/prepare-sidecars.mjs +++ b/src-tauri/scripts/prepare-sidecars.mjs @@ -120,6 +120,38 @@ function main() { chmodSync(dest, 0o755) } log(`sidecar staged at ${dest}`) + stageTsnet(target, ext, isWindows) +} + +function stageTsnet(target, ext, isWindows) { + const goDir = resolve(SRC_TAURI, "..", "codeg-tsnet") + if (!existsSync(join(goDir, "main.go"))) { + log("codeg-tsnet source missing — skip") + return + } + try { + execFileSync("go", ["version"], { encoding: "utf8" }) + } catch { + log("go not on PATH — skip codeg-tsnet sidecar") + return + } + log("building codeg-tsnet (latest pinned tailscale.com)") + execFileSync("go", ["mod", "tidy"], { stdio: "inherit", cwd: goDir }) + const outName = `codeg-tsnet${ext}` + execFileSync("go", ["build", "-o", outName, "."], { + stdio: "inherit", + cwd: goDir, + env: { ...process.env, CGO_ENABLED: "0" }, + }) + const built = join(goDir, outName) + if (!existsSync(built)) { + die(`expected ${built} after go build`) + } + mkdirSync(BINARIES_DIR, { recursive: true }) + const dest = join(BINARIES_DIR, `codeg-tsnet-${target}${ext}`) + copyFileSync(built, dest) + if (!isWindows) chmodSync(dest, 0o755) + log(`sidecar staged at ${dest}`) } main() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f780ebbb0..6f3b9f311 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1394,6 +1394,12 @@ mod tauri_app { model_provider_commands::update_model_provider, model_provider_commands::delete_model_provider, web::start_web_server, + web::funnel::tailscale_serve_status, + web::funnel::tailscale_serve_enable, + web::funnel::tailscale_serve_disable, + web::funnel::tailscale_funnel_status, + web::funnel::tailscale_funnel_enable, + web::funnel::tailscale_funnel_disable, web::stop_web_server, web::get_web_server_status, web::get_web_service_config, diff --git a/src-tauri/src/web/auth.rs b/src-tauri/src/web/auth.rs index 4e4d9630f..c0f711ef8 100644 --- a/src-tauri/src/web/auth.rs +++ b/src-tauri/src/web/auth.rs @@ -9,6 +9,20 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; pub const WS_EVENT_PROTOCOL: &str = "codeg-events"; const WS_TOKEN_PROTOCOL_PREFIX: &str = "codeg-token."; +/// Length-aware compare so a wrong token cannot be probed by timing. +fn token_eq(provided: &str, expected: &str) -> bool { + let a = provided.as_bytes(); + let b = expected.as_bytes(); + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + fn token_from_ws_protocols(value: &str) -> Option { value .split(',') @@ -27,7 +41,10 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo if let Some(auth_header) = request.headers().get("authorization") { if let Ok(auth_str) = auth_header.to_str() { - if auth_str.strip_prefix("Bearer ").is_some_and(|t| t == token) { + if auth_str + .strip_prefix("Bearer ") + .is_some_and(|t| token_eq(t, &token)) + { return next.run(request).await; } } @@ -35,7 +52,7 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo if let Some(protocol_header) = request.headers().get("sec-websocket-protocol") { if let Ok(protocols) = protocol_header.to_str() { - if token_from_ws_protocols(protocols).is_some_and(|t| t == token) { + if token_from_ws_protocols(protocols).is_some_and(|t| token_eq(&t, &token)) { return next.run(request).await; } } @@ -63,4 +80,13 @@ mod tests { fn ignores_invalid_ws_protocol_token() { assert!(token_from_ws_protocols("codeg-events, codeg-token.not-valid-@@@@").is_none()); } + + #[test] + fn token_eq_is_length_and_value_sensitive() { + assert!(token_eq("secret", "secret")); + assert!(!token_eq("secret", "Secret")); + assert!(!token_eq("secret", "secre")); + assert!(!token_eq("secret", "secrets")); + assert!(!token_eq("", "x")); + } } diff --git a/src-tauri/src/web/funnel.rs b/src-tauri/src/web/funnel.rs new file mode 100644 index 000000000..f11227d34 --- /dev/null +++ b/src-tauri/src/web/funnel.rs @@ -0,0 +1,336 @@ +//! Official Tailscale Serve (private tailnet) and Funnel (public HTTPS). +//! Both terminate TLS on this PC and proxy HTTP to 127.0.0.1 only. +//! Serve is the both-devices privacy model. Funnel is a public door + token. + +use std::process::Stdio; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::time::timeout; + +use crate::app_error::{AppCommandError, AppErrorCode}; + +const FUNNEL_DEADLINE: Duration = Duration::from_secs(20); +const FUNNEL_STOP_DEADLINE: Duration = Duration::from_secs(4); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FunnelStatus { + pub enabled: bool, + pub url: Option, + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub login_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, +} + +fn tailscale_program() -> String { + let windows = r"C:\Program Files\Tailscale\tailscale.exe"; + if cfg!(windows) && std::path::Path::new(windows).exists() { + return windows.to_string(); + } + "tailscale".to_string() +} + +pub fn funnel_target(port: u16) -> String { + format!("http://127.0.0.1:{port}") +} + +pub fn extract_funnel_url(status: &Value) -> Option { + let web = status.get("Web")?.as_object()?; + for key in web.keys() { + let host = key.split(':').next().unwrap_or(key); + if host.contains('.') { + return Some(format!("https://{host}")); + } + } + None +} + +pub fn extract_funnel_target(status: &Value) -> Option { + let web = status.get("Web")?.as_object()?; + for value in web.values() { + let handlers = value.get("Handlers")?.as_object()?; + for handler in handlers.values() { + if let Some(proxy) = handler.get("Proxy").and_then(Value::as_str) { + return Some(proxy.to_string()); + } + } + } + None +} + +fn target_is_loopback(target: &str) -> bool { + target.contains("127.0.0.1") || target.contains("localhost") +} + +pub fn allow_funnel(status: &Value) -> bool { + match status.get("AllowFunnel") { + Some(Value::Object(map)) => map.values().any(|value| value.as_bool() == Some(true)), + Some(Value::Bool(flag)) => *flag, + _ => false, + } +} + +fn status_from_json(raw: &str, want_public: bool) -> FunnelStatus { + let value: Value = serde_json::from_str(raw).unwrap_or(Value::Object(Default::default())); + let url = extract_funnel_url(&value); + let target = extract_funnel_target(&value); + let public = allow_funnel(&value); + let enabled = url.is_some() && public == want_public; + FunnelStatus { + enabled, + url: if enabled { url } else { None }, + target: if enabled { target } else { None }, + login_url: None, + unavailable_reason: None, + } +} + +async fn run_tailscale_with_deadline( + args: &[&str], + deadline: Duration, +) -> Result { + let mut cmd = crate::process::tokio_command(tailscale_program()); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let child = cmd.spawn().map_err(|err| { + AppCommandError::new( + AppErrorCode::DependencyMissing, + "Tailscale CLI is not available", + ) + .with_detail(err.to_string()) + })?; + let output = timeout(deadline, child.wait_with_output()) + .await + .map_err(|_| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Tailscale timed out") + })? + .map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Tailscale failed") + .with_detail(err.to_string()) + })?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + "Tailscale command failed", + ) + .with_detail(stderr.chars().take(400).collect::())); + } + Ok(stdout) +} + +async fn run_tailscale(args: &[&str]) -> Result { + run_tailscale_with_deadline(args, FUNNEL_DEADLINE).await +} + +async fn expose_status_json() -> Result { + match run_tailscale(&["serve", "status", "--json"]).await { + Ok(raw) => Ok(raw), + Err(_) => run_tailscale(&["funnel", "status", "--json"]).await, + } +} + +pub async fn serve_status_core() -> FunnelStatus { + if let Some(status) = crate::web::tsnet_sidecar::sidecar_status(false) { + return status; + } + match expose_status_json().await { + Ok(raw) => status_from_json(&raw, false), + Err(err) => FunnelStatus { + enabled: false, + url: None, + target: None, + login_url: None, + unavailable_reason: Some(err.message), + }, + } +} + +pub async fn funnel_status_core() -> FunnelStatus { + if let Some(status) = crate::web::tsnet_sidecar::sidecar_status(true) { + return status; + } + match expose_status_json().await { + Ok(raw) => status_from_json(&raw, true), + Err(err) => FunnelStatus { + enabled: false, + url: None, + target: None, + login_url: None, + unavailable_reason: Some(err.message), + }, + } +} + +pub fn require_running_web_port( + running_port: Option, + requested: u16, +) -> Result<(), AppCommandError> { + match running_port { + Some(port) if port == requested => Ok(()), + Some(_) => Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Tailscale port must match the running Web Service", + )), + None => Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Start the Web Service before enabling Tailscale", + )), + } +} + +async fn enable_expose(port: u16, public: bool) -> Result { + if crate::web::tsnet_sidecar::sidecar_available() { + return crate::web::tsnet_sidecar::sidecar_enable(port, public).await; + } + let target = funnel_target(port); + if !target_is_loopback(&target) { + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Tailscale target must be loopback", + )); + } + // Same port cannot be Serve and Funnel. Reset both, then set the mode. + let _ = run_tailscale(&["funnel", "reset"]).await; + let _ = run_tailscale(&["serve", "reset"]).await; + if public { + run_tailscale(&["funnel", "--bg", "--yes", &target]).await?; + Ok(funnel_status_core().await) + } else { + run_tailscale(&["serve", "--bg", "--yes", &target]).await?; + Ok(serve_status_core().await) + } +} + +pub async fn serve_enable_core(port: u16) -> Result { + enable_expose(port, false).await +} + +pub async fn funnel_enable_core(port: u16) -> Result { + enable_expose(port, true).await +} + +pub async fn serve_disable_core() -> Result { + expose_reset().await; + Ok(serve_status_core().await) +} + +pub async fn funnel_disable_core() -> Result { + expose_reset().await; + Ok(funnel_status_core().await) +} + +async fn expose_reset() { + crate::web::tsnet_sidecar::sidecar_disable().await; + let _ = run_tailscale(&["funnel", "reset"]).await; + let _ = run_tailscale(&["serve", "reset"]).await; +} + +/// Tear down leftover Serve/Funnel URLs without blocking Stop / quit for 20s. +pub async fn funnel_disable_best_effort() { + crate::web::tsnet_sidecar::sidecar_disable().await; + let _ = run_tailscale_with_deadline(&["funnel", "reset"], FUNNEL_STOP_DEADLINE).await; + let _ = run_tailscale_with_deadline(&["serve", "reset"], FUNNEL_STOP_DEADLINE).await; +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn tailscale_serve_status() -> Result { + Ok(serve_status_core().await) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn tailscale_serve_enable( + state: tauri::State<'_, crate::web::WebServerState>, + port: u16, +) -> Result { + let running = crate::web::do_get_web_server_status(&state).map(|info| info.port); + require_running_web_port(running, port)?; + serve_enable_core(port).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn tailscale_serve_disable() -> Result { + serve_disable_core().await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn tailscale_funnel_status() -> Result { + Ok(funnel_status_core().await) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn tailscale_funnel_enable( + state: tauri::State<'_, crate::web::WebServerState>, + port: u16, +) -> Result { + let running = crate::web::do_get_web_server_status(&state).map(|info| info.port); + require_running_web_port(running, port)?; + funnel_enable_core(port).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn tailscale_funnel_disable() -> Result { + funnel_disable_core().await +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extracts_https_url_and_loopback_proxy() { + let status = json!({ + "Web": { + "codeg.tail123.ts.net:443": { + "Handlers": { + "/": { "Proxy": "http://127.0.0.1:3080" } + } + } + } + }); + assert_eq!( + extract_funnel_url(&status).as_deref(), + Some("https://codeg.tail123.ts.net") + ); + assert_eq!( + extract_funnel_target(&status).as_deref(), + Some("http://127.0.0.1:3080") + ); + assert!(target_is_loopback("http://127.0.0.1:3080")); + assert!(!target_is_loopback("http://0.0.0.0:3080")); + assert!(require_running_web_port(Some(3080), 3080).is_ok()); + assert!(require_running_web_port(None, 3080).is_err()); + assert!(require_running_web_port(Some(3080), 4000).is_err()); + assert!(!allow_funnel(&status)); + let public = json!({ + "Web": { + "codeg.tail123.ts.net:443": { + "Handlers": { + "/": { "Proxy": "http://127.0.0.1:3080" } + } + } + }, + "AllowFunnel": { "codeg.tail123.ts.net:443": true } + }); + assert!(allow_funnel(&public)); + let serve = status_from_json(&public.to_string(), false); + let funnel = status_from_json(&public.to_string(), true); + assert!(!serve.enabled); + assert!(funnel.enabled); + } +} diff --git a/src-tauri/src/web/handlers/web_server.rs b/src-tauri/src/web/handlers/web_server.rs index 30cd210de..0b8129269 100644 --- a/src-tauri/src/web/handlers/web_server.rs +++ b/src-tauri/src/web/handlers/web_server.rs @@ -10,6 +10,10 @@ use crate::web::{ load_web_service_config, update_web_service_config_core, WebServerInfo, WebServiceConfig, WebServicePortProbe, }; +use crate::web::funnel::{ + funnel_disable_core, funnel_enable_core, funnel_status_core, require_running_web_port, + serve_disable_core, serve_enable_core, serve_status_core, FunnelStatus, +}; pub async fn get_web_server_status( Extension(state): Extension>, @@ -96,6 +100,46 @@ pub async fn probe_web_service_port( .map(Json) } +pub async fn tailscale_serve_status() -> Result, AppCommandError> { + Ok(Json(serve_status_core().await)) +} + +pub async fn tailscale_serve_enable( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let running = do_get_web_server_status(&state.web_server_state).map(|info| info.port); + require_running_web_port(running, params.port)?; + serve_enable_core(params.port).await.map(Json) +} + +pub async fn tailscale_serve_disable() -> Result, AppCommandError> { + serve_disable_core().await.map(Json) +} + +pub async fn tailscale_funnel_status() -> Result, AppCommandError> { + Ok(Json(funnel_status_core().await)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FunnelEnableParams { + pub port: u16, +} + +pub async fn tailscale_funnel_enable( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let running = do_get_web_server_status(&state.web_server_state).map(|info| info.port); + require_running_web_port(running, params.port)?; + funnel_enable_core(params.port).await.map(Json) +} + +pub async fn tailscale_funnel_disable() -> Result, AppCommandError> { + funnel_disable_core().await.map(Json) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct AppUpdateInfo { diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index d48070368..ee9b2645b 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -1,4 +1,6 @@ pub mod auth; +pub mod funnel; +pub mod tsnet_sidecar; pub mod compression; pub mod event_bridge; pub mod handlers; @@ -28,8 +30,42 @@ use crate::db::service::app_metadata_service; const WEB_SERVICE_TOKEN_KEY: &str = "web_service_token"; const WEB_SERVICE_PORT_KEY: &str = "web_service_port"; const WEB_SERVICE_AUTO_START_KEY: &str = "web_service_auto_start"; +const WEB_SERVICE_BIND_MODE_KEY: &str = "web_service_bind_mode"; pub const DEFAULT_WEB_SERVICE_PORT: u16 = 3080; +/// Where the desktop Web Service listens when the caller does not pass a host. +/// Loopback is the default so "Start" is not a LAN-wide bind. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebServiceBindMode { + #[default] + Loopback, + Lan, +} + +impl WebServiceBindMode { + fn as_str(self) -> &'static str { + match self { + Self::Loopback => "loopback", + Self::Lan => "lan", + } + } + + fn host(self) -> &'static str { + match self { + Self::Loopback => "127.0.0.1", + Self::Lan => "0.0.0.0", + } + } +} + +fn parse_bind_mode(value: Option) -> WebServiceBindMode { + match value.as_deref().map(str::trim) { + Some("lan") | Some("0.0.0.0") | Some("*") => WebServiceBindMode::Lan, + _ => WebServiceBindMode::Loopback, + } +} + pub struct WebServerState { handle: Mutex>>, shutdown_tx: Mutex>>, @@ -62,7 +98,7 @@ impl WebServerState { shutdown_signal: Arc::new(ShutdownSignal::new()), port: AtomicU16::new(0), token: Mutex::new(String::new()), - host: Mutex::new("0.0.0.0".to_string()), + host: Mutex::new("127.0.0.1".to_string()), running: std::sync::atomic::AtomicBool::new(false), } } @@ -190,6 +226,24 @@ async fn resolve_web_service_port( Ok(port) } +async fn resolve_web_service_host( + conn: &DatabaseConnection, + override_host: Option, +) -> Result { + if let Some(host) = override_host + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + return Ok(host); + } + let mode = parse_bind_mode( + app_metadata_service::get_value(conn, WEB_SERVICE_BIND_MODE_KEY) + .await + .map_err(AppCommandError::from)?, + ); + Ok(mode.host().to_string()) +} + /// Persist token and port atomically so a partial failure cannot leave /// `app_metadata` in a mixed old/new state. async fn persist_web_service_config( @@ -234,6 +288,8 @@ pub struct WebServiceConfig { pub token: Option, pub port: Option, pub auto_start: bool, + #[serde(default)] + pub bind_mode: WebServiceBindMode, } pub async fn load_web_service_config( @@ -252,10 +308,16 @@ pub async fn load_web_service_config( .await .map_err(AppCommandError::from)?, ); + let bind_mode = parse_bind_mode( + app_metadata_service::get_value(conn, WEB_SERVICE_BIND_MODE_KEY) + .await + .map_err(AppCommandError::from)?, + ); Ok(WebServiceConfig { token: token.filter(|value| !value.trim().is_empty()), port, auto_start, + bind_mode, }) } @@ -266,6 +328,7 @@ pub async fn update_web_service_config_core( let token = config.token.unwrap_or_default().trim().to_string(); let port = config.port.unwrap_or(DEFAULT_WEB_SERVICE_PORT); let auto_start = if config.auto_start { "true" } else { "false" }.to_string(); + let bind_mode = config.bind_mode.as_str().to_string(); let port_str = port.to_string(); conn.transaction::<_, (), AppCommandError>(move |txn| { @@ -279,6 +342,9 @@ pub async fn update_web_service_config_core( app_metadata_service::upsert_value(txn, WEB_SERVICE_AUTO_START_KEY, &auto_start) .await .map_err(AppCommandError::from)?; + app_metadata_service::upsert_value(txn, WEB_SERVICE_BIND_MODE_KEY, &bind_mode) + .await + .map_err(AppCommandError::from)?; Ok(()) }) }) @@ -424,12 +490,11 @@ fn is_advertisable_ipv4(ip: std::net::Ipv4Addr) -> bool { /// service. Loopback is always listed first (a safe default target), then /// every advertisable local IPv4 (see [`is_advertisable_ipv4`]). /// -/// In the desktop settings flow the listener binds `0.0.0.0`, so each -/// advertised address is reachable and the UI lets the user pick which one -/// to display / open — that choice is display-only and never changes what -/// the service binds to. If interface enumeration is unavailable we fall -/// back to the default-route UDP probe so the result never regresses below -/// the previous single-LAN-IP behavior. +/// Loopback binds advertise only that address. A LAN (`0.0.0.0`) bind +/// enumerates every local IPv4 so the UI can pick which URL to show. +/// If interface enumeration is unavailable we fall back to the +/// default-route UDP probe so the result never regresses below the +/// previous single-LAN-IP behavior. pub fn get_local_addresses(port: u16) -> Vec { use std::net::{IpAddr, Ipv4Addr}; @@ -536,7 +601,7 @@ pub(crate) async fn do_start_web_server_with_state( }; let port = resolve_web_service_port(&app_state.db.conn, port).await?; - let host = host.unwrap_or_else(|| "0.0.0.0".to_string()); + let host = resolve_web_service_host(&app_state.db.conn, host).await?; let token = resolve_web_service_token(&app_state.db.conn, token).await?; // Validate the upload-quota strict-mode posture before any I/O. A @@ -629,6 +694,8 @@ pub(crate) async fn do_start_web_server_with_state( } pub(crate) async fn do_stop_web_server(state: &WebServerState) { + // Drop the public HTTPS URL before the local listener dies. + funnel::funnel_disable_best_effort().await; let handle_opt = state.handle.lock().unwrap().take(); let shutdown_tx = state.shutdown_tx.lock().unwrap().take(); @@ -661,7 +728,7 @@ pub(crate) async fn do_stop_web_server(state: &WebServerState) { // so a concurrent start() cannot race into a bind() while the old socket lingers. state.port.store(0, Ordering::Relaxed); *state.token.lock().unwrap() = String::new(); - *state.host.lock().unwrap() = "0.0.0.0".to_string(); + *state.host.lock().unwrap() = "127.0.0.1".to_string(); state.running.store(false, Ordering::Release); tracing::info!("[WEB] Web server stopped"); } @@ -722,7 +789,7 @@ pub(crate) async fn do_start_web_server_tauri( let db = app.state::(); let port_val = resolve_web_service_port(&db.conn, port).await?; - let host_val = host.unwrap_or_else(|| "0.0.0.0".to_string()); + let host_val = resolve_web_service_host(&db.conn, host).await?; let token = resolve_web_service_token(&db.conn, token).await?; // Same strict-mode validation as `do_start_web_server_with_state`: @@ -953,6 +1020,7 @@ pub async fn probe_web_service_port( mod local_address_tests { use super::{ addresses_for_bind, advertise_host, get_local_addresses, is_advertisable_ipv4, + parse_bind_mode, WebServiceBindMode, }; use std::net::{Ipv4Addr, SocketAddr}; @@ -1055,6 +1123,19 @@ mod local_address_tests { // never leak into the list the UI offers for "open". assert!(!addrs.iter().any(|a| a.contains("0.0.0.0"))); + assert_eq!( + parse_bind_mode(None), + WebServiceBindMode::Loopback + ); + assert_eq!( + parse_bind_mode(Some("lan".into())), + WebServiceBindMode::Lan + ); + assert_eq!( + parse_bind_mode(Some("loopback".into())), + WebServiceBindMode::Loopback + ); + // Every entry uses the http scheme, carries the requested port, and // is unique (enumeration de-dupes addresses seen on multiple ifaces). let mut seen = std::collections::HashSet::new(); diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index faae6d2cb..f43a264db 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1061,6 +1061,30 @@ pub fn build_router( "/probe_web_service_port", post(handlers::web_server::probe_web_service_port), ) + .route( + "/tailscale_serve_status", + post(handlers::web_server::tailscale_serve_status), + ) + .route( + "/tailscale_serve_enable", + post(handlers::web_server::tailscale_serve_enable), + ) + .route( + "/tailscale_serve_disable", + post(handlers::web_server::tailscale_serve_disable), + ) + .route( + "/tailscale_funnel_status", + post(handlers::web_server::tailscale_funnel_status), + ) + .route( + "/tailscale_funnel_enable", + post(handlers::web_server::tailscale_funnel_enable), + ) + .route( + "/tailscale_funnel_disable", + post(handlers::web_server::tailscale_funnel_disable), + ) .route( "/check_app_update", post(handlers::web_server::check_app_update), diff --git a/src-tauri/src/web/tsnet_sidecar.rs b/src-tauri/src/web/tsnet_sidecar.rs new file mode 100644 index 000000000..eb41b0bf2 --- /dev/null +++ b/src-tauri/src/web/tsnet_sidecar.rs @@ -0,0 +1,219 @@ +//! Bundled userspace Tailscale (`codeg-tsnet`). No Tailscale app on the PC. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Mutex; +use std::time::Duration; + +use serde::Deserialize; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Child; +use tokio::time::timeout; + +use super::funnel::{funnel_target, FunnelStatus}; +use crate::app_error::{AppCommandError, AppErrorCode}; + +const START_DEADLINE: Duration = Duration::from_secs(180); + +#[derive(Debug, Clone)] +struct SidecarState { + public: bool, + url: Option, + login_url: Option, + target: String, +} + +struct SidecarProc { + child: Child, + state: SidecarState, +} + +static SIDECAR: Mutex> = Mutex::new(None); + +#[derive(Debug, Deserialize)] +struct SidecarEvent { + event: String, + url: Option, + #[allow(dead_code)] + mode: Option, + message: Option, +} + +pub fn locate_codeg_tsnet() -> Option { + let filename = if cfg!(windows) { + "codeg-tsnet.exe" + } else { + "codeg-tsnet" + }; + if let Some(raw) = std::env::var_os("CODEG_TSNET_BIN") { + let candidate = PathBuf::from(raw); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + if let Some(dir) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + { + let candidate = dir.join(filename); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + which::which(filename).ok().filter(|p| is_executable_file(p)) +} + +fn is_executable_file(path: &Path) -> bool { + path.is_file() && std::fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false) +} + +fn state_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("app.codeg") + .join("tsnet") +} + +pub fn sidecar_status(want_public: bool) -> Option { + let guard = SIDECAR.lock().ok()?; + let proc = guard.as_ref()?; + if proc.state.public != want_public { + return Some(FunnelStatus { + enabled: false, + url: None, + target: None, + login_url: None, + unavailable_reason: None, + }); + } + Some(FunnelStatus { + enabled: proc.state.url.is_some() || proc.state.login_url.is_some(), + url: proc.state.url.clone(), + target: Some(proc.state.target.clone()), + login_url: proc.state.login_url.clone(), + unavailable_reason: None, + }) +} + +pub async fn sidecar_enable(port: u16, public: bool) -> Result { + sidecar_disable().await; + let binary = locate_codeg_tsnet().ok_or_else(|| { + AppCommandError::new( + AppErrorCode::DependencyMissing, + "Codeg private networking is not in this build. Rebuild Codeg (it bundles Tailscale).", + ) + })?; + let target = funnel_target(port); + let mut cmd = crate::process::tokio_command(&binary); + cmd.arg("--target") + .arg(&target) + .arg("--hostname") + .arg("codeg") + .arg("--state-dir") + .arg(state_dir()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if public { + cmd.arg("--public"); + } + let mut child = cmd.spawn().map_err(|err| { + AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + "Could not start Codeg private networking", + ) + .with_detail(err.to_string()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "sidecar stdout missing") + })?; + + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + let mut login_url = None; + let mut url = None; + let started = timeout(START_DEADLINE, async { + loop { + line.clear(); + let n = reader.read_line(&mut line).await.map_err(|err| { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "sidecar read") + .with_detail(err.to_string()) + })?; + if n == 0 { + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + "Codeg private networking exited before it was ready", + )); + } + let Ok(event) = serde_json::from_str::(line.trim()) else { + continue; + }; + match event.event.as_str() { + "auth_url" => { + login_url = event.url; + if url.is_some() { + return Ok(()); + } + } + "ready" => { + url = event.url; + return Ok(()); + } + "error" => { + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + event.message.unwrap_or_else(|| "sidecar failed".into()), + )); + } + _ => {} + } + } + }) + .await; + + match started { + Ok(Ok(())) => {} + Ok(Err(err)) => { + let _ = child.kill().await; + return Err(err); + } + Err(_) => { + let _ = child.kill().await; + return Err(AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + "Timed out waiting for Codeg private networking", + )); + } + } + + let status = FunnelStatus { + enabled: url.is_some() || login_url.is_some(), + url: url.clone(), + target: Some(target.clone()), + login_url: login_url.clone(), + unavailable_reason: None, + }; + *SIDECAR.lock().unwrap() = Some(SidecarProc { + child, + state: SidecarState { + public, + url, + login_url, + target, + }, + }); + Ok(status) +} + +pub async fn sidecar_disable() { + let child = SIDECAR.lock().ok().and_then(|mut g| g.take()); + if let Some(mut proc) = child { + let _ = proc.child.kill().await; + let _ = proc.child.wait().await; + } +} + +pub fn sidecar_available() -> bool { + locate_codeg_tsnet().is_some() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2aa21f4c7..968724d7e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "resources": { "../out": "web/" }, - "externalBin": ["binaries/codeg-mcp"], + "externalBin": ["binaries/codeg-mcp", "binaries/codeg-tsnet"], "windows": { "nsis": { "installerHooks": "./windows/installer-hooks.nsh" diff --git a/src/components/settings/web-service-settings.tsx b/src/components/settings/web-service-settings.tsx index b31f61f7f..d68dbcd68 100644 --- a/src/components/settings/web-service-settings.tsx +++ b/src/components/settings/web-service-settings.tsx @@ -35,6 +35,13 @@ import { getWebServiceConfig, updateWebServiceConfig, probeWebServicePort, + tailscaleFunnelDisable, + tailscaleFunnelEnable, + tailscaleFunnelStatus, + tailscaleServeDisable, + tailscaleServeEnable, + tailscaleServeStatus, + type FunnelStatus, type WebServerInfo, type WebServicePortProbe, } from "@/lib/api" @@ -43,6 +50,8 @@ const DEFAULT_PORT = 3080 import { openUrl } from "@/lib/platform" import { copyTextToClipboard } from "@/lib/utils" import { useCopiedFlag } from "@/hooks/use-copied-flag" +import { displayAddresses } from "@/lib/tailscale-funnel" +import { pairingMode, pairingQrValue } from "@/lib/codeg-pairing" // Remembers which reachable address the user last chose to display/open. // Keyed by host (IP) only, so the choice survives a port change. @@ -78,11 +87,13 @@ function AddressBar({ addresses, hasMultiple, onSelect, + qrValue, }: { address: string addresses: string[] hasMultiple: boolean onSelect: (address: string) => void + qrValue: string }) { const t = useTranslations("WebServiceSettings") const [copied, markCopied] = useCopiedFlag() @@ -149,7 +160,8 @@ function AddressBar({ @@ -159,10 +171,12 @@ function AddressBar({ function AddressQrcodeDialog({ open, address, + displayAddress, onOpenChange, }: { open: boolean address: string + displayAddress: string onOpenChange: (open: boolean) => void }) { const t = useTranslations("WebServiceSettings") @@ -177,7 +191,7 @@ function AddressQrcodeDialog({ - {address} + {displayAddress}

{t("qrcodeHint")} @@ -286,6 +300,9 @@ export function WebServiceSettings() { const [error, setError] = useState("") const [portProbe, setPortProbe] = useState(null) const [autoStart, setAutoStart] = useState(false) + const [lanAccess, setLanAccess] = useState(false) + const [serve, setServe] = useState(null) + const [funnel, setFunnel] = useState(null) const [configLoaded, setConfigLoaded] = useState(false) const [selectedAddress, setSelectedAddress] = useState(null) @@ -304,6 +321,7 @@ export function WebServiceSettings() { token: null, port: null, autoStart: false, + bindMode: "loopback" as const, } const [info, configResult] = await Promise.all([ getWebServerStatus(), @@ -314,6 +332,18 @@ export function WebServiceSettings() { const savedConfig = configResult.config setStatus(info) setAutoStart(savedConfig.autoStart ?? false) + setLanAccess(savedConfig.bindMode === "lan") + try { + const [serveStatus, funnelStatus] = await Promise.all([ + tailscaleServeStatus(), + tailscaleFunnelStatus(), + ]) + setServe(serveStatus) + setFunnel(funnelStatus) + } catch { + setServe(null) + setFunnel(null) + } if (info) { setPort(String(info.port)) setToken(info.token) @@ -340,15 +370,29 @@ export function WebServiceSettings() { // Pick which reachable address to display/open. Keep a still-valid prior // choice; otherwise honor the remembered host, falling back to the first - // entry (loopback). Selection is display-only — the service always binds - // 0.0.0.0, so every listed address stays reachable regardless of choice. + // entry (loopback). A loopback bind only advertises 127.0.0.1. LAN bind + // advertises every local IPv4; the selector is display-only. useEffect(() => { - const addresses = status?.addresses ?? [] + const addresses = displayAddresses( + status?.addresses ?? [], + serve?.url ?? funnel?.url + ) if (addresses.length === 0) { setSelectedAddress(null) return } setSelectedAddress((prev) => { + const remoteUrl = serve?.url ?? funnel?.url ?? null + if ( + remoteUrl && + addresses.includes(remoteUrl) && + (!prev || + !addresses.includes(prev) || + addressHost(prev) === "127.0.0.1" || + addressHost(prev) === "localhost") + ) { + return remoteUrl + } if (prev && addresses.includes(prev)) return prev const savedHost = readSavedDisplayHost() const matched = savedHost @@ -356,7 +400,7 @@ export function WebServiceSettings() { : undefined return matched ?? addresses[0] }) - }, [status]) + }, [status, serve?.url, funnel?.url]) function handleSelectAddress(address: string) { setSelectedAddress(address) @@ -372,7 +416,7 @@ export function WebServiceSettings() { } const persistWebServiceConfig = useCallback( - async (nextAutoStart = autoStart) => { + async (nextAutoStart = autoStart, nextLanAccess = lanAccess) => { const portNum = parseInt(port, 10) if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) { return @@ -383,12 +427,13 @@ export function WebServiceSettings() { port: portNum, token: token.trim() || null, autoStart: nextAutoStart, + bindMode: nextLanAccess ? "lan" : "loopback", }) } catch { setError(t("saveConfigFailed")) } }, - [autoStart, port, t, token] + [autoStart, lanAccess, port, t, token] ) useEffect(() => { @@ -421,6 +466,7 @@ export function WebServiceSettings() { const portNum = parseInt(port, 10) || DEFAULT_PORT const info = await startWebServer({ port: portNum, + host: lanAccess ? "0.0.0.0" : "127.0.0.1", token: token.trim() || null, }) setStatus(info) @@ -458,6 +504,8 @@ export function WebServiceSettings() { try { await stopWebServer() setStatus(null) + setServe(null) + setFunnel(null) // After stop, re-probe so the user can see whether the port was // released cleanly or is being held by an orphan child process. probePort(parseInt(port, 10) || DEFAULT_PORT) @@ -469,8 +517,12 @@ export function WebServiceSettings() { } const isRunning = status !== null - const currentAddress = selectedAddress ?? status?.addresses[0] ?? null - const hasMultipleAddresses = (status?.addresses.length ?? 0) > 1 + const visibleAddresses = displayAddresses( + status?.addresses ?? [], + serve?.url ?? funnel?.url + ) + const currentAddress = selectedAddress ?? visibleAddresses[0] ?? null + const hasMultipleAddresses = visibleAddresses.length > 1 const showStaleBanner = !isRunning && portProbe !== null && @@ -549,6 +601,115 @@ export function WebServiceSettings() { +

+ +
+ { + setLanAccess(checked) + void persistWebServiceConfig(autoStart, checked) + }} + /> + + {t("lanAccessHint")} + +
+
+ +
+ +
+
+ { + void (async () => { + setLoading(true) + setError("") + try { + const portNum = parseInt(port, 10) || DEFAULT_PORT + const next = checked + ? await tailscaleServeEnable(portNum) + : await tailscaleServeDisable() + setServe(next) + if (checked) setFunnel({ enabled: false }) + if (next.loginUrl) { + void openUrl(next.loginUrl) + } + if (next.unavailableReason) { + setError(next.unavailableReason) + } + } catch (e: unknown) { + const msg = + e && typeof e === "object" && "message" in e + ? String((e as { message: string }).message) + : t("privateFailed") + setError(msg) + } finally { + setLoading(false) + } + })() + }} + /> + + {t("privateHint")} + +
+ {serve?.url ? ( + {serve.url} + ) : null} +
+
+ +
+ +
+
+ { + void (async () => { + setLoading(true) + setError("") + try { + const portNum = parseInt(port, 10) || DEFAULT_PORT + const next = checked + ? await tailscaleFunnelEnable(portNum) + : await tailscaleFunnelDisable() + setFunnel(next) + if (checked) setServe({ enabled: false }) + if (next.loginUrl) { + void openUrl(next.loginUrl) + } + if (next.unavailableReason) { + setError(next.unavailableReason) + } + } catch (e: unknown) { + const msg = + e && typeof e === "object" && "message" in e + ? String((e as { message: string }).message) + : t("anywhereFailed") + setError(msg) + } finally { + setLoading(false) + } + })() + }} + /> + + {t("anywhereHint")} + +
+ {funnel?.url ? ( + {funnel.url} + ) : null} +
+
+ {/* Start/Stop button */}
@@ -573,10 +734,8 @@ export function WebServiceSettings() { {error &&

{error}

} - {/* Address (only when running). The listener is bound to - 0.0.0.0, so every local IP reaches the service; the selector - only changes which address is shown and opened by the arrow — - it never changes what the service actually listens on. */} + {/* Address (only when running). Loopback bind shows 127.0.0.1. + LAN bind lists every local IPv4; the selector is display-only. */} {isRunning && currentAddress && (
@@ -584,9 +743,17 @@ export function WebServiceSettings() {
{hasMultipleAddresses && (

diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bdf81590d..ff897155d 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3472,6 +3472,14 @@ "status": "الحالة", "autoStart": "تشغيل تلقائي", "autoStartHint": "تشغيل خدمة الويب عند بدء Codeg", + "lanAccess": "LAN", + "lanAccessHint": "الاستماع على كل الواجهات حتى يتصل هاتف على الشبكة نفسها. الإيقاف يبقي الخدمة على هذا الجهاز فقط.", + "private": "خاص", + "privateHint": "Tailscale Serve: شبكتك فقط. نفس خصوصية Tailscale على الجهازين. مشفّر بـ WireGuard. يجب أن ينضم الهاتف إلى الشبكة.", + "privateFailed": "تعذر بدء Tailscale Serve", + "anywhere": "عام", + "anywhereHint": "Tailscale Funnel: HTTPS عام. مشفّر، لكن أي شخص معه الرابط والرمز يمكنه الاتصال. أقل خصوصية من Tailscale على الجهازين.", + "anywhereFailed": "تعذر بدء Tailscale Funnel", "running": "قيد التشغيل", "stopped": "متوقف", "processing": "جارٍ المعالجة...", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c40db5d3..0a9c34897 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3472,6 +3472,14 @@ "status": "Status", "autoStart": "Automatisch starten", "autoStartHint": "Webdienst beim Start von Codeg starten", + "lanAccess": "LAN", + "lanAccessHint": "Auf allen Schnittstellen lauschen, damit ein Telefon im selben Netz verbinden kann. Aus bleibt der Dienst nur auf diesem Rechner.", + "private": "Privat", + "privateHint": "Tailscale Serve: nur dein Tailnet. Gleiche Privatsphäre wie Tailscale auf beiden Geräten. WireGuard-verschlüsselt. Das Telefon muss im Tailnet sein.", + "privateFailed": "Tailscale Serve konnte nicht gestartet werden", + "anywhere": "Öffentlich", + "anywhereHint": "Tailscale Funnel: öffentliches HTTPS. Verschlüsselt, aber jeder mit URL und Token kommt rein. Nicht so privat wie Tailscale auf beiden Geräten.", + "anywhereFailed": "Tailscale Funnel konnte nicht gestartet werden", "running": "Läuft", "stopped": "Gestoppt", "processing": "Verarbeitung...", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 273af3aa0..01b07d13a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3465,13 +3465,21 @@ } }, "WebServiceSettings": { - "addressSwitchHint": "Switching only changes the address shown and opened here — the service listens on all interfaces and stays reachable at every address.", + "addressSwitchHint": "This only changes the address shown here. Turn on LAN access to listen on every interface.", "sectionTitle": "Web Service", - "sectionDescription": "Enable to access Codeg remotely via browser", + "sectionDescription": "Start a local Web Service for the phone or browser client. Default is this computer only.", "port": "Port", "status": "Status", "autoStart": "Auto-start", "autoStartHint": "Start the Web service when Codeg launches", + "lanAccess": "LAN", + "lanAccessHint": "Listen on every interface so a phone on the same network can connect. Off keeps the service on this computer only.", + "private": "Private", + "privateHint": "Built into Codeg. No Tailscale app. Only your tailnet. Same privacy as Tailscale on both devices.", + "privateFailed": "Could not start Tailscale Serve", + "anywhere": "Public", + "anywhereHint": "Tailscale Funnel: public HTTPS. Encrypted, but anyone with the URL and token can connect. Not as private as both-devices Tailscale.", + "anywhereFailed": "Could not start Tailscale Funnel", "running": "Running", "stopped": "Stopped", "processing": "Processing...", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9948070a4..9e9e9f83b 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3472,6 +3472,14 @@ "status": "Estado", "autoStart": "Inicio automático", "autoStartHint": "Iniciar el servicio web al abrir Codeg", + "lanAccess": "LAN", + "lanAccessHint": "Escuchar en todas las interfaces para que un teléfono de la misma red pueda conectar. Desactivado deja el servicio solo en este equipo.", + "private": "Privado", + "privateHint": "Tailscale Serve: solo tu tailnet. La misma privacidad que Tailscale en ambos dispositivos. Cifrado WireGuard. El teléfono debe unirse al tailnet.", + "privateFailed": "No se pudo iniciar Tailscale Serve", + "anywhere": "Público", + "anywhereHint": "Tailscale Funnel: HTTPS público. Cifrado, pero cualquiera con la URL y el token puede entrar. No es tan privado como Tailscale en ambos dispositivos.", + "anywhereFailed": "No se pudo iniciar Tailscale Funnel", "running": "En ejecución", "stopped": "Detenido", "processing": "Procesando...", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9638508d6..7f1d8f1ec 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3472,6 +3472,14 @@ "status": "Statut", "autoStart": "Démarrage auto", "autoStartHint": "Démarrer le service Web au lancement de Codeg", + "lanAccess": "LAN", + "lanAccessHint": "Écouter sur toutes les interfaces pour qu’un téléphone du même réseau puisse se connecter. Désactivé = cet ordinateur uniquement.", + "private": "Privé", + "privateHint": "Tailscale Serve : uniquement votre tailnet. Même confidentialité que Tailscale sur les deux appareils. Chiffré WireGuard. Le téléphone doit rejoindre le tailnet.", + "privateFailed": "Impossible de démarrer Tailscale Serve", + "anywhere": "Public", + "anywhereHint": "Tailscale Funnel : HTTPS public. Chiffré, mais quiconque a l'URL et le jeton peut se connecter. Moins privé que Tailscale sur les deux appareils.", + "anywhereFailed": "Impossible de démarrer Tailscale Funnel", "running": "En cours", "stopped": "Arrêté", "processing": "Traitement...", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f44e8c88d..6a7ed229e 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3472,6 +3472,14 @@ "status": "ステータス", "autoStart": "自動起動", "autoStartHint": "Codeg の起動時に Web サービスを開始", + "lanAccess": "LAN", + "lanAccessHint": "同じネットワークのスマホが接続できるよう全インターフェースで待受します。オフだとこのコンピュータのみです。", + "private": "プライベート", + "privateHint": "Tailscale Serve: 自分の tailnet のみ。両端末に Tailscale を入れるのと同じプライバシー。WireGuard 暗号化。スマホは tailnet に入る必要があります。", + "privateFailed": "Tailscale Serve を開始できませんでした", + "anywhere": "公開", + "anywhereHint": "Tailscale Funnel: 公開 HTTPS。暗号化されますが、URL とトークンを持つ人は接続できます。両端末 Tailscale より私的ではありません。", + "anywhereFailed": "Tailscale Funnel を開始できませんでした", "running": "実行中", "stopped": "停止中", "processing": "処理中...", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8c404715..b2665708c 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3472,6 +3472,14 @@ "status": "상태", "autoStart": "자동 시작", "autoStartHint": "Codeg가 시작될 때 웹 서비스를 시작합니다", + "lanAccess": "LAN", + "lanAccessHint": "같은 네트워크의 휴대폰이 연결되도록 모든 인터페이스에서 수신합니다. 끄면 이 컴퓨터에서만 열립니다.", + "private": "비공개", + "privateHint": "Tailscale Serve: 내 tailnet만. 두 기기에 Tailscale을 설치한 것과 같은 프라이버시. WireGuard 암호화. 휴대폰이 tailnet에 있어야 합니다.", + "privateFailed": "Tailscale Serve를 시작할 수 없습니다", + "anywhere": "공개", + "anywhereHint": "Tailscale Funnel: 공개 HTTPS. 암호화되지만 URL과 토큰이 있으면 누구나 접속할 수 있습니다. 양쪽 Tailscale보다 덜 비공개입니다.", + "anywhereFailed": "Tailscale Funnel을 시작할 수 없습니다", "running": "실행 중", "stopped": "중지됨", "processing": "처리 중...", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5b31f9ead..04aad698f 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3472,6 +3472,14 @@ "status": "Status", "autoStart": "Início automático", "autoStartHint": "Iniciar o serviço Web ao abrir o Codeg", + "lanAccess": "LAN", + "lanAccessHint": "Ouvir em todas as interfaces para um telemóvel na mesma rede ligar. Desligado mantém o serviço só neste computador.", + "private": "Privado", + "privateHint": "Tailscale Serve: só a tua tailnet. A mesma privacidade que Tailscale nos dois dispositivos. Encriptado com WireGuard. O telemóvel tem de entrar na tailnet.", + "privateFailed": "Não foi possível iniciar o Tailscale Serve", + "anywhere": "Público", + "anywhereHint": "Tailscale Funnel: HTTPS público. Encriptado, mas quem tiver o URL e o token consegue ligar. Não é tão privado como Tailscale nos dois dispositivos.", + "anywhereFailed": "Não foi possível iniciar o Tailscale Funnel", "running": "Em execução", "stopped": "Parado", "processing": "Processando...", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f6a2638c1..0513a3b8e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3472,6 +3472,14 @@ "status": "状态", "autoStart": "自动启动", "autoStartHint": "Codeg 启动时自动开启 Web 服务", + "lanAccess": "局域网", + "lanAccessHint": "在所有网卡上监听,同一网络的手机才能连接。关闭则仅本机可访问。", + "private": "私有", + "privateHint": "Tailscale Serve:仅限你的 tailnet。与两台设备都装 Tailscale 同等隐私。WireGuard 加密。手机必须加入 tailnet。", + "privateFailed": "无法启动 Tailscale Serve", + "anywhere": "公开", + "anywhereHint": "Tailscale Funnel:公开 HTTPS。已加密,但任何人拿到网址和令牌都能连。不如两台设备都装 Tailscale 私密。", + "anywhereFailed": "无法启动 Tailscale Funnel", "running": "运行中", "stopped": "已停止", "processing": "处理中...", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 13e7b6eae..0fc3d505b 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3472,6 +3472,14 @@ "status": "狀態", "autoStart": "自動啟動", "autoStartHint": "Codeg 啟動時自動開啟 Web 服務", + "lanAccess": "區域網路", + "lanAccessHint": "在所有介面上監聽,同一網路的手機才能連線。關閉則僅本機可存取。", + "private": "私有", + "privateHint": "Tailscale Serve:僅限你的 tailnet。與兩台裝置都裝 Tailscale 同等隱私。WireGuard 加密。手機必須加入 tailnet。", + "privateFailed": "無法啟動 Tailscale Serve", + "anywhere": "公開", + "anywhereHint": "Tailscale Funnel:公開 HTTPS。已加密,但任何人拿到網址和權杖都能連。不如兩台裝置都裝 Tailscale 私密。", + "anywhereFailed": "無法啟動 Tailscale Funnel", "running": "執行中", "stopped": "已停止", "processing": "處理中...", diff --git a/src/lib/api.ts b/src/lib/api.ts index 9b547f2cf..daab9d5c3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4169,10 +4169,13 @@ export async function getWebServerStatus(): Promise { return getTransport().call("get_web_server_status") } +export type WebServiceBindMode = "loopback" | "lan" + export interface WebServiceConfig { token: string | null port: number | null autoStart: boolean + bindMode?: WebServiceBindMode } export async function getWebServiceConfig(): Promise { @@ -4192,6 +4195,42 @@ export interface WebServicePortProbe { state: WebServicePortState } +export type FunnelStatus = { + enabled: boolean + url?: string | null + target?: string | null + loginUrl?: string | null + unavailableReason?: string | null +} + +export async function tailscaleServeStatus(): Promise { + return getTransport().call("tailscale_serve_status") +} + +export async function tailscaleServeEnable( + port: number +): Promise { + return getTransport().call("tailscale_serve_enable", { port }) +} + +export async function tailscaleServeDisable(): Promise { + return getTransport().call("tailscale_serve_disable") +} + +export async function tailscaleFunnelStatus(): Promise { + return getTransport().call("tailscale_funnel_status") +} + +export async function tailscaleFunnelEnable( + port: number +): Promise { + return getTransport().call("tailscale_funnel_enable", { port }) +} + +export async function tailscaleFunnelDisable(): Promise { + return getTransport().call("tailscale_funnel_disable") +} + export async function probeWebServicePort( port?: number ): Promise { diff --git a/src/lib/codeg-pairing.test.ts b/src/lib/codeg-pairing.test.ts new file mode 100644 index 000000000..c1802fb45 --- /dev/null +++ b/src/lib/codeg-pairing.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" +import { pairingMode, pairingQrValue } from "./codeg-pairing" + +describe("codeg pairing QR", () => { + it("encodes url, token, and private mode for iOS scan", () => { + const raw = pairingQrValue({ + url: "https://codeg.tail123.ts.net", + token: "secret-token", + mode: "private", + }) + expect(JSON.parse(raw)).toEqual({ + url: "https://codeg.tail123.ts.net", + token: "secret-token", + mode: "private", + name: "Codeg", + }) + }) + + it("falls back to the bare URL when there is no token", () => { + expect( + pairingQrValue({ + url: "http://127.0.0.1:3080", + token: " ", + mode: "local", + }) + ).toBe("http://127.0.0.1:3080") + }) + + it("picks private over public", () => { + expect(pairingMode({ serveEnabled: true, funnelEnabled: true })).toBe( + "private" + ) + expect(pairingMode({ funnelEnabled: true })).toBe("public") + expect(pairingMode({})).toBe("local") + }) +}) diff --git a/src/lib/codeg-pairing.ts b/src/lib/codeg-pairing.ts new file mode 100644 index 000000000..c05660a8e --- /dev/null +++ b/src/lib/codeg-pairing.ts @@ -0,0 +1,39 @@ +/** + * Desktop Web Service QR payload. Keep in lock-step with + * CodegiOS/Networking/CodegPairing.swift. + */ + +export type PairingMode = "local" | "private" | "public" + +export type PairingPayload = { + url: string + token: string + mode: PairingMode + name: string +} + +export function pairingQrValue(input: { + url: string + token: string + mode: PairingMode + name?: string +}): string { + const token = input.token.trim() + if (!token) return input.url + const payload: PairingPayload = { + url: input.url, + token, + mode: input.mode, + name: input.name ?? "Codeg", + } + return JSON.stringify(payload) +} + +export function pairingMode(input: { + serveEnabled?: boolean + funnelEnabled?: boolean +}): PairingMode { + if (input.serveEnabled) return "private" + if (input.funnelEnabled) return "public" + return "local" +} diff --git a/src/lib/tailscale-funnel.test.ts b/src/lib/tailscale-funnel.test.ts new file mode 100644 index 000000000..f8427b699 --- /dev/null +++ b/src/lib/tailscale-funnel.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" +import { + FunnelError, + displayAddresses, + funnelDisableArgs, + funnelEnableArgs, + funnelTarget, + isLoopbackTarget, + serveDisableArgs, + serveEnableArgs, +} from "./tailscale-funnel" + +describe("tailscale serve and funnel commands", () => { + it("only targets loopback HTTP", () => { + expect(funnelTarget(3080)).toBe("http://127.0.0.1:3080") + expect(serveEnableArgs(3080)).toEqual([ + "serve", + "--bg", + "--yes", + "http://127.0.0.1:3080", + ]) + expect(serveDisableArgs()).toEqual(["serve", "reset"]) + expect(funnelEnableArgs(3080)).toEqual([ + "funnel", + "--bg", + "--yes", + "http://127.0.0.1:3080", + ]) + expect(funnelDisableArgs()).toEqual(["funnel", "reset"]) + expect(isLoopbackTarget("http://127.0.0.1:3080")).toBe(true) + expect(isLoopbackTarget("http://0.0.0.0:3080")).toBe(false) + expect(isLoopbackTarget("http://192.168.1.5:3080")).toBe(false) + }) + + it("rejects a bad port", () => { + expect(() => funnelTarget(0)).toThrow(FunnelError) + expect(() => funnelTarget(70_000)).toThrow(FunnelError) + }) + + it("puts the public HTTPS URL first", () => { + expect( + displayAddresses( + ["http://127.0.0.1:3080"], + "https://codeg.tail123.ts.net" + ) + ).toEqual(["https://codeg.tail123.ts.net", "http://127.0.0.1:3080"]) + expect(displayAddresses(["http://127.0.0.1:3080"], null)).toEqual([ + "http://127.0.0.1:3080", + ]) + }) +}) diff --git a/src/lib/tailscale-funnel.ts b/src/lib/tailscale-funnel.ts new file mode 100644 index 000000000..afecf6bc5 --- /dev/null +++ b/src/lib/tailscale-funnel.ts @@ -0,0 +1,65 @@ +/** + * Tailscale Serve / Funnel command builders. + * + * Serve is private to the tailnet (same privacy as Tailscale on both devices). + * Funnel is public HTTPS. Relays do not decrypt either path. + * The local target is always loopback. + */ + +export class FunnelError extends Error { + constructor(message: string) { + super(message) + this.name = "FunnelError" + } +} + +export function funnelTarget(port: number): string { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new FunnelError("port must be an integer 1-65535") + } + return `http://127.0.0.1:${port}` +} + +export function serveEnableArgs(port: number): string[] { + return ["serve", "--bg", "--yes", funnelTarget(port)] +} + +export function serveDisableArgs(): string[] { + return ["serve", "reset"] +} + +export function serveStatusArgs(): string[] { + return ["serve", "status", "--json"] +} + +export function funnelEnableArgs(port: number): string[] { + return ["funnel", "--bg", "--yes", funnelTarget(port)] +} + +export function funnelDisableArgs(): string[] { + return ["funnel", "reset"] +} + +export function funnelStatusArgs(): string[] { + return ["funnel", "status", "--json"] +} + +export function isLoopbackTarget(target: string): boolean { + try { + const url = new URL(target) + return url.hostname === "127.0.0.1" || url.hostname === "localhost" + } catch { + return false + } +} + +/** Public Funnel HTTPS first, then local bind addresses. */ +export function displayAddresses( + local: string[], + funnelUrl?: string | null +): string[] { + if (funnelUrl && !local.includes(funnelUrl)) { + return [funnelUrl, ...local] + } + return local +}