Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EXPERIMENTAL] prototyping a next-generation cross-platform outline client with the sdk #193

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions x/examples/outline-vpn-app/.air.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# .air.conf
# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "."
tmp_dir = "output/air"

[build]
# Just plain old shell command. You could use `make` as well.
cmd = "go build -o ./output/air/main ."
# Binary file yields from `cmd`.
bin = "output/air/main"
# Customize binary.
full_bin = "APP_ENV=dev APP_USER=air ./output/air/main"
# Watch these filename extensions.
include_ext = ["go"]
# Ignore these filename extensions or directories.
exclude_dir = []
# Watch these directories if you specified.
include_dir = ["fullstack_app"]
# Exclude files.
exclude_file = []
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 1000 # ms
# Stop to run old binary when build errors occur.
stop_on_error = true
# This log file places in your tmp_dir.
log = "output/air/errors.log"

[log]
# Show log time
time = false

[color]
# Customize each part's color. If no color found, use the raw app log.
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"

[misc]
# Delete tmp directory on exit
clean_on_exit = true
1 change: 1 addition & 0 deletions x/examples/outline-vpn-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
output
45 changes: 45 additions & 0 deletions x/examples/outline-vpn-app/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@

OUTDIR=$(CURDIR)/output
BUILDDIR=$(OUTDIR)/build

GOBIN=$(OUTDIR)/bin
GOMOBILE=$(GOBIN)/gomobile
GOBIND=env PATH="$(GOBIN):$(PATH)" "$(GOMOBILE)" bind

IMPORT_HOST=github.com
IMPORT_PATH=$(IMPORT_HOST)/outline_prototype

ROOT_GO_PKG=fullstack_app

.PHONY: android apple watch all
all: android apple
watch:
mkdir -p ${OUTDIR}
templ generate -watch &
go run github.com/cosmtrek/air

android: $(BUILDDIR)/android/fullstack_app.aar

$(BUILDDIR)/android/fullstack_app.aar: $(GOMOBILE) fullstack_app/*_templ.go
mkdir -p "$(BUILDDIR)/android"
$(GOBIND) -o $(BUILDDIR)/android/fullstack_app.aar -target=android ./fullstack_app

apple: $(BUILDDIR)/apple/fullstack_app.xcframework

$(BUILDDIR)/apple/fullstack_app.xcframework: $(GOMOBILE) fullstack_app/*_templ.go
mkdir -p "$(BUILDDIR)/apple"
$(GOBIND) \
-o $(BUILDDIR)/apple/fullstack_app.xcframework \
-target=ios,iossimulator,maccatalyst \
-iosversion=13.1 \
./fullstack_app

fullstack_app/*_templ.go: fullstack_app/*.templ
templ generate

$(GOMOBILE): go.mod
env GOBIN="$(GOBIN)" go install golang.org/x/mobile/cmd/gomobile
env GOBIN="$(GOBIN)" $(GOMOBILE) init

go.mod:
go mod tidy
1 change: 1 addition & 0 deletions x/examples/outline-vpn-app/app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*_templ.go
113 changes: 113 additions & 0 deletions x/examples/outline-vpn-app/app/server_view.templ
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package app

templ serverView() {
<html>
<head>
<style>
body, main, main * {
all: initial;
font-family: system-ui;
box-sizing: border-box;
}
main {
width: 100vw;
min-height: 100svh;
display: flex;
flex-direction: column;
}

header {
width: 100%;
background-color: hsl(170, 45%, 15%);
padding: 1rem 0;
flex-shrink: 0;
}

h1 {
display: block;
color: white;
font-size: 2rem;
text-align: center;
width: 100%;
}

article {
background-color: hsl(170, 3%, 95%);
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
}

button {
cursor: pointer;
border: 0.5rem dashed hsl(170, 5%, 45%);
background-color: hsl(170, 8%, 85%);
width: clamp(240px, 33vw, 480px);
height: clamp(240px, 33vw, 480px);
border-radius: 100%;
color: hsl(170, 5%, 45%);
text-align: center;
font-size: 1.5rem;
font-weight: bold;
transition-duration: 1000ms;
transition-property: background-color, color, border;
transition-timing-function: ease;
}

button.connected {
border-style: solid;
border-color: hsl(170, 40%, 55%);
background: hsl(170, 40%, 55%);
color: hsl(170, 8%, 85%);
}

h2, h3 {
display: block;
}

h2 {
font-weight: bold;
}

.info {
flex-grow: 1;
}

.caret {
flex-shrink: 0;
font-size: 1,5rem;
padding: 0 0.5rem;
}
</style>
<script src="https://unpkg.com/[email protected]" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
</head>
<body>
<main>
<header><h1>outline</h1></header>
<article>
<section>
@connectionButton()
</section>
</article>
</main>

<script type="text/javascript">
let conn = new WebSocket("ws://" + document.location.host + "/__reload__");
conn.onclose = function (evt) {
console.log("Connection Closed")
setTimeout(() => location.reload(), 5000);
};
</script>

</body>
</html>
}

templ connectionButton() {
<button id="connection-button" hx-get="/connection" hx-swap="outerHTML">connect</button>
}

templ disconnectionButton() {
<button id="connection-button" hx-get="/disconnection" hx-swap="outerHTML" class="connected">disconnect</button>
}
87 changes: 87 additions & 0 deletions x/examples/outline-vpn-app/app/start.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package app

import (
"context"
"io"
"net/http"
"os"

"github.com/Jigsaw-Code/outline-sdk/x/config"
"github.com/Jigsaw-Code/outline-sdk/x/mobileproxy"
"github.com/a-h/templ"
)

var appAddress = ":8080"
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
var proxyAddress = ":8181"
var systemAddress = ":8282"

var proxyDisconnectTimeoutSecond = 5
var systemTunnelEndpoint = "${systemAddress}/tunnel/${proxyAddress}"
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved

func Start() {
vpn := VPNController{}

http.Handle("/", templ.Handler(serverView()))
http.Handle("/connection/ss://<my-shadowsocks-key>", http.HandlerFunc(vpn.handleConnectionEndpoint))
http.Handle("/disconnection/ss://<my-shadowsocks-key>", http.HandlerFunc(vpn.handleDisconnectionEndpoint))

http.ListenAndServe(appAddress, nil)
}

type VPNController struct {
proxy *mobileproxy.Proxy
tunnel io.Reader
}

func (vpn *VPNController) handleConnectionEndpoint(responseWriter http.ResponseWriter, request *http.Request) {
proxyDialer, err := config.NewStreamDialer(request.URL.Path)

if err != nil {
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
return
}

proxy, err := mobileproxy.RunProxy(proxyAddress, &mobileproxy.StreamDialer{proxyDialer})

if err != nil {
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
return
}

vpn.proxy = proxy

// TODO: why this code isn't very good at all!
tunnel, err := os.Open("tunnel.sock")

if err != nil {
// do something
}

vpn.tunnel = tunnel

// TODO: implement system vpn tunnel service
// => POST /tunnel/URL forward all non-local traffic to that URL
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jyyi1 Am I thinking about the tunnel and proxy the right way here?

Copy link
Contributor

@jyyi1 jyyi1 Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Tunnel here is more like a system VPN configuration, typically it involves the source and the target. The source can be simple, like "all non-local TCP and UDP traffic from the system", or it can be complicated, such as "all UDP traffic destined to 8.8.8.8 from a specific app". The target would typically be represented by IP:port instead of a URL. But here I guess you will setup a local proxy to handle the traffic.

For the simplicity, I think we can start with source=all TCP & UDP and target=127.0.0.1:<proxy-port>. And we need to provide different implementations for different OS.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay cool. The idea is that the OS-specific logic will live behind this service we create that I am gonna stub out for now.

Is there a material difference between what the tunnel in tun2socks does and what a system VPN configuration does? Functionally it's sort of the same goal, no?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. At least for Windows and Linux there are no differences. But on Android and iOS, we need to adapt to the VPN API provided by the system.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, so conceptually it's the same, it's just on mobile the tunnel is done through the VPN APIs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right.

// => DELETE /tunnel/URL gracefully shuts down the tunnel

// => apple box uses VPN API
// => kotlin box uses SSH tunnel (for now)

// ! these boxes will be reusable across VPN apps !
http.NewRequest("POST", systemTunnelEndpoint, vpn.tunnel)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jyyi1 is this sufficient or do I need to actually execute the request?

Copy link
Contributor

@jyyi1 jyyi1 Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, but the request might return permission error, in this case the app should handle it (e.g., navigate to the permission approval settings page in Android, or pop-up a dialog in Linux to enter the root password).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permission stuff I envision being handled by the service itself, but I should definitely be handling the error.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good.


writeTemplate(disconnectionButton, responseWriter)
}

func (vpn *VPNController) handleDisconnectionEndpoint(responseWriter http.ResponseWriter, _ *http.Request) {
http.NewRequest("DELETE", systemTunnelEndpoint, vpn.tunnel)

vpn.proxy.Stop(proxyDisconnectTimeoutSecond)

writeTemplate(connectionButton, responseWriter)
}

// TODO: template arguments
func writeTemplate(template func() templ.Component, writer io.Writer) {
component := template()
component.Render(context.Background(), writer)
}
Loading
Loading