diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 3c830e621ab8a9f24e48d0cd4435e71db9e6058c..830e638b9e3169b311b6149f3e532e187539e595 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -33,7 +33,7 @@ validate:
       staticcheck -checks inherit,ST1000,ST1016,ST1020,ST1021,ST1022,ST1023 ./...
       # gosec does not handle modules correctly.
       # See: https://github.com/securego/gosec/issues/622
-      gosec -exclude-dir=obfsproxy ./...
+      gosec ./...
 
       go mod tidy
       git diff --exit-code -- go.mod go.sum
diff --git a/go.mod b/go.mod
index c508bed63d162f8cc4a730cef3361ca5c83dfd6e..5514555e87c1faa264e8def1b15872b50f611502 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.17
 require (
 	git.torproject.org/pluggable-transports/goptlib.git v1.0.0
 	gitlab.com/yawning/obfs4.git v0.0.0-20220204003609-77af0cba934d
+	golang.org/x/net v0.0.0-20210226172049-e18ecbb05110
 )
 
 require (
diff --git a/go.sum b/go.sum
index b64b2f4873d13a9e4969ae885bf1cba87a1727ae..031a40c86c347494471102f5757c632899606a10 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,7 @@ gitlab.com/yawning/obfs4.git v0.0.0-20220204003609-77af0cba934d h1:tJ8F7ABaQ3p3w
 gitlab.com/yawning/obfs4.git v0.0.0-20220204003609-77af0cba934d/go.mod h1:9GcM8QNU9/wXtEEH2q8bVOnPI7FtIF6VVLzZ1l6Hgf8=
 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
diff --git a/obfsproxy/Makefile b/obfsproxy/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..c0ad4bc14a2d5590a675e0005f3dad02810dddf8
--- /dev/null
+++ b/obfsproxy/Makefile
@@ -0,0 +1,18 @@
+GW=37.218.241.98
+
+certs:
+	curl -k https://black.riseup.net/ca.crt > /tmp/ca.crt
+	curl -k https://api.black.riseup.net/3/cert > /tmp/cert.pem
+
+proxy: certs
+	#./obfsproxy -proxy=${GW}:4430 -addr=127.0.0.1:4430
+	GW=${GW} ./leap-vpn.sh
+
+check:
+	curl https://wtfismyip.com/json
+
+stop:
+	pkill -9 shape
+
+obfsproxy:
+	go build
diff --git a/obfsproxy/main.go b/obfsproxy/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..a35a26d14a7899c7f7d87c9e2cac8624c35ce238
--- /dev/null
+++ b/obfsproxy/main.go
@@ -0,0 +1,148 @@
+// The obfsproxy command creates a SOCKS5 obfuscating proxy.
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"io"
+	"log"
+	"net"
+	"os"
+	"os/signal"
+
+	"0xacab.org/leap/obfsvpn"
+
+	"git.torproject.org/pluggable-transports/goptlib.git"
+)
+
+const transportName = "obfs4"
+
+func main() {
+	// Setup logging.
+	logger := log.New(os.Stderr, "", log.LstdFlags)
+	debug := log.New(io.Discard, "DEBUG ", log.LstdFlags)
+
+	// Setup command line flags.
+	var (
+		verbose  bool
+		addr     string = "[::1]:0"
+		vpnAddr  string
+		cfgFile  string
+		stateDir string
+	)
+	flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
+	flags.BoolVar(&verbose, "v", verbose, "Enable verbose logging")
+	flags.StringVar(&addr, "addr", addr, "The address to listen on for client connections")
+	flags.StringVar(&vpnAddr, "vpn", vpnAddr, "The address of the OpenVPN server to connect to")
+	flags.StringVar(&cfgFile, "c", cfgFile, "The JSON config file to load")
+	flags.StringVar(&stateDir, "state", stateDir, "A directory in which to store bridge state")
+	err := flags.Parse(os.Args[1:])
+	if err != nil {
+		logger.Fatalf("error parsing flags: %v", err)
+	}
+
+	if vpnAddr == "" {
+		flags.PrintDefaults()
+		logger.Fatal("must specify -vpn")
+	}
+
+	tcpVPNAddr, err := net.ResolveTCPAddr("tcp", vpnAddr)
+	if err != nil {
+		logger.Fatalf("error resolving VPN address: %v", err)
+	}
+
+	fd, err := os.Open(cfgFile)
+	if err != nil {
+		logger.Fatalf("error opening config file: %v", err)
+	}
+	var cfg Config
+	err = json.NewDecoder(fd).Decode(&cfg)
+	if err != nil {
+		logger.Fatalf("error decoding config: %v", err)
+	}
+
+	// Configure logging.
+	if verbose {
+		debug.SetOutput(os.Stderr)
+	}
+
+	// Setup graceful shutdown.
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+
+	listenConfig := obfsvpn.ListenConfig{
+		NodeID:     cfg.NodeID,
+		PrivateKey: cfg.PrivateKey,
+		Seed:       cfg.DRBGSeed,
+		StateDir:   stateDir,
+	}
+	ln, err := listenConfig.Listen(ctx, "tcp", addr)
+	if err != nil {
+		logger.Fatalf("error binding to %s: %v", addr, err)
+	}
+
+	go func() {
+		<-ctx.Done()
+		// Stop releases the signal handling and falls back to the default behavior,
+		// so sending another interrupt will immediately terminate.
+		stop()
+		logger.Printf("shutting down…")
+		err := ln.Close()
+		if err != nil {
+			logger.Printf("error closing listener: %v", err)
+		}
+	}()
+
+	info := &pt.ServerInfo{
+		OrAddr: tcpVPNAddr,
+	}
+
+	logger.Printf("listening on %s…", ln.Addr())
+	for {
+		conn, err := ln.Accept()
+		if err != nil {
+			debug.Printf("error accepting connection: %v", err)
+			return
+		}
+		debug.Printf("accepted connection %v…", conn)
+		go proxyConn(ctx, info, conn, net.Dialer{}, logger, debug)
+	}
+}
+
+func proxyConn(ctx context.Context, info *pt.ServerInfo, conn net.Conn, d net.Dialer, logger, debug *log.Logger) {
+	defer func() {
+		err := conn.Close()
+		if err != nil {
+			debug.Printf("error closing connection: %v", err)
+		}
+	}()
+
+	// TODO: do we actually want to send the USERADDR/TRANSPORT ExtOrPort
+	// commands? I don't understand how this works, so I'm unsure.
+	remote, err := pt.DialOr(info, conn.RemoteAddr().String(), transportName)
+	if err != nil {
+		logger.Printf("error dialing remote: %v", err)
+		return
+	}
+	defer func() {
+		err := remote.Close()
+		if err != nil {
+			debug.Printf("error closing remote connection: %v", err)
+		}
+	}()
+
+	go func() {
+		_, err := io.Copy(remote, conn)
+		if err != nil {
+			logger.Printf("error proxying client data to remote: %v", err)
+			return
+		}
+	}()
+	go func() {
+		_, err := io.Copy(conn, remote)
+		if err != nil {
+			logger.Printf("error proxying remote data to client: %v", err)
+			return
+		}
+	}()
+}
diff --git a/obfsproxy/main_test.go b/obfsproxy/main_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..d22d3afeafcd7f5ad87fae1af74fd34e64f76c36
--- /dev/null
+++ b/obfsproxy/main_test.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"net"
+	"os"
+	"os/exec"
+	"testing"
+	"time"
+
+	"golang.org/x/net/proxy"
+)
+
+type testWriter struct {
+	t      *testing.T
+	prefix string
+}
+
+func (w testWriter) Write(p []byte) (int, error) {
+	w.t.Logf("%s%s", w.prefix, p)
+	return len(p), nil
+}
+
+func TestMain(m *testing.M) {
+	runProxy := flag.Bool("runproxy", false, "Start the command instead of running the tests")
+	flag.Parse()
+	if *runProxy {
+		os.Args = append(os.Args[0:1], flag.Args()...)
+		main()
+		return
+	}
+	os.Exit(m.Run())
+}
+
+func TestRoundTrip(t *testing.T) {
+	// Setup and exec the proxy:
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	// Instead of passing a listener to the command or doing IPC to get the
+	// address of the listener created by the command back out, which would all
+	// require changes to the actual binary for something that has no use outside
+	// of tests and is just another potential source of errors, just start and
+	// stop a listener to get a random port and then pass that in (for the command
+	// to re-open) as a string. It's not ideal, but it's simple.
+	ln, err := net.Listen("tcp", "[::1]:0")
+	if err != nil {
+		t.Fatalf("error listening: %v", err)
+	}
+	addr := ln.Addr()
+	err = ln.Close()
+	if err != nil {
+		t.Fatalf("error closing listener: %v", err)
+	}
+	cmd := exec.CommandContext(ctx, os.Args[0], "-runproxy", "--", "-addr", addr.String(), "-proxy", "37.218.241.98:4430")
+	cmd.Stdout = testWriter{prefix: "stdout ", t: t}
+	cmd.Stderr = testWriter{prefix: "stderr ", t: t}
+	t.Logf("running proxy command %v", cmd.Args)
+	err = cmd.Start()
+	if err != nil {
+		t.Fatalf("error starting proxy: %v", err)
+	}
+
+	// Once the proxy is running, try to connect:
+	ln, err = net.Listen("tcp", "[::1]:0")
+	if err != nil {
+		t.Fatalf("error listening for connection: %v", err)
+	}
+	go func() {
+		conn, err := ln.Accept()
+		if err != nil {
+			t.Logf("error accepting connection: %v", err)
+		}
+		t.Logf("got conn: %v", conn)
+	}()
+	dialer, err := proxy.SOCKS5("tcp", addr.String(), nil, proxy.Direct)
+	if err != nil {
+		t.Fatalf("error creating socks dialer: %v", err)
+	}
+
+	// TODO: this is slow, flakey, and generally jank. Can we watch /proc for a
+	// new file descriptor or just poll until the listener is open?
+	t.Logf("waiting 3 seconds for command to start…")
+	time.Sleep(3 * time.Second)
+
+	_, err = dialer.Dial("tcp", ln.Addr().String())
+	if err != nil {
+		t.Fatalf("error dialing: %v", err)
+	}
+
+	select {}
+}