Merge pull request #115 from boringproxy/takingnames-io-integration

Takingnames io integration
This commit is contained in:
Anders Pitman 2021-12-19 21:46:46 -07:00 committed by GitHub
commit cfdae775fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 984 additions and 684 deletions

View File

@ -1,15 +1,13 @@
FROM golang:1.15-alpine3.12 as builder FROM golang:1.17-alpine3.15 as builder
WORKDIR /build WORKDIR /build
RUN apk add git RUN apk add git
RUN go get github.com/GeertJohan/go.rice/rice
COPY go.* ./ COPY go.* ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN rice embed-go
RUN cd cmd/boringproxy && CGO_ENABLED=0 go build -o boringproxy RUN cd cmd/boringproxy && CGO_ENABLED=0 go build -o boringproxy
FROM scratch FROM scratch

View File

@ -40,14 +40,6 @@ source $HOME/.bashrc
go build go build
``` ```
To embed the web UI into the executable:
```bash
go get github.com/GeertJohan/go.rice/rice
rice embed-go
go build
```
# Running # Running
## Server ## Server

View File

@ -3,6 +3,8 @@ package boringproxy
import ( import (
"bufio" "bufio"
"crypto/tls" "crypto/tls"
"encoding/json"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -18,8 +20,8 @@ import (
) )
type Config struct { type Config struct {
WebUiDomain string `json:"webui_domain"`
SshServerPort int `json:"ssh_server_port"` SshServerPort int `json:"ssh_server_port"`
PublicIp string `json:"public_ip"`
} }
type SmtpConfig struct { type SmtpConfig struct {
@ -36,9 +38,74 @@ type Server struct {
httpListener *PassthroughListener httpListener *PassthroughListener
} }
type IpResponse struct {
Ip string `json:"ip"`
}
func checkPublicAddress(host string, port int) error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return err
}
defer ln.Close()
code, err := genRandomCode(32)
if err != nil {
return err
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
break
}
conn.Write([]byte(code))
conn.Close()
}
}()
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return nil
}
defer conn.Close()
data, err := io.ReadAll(conn)
if err != nil {
return nil
}
retCode := string(data)
if retCode != code {
return errors.New("Mismatched codes")
}
return nil
}
func getPublicIp() (string, error) {
resp, err := http.Get("https://api.ipify.org?format=json")
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
var ipRes *IpResponse
err = json.Unmarshal([]byte(body), &ipRes)
if err != nil {
return "", err
}
return ipRes.Ip, nil
}
func Listen() { func Listen() {
flagSet := flag.NewFlagSet(os.Args[0], flag.ExitOnError) flagSet := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
adminDomain := flagSet.String("admin-domain", "", "Admin Domain") newAdminDomain := flagSet.String("admin-domain", "", "Admin Domain")
sshServerPort := flagSet.Int("ssh-server-port", 22, "SSH Server Port") sshServerPort := flagSet.Int("ssh-server-port", 22, "SSH Server Port")
certDir := flagSet.String("cert-dir", "", "TLS cert directory") certDir := flagSet.String("cert-dir", "", "TLS cert directory")
err := flagSet.Parse(os.Args[2:]) err := flagSet.Parse(os.Args[2:])
@ -48,50 +115,70 @@ func Listen() {
log.Println("Starting up") log.Println("Starting up")
webUiDomain := *adminDomain ip, err := getPublicIp()
if *adminDomain == "" {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter Admin Domain: ")
text, _ := reader.ReadString('\n')
webUiDomain = strings.TrimSpace(text)
}
config := &Config{
WebUiDomain: webUiDomain,
SshServerPort: *sshServerPort,
}
certmagic.DefaultACME.DisableHTTPChallenge = true
if *certDir != "" {
certmagic.Default.Storage = &certmagic.FileStorage{*certDir}
}
//certmagic.DefaultACME.DisableTLSALPNChallenge = true
//certmagic.DefaultACME.CA = certmagic.LetsEncryptStagingCA
certConfig := certmagic.NewDefault()
err = certConfig.ManageSync([]string{config.WebUiDomain})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
log.Print("Successfully acquired admin certificate") err = checkPublicAddress(ip, 80)
if err != nil {
log.Fatal(err)
}
err = checkPublicAddress(ip, 443)
if err != nil {
log.Fatal(err)
}
db, err := NewDatabase() db, err := NewDatabase()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if *certDir != "" {
certmagic.Default.Storage = &certmagic.FileStorage{*certDir}
}
//certmagic.DefaultACME.DisableHTTPChallenge = true
//certmagic.DefaultACME.DisableTLSALPNChallenge = true
//certmagic.DefaultACME.CA = certmagic.LetsEncryptStagingCA
certConfig := certmagic.NewDefault()
if *newAdminDomain != "" {
db.SetAdminDomain(*newAdminDomain)
}
adminDomain := db.GetAdminDomain()
if adminDomain == "" {
err = setAdminDomain(ip, certConfig, db)
if err != nil {
log.Fatal(err)
}
} else {
err = certConfig.ManageSync([]string{adminDomain})
if err != nil {
log.Fatal(err)
}
log.Print(fmt.Sprintf("Successfully acquired certificate for admin domain (%s)", adminDomain))
}
users := db.GetUsers() users := db.GetUsers()
if len(users) == 0 { if len(users) == 0 {
db.AddUser("admin", true) db.AddUser("admin", true)
token, err := db.AddToken("admin") //token, err := db.AddToken("admin")
_, err := db.AddToken("admin")
if err != nil { if err != nil {
log.Fatal("Failed to initialize admin user") log.Fatal("Failed to initialize admin user")
} }
log.Println("Admin token: " + token) //log.Println("Admin token: " + token)
log.Println(fmt.Sprintf("Admin login link: https://%s/login?access_token=%s", webUiDomain, token)) //log.Println(fmt.Sprintf("Admin login link: https://%s/login?access_token=%s", adminDomain, token))
}
config := &Config{
SshServerPort: *sshServerPort,
PublicIp: ip,
} }
tunMan := NewTunnelManager(config, db, certConfig) tunMan := NewTunnelManager(config, db, certConfig)
@ -123,7 +210,60 @@ func Listen() {
timestamp := time.Now().Format(time.RFC3339) timestamp := time.Now().Format(time.RFC3339)
srcIp := strings.Split(r.RemoteAddr, ":")[0] srcIp := strings.Split(r.RemoteAddr, ":")[0]
fmt.Println(fmt.Sprintf("%s %s %s %s %s", timestamp, srcIp, r.Method, r.Host, r.URL.Path)) fmt.Println(fmt.Sprintf("%s %s %s %s %s", timestamp, srcIp, r.Method, r.Host, r.URL.Path))
if r.Host == config.WebUiDomain { if r.URL.Path == "/dnsapi/requests" {
r.ParseForm()
requestId := r.Form.Get("request-id")
dnsRequest, err := db.GetDNSRequest(requestId)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, err.Error())
return
}
jsonBytes, err := json.Marshal(dnsRequest)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, err.Error())
return
}
w.Write(jsonBytes)
} else if r.URL.Path == "/dnsapi/callback" {
r.ParseForm()
requestId := r.Form.Get("request-id")
// Ensure the request exists
dnsRequest, err := db.GetDNSRequest(requestId)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, err.Error())
return
}
db.DeleteDNSRequest(requestId)
domain := r.Form.Get("domain")
if dnsRequest.IsAdminDomain {
db.SetAdminDomain(domain)
// TODO: Might want to get all certs here, not just the admin domain
err := certConfig.ManageSync([]string{domain})
if err != nil {
log.Fatal(err)
}
http.Redirect(w, r, fmt.Sprintf("https://%s", domain), 303)
} else {
adminDomain := db.GetAdminDomain()
http.Redirect(w, r, fmt.Sprintf("https://%s/edit-tunnel?domain=%s", adminDomain, domain), 303)
}
} else if r.Host == db.GetAdminDomain() {
if strings.HasPrefix(r.URL.Path, "/api/") { if strings.HasPrefix(r.URL.Path, "/api/") {
http.StripPrefix("/api", api).ServeHTTP(w, r) http.StripPrefix("/api", api).ServeHTTP(w, r)
} else { } else {
@ -143,9 +283,8 @@ func Listen() {
} }
}) })
// taken from: https://stackoverflow.com/a/37537134/943814
go func() { go func() {
if err := http.ListenAndServe(":80", http.HandlerFunc(redirectTLS)); err != nil { if err := http.ListenAndServe(":80", nil); err != nil {
log.Fatalf("ListenAndServe error: %v", err) log.Fatalf("ListenAndServe error: %v", err)
} }
}() }()
@ -157,6 +296,8 @@ func Listen() {
log.Fatal(err) log.Fatal(err)
} }
log.Println("Ready")
for { for {
conn, err := listener.Accept() conn, err := listener.Accept()
if err != nil { if err != nil {
@ -215,7 +356,71 @@ func (p *Server) passthroughRequest(conn net.Conn, tunnel Tunnel) {
wg.Wait() wg.Wait()
} }
func redirectTLS(w http.ResponseWriter, r *http.Request) { func setAdminDomain(ip string, certConfig *certmagic.Config, db *Database) error {
url := fmt.Sprintf("https://%s:443%s", r.Host, r.RequestURI) action := prompt("\nNo admin domain set. Enter '1' to input manually, or '2' to configure through TakingNames.io\n")
http.Redirect(w, r, url, http.StatusMovedPermanently) switch action {
case "1":
adminDomain := prompt("\nEnter admin domain:\n")
err := certConfig.ManageSync([]string{adminDomain})
if err != nil {
log.Fatal(err)
}
db.SetAdminDomain(adminDomain)
case "2":
log.Println("Get bootstrap domain")
resp, err := http.Get("https://takingnames.io/dnsapi/bootstrap-domain")
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
bootstrapDomain := string(body)
if resp.StatusCode != 200 {
fmt.Println(bootstrapDomain)
return errors.New("bootstrap domain request failed")
}
log.Println("Get cert")
err = certConfig.ManageSync([]string{bootstrapDomain})
if err != nil {
log.Fatal(err)
}
requestId, _ := genRandomCode(32)
req := DNSRequest{
IsAdminDomain: true,
Records: []*DNSRecord{
&DNSRecord{
Type: "A",
Value: ip,
TTL: 300,
},
},
}
db.SetDNSRequest(requestId, req)
tnLink := fmt.Sprintf("https://takingnames.io/dnsapi?requester=%s&request-id=%s", bootstrapDomain, requestId)
fmt.Println("Use the link below to select an admin domain:\n\n" + tnLink + "\n")
default:
log.Fatal("Invalid option")
}
return nil
}
func prompt(promptText string) string {
reader := bufio.NewReader(os.Stdin)
fmt.Print(promptText)
text, _ := reader.ReadString('\n')
return strings.TrimSpace(text)
} }

View File

@ -9,11 +9,13 @@ import (
) )
type Database struct { type Database struct {
Tokens map[string]TokenData `json:"tokens"` AdminDomain string `json:"admin_domain"`
Tunnels map[string]Tunnel `json:"tunnels"` Tokens map[string]TokenData `json:"tokens"`
Users map[string]User `json:"users"` Tunnels map[string]Tunnel `json:"tunnels"`
SshKeys map[string]SshKey `json:"ssh_keys"` Users map[string]User `json:"users"`
mutex *sync.Mutex SshKeys map[string]SshKey `json:"ssh_keys"`
dnsRequests map[string]DNSRequest `json:"dns_requests"`
mutex *sync.Mutex
} }
type TokenData struct { type TokenData struct {
@ -33,6 +35,17 @@ type SshKey struct {
type DbClient struct { type DbClient struct {
} }
type DNSRequest struct {
IsAdminDomain bool `json:"is_admin_domain"`
Records []*DNSRecord `json:"records"`
}
type DNSRecord struct {
Type string `json:"type"`
Value string `json:"value"`
TTL int `json:"ttl"`
}
type Tunnel struct { type Tunnel struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Domain string `json:"domain"` Domain string `json:"domain"`
@ -85,6 +98,10 @@ func NewDatabase() (*Database, error) {
db.SshKeys = make(map[string]SshKey) db.SshKeys = make(map[string]SshKey)
} }
if db.dnsRequests == nil {
db.dnsRequests = make(map[string]DNSRequest)
}
db.mutex = &sync.Mutex{} db.mutex = &sync.Mutex{}
db.mutex.Lock() db.mutex.Lock()
@ -94,6 +111,48 @@ func NewDatabase() (*Database, error) {
return db, nil return db, nil
} }
func (d *Database) SetAdminDomain(adminDomain string) {
d.mutex.Lock()
defer d.mutex.Unlock()
d.AdminDomain = adminDomain
d.persist()
}
func (d *Database) GetAdminDomain() string {
d.mutex.Lock()
defer d.mutex.Unlock()
return d.AdminDomain
}
func (d *Database) SetDNSRequest(requestId string, request DNSRequest) {
d.mutex.Lock()
defer d.mutex.Unlock()
d.dnsRequests[requestId] = request
// Not currently persisting because dnsRequests is only stored in
// memory. May change in the future.
//d.persist()
}
func (d *Database) GetDNSRequest(requestId string) (DNSRequest, error) {
d.mutex.Lock()
defer d.mutex.Unlock()
if req, ok := d.dnsRequests[requestId]; ok {
return req, nil
}
return DNSRequest{}, errors.New("No such DNS Request")
}
func (d *Database) DeleteDNSRequest(requestId string) {
d.mutex.Lock()
defer d.mutex.Unlock()
delete(d.dnsRequests, requestId)
}
func (d *Database) AddToken(owner string) (string, error) { func (d *Database) AddToken(owner string) (string, error) {
d.mutex.Lock() d.mutex.Lock()
defer d.mutex.Unlock() defer d.mutex.Unlock()

16
go.mod
View File

@ -1,10 +1,22 @@
module github.com/boringproxy/boringproxy module github.com/boringproxy/boringproxy
go 1.15 go 1.17
require ( require (
github.com/GeertJohan/go.rice v1.0.0
github.com/caddyserver/certmagic v0.12.0 github.com/caddyserver/certmagic v0.12.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de
) )
require (
github.com/klauspost/cpuid v1.2.5 // indirect
github.com/libdns/libdns v0.1.0 // indirect
github.com/mholt/acmez v0.1.1 // indirect
github.com/miekg/dns v1.1.30 // indirect
go.uber.org/atomic v1.6.0 // indirect
go.uber.org/multierr v1.5.0 // indirect
go.uber.org/zap v1.15.0 // indirect
golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect
golang.org/x/text v0.3.0 // indirect
)

19
go.sum
View File

@ -1,28 +1,16 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/jt0CW30vsg=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0 h1:KkI6O9uMaQU3VEKaj01ulavtF7o1fWT7+pk/4voiMLQ=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/akavel/rsrc v0.8.0 h1:zjWn7ukO9Kc5Q62DOJCcxGpXC18RawVtYAGdz2aLlfw=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/caddyserver/certmagic v0.12.0 h1:1f7kxykaJkOVVpXJ8ZrC6RAO5F6+kKm9U7dBFbLNeug= github.com/caddyserver/certmagic v0.12.0 h1:1f7kxykaJkOVVpXJ8ZrC6RAO5F6+kKm9U7dBFbLNeug=
github.com/caddyserver/certmagic v0.12.0/go.mod h1:tr26xh+9fY5dN0J6IPAlMj07qpog22PJKa7Nw7j835U= github.com/caddyserver/certmagic v0.12.0/go.mod h1:tr26xh+9fY5dN0J6IPAlMj07qpog22PJKa7Nw7j835U=
github.com/daaku/go.zipexe v1.0.0 h1:VSOgZtH418pH9L16hC/JrgSNJbbAL26pj7lmD1+CGdY=
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid v1.2.5 h1:VBd9MyVIiJHzzgnrLQG5Bcv75H4YaWrlKqWHjurxCGo= github.com/klauspost/cpuid v1.2.5 h1:VBd9MyVIiJHzzgnrLQG5Bcv75H4YaWrlKqWHjurxCGo=
github.com/klauspost/cpuid v1.2.5/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= github.com/klauspost/cpuid v1.2.5/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/libdns/libdns v0.1.0 h1:0ctCOrVJsVzj53mop1angHp/pE3hmAhP7KiHvR0HD04= github.com/libdns/libdns v0.1.0 h1:0ctCOrVJsVzj53mop1angHp/pE3hmAhP7KiHvR0HD04=
github.com/libdns/libdns v0.1.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/libdns/libdns v0.1.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
@ -30,8 +18,6 @@ github.com/mholt/acmez v0.1.1 h1:KQODCqk+hBn3O7qfCRPj6L96uG65T5BSS95FKNEqtdA=
github.com/mholt/acmez v0.1.1/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM= github.com/mholt/acmez v0.1.1/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM=
github.com/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo= github.com/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo=
github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229 h1:E2B8qYyeSgv5MXpmzZXRNp8IAQ4vjxIjhpAf5hv/tAg=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -43,10 +29,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A=
@ -89,7 +71,6 @@ golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapK
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
) )
@ -49,6 +50,21 @@ func proxyRequest(w http.ResponseWriter, r *http.Request, tunnel Tunnel, httpCli
upstreamReq.Header = downstreamReqHeaders upstreamReq.Header = downstreamReqHeaders
upstreamReq.Header["X-Forwarded-Host"] = []string{r.Host} upstreamReq.Header["X-Forwarded-Host"] = []string{r.Host}
// TODO: Handle IPv6 addresses
addrParts := strings.Split(r.RemoteAddr, ":")
remoteIp := addrParts[0]
xForwardedFor := downstreamReqHeaders.Get("X-Forwarded-For")
if xForwardedFor == "" {
xForwardedFor = remoteIp
} else {
xForwardedFor = xForwardedFor + ", " + remoteIp
}
upstreamReq.Header.Set("X-Forwarded-For", xForwardedFor)
upstreamReq.Header.Set("Forwarded", fmt.Sprintf("for=%s", remoteIp))
upstreamReq.Host = fmt.Sprintf("%s:%d", tunnel.ClientAddress, tunnel.ClientPort) upstreamReq.Host = fmt.Sprintf("%s:%d", tunnel.ClientAddress, tunnel.ClientPort)
upstreamRes, err := httpClient.Do(upstreamReq) upstreamRes, err := httpClient.Do(upstreamReq)

View File

@ -11,4 +11,3 @@ WORKDIR boringproxy
RUN ./scripts/install_go.sh RUN ./scripts/install_go.sh
ENV PATH="${PATH}:/usr/local/go/bin" ENV PATH="${PATH}:/usr/local/go/bin"
ENV PATH="${PATH}:/root/go/bin" ENV PATH="${PATH}:/root/go/bin"
RUN go get github.com/GeertJohan/go.rice/rice

View File

@ -4,8 +4,6 @@ version=$(git describe --tags)
./scripts/generate_logo.sh ./scripts/generate_logo.sh
rice embed-go
cd ./cmd/boringproxy cd ./cmd/boringproxy
../../scripts/build_x86_64.sh linux ../../scripts/build_x86_64.sh linux

View File

@ -1,2 +1,6 @@
#!/bin/bash #!/bin/bash
inkscape -z -w 192 -h 192 logo.svg -e webui/logo.png
# old inkscape
#inkscape -z -w 192 -h 192 logo.svg -e webui/logo.png
inkscape -w 192 -h 192 logo.svg -o logo.png

7
sni.go
View File

@ -74,8 +74,11 @@ func (f *PassthroughListener) PassConn(conn net.Conn) {
} }
// This type creates a new net.Conn that's the same as an old one, except a new // This type creates a new net.Conn that's the same as an old one, except a new
// reader is provided. So it proxies every method except Read. I'm sure there's // reader is provided. So it proxies every method except Read. This is
// a cleaner way to do this... // necessary because by calling peekClientHello, part of the reader is read,
// so we need to create a new reader with the already read data inserted back
// in the front.
// I'm sure there's a cleaner way to do this...
type ProxyConn struct { type ProxyConn struct {
conn net.Conn conn net.Conn
reader io.Reader reader io.Reader

View File

@ -1,9 +1,10 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
{{.Head}}
<style> <style>
{{ template "styles.tmpl" }}
.dialog { .dialog {
display: block; display: block;
} }

View File

@ -1,8 +1,7 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
{{.Head}} {{ template "head_common.tmpl" }}
<style> <style>
.dialog { .dialog {
display: block; display: block;

View File

@ -0,0 +1,64 @@
{{ template "header.tmpl" . }}
<div class='tunnel-adder'>
<h1>Add Tunnel</h1>
<form action="/tunnels" method="POST">
<div class='input'>
<p>
Enter a domain below, or automatically configure DNS using
<a href='{{$.TakingNamesLink}}'>TakingNames.io</a>
</p>
<label for="domain">Domain:</label>
<input type="text" id="domain" name="domain" value="{{$.Domain}}" required>
<input type="hidden" id="tunnel-owner" name="owner" value="{{$.UserId}}">
</div>
<div class='input'>
<label for="tunnel-port">Tunnel Port:</label>
<input type="text" id="tunnel-port" name="tunnel-port" value="Random">
</div>
<div class='input'>
<label for="client-name">Client Name:</label>
<select id="client-name" name="client-name">
<option value="none">No client</option>
{{range $id, $client := (index $.Users $.UserId).Clients}}
<option value="{{$id}}">{{$id}}</option>
{{end}}
</select>
</div>
<div class='input'>
<label for="client-addr">Client Address:</label>
<input type="text" id="client-addr" name="client-addr" value='127.0.0.1'>
</div>
<div class='input'>
<label for="client-port">Client Port:</label>
<input type="text" id="client-port" name="client-port">
</div>
<div class='input'>
<label for="tls-termination">TLS Termination:</label>
<select id="tls-termination" name="tls-termination">
<option value="client">Client</option>
<option value="server">Server</option>
<option value="passthrough">Passthrough</option>
</select>
</div>
<div class='input'>
<label for="allow-external-tcp">Allow External TCP:</label>
<input type="checkbox" id="allow-external-tcp" name="allow-external-tcp">
</div>
<div class='input'>
<label for="password-protect">Password Protect:</label>
<input type="checkbox" id="password-protect" name="password-protect">
<div id='login-inputs'>
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
</div>
</div>
<button class='button' type="submit">Submit</button>
</form>
</div>
{{ template "footer.tmpl" . }}

5
templates/footer.tmpl Normal file
View File

@ -0,0 +1,5 @@
</div>
</div>
</main>
</body>
</html>

View File

@ -6,6 +6,5 @@
<link rel="icon" href="/logo.png"> <link rel="icon" href="/logo.png">
<style> <style>
{{.Styles}} {{ template "styles.tmpl" }}
</style> </style>

36
templates/header.tmpl Normal file
View File

@ -0,0 +1,36 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>boringproxy</title>
<link rel="icon" href="/logo.png">
<style>
{{ template "styles.tmpl" }}
</style>
<style>
</style>
</head>
<body>
<main>
<input type='checkbox' id='menu-toggle'/>
<label id='menu-label' for='menu-toggle'>Menu</label>
<div class='page'>
<div class='menu'>
<a class='menu-item' href='/tunnels'>Tunnels</a>
<a class='menu-item' href='/edit-tunnel'>Add Tunnel</a>
<a class='menu-item' href='/tokens'>Tokens</a>
{{ if $.User.IsAdmin }}
<a class='menu-item' href='/users'>Users</a>
{{ end }}
<a class='menu-item' href='/confirm-logout'>Logout</a>
</div>
<div class='content'>

View File

@ -2,9 +2,9 @@
<html> <html>
<head> <head>
<meta http-equiv="refresh" content="0; URL='{{$.TargetUrl}}'" /> <meta http-equiv="refresh" content="0; URL='{{$.TargetUrl}}'" />
{{.Head}}
<style> <style>
{{ template "styles.tmpl" }}
.dialog { .dialog {
display: block; display: block;
} }

View File

@ -1,9 +1,8 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
{{.Head}}
<style> <style>
{{ template "styles.tmpl" }}
.dialog { .dialog {
display: block; display: block;
} }

View File

@ -7,6 +7,7 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
font-family: Arial;
} }
html { html {
@ -23,6 +24,51 @@ main {
width: 100%; width: 100%;
} }
.tn-tunnel-list-table {
display: none;
}
.tn-tunnel-list-item {
padding: 10px;
border: 1px solid var(--main-color);
}
.tn-attribute {
padding: 5px;
}
.tn-attribute__name {
font-weight: bold;
}
.tn-tunnel-table, .tn-tunnel-table__cell {
border: 1px solid var(--main-color);
border-collapse: collapse;
text-align: center;
padding: 10px;
word-wrap: break-word;
}
.tn-tunnel-table {
width: 100%;
}
.tn-tunnel-table__link {
display: contents;
}
.tn-tunnel-table__row:hover {
background: var(--hover-color);
cursor: pointer;
}
.tn-tunnel-table__cell {
max-width: 256px;
}
.tn-tunnel-link {
color: #000;
text-decoration: none;
}
.tn-tunnel-link:hover {
background: var(--hover-color);
}
#menu-label { #menu-label {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
@ -145,12 +191,6 @@ main {
margin: .2em; margin: .2em;
} }
.tunnel-adder form {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.ssh-key-adder form { .ssh-key-adder form {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -158,16 +198,13 @@ main {
} }
.input { .input {
padding: .7em;
margin: .2em; margin: .2em;
border: 1px solid var(--main-color);
display: flex;
flex-direction: column;
} }
.input label { .input label {
padding: .2em; padding: .2em;
font-weight: bold; font-weight: bold;
display: block;
} }
#login-inputs { #login-inputs {
@ -193,8 +230,9 @@ main {
.page { .page {
margin-top: var(--menu-label-height); margin-top: var(--menu-label-height);
display: none; /*display: none;*/
flex-direction: column; flex-direction: column;
display: flex;
} }
.qr-code { .qr-code {
@ -202,10 +240,6 @@ main {
height: 8em; height: 8em;
} }
main *:target {
display: flex;
}
.dialog { .dialog {
display: none; display: none;
} }
@ -229,7 +263,7 @@ main *:target {
z-index: 1010; z-index: 1010;
} }
@media (min-width: 640px) { @media (min-width: 960px) {
main { main {
display: flex; display: flex;
@ -255,4 +289,12 @@ main *:target {
.content { .content {
width: 100%; width: 100%;
} }
.tn-tunnel-list-table {
display: block;
}
.tn-tunnel-list {
display: none;
}
} }

27
templates/tokens.tmpl Normal file
View File

@ -0,0 +1,27 @@
{{ template "header.tmpl" . }}
<div class='list'>
{{range $token, $tokenData := .Tokens}}
<div class='list-item'>
<span class='token'>{{$token}} ({{$tokenData.Owner}})</span>
<a href='/login?access_token={{$token}}'>Login link</a>
<img class='qr-code' src='{{index $.QrCodes $token}}' width=100 height=100>
<a href="/confirm-delete-token?token={{$token}}">
<button class='button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='token-adder'>
<form action="/tokens" method="POST">
<label for="token-owner">Owner:</label>
<select id="token-owner" name="owner">
{{range $username, $user := .Users}}
<option value="{{$username}}">{{$username}}</option>
{{end}}
</select>
<button class='button' type="submit">Add Token</button>
</form>
</div>
{{ template "footer.tmpl" . }}

37
templates/tunnel.tmpl Normal file
View File

@ -0,0 +1,37 @@
{{ template "header.tmpl" . }}
<div class='tn-attribute'>
<div class='tn-attribute__name'>Domain:</div>
<div class='tn-attribute__value'><a href='https://{{$.Tunnel.Domain}}'>{{$.Tunnel.Domain}}</a></div>
</div>
<div class='tn-attribute'>
<div class='tn-attribute__name'>Server Tunnel Port:</div>
<div class='tn-attribute__value'>{{$.Tunnel.TunnelPort}}</div>
</div>
<div class='tn-attribute'>
<div class='tn-attribute__name'>Client:</div>
<div class='tn-attribute__value'>{{$.Tunnel.ClientName}}</div>
</div>
<div class='tn-attribute'>
<div class='tn-attribute__name'>Target:</div>
<div class='tn-attribute__value'>{{$.Tunnel.ClientAddress}}:{{$.Tunnel.ClientPort}}</div>
</div>
<div class='tn-attribute'>
<div class='tn-attribute__name'>TLS Termination:</div>
<div class='tn-attribute__value'>{{$.Tunnel.TlsTermination}}</div>
</div>
<div class='tn-attribute'>
<div class='tn-attribute__name'>Allow External TCP:</div>
<div class='tn-attribute__value'>{{$.Tunnel.AllowExternalTcp}}</div>
</div>
<div class='tn-attribute'>
<div class='tn-attribute__name'>Owner:</div>
<div class='tn-attribute__value'>{{$.Tunnel.Owner}}</div>
</div>
<div class='button-row'>
<a class='button' href="/tunnel-private-key?domain={{$.Tunnel.Domain}}">Download Private Key</a>
<a class='button' href="/confirm-delete-tunnel?domain={{$.Tunnel.Domain}}">Delete</a>
</div>
{{ template "footer.tmpl" . }}

54
templates/tunnels.tmpl Normal file
View File

@ -0,0 +1,54 @@
{{ template "header.tmpl" . }}
<div class='tn-tunnel-list'>
{{ range $domain, $tunnel:= .Tunnels }}
<div class='tn-tunnel-list-item'>
<div class='tn-attribute'>
<div class='tn-attribute__name'>Domain:</div>
<div class='tn-attribute__value'><a href='https://{{$domain}}'>{{$domain}}</a></div>
</div>
<div class='tn-attribute'>
<div class='tn-attribute__name'>Client:</div>
<div class='tn-attribute__value'>{{$tunnel.ClientName}}</div>
</div>
<div class='tn-attribute'>
<div class='tn-attribute__name'>Target:</div>
<div class='tn-attribute__value'>{{$tunnel.ClientAddress}}:{{$tunnel.ClientPort}}</div>
</div>
<div class='button-row'>
<a class='button' href="/tunnels/{{$domain}}">View</a>
<a class='button' href="/confirm-delete-tunnel?domain={{$domain}}">Delete</a>
</div>
</div>
{{ end }}
</div>
<div class='tn-tunnel-list-table'>
<table class='tn-tunnel-table'>
<thead>
<tr>
<th class='tn-tunnel-table__cell'>Domain</th>
<th class='tn-tunnel-table__cell'>Client</th>
<th class='tn-tunnel-table__cell'>Target</th>
<th class='tn-tunnel-table__cell'>Actions</th>
</tr>
</thead>
<tbody>
{{range $domain, $tunnel:= .Tunnels}}
<tr>
<td class='tn-tunnel-table__cell'>
<a href='https://{{$domain}}'>{{$domain}}</a>
</td>
<td class='tn-tunnel-table__cell'>{{$tunnel.ClientName}}</td>
<td class='tn-tunnel-table__cell'>{{$tunnel.ClientAddress}}:{{$tunnel.ClientPort}}</td>
<td class='tn-tunnel-table__cell'>
<div class='button-row'>
<a class='button' href="/tunnels/{{$domain}}">View</a>
<a class='button' href="/confirm-delete-tunnel?domain={{$domain}}">Delete</a>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ template "footer.tmpl" . }}

21
templates/users.tmpl Normal file
View File

@ -0,0 +1,21 @@
{{ template "header.tmpl" . }}
<div class='list'>
{{range $username, $user := .Users}}
<div class='list-item'>
{{$username}}
<a href="/confirm-delete-user?username={{$username}}">
<button class='button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='user-adder'>
<form action="/users" method="POST">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
<label for="is-admin">Is Admin:</label>
<input type="checkbox" id="is-admin" name="is-admin">
<button class='button' type="submit">Add User</button>
</form>
</div>
{{ template "footer.tmpl" . }}

View File

@ -79,7 +79,6 @@ func (m *TunnelManager) RequestCreateTunnel(tunReq Tunnel) (Tunnel, error) {
} }
for _, tun := range m.db.GetTunnels() { for _, tun := range m.db.GetTunnels() {
fmt.Println(tunReq.Domain, tun.Domain)
if tunReq.Domain == tun.Domain { if tunReq.Domain == tun.Domain {
return Tunnel{}, errors.New("Tunnel domain already in use") return Tunnel{}, errors.New("Tunnel domain already in use")
} }
@ -94,7 +93,7 @@ func (m *TunnelManager) RequestCreateTunnel(tunReq Tunnel) (Tunnel, error) {
return Tunnel{}, err return Tunnel{}, err
} }
tunReq.ServerAddress = m.config.WebUiDomain tunReq.ServerAddress = m.db.GetAdminDomain()
tunReq.ServerPort = m.config.SshServerPort tunReq.ServerPort = m.config.SshServerPort
tunReq.ServerPublicKey = "" tunReq.ServerPublicKey = ""
tunReq.Username = m.user.Username tunReq.Username = m.user.Username

View File

@ -1,26 +1,32 @@
package boringproxy package boringproxy
import ( import (
"embed"
"encoding/base64" "encoding/base64"
//"encoding/json"
"fmt" "fmt"
"github.com/GeertJohan/go.rice"
qrcode "github.com/skip2/go-qrcode" qrcode "github.com/skip2/go-qrcode"
"html/template" "html/template"
"io" "io"
"net/http" "net/http"
//"net/url"
//"os"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
//go:embed logo.png templates
var fs embed.FS
type WebUiHandler struct { type WebUiHandler struct {
config *Config config *Config
db *Database db *Database
api *Api api *Api
auth *Auth auth *Auth
tunMan *TunnelManager tunMan *TunnelManager
box *rice.Box
headHtml template.HTML headHtml template.HTML
tmpl *template.Template
pendingRequests map[string]chan ReqResult pendingRequests map[string]chan ReqResult
mutex *sync.Mutex mutex *sync.Mutex
} }
@ -30,22 +36,6 @@ type ReqResult struct {
redirectUrl string redirectUrl string
} }
type IndexData struct {
Head template.HTML
Tunnels map[string]Tunnel
Tokens map[string]TokenData
SshKeys map[string]SshKey
Users map[string]User
UserId string
IsAdmin bool
QrCodes map[string]template.URL
}
type TunnelsData struct {
Head template.HTML
Tunnels map[string]Tunnel
}
type ConfirmData struct { type ConfirmData struct {
Head template.HTML Head template.HTML
Message string Message string
@ -68,25 +58,6 @@ type LoginData struct {
Head template.HTML Head template.HTML
} }
type HeadData struct {
Styles template.CSS
}
type MenuData struct {
IsAdmin bool
}
type UsersData struct {
Head template.HTML
Users map[string]User
}
type TokensData struct {
Head template.HTML
Tokens map[string]TokenData
Users map[string]User
}
func NewWebUiHandler(config *Config, db *Database, api *Api, auth *Auth, tunMan *TunnelManager) *WebUiHandler { func NewWebUiHandler(config *Config, db *Database, api *Api, auth *Auth, tunMan *TunnelManager) *WebUiHandler {
return &WebUiHandler{ return &WebUiHandler{
config: config, config: config,
@ -101,40 +72,12 @@ func NewWebUiHandler(config *Config, db *Database, api *Api, auth *Auth, tunMan
func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request) { func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request) {
homePath := "/#/tunnel" var err error
h.tmpl, err = template.ParseFS(fs, "templates/*.tmpl")
// Note: h.box and h.headHtml need to be ready before pretty much
// everything else, including sendLoginPage
box, err := rice.FindBox("webui")
if err != nil { if err != nil {
w.WriteHeader(500) fmt.Println(err.Error())
io.WriteString(w, "Error opening webui")
return return
} }
h.box = box
stylesText, err := box.String("styles.css")
if err != nil {
w.WriteHeader(500)
io.WriteString(w, "Error reading styles.css")
return
}
headTmplStr, err := box.String("head.tmpl")
if err != nil {
w.WriteHeader(500)
io.WriteString(w, "Error reading head.tmpl")
return
}
headTmpl, err := template.New("head").Parse(headTmplStr)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, "Error compiling head.tmpl")
return
}
var headBuilder strings.Builder
headTmpl.Execute(&headBuilder, HeadData{Styles: template.CSS(stylesText)})
h.headHtml = template.HTML(headBuilder.String())
token, err := extractToken("access_token", r) token, err := extractToken("access_token", r)
if err != nil { if err != nil {
@ -162,7 +105,7 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
case "/login": case "/login":
h.handleLogin(w, r) h.handleLogin(w, r)
case "/users": case "/users":
h.handleUsers(w, r, tokenData) h.handleUsers(w, r, tokenData, user)
case "/confirm-delete-user": case "/confirm-delete-user":
h.confirmDeleteUser(w, r) h.confirmDeleteUser(w, r)
@ -170,10 +113,10 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
h.deleteUser(w, r, tokenData) h.deleteUser(w, r, tokenData)
case "/logo.png": case "/logo.png":
logoPngBytes, err := box.Bytes("logo.png") logoPngBytes, err := fs.ReadFile("logo.png")
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), homePath) h.alertDialog(w, r, err.Error(), "/")
return return
} }
@ -183,20 +126,210 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
w.Write(logoPngBytes) w.Write(logoPngBytes)
case "/": case "/":
indexTmplStr, err := h.box.String("index.tmpl") http.Redirect(w, r, "/tunnels", 303)
case "/tunnels":
h.handleTunnels(w, r, tokenData, user)
case "/confirm-delete-tunnel":
r.ParseForm()
if len(r.Form["domain"]) != 1 {
w.WriteHeader(400)
w.Write([]byte("Invalid domain parameter"))
return
}
domain := r.Form["domain"][0]
data := &ConfirmData{
Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete %s?", domain),
ConfirmUrl: fmt.Sprintf("/delete-tunnel?domain=%s", domain),
CancelUrl: "/tunnels",
}
h.tmpl.ExecuteTemplate(w, "confirm.tmpl", data)
case "/edit-tunnel":
r.ParseForm()
domain := r.Form.Get("domain")
var users map[string]User
// TODO: handle security checks in api
if user.IsAdmin {
users = h.db.GetUsers()
} else {
users = make(map[string]User)
users[tokenData.Owner] = user
}
requestId, _ := genRandomCode(32)
req := DNSRequest{
Records: []*DNSRecord{
&DNSRecord{
Type: "A",
Value: h.config.PublicIp,
TTL: 300,
},
},
}
h.db.SetDNSRequest(requestId, req)
adminDomain := h.db.GetAdminDomain()
tnLink := fmt.Sprintf("https://takingnames.io/dnsapi?requester=%s&request-id=%s", adminDomain, requestId)
templateData := struct {
Domain string
UserId string
User User
Users map[string]User
TakingNamesLink string
}{
Domain: domain,
UserId: tokenData.Owner,
User: user,
Users: users,
TakingNamesLink: tnLink,
}
err = h.tmpl.ExecuteTemplate(w, "edit_tunnel.tmpl", templateData)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
h.alertDialog(w, r, "Error reading index.tmpl", "/#/tunnels") io.WriteString(w, err.Error())
return return
} }
tmpl, err := template.New("index").Parse(indexTmplStr) case "/delete-tunnel":
r.ParseForm()
err := h.api.DeleteTunnel(tokenData, r.Form)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(400)
h.alertDialog(w, r, "Error compiling index.tmpl", "/#/tunnels") h.alertDialog(w, r, err.Error(), "/tunnels")
return return
} }
http.Redirect(w, r, "/tunnels", 303)
case "/tunnel-private-key":
r.ParseForm()
tun, err := h.api.GetTunnel(tokenData, r.Form)
if err != nil {
w.WriteHeader(400)
h.alertDialog(w, r, err.Error(), "/tunnels")
return
}
w.Header().Set("Content-Disposition", "attachment; filename=id_rsa")
io.WriteString(w, tun.TunnelPrivateKey)
case "/tokens":
h.handleTokens(w, r, user, tokenData)
case "/confirm-delete-token":
h.confirmDeleteToken(w, r)
case "/delete-token":
h.deleteToken(w, r, tokenData)
//case "/ssh-keys":
// h.handleSshKeys(w, r, user, tokenData)
//case "/delete-ssh-key":
// r.ParseForm()
// err := h.api.DeleteSshKey(tokenData, r.Form)
// if err != nil {
// w.WriteHeader(400)
// h.alertDialog(w, r, err.Error(), "/ssh-keys")
// return
// }
// http.Redirect(w, r, "/ssh-keys", 303)
case "/confirm-logout":
data := &ConfirmData{
Head: h.headHtml,
Message: "Are you sure you want to log out?",
ConfirmUrl: "/logout",
CancelUrl: "/",
}
err := h.tmpl.ExecuteTemplate(w, "confirm.tmpl", data)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/")
return
}
case "/logout":
cookie := &http.Cookie{
Name: "access_token",
Value: "",
Secure: true,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/tunnels", 303)
case "/loading":
h.handleLoading(w, r)
default:
if strings.HasPrefix(r.URL.Path, "/tunnels/") {
r.ParseForm()
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
w.WriteHeader(400)
h.alertDialog(w, r, "Invalid path", "/tunnels")
return
}
domain := parts[2]
r.Form.Set("domain", domain)
tunnel, err := h.api.GetTunnel(tokenData, r.Form)
if err != nil {
w.WriteHeader(400)
h.alertDialog(w, r, err.Error(), "/tunnels")
return
}
templateData := struct {
User User
Tunnel Tunnel
}{
User: user,
Tunnel: tunnel,
}
err = h.tmpl.ExecuteTemplate(w, "tunnel.tmpl", templateData)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, err.Error())
return
}
} else {
w.WriteHeader(404)
h.alertDialog(w, r, "Unknown page "+r.URL.Path, "/tunnels")
return
}
}
}
func (h *WebUiHandler) handleTokens(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) {
r.ParseForm()
switch r.Method {
case "GET":
var tokens map[string]TokenData var tokens map[string]TokenData
var users map[string]User var users map[string]User
@ -220,13 +353,14 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
qrCodes := make(map[string]template.URL) qrCodes := make(map[string]template.URL)
for token := range tokens { for token := range tokens {
loginUrl := fmt.Sprintf("https://%s/login?access_token=%s", h.config.WebUiDomain, token) adminDomain := h.db.GetAdminDomain()
loginUrl := fmt.Sprintf("https://%s/login?access_token=%s", adminDomain, token)
var png []byte var png []byte
png, err := qrcode.Encode(loginUrl, qrcode.Medium, 256) png, err := qrcode.Encode(loginUrl, qrcode.Medium, 256)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/#/tokens") h.alertDialog(w, r, err.Error(), "/tokens")
return return
} }
@ -234,159 +368,45 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
qrCodes[token] = template.URL("data:image/png;base64," + data) qrCodes[token] = template.URL("data:image/png;base64," + data)
} }
indexData := IndexData{ templateData := struct {
Head: h.headHtml, Tokens map[string]TokenData
Tunnels: tunnels, User User
Users map[string]User
QrCodes map[string]template.URL
}{
Tokens: tokens, Tokens: tokens,
SshKeys: h.api.GetSshKeys(tokenData), User: user,
Users: users, Users: users,
UserId: tokenData.Owner,
IsAdmin: user.IsAdmin,
QrCodes: qrCodes, QrCodes: qrCodes,
} }
err = tmpl.Execute(w, indexData) err := h.tmpl.ExecuteTemplate(w, "tokens.tmpl", templateData)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/#/tokens")
return
}
case "/tunnels":
h.handleTunnels(w, r, tokenData)
case "/confirm-delete-tunnel":
r.ParseForm()
if len(r.Form["domain"]) != 1 {
w.WriteHeader(400)
w.Write([]byte("Invalid domain parameter"))
return
}
domain := r.Form["domain"][0]
tmpl, err := h.loadTemplate("confirm.tmpl")
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
io.WriteString(w, err.Error()) io.WriteString(w, err.Error())
return return
} }
case "POST":
data := &ConfirmData{ _, err := h.api.CreateToken(tokenData, r.Form)
Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete %s?", domain),
ConfirmUrl: fmt.Sprintf("/delete-tunnel?domain=%s", domain),
CancelUrl: "/#/tunnels",
}
tmpl.Execute(w, data)
case "/delete-tunnel":
r.ParseForm()
err := h.api.DeleteTunnel(tokenData, r.Form)
if err != nil {
w.WriteHeader(400)
h.alertDialog(w, r, err.Error(), "/#/tunnels")
return
}
case "/tunnel-private-key":
r.ParseForm()
tun, err := h.api.GetTunnel(tokenData, r.Form)
if err != nil {
w.WriteHeader(400)
h.alertDialog(w, r, err.Error(), "/#/tunnels")
return
}
w.Header().Set("Content-Disposition", "attachment; filename=id_rsa")
io.WriteString(w, tun.TunnelPrivateKey)
case "/tokens":
h.handleTokens(w, r, user, tokenData)
case "/confirm-delete-token":
h.confirmDeleteToken(w, r)
case "/delete-token":
h.deleteToken(w, r, tokenData)
//case "/ssh-keys":
// h.handleSshKeys(w, r, user, tokenData)
//case "/delete-ssh-key":
// r.ParseForm()
// err := h.api.DeleteSshKey(tokenData, r.Form)
// if err != nil {
// w.WriteHeader(400)
// h.alertDialog(w, r, err.Error(), "/#/ssh-keys")
// return
// }
// http.Redirect(w, r, "/#/ssh-keys", 303)
case "/confirm-logout":
tmpl, err := h.loadTemplate("confirm.tmpl")
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/#/tunnels") h.alertDialog(w, r, err.Error(), "/tokens")
return return
} }
data := &ConfirmData{ http.Redirect(w, r, "/tokens", 303)
Head: h.headHtml,
Message: "Are you sure you want to log out?",
ConfirmUrl: "/logout",
CancelUrl: "/#/tunnels",
}
tmpl.Execute(w, data)
case "/logout":
cookie := &http.Cookie{
Name: "access_token",
Value: "",
Secure: true,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/#/tunnels", 303)
case "/loading":
h.handleLoading(w, r)
default: default:
w.WriteHeader(404)
h.alertDialog(w, r, "Unknown page "+r.URL.Path, "/#/tunnels")
return
}
}
func (h *WebUiHandler) handleTokens(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) {
if r.Method != "POST" {
w.WriteHeader(405) w.WriteHeader(405)
h.alertDialog(w, r, "Invalid method for tokens", "/#/tokens") h.alertDialog(w, r, "Invalid method for tokens", "/tokens")
return return
} }
r.ParseForm()
_, err := h.api.CreateToken(tokenData, r.Form)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/#/tokens")
return
}
http.Redirect(w, r, "/#/tokens", 303)
} }
func (h *WebUiHandler) handleSshKeys(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) { func (h *WebUiHandler) handleSshKeys(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) {
if r.Method != "POST" { if r.Method != "POST" {
w.WriteHeader(405) w.WriteHeader(405)
h.alertDialog(w, r, "Invalid method for /ssh-keys", "/#/ssh-keys") h.alertDialog(w, r, "Invalid method for /ssh-keys", "/ssh-keys")
return return
} }
@ -395,14 +415,14 @@ func (h *WebUiHandler) handleSshKeys(w http.ResponseWriter, r *http.Request, use
id := r.Form.Get("id") id := r.Form.Get("id")
if id == "" { if id == "" {
w.WriteHeader(400) w.WriteHeader(400)
h.alertDialog(w, r, "Invalid id parameter", "/#/ssh-keys") h.alertDialog(w, r, "Invalid id parameter", "/ssh-keys")
return return
} }
keyParam := r.Form.Get("key") keyParam := r.Form.Get("key")
if keyParam == "" { if keyParam == "" {
w.WriteHeader(400) w.WriteHeader(400)
h.alertDialog(w, r, "Invalid key parameter", "/#/ssh-keys") h.alertDialog(w, r, "Invalid key parameter", "/ssh-keys")
return return
} }
@ -418,11 +438,11 @@ func (h *WebUiHandler) handleSshKeys(w http.ResponseWriter, r *http.Request, use
err := h.db.AddSshKey(id, key) err := h.db.AddSshKey(id, key)
if err != nil { if err != nil {
w.WriteHeader(400) w.WriteHeader(400)
h.alertDialog(w, r, err.Error(), "/#/ssh-keys") h.alertDialog(w, r, err.Error(), "/ssh-keys")
return return
} }
http.Redirect(w, r, "/#/ssh-keys", 303) http.Redirect(w, r, "/ssh-keys", 303)
} }
func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) { func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
@ -453,21 +473,38 @@ func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
MaxAge: 86400 * 365, MaxAge: 86400 * 365,
} }
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
http.Redirect(w, r, "/#/tunnels", 303) http.Redirect(w, r, "/tunnels", 303)
} else { } else {
h.sendLoginPage(w, r, 403) h.sendLoginPage(w, r, 403)
return return
} }
} }
func (h *WebUiHandler) handleTunnels(w http.ResponseWriter, r *http.Request, tokenData TokenData) { func (h *WebUiHandler) handleTunnels(w http.ResponseWriter, r *http.Request, tokenData TokenData, user User) {
switch r.Method { switch r.Method {
case "POST": case "POST":
h.handleCreateTunnel(w, r, tokenData) h.handleCreateTunnel(w, r, tokenData)
case "GET":
tunnels := h.api.GetTunnels(tokenData)
templateData := struct {
User User
Tunnels map[string]Tunnel
}{
User: user,
Tunnels: tunnels,
}
err := h.tmpl.ExecuteTemplate(w, "tunnels.tmpl", templateData)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, err.Error())
return
}
default: default:
w.WriteHeader(405) w.WriteHeader(405)
w.Write([]byte("Invalid method for /#/tunnels")) w.Write([]byte("Invalid method for /tunnels"))
return return
} }
} }
@ -477,7 +514,7 @@ func (h *WebUiHandler) handleCreateTunnel(w http.ResponseWriter, r *http.Request
pendingId, err := genRandomCode(16) pendingId, err := genRandomCode(16)
if err != nil { if err != nil {
w.WriteHeader(400) w.WriteHeader(400)
h.alertDialog(w, r, err.Error(), "/#/tunnels") h.alertDialog(w, r, err.Error(), "/tunnels")
} }
doneSignal := make(chan ReqResult) doneSignal := make(chan ReqResult)
@ -491,7 +528,7 @@ func (h *WebUiHandler) handleCreateTunnel(w http.ResponseWriter, r *http.Request
_, err := h.api.CreateTunnel(tokenData, r.Form) _, err := h.api.CreateTunnel(tokenData, r.Form)
doneSignal <- ReqResult{err, "/#/tunnels"} doneSignal <- ReqResult{err, "/tunnels"}
}() }()
timeout := make(chan bool, 1) timeout := make(chan bool, 1)
@ -504,19 +541,17 @@ func (h *WebUiHandler) handleCreateTunnel(w http.ResponseWriter, r *http.Request
case <-timeout: case <-timeout:
url := fmt.Sprintf("/loading?id=%s", pendingId) url := fmt.Sprintf("/loading?id=%s", pendingId)
tmpl, err := h.loadTemplate("loading.tmpl")
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/#/tunnels")
return
}
data := &LoadingData{ data := &LoadingData{
Head: h.headHtml, Head: h.headHtml,
TargetUrl: url, TargetUrl: url,
} }
tmpl.Execute(w, data) h.tmpl.ExecuteTemplate(w, "loading.tmpl", data)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/tunnels")
return
}
case result := <-doneSignal: case result := <-doneSignal:
if result.err != nil { if result.err != nil {
@ -531,46 +566,62 @@ func (h *WebUiHandler) handleCreateTunnel(w http.ResponseWriter, r *http.Request
func (h *WebUiHandler) sendLoginPage(w http.ResponseWriter, r *http.Request, code int) { func (h *WebUiHandler) sendLoginPage(w http.ResponseWriter, r *http.Request, code int) {
loginTemplateStr, err := h.box.String("login.tmpl")
if err != nil {
w.WriteHeader(500)
io.WriteString(w, "Error reading login.tmpl")
return
}
loginTemplate, err := template.New("login").Parse(loginTemplateStr)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, "Error compiling login.tmpl")
return
}
loginData := LoginData{ loginData := LoginData{
Head: h.headHtml, Head: h.headHtml,
} }
w.WriteHeader(code) w.WriteHeader(code)
loginTemplate.Execute(w, loginData) err := h.tmpl.ExecuteTemplate(w, "login.tmpl", loginData)
} if err != nil {
w.WriteHeader(500)
func (h *WebUiHandler) handleUsers(w http.ResponseWriter, r *http.Request, tokenData TokenData) { io.WriteString(w, err.Error())
if r.Method != "POST" {
w.WriteHeader(405)
h.alertDialog(w, r, "Invalid method for users", "/#/users")
return return
} }
}
func (h *WebUiHandler) handleUsers(w http.ResponseWriter, r *http.Request, tokenData TokenData, user User) {
r.ParseForm() r.ParseForm()
err := h.api.CreateUser(tokenData, r.Form) switch r.Method {
if err != nil { case "GET":
w.WriteHeader(500) var users map[string]User
h.alertDialog(w, r, err.Error(), "/#/users")
return
}
http.Redirect(w, r, "/#/users", 303) // TODO: handle security checks in api
if user.IsAdmin {
users = h.db.GetUsers()
} else {
users = make(map[string]User)
users[tokenData.Owner] = user
}
templateData := struct {
User User
Users map[string]User
}{
User: user,
Users: users,
}
err := h.tmpl.ExecuteTemplate(w, "users.tmpl", templateData)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, err.Error())
return
}
case "POST":
err := h.api.CreateUser(tokenData, r.Form)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/users")
return
}
http.Redirect(w, r, "/users", 303)
default:
w.WriteHeader(405)
h.alertDialog(w, r, "Invalid method for users", "/users")
}
} }
func (h *WebUiHandler) confirmDeleteUser(w http.ResponseWriter, r *http.Request) { func (h *WebUiHandler) confirmDeleteUser(w http.ResponseWriter, r *http.Request) {
@ -584,21 +635,19 @@ func (h *WebUiHandler) confirmDeleteUser(w http.ResponseWriter, r *http.Request)
} }
username := r.Form["username"][0] username := r.Form["username"][0]
tmpl, err := h.loadTemplate("confirm.tmpl") data := &ConfirmData{
Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete user %s?", username),
ConfirmUrl: fmt.Sprintf("/delete-user?username=%s", username),
CancelUrl: "/users",
}
err := h.tmpl.ExecuteTemplate(w, "confirm.tmpl", data)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
io.WriteString(w, err.Error()) io.WriteString(w, err.Error())
return return
} }
data := &ConfirmData{
Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete user %s?", username),
ConfirmUrl: fmt.Sprintf("/delete-user?username=%s", username),
CancelUrl: "/#/users",
}
tmpl.Execute(w, data)
} }
func (h *WebUiHandler) deleteUser(w http.ResponseWriter, r *http.Request, tokenData TokenData) { func (h *WebUiHandler) deleteUser(w http.ResponseWriter, r *http.Request, tokenData TokenData) {
@ -608,11 +657,11 @@ func (h *WebUiHandler) deleteUser(w http.ResponseWriter, r *http.Request, tokenD
err := h.api.DeleteUser(tokenData, r.Form) err := h.api.DeleteUser(tokenData, r.Form)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/#/users") h.alertDialog(w, r, err.Error(), "/users")
return return
} }
http.Redirect(w, r, "/#/users", 303) http.Redirect(w, r, "/users", 303)
} }
func (h *WebUiHandler) confirmDeleteToken(w http.ResponseWriter, r *http.Request) { func (h *WebUiHandler) confirmDeleteToken(w http.ResponseWriter, r *http.Request) {
@ -626,21 +675,19 @@ func (h *WebUiHandler) confirmDeleteToken(w http.ResponseWriter, r *http.Request
} }
token := r.Form["token"][0] token := r.Form["token"][0]
tmpl, err := h.loadTemplate("confirm.tmpl") data := &ConfirmData{
Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete token %s?", token),
ConfirmUrl: fmt.Sprintf("/delete-token?token=%s", token),
CancelUrl: "/tokens",
}
err := h.tmpl.ExecuteTemplate(w, "confirm.tmpl", data)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
io.WriteString(w, err.Error()) io.WriteString(w, err.Error())
return return
} }
data := &ConfirmData{
Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete token %s?", token),
ConfirmUrl: fmt.Sprintf("/delete-token?token=%s", token),
CancelUrl: "/#/tokens",
}
tmpl.Execute(w, data)
} }
func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request, tokenData TokenData) { func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request, tokenData TokenData) {
@ -649,25 +696,24 @@ func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request, token
err := h.api.DeleteToken(tokenData, r.Form) err := h.api.DeleteToken(tokenData, r.Form)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/#/tokens") h.alertDialog(w, r, err.Error(), "/tokens")
return return
} }
http.Redirect(w, r, "/#/tokens", 303) http.Redirect(w, r, "/tokens", 303)
} }
func (h *WebUiHandler) alertDialog(w http.ResponseWriter, r *http.Request, message, redirectUrl string) error { func (h *WebUiHandler) alertDialog(w http.ResponseWriter, r *http.Request, message, redirectUrl string) error {
tmpl, err := h.loadTemplate("alert.tmpl") err := h.tmpl.ExecuteTemplate(w, "alert.tmpl", &AlertData{
if err != nil {
return err
}
tmpl.Execute(w, &AlertData{
Head: h.headHtml, Head: h.headHtml,
Message: message, Message: message,
RedirectUrl: redirectUrl, RedirectUrl: redirectUrl,
}) })
if err != nil {
return err
}
return nil return nil
} }
@ -675,7 +721,7 @@ func (h *WebUiHandler) handleLoading(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" { if r.Method != "GET" {
w.WriteHeader(405) w.WriteHeader(405)
h.alertDialog(w, r, "Invalid method for users", "/#/tunnels") h.alertDialog(w, r, "Invalid method for users", "/tunnels")
} }
r.ParseForm() r.ParseForm()
@ -697,18 +743,3 @@ func (h *WebUiHandler) handleLoading(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, result.redirectUrl, 303) http.Redirect(w, r, result.redirectUrl, 303)
} }
func (h *WebUiHandler) loadTemplate(name string) (*template.Template, error) {
tmplStr, err := h.box.String(name)
if err != nil {
return nil, err
}
tmpl, err := template.New(name).Parse(tmplStr)
if err != nil {
return nil, err
}
return tmpl, nil
}

View File

@ -1,281 +0,0 @@
<!doctype html>
<html>
<head>
{{.Head}}
<style>
{{range $domain, $tunnel:= .Tunnels}}
#toggle-tunnel-delete-dialog-{{$tunnel.CssId}} {
display: none;
}
#toggle-tunnel-delete-dialog-{{$tunnel.CssId}}:checked + .dialog {
display: block;
}
#toggle-tunnel-hide-deleted-{{$tunnel.CssId}}:checked + .list-item {
/* This is a trick to make the delete request after the delete button is
* clicked. The background will never actually be displayed, because it's
* moved offscreen. */
position: absolute;
left: -999em;
background: url("/delete-tunnel?domain={{$domain}}");
}
#toggle-tunnel-hide-deleted-{{$tunnel.CssId}}:checked ~ .dialog {
display: none;
}
{{end}}
</style>
</head>
<body>
<main>
<input type='checkbox' id='menu-toggle'/>
<label id='menu-label' for='menu-toggle'>Menu</label>
<div class='page' id='/tunnels'>
<div class='menu'>
<a class='menu-item active-tab' href='/#/tunnels'>Tunnels</a>
<a class='menu-item' href='/#/tokens'>Tokens</a>
<!--
<a class='menu-item' href='/#/ssh-keys'>SSH Keys</a>
-->
{{if .IsAdmin}}
<a class='menu-item' href='/#/users'>Users</a>
{{end}}
<a class='menu-item' href='/confirm-logout'>Logout</a>
</div>
<div class='content'>
<div class='list'>
{{range $domain, $tunnel:= .Tunnels}}
<input autocomplete='off' type='checkbox' class='toggle' id='toggle-tunnel-hide-deleted-{{$tunnel.CssId}}'>
<div class='list-item'>
<div>
<a href="https://{{$domain}}">{{$domain}}</a>:{{$tunnel.TunnelPort}} -> {{$tunnel.ClientName}} -> {{$tunnel.ClientAddress}}:{{$tunnel.ClientPort}}
</div>
<a class='button' href="/tunnel-private-key?domain={{$domain}}">Download Private Key</a>
<label class='button' for='toggle-tunnel-delete-dialog-{{$tunnel.CssId}}'>
Delete
</label>
</div>
<input autocomplete='off' type='checkbox' id='toggle-tunnel-delete-dialog-{{$tunnel.CssId}}'>
<div class='dialog'>
<label for='toggle-tunnel-delete-dialog-{{$tunnel.CssId}}' class='dialog__overlay'></label>
<div class='dialog__content'>
<p class='dialog__text'>
Are you sure you want to delete {{$domain}}?
</p>
<div class='button-row'>
<label for='toggle-tunnel-hide-deleted-{{$tunnel.CssId}}' class='button'>
Confirm
</label>
<label for='toggle-tunnel-delete-dialog-{{$tunnel.CssId}}' class='button'>
Cancel
</button>
</div>
</div>
</div>
{{end}}
</div>
<div class='tunnel-adder'>
<h1>Add Tunnel</h1>
<form action="/tunnels" method="POST">
<div class='input'>
<label for="domain">Domain:</label>
<input type="text" id="domain" name="domain" required>
<input type="hidden" id="tunnel-owner" name="owner" value="{{$.UserId}}">
</div>
<div class='input'>
<label for="tunnel-port">Tunnel Port:</label>
<input type="text" id="tunnel-port" name="tunnel-port" value="Random">
</div>
<!--
<div class='input'>
<label for="ssh-key-id-select">SSH Key:</label>
<select id="ssh-key-id-select" name="ssh-key-id">
<option value="generate">Generate</option>
{{range $id, $sshKey := $.SshKeys}}
<option value="{{$id}}">{{$id}}</option>
{{end}}
</select>
</div>
-->
<div class='input'>
<label for="client-name">Client Name:</label>
<select id="client-name" name="client-name">
<option value="none">No client</option>
{{range $id, $client := (index $.Users $.UserId).Clients}}
<option value="{{$id}}">{{$id}}</option>
{{end}}
</select>
</div>
<div class='input'>
<label for="client-addr">Client Address:</label>
<input type="text" id="client-addr" name="client-addr" value='127.0.0.1'>
</div>
<div class='input'>
<label for="client-port">Client Port:</label>
<input type="text" id="client-port" name="client-port">
</div>
<div class='input'>
<label for="allow-external-tcp">Allow External TCP:</label>
<input type="checkbox" id="allow-external-tcp" name="allow-external-tcp">
</div>
<div class='input'>
<label for="password-protect">Password Protect:</label>
<input type="checkbox" id="password-protect" name="password-protect">
<div id='login-inputs'>
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
</div>
</div>
<div class='input'>
<label for="tls-termination">TLS Termination:</label>
<select id="tls-termination" name="tls-termination">
<option value="server">Server</option>
<option value="client">Client</option>
<option value="passthrough">Passthrough</option>
</select>
</div>
<button class='button' type="submit">Submit</button>
</form>
</div>
</div>
</div>
<div class='page' id='/tokens'>
<div class='menu'>
<a class='menu-item' href='/#/tunnels'>Tunnels</a>
<a class='menu-item active-tab' href='/#/tokens'>Tokens</a>
<!--
<a class='menu-item' href='/#/ssh-keys'>SSH Keys</a>
-->
{{if .IsAdmin}}
<a class='menu-item' href='/#/users'>Users</a>
{{end}}
<a class='menu-item' href='/confirm-logout'>Logout</a>
</div>
<div class='content'>
<div class='list'>
{{range $token, $tokenData := .Tokens}}
<div class='list-item'>
<span class='token'>{{$token}} ({{$tokenData.Owner}})</span>
<a href='/login?access_token={{$token}}'>Login link</a>
<img class='qr-code' src='{{index $.QrCodes $token}}' width=100 height=100>
<a href="/confirm-delete-token?token={{$token}}">
<button class='button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='token-adder'>
<form action="/tokens" method="POST">
<label for="token-owner">Owner:</label>
<select id="token-owner" name="owner">
{{range $username, $user := .Users}}
<option value="{{$username}}">{{$username}}</option>
{{end}}
</select>
<button class='button' type="submit">Add Token</button>
</form>
</div>
</div>
</div>
<!--
<div class='page' id='/ssh-keys'>
<div class='menu'>
<a class='menu-item' href='/#/tunnels'>Tunnels</a>
<a class='menu-item' href='/#/tokens'>Tokens</a>
<a class='menu-item active-tab' href='/#/ssh-keys'>SSH Keys</a>
{{if .IsAdmin}}
<a class='menu-item' href='/#/users'>Users</a>
{{end}}
<a class='menu-item' href='/confirm-logout'>Logout</a>
</div>
<div class='content'>
<div class='list'>
{{range $id, $sshKey := .SshKeys}}
<div class='list-item'>
<span class='monospace'>{{$id}} ({{$sshKey.Owner}})</span>
<div class='monospace'>{{$sshKey.Key}}</div>
<a href="/delete-ssh-key?id={{$id}}">
<button class='button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='ssh-key-adder'>
<h1>Add SSH Key</h1>
<form action="/ssh-keys" method="POST">
<div class='input'>
<label for="ssh-key-id">SSH Key ID:</label>
<input type="text" id="ssh-key-id" name="id" required>
</div>
<div class='input'>
<label for="ssh-key-owner">Owner:</label>
<select id="ssh-key-owner" name="owner">
{{range $username, $user := .Users}}
<option value="{{$username}}">{{$username}}</option>
{{end}}
</select>
</div>
<div class='input'>
<label for="ssh-key">SSH Key:</label>
<textarea id='ssh-key' name='key' rows='8' cols='30'>
</textarea>
</div>
<button class='button' type="submit">Submit</button>
</form>
</div>
</div>
</div>
-->
{{if .IsAdmin}}
<div class='page' id='/users'>
<div class='menu'>
<a class='menu-item' href='/#/tunnels'>Tunnels</a>
<a class='menu-item' href='/#/tokens'>Tokens</a>
<a class='menu-item' href='/#/users'>Users</a>
<a class='menu-item' href='/confirm-logout'>Logout</a>
</div>
<div class='content'>
<div class='list'>
{{range $username, $user := .Users}}
<div class='list-item'>
{{$username}}
<a href="/confirm-delete-user?username={{$username}}">
<button class='button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='user-adder'>
<form action="/users" method="POST">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
<label for="is-admin">Is Admin:</label>
<input type="checkbox" id="is-admin" name="is-admin">
<button class='button' type="submit">Add User</button>
</form>
</div>
</div>
</div>
{{end}}
</main>
</body>
</html>