mirror of
https://github.com/boringproxy/boringproxy.git
synced 2025-02-25 18:55:29 -06:00
Merge pull request #115 from boringproxy/takingnames-io-integration
Takingnames io integration
This commit is contained in:
commit
cfdae775fa
@ -1,15 +1,13 @@
|
||||
FROM golang:1.15-alpine3.12 as builder
|
||||
FROM golang:1.17-alpine3.15 as builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apk add git
|
||||
RUN go get github.com/GeertJohan/go.rice/rice
|
||||
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
RUN rice embed-go
|
||||
RUN cd cmd/boringproxy && CGO_ENABLED=0 go build -o boringproxy
|
||||
|
||||
FROM scratch
|
||||
|
@ -40,14 +40,6 @@ source $HOME/.bashrc
|
||||
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
|
||||
|
||||
## Server
|
||||
|
275
boringproxy.go
275
boringproxy.go
@ -3,6 +3,8 @@ package boringproxy
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -18,8 +20,8 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebUiDomain string `json:"webui_domain"`
|
||||
SshServerPort int `json:"ssh_server_port"`
|
||||
PublicIp string `json:"public_ip"`
|
||||
}
|
||||
|
||||
type SmtpConfig struct {
|
||||
@ -36,9 +38,74 @@ type Server struct {
|
||||
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() {
|
||||
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")
|
||||
certDir := flagSet.String("cert-dir", "", "TLS cert directory")
|
||||
err := flagSet.Parse(os.Args[2:])
|
||||
@ -48,50 +115,70 @@ func Listen() {
|
||||
|
||||
log.Println("Starting up")
|
||||
|
||||
webUiDomain := *adminDomain
|
||||
|
||||
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})
|
||||
ip, err := getPublicIp()
|
||||
if err != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
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()
|
||||
if len(users) == 0 {
|
||||
db.AddUser("admin", true)
|
||||
token, err := db.AddToken("admin")
|
||||
//token, err := db.AddToken("admin")
|
||||
_, err := db.AddToken("admin")
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize admin user")
|
||||
}
|
||||
|
||||
log.Println("Admin token: " + token)
|
||||
log.Println(fmt.Sprintf("Admin login link: https://%s/login?access_token=%s", webUiDomain, token))
|
||||
//log.Println("Admin token: " + 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)
|
||||
@ -123,7 +210,60 @@ func Listen() {
|
||||
timestamp := time.Now().Format(time.RFC3339)
|
||||
srcIp := strings.Split(r.RemoteAddr, ":")[0]
|
||||
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/") {
|
||||
http.StripPrefix("/api", api).ServeHTTP(w, r)
|
||||
} else {
|
||||
@ -143,9 +283,8 @@ func Listen() {
|
||||
}
|
||||
})
|
||||
|
||||
// taken from: https://stackoverflow.com/a/37537134/943814
|
||||
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)
|
||||
}
|
||||
}()
|
||||
@ -157,6 +296,8 @@ func Listen() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Println("Ready")
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
@ -215,7 +356,71 @@ func (p *Server) passthroughRequest(conn net.Conn, tunnel Tunnel) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func redirectTLS(w http.ResponseWriter, r *http.Request) {
|
||||
url := fmt.Sprintf("https://%s:443%s", r.Host, r.RequestURI)
|
||||
http.Redirect(w, r, url, http.StatusMovedPermanently)
|
||||
func setAdminDomain(ip string, certConfig *certmagic.Config, db *Database) error {
|
||||
action := prompt("\nNo admin domain set. Enter '1' to input manually, or '2' to configure through TakingNames.io\n")
|
||||
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)
|
||||
}
|
||||
|
59
database.go
59
database.go
@ -9,10 +9,12 @@ import (
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
AdminDomain string `json:"admin_domain"`
|
||||
Tokens map[string]TokenData `json:"tokens"`
|
||||
Tunnels map[string]Tunnel `json:"tunnels"`
|
||||
Users map[string]User `json:"users"`
|
||||
SshKeys map[string]SshKey `json:"ssh_keys"`
|
||||
dnsRequests map[string]DNSRequest `json:"dns_requests"`
|
||||
mutex *sync.Mutex
|
||||
}
|
||||
|
||||
@ -33,6 +35,17 @@ type SshKey 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 {
|
||||
Owner string `json:"owner"`
|
||||
Domain string `json:"domain"`
|
||||
@ -85,6 +98,10 @@ func NewDatabase() (*Database, error) {
|
||||
db.SshKeys = make(map[string]SshKey)
|
||||
}
|
||||
|
||||
if db.dnsRequests == nil {
|
||||
db.dnsRequests = make(map[string]DNSRequest)
|
||||
}
|
||||
|
||||
db.mutex = &sync.Mutex{}
|
||||
|
||||
db.mutex.Lock()
|
||||
@ -94,6 +111,48 @@ func NewDatabase() (*Database, error) {
|
||||
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) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
16
go.mod
16
go.mod
@ -1,10 +1,22 @@
|
||||
module github.com/boringproxy/boringproxy
|
||||
|
||||
go 1.15
|
||||
go 1.17
|
||||
|
||||
require (
|
||||
github.com/GeertJohan/go.rice v1.0.0
|
||||
github.com/caddyserver/certmagic v0.12.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
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
19
go.sum
@ -1,28 +1,16 @@
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/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/klauspost/cpuid v1.2.5 h1:VBd9MyVIiJHzzgnrLQG5Bcv75H4YaWrlKqWHjurxCGo=
|
||||
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/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/libdns/libdns v0.1.0 h1:0ctCOrVJsVzj53mop1angHp/pE3hmAhP7KiHvR0HD04=
|
||||
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/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo=
|
||||
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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
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/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
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-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 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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -49,6 +50,21 @@ func proxyRequest(w http.ResponseWriter, r *http.Request, tunnel Tunnel, httpCli
|
||||
upstreamReq.Header = downstreamReqHeaders
|
||||
|
||||
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)
|
||||
|
||||
upstreamRes, err := httpClient.Do(upstreamReq)
|
||||
|
@ -11,4 +11,3 @@ WORKDIR boringproxy
|
||||
RUN ./scripts/install_go.sh
|
||||
ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV PATH="${PATH}:/root/go/bin"
|
||||
RUN go get github.com/GeertJohan/go.rice/rice
|
||||
|
@ -4,8 +4,6 @@ version=$(git describe --tags)
|
||||
|
||||
./scripts/generate_logo.sh
|
||||
|
||||
rice embed-go
|
||||
|
||||
cd ./cmd/boringproxy
|
||||
|
||||
../../scripts/build_x86_64.sh linux
|
||||
|
@ -1,2 +1,6 @@
|
||||
#!/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
7
sni.go
@ -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
|
||||
// reader is provided. So it proxies every method except Read. I'm sure there's
|
||||
// a cleaner way to do this...
|
||||
// reader is provided. So it proxies every method except Read. This is
|
||||
// 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 {
|
||||
conn net.Conn
|
||||
reader io.Reader
|
||||
|
@ -1,9 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
{{.Head}}
|
||||
|
||||
<style>
|
||||
|
||||
{{ template "styles.tmpl" }}
|
||||
|
||||
.dialog {
|
||||
display: block;
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
{{.Head}}
|
||||
|
||||
{{ template "head_common.tmpl" }}
|
||||
<style>
|
||||
.dialog {
|
||||
display: block;
|
64
templates/edit_tunnel.tmpl
Normal file
64
templates/edit_tunnel.tmpl
Normal 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
5
templates/footer.tmpl
Normal file
@ -0,0 +1,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -6,6 +6,5 @@
|
||||
<link rel="icon" href="/logo.png">
|
||||
|
||||
<style>
|
||||
{{.Styles}}
|
||||
{{ template "styles.tmpl" }}
|
||||
</style>
|
||||
|
36
templates/header.tmpl
Normal file
36
templates/header.tmpl
Normal 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'>
|
||||
|
@ -2,9 +2,9 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0; URL='{{$.TargetUrl}}'" />
|
||||
{{.Head}}
|
||||
|
||||
<style>
|
||||
{{ template "styles.tmpl" }}
|
||||
.dialog {
|
||||
display: block;
|
||||
}
|
@ -1,9 +1,8 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
{{.Head}}
|
||||
|
||||
<style>
|
||||
{{ template "styles.tmpl" }}
|
||||
.dialog {
|
||||
display: block;
|
||||
}
|
@ -7,6 +7,7 @@
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family: Arial;
|
||||
}
|
||||
|
||||
html {
|
||||
@ -23,6 +24,51 @@ main {
|
||||
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 {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
@ -145,12 +191,6 @@ main {
|
||||
margin: .2em;
|
||||
}
|
||||
|
||||
.tunnel-adder form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ssh-key-adder form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -158,16 +198,13 @@ main {
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: .7em;
|
||||
margin: .2em;
|
||||
border: 1px solid var(--main-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input label {
|
||||
padding: .2em;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#login-inputs {
|
||||
@ -193,8 +230,9 @@ main {
|
||||
|
||||
.page {
|
||||
margin-top: var(--menu-label-height);
|
||||
display: none;
|
||||
/*display: none;*/
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
@ -202,10 +240,6 @@ main {
|
||||
height: 8em;
|
||||
}
|
||||
|
||||
main *:target {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: none;
|
||||
}
|
||||
@ -229,7 +263,7 @@ main *:target {
|
||||
z-index: 1010;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@media (min-width: 960px) {
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
@ -255,4 +289,12 @@ main *:target {
|
||||
.content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tn-tunnel-list-table {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tn-tunnel-list {
|
||||
display: none;
|
||||
}
|
||||
}
|
27
templates/tokens.tmpl
Normal file
27
templates/tokens.tmpl
Normal 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
37
templates/tunnel.tmpl
Normal 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
54
templates/tunnels.tmpl
Normal 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
21
templates/users.tmpl
Normal 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" . }}
|
@ -79,7 +79,6 @@ func (m *TunnelManager) RequestCreateTunnel(tunReq Tunnel) (Tunnel, error) {
|
||||
}
|
||||
|
||||
for _, tun := range m.db.GetTunnels() {
|
||||
fmt.Println(tunReq.Domain, tun.Domain)
|
||||
if tunReq.Domain == tun.Domain {
|
||||
return Tunnel{}, errors.New("Tunnel domain already in use")
|
||||
}
|
||||
@ -94,7 +93,7 @@ func (m *TunnelManager) RequestCreateTunnel(tunReq Tunnel) (Tunnel, error) {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
|
||||
tunReq.ServerAddress = m.config.WebUiDomain
|
||||
tunReq.ServerAddress = m.db.GetAdminDomain()
|
||||
tunReq.ServerPort = m.config.SshServerPort
|
||||
tunReq.ServerPublicKey = ""
|
||||
tunReq.Username = m.user.Username
|
||||
|
623
ui_handler.go
623
ui_handler.go
@ -1,26 +1,32 @@
|
||||
package boringproxy
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
//"encoding/json"
|
||||
"fmt"
|
||||
"github.com/GeertJohan/go.rice"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
//"net/url"
|
||||
//"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed logo.png templates
|
||||
var fs embed.FS
|
||||
|
||||
type WebUiHandler struct {
|
||||
config *Config
|
||||
db *Database
|
||||
api *Api
|
||||
auth *Auth
|
||||
tunMan *TunnelManager
|
||||
box *rice.Box
|
||||
headHtml template.HTML
|
||||
tmpl *template.Template
|
||||
pendingRequests map[string]chan ReqResult
|
||||
mutex *sync.Mutex
|
||||
}
|
||||
@ -30,22 +36,6 @@ type ReqResult struct {
|
||||
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 {
|
||||
Head template.HTML
|
||||
Message string
|
||||
@ -68,25 +58,6 @@ type LoginData struct {
|
||||
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 {
|
||||
return &WebUiHandler{
|
||||
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) {
|
||||
|
||||
homePath := "/#/tunnel"
|
||||
|
||||
// Note: h.box and h.headHtml need to be ready before pretty much
|
||||
// everything else, including sendLoginPage
|
||||
|
||||
box, err := rice.FindBox("webui")
|
||||
var err error
|
||||
h.tmpl, err = template.ParseFS(fs, "templates/*.tmpl")
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, "Error opening webui")
|
||||
fmt.Println(err.Error())
|
||||
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)
|
||||
if err != nil {
|
||||
@ -162,7 +105,7 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
|
||||
case "/login":
|
||||
h.handleLogin(w, r)
|
||||
case "/users":
|
||||
h.handleUsers(w, r, tokenData)
|
||||
h.handleUsers(w, r, tokenData, user)
|
||||
|
||||
case "/confirm-delete-user":
|
||||
h.confirmDeleteUser(w, r)
|
||||
@ -170,10 +113,10 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
|
||||
h.deleteUser(w, r, tokenData)
|
||||
case "/logo.png":
|
||||
|
||||
logoPngBytes, err := box.Bytes("logo.png")
|
||||
logoPngBytes, err := fs.ReadFile("logo.png")
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), homePath)
|
||||
h.alertDialog(w, r, err.Error(), "/")
|
||||
return
|
||||
}
|
||||
|
||||
@ -183,20 +126,210 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
|
||||
w.Write(logoPngBytes)
|
||||
|
||||
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 {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, "Error reading index.tmpl", "/#/tunnels")
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := template.New("index").Parse(indexTmplStr)
|
||||
case "/delete-tunnel":
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
err := h.api.DeleteTunnel(tokenData, r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, "Error compiling index.tmpl", "/#/tunnels")
|
||||
w.WriteHeader(400)
|
||||
h.alertDialog(w, r, err.Error(), "/tunnels")
|
||||
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 users map[string]User
|
||||
|
||||
@ -220,13 +353,14 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
|
||||
|
||||
qrCodes := make(map[string]template.URL)
|
||||
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
|
||||
png, err := qrcode.Encode(loginUrl, qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), "/#/tokens")
|
||||
h.alertDialog(w, r, err.Error(), "/tokens")
|
||||
return
|
||||
}
|
||||
|
||||
@ -234,159 +368,45 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
|
||||
qrCodes[token] = template.URL("data:image/png;base64," + data)
|
||||
}
|
||||
|
||||
indexData := IndexData{
|
||||
Head: h.headHtml,
|
||||
Tunnels: tunnels,
|
||||
templateData := struct {
|
||||
Tokens map[string]TokenData
|
||||
User User
|
||||
Users map[string]User
|
||||
QrCodes map[string]template.URL
|
||||
}{
|
||||
Tokens: tokens,
|
||||
SshKeys: h.api.GetSshKeys(tokenData),
|
||||
User: user,
|
||||
Users: users,
|
||||
UserId: tokenData.Owner,
|
||||
IsAdmin: user.IsAdmin,
|
||||
QrCodes: qrCodes,
|
||||
}
|
||||
|
||||
err = tmpl.Execute(w, indexData)
|
||||
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")
|
||||
err := h.tmpl.ExecuteTemplate(w, "tokens.tmpl", templateData)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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 {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), "/#/tunnels")
|
||||
return
|
||||
}
|
||||
|
||||
data := &ConfirmData{
|
||||
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:
|
||||
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)
|
||||
h.alertDialog(w, r, "Invalid method for tokens", "/#/tokens")
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
case "POST":
|
||||
_, err := h.api.CreateToken(tokenData, r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), "/#/tokens")
|
||||
h.alertDialog(w, r, err.Error(), "/tokens")
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/#/tokens", 303)
|
||||
http.Redirect(w, r, "/tokens", 303)
|
||||
default:
|
||||
w.WriteHeader(405)
|
||||
h.alertDialog(w, r, "Invalid method for tokens", "/tokens")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebUiHandler) handleSshKeys(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) {
|
||||
|
||||
if r.Method != "POST" {
|
||||
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
|
||||
}
|
||||
|
||||
@ -395,14 +415,14 @@ func (h *WebUiHandler) handleSshKeys(w http.ResponseWriter, r *http.Request, use
|
||||
id := r.Form.Get("id")
|
||||
if id == "" {
|
||||
w.WriteHeader(400)
|
||||
h.alertDialog(w, r, "Invalid id parameter", "/#/ssh-keys")
|
||||
h.alertDialog(w, r, "Invalid id parameter", "/ssh-keys")
|
||||
return
|
||||
}
|
||||
|
||||
keyParam := r.Form.Get("key")
|
||||
if keyParam == "" {
|
||||
w.WriteHeader(400)
|
||||
h.alertDialog(w, r, "Invalid key parameter", "/#/ssh-keys")
|
||||
h.alertDialog(w, r, "Invalid key parameter", "/ssh-keys")
|
||||
return
|
||||
}
|
||||
|
||||
@ -418,11 +438,11 @@ func (h *WebUiHandler) handleSshKeys(w http.ResponseWriter, r *http.Request, use
|
||||
err := h.db.AddSshKey(id, key)
|
||||
if err != nil {
|
||||
w.WriteHeader(400)
|
||||
h.alertDialog(w, r, err.Error(), "/#/ssh-keys")
|
||||
h.alertDialog(w, r, err.Error(), "/ssh-keys")
|
||||
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) {
|
||||
@ -453,21 +473,38 @@ func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
MaxAge: 86400 * 365,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, "/#/tunnels", 303)
|
||||
http.Redirect(w, r, "/tunnels", 303)
|
||||
} else {
|
||||
h.sendLoginPage(w, r, 403)
|
||||
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 {
|
||||
case "POST":
|
||||
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:
|
||||
w.WriteHeader(405)
|
||||
w.Write([]byte("Invalid method for /#/tunnels"))
|
||||
w.Write([]byte("Invalid method for /tunnels"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -477,7 +514,7 @@ func (h *WebUiHandler) handleCreateTunnel(w http.ResponseWriter, r *http.Request
|
||||
pendingId, err := genRandomCode(16)
|
||||
if err != nil {
|
||||
w.WriteHeader(400)
|
||||
h.alertDialog(w, r, err.Error(), "/#/tunnels")
|
||||
h.alertDialog(w, r, err.Error(), "/tunnels")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
doneSignal <- ReqResult{err, "/#/tunnels"}
|
||||
doneSignal <- ReqResult{err, "/tunnels"}
|
||||
}()
|
||||
|
||||
timeout := make(chan bool, 1)
|
||||
@ -504,19 +541,17 @@ func (h *WebUiHandler) handleCreateTunnel(w http.ResponseWriter, r *http.Request
|
||||
case <-timeout:
|
||||
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{
|
||||
Head: h.headHtml,
|
||||
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:
|
||||
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) {
|
||||
|
||||
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{
|
||||
Head: h.headHtml,
|
||||
}
|
||||
|
||||
w.WriteHeader(code)
|
||||
loginTemplate.Execute(w, loginData)
|
||||
}
|
||||
|
||||
func (h *WebUiHandler) handleUsers(w http.ResponseWriter, r *http.Request, tokenData TokenData) {
|
||||
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
h.alertDialog(w, r, "Invalid method for users", "/#/users")
|
||||
err := h.tmpl.ExecuteTemplate(w, "login.tmpl", loginData)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebUiHandler) handleUsers(w http.ResponseWriter, r *http.Request, tokenData TokenData, user User) {
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
h.alertDialog(w, r, err.Error(), "/users")
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/#/users", 303)
|
||||
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) {
|
||||
@ -584,21 +635,19 @@ func (h *WebUiHandler) confirmDeleteUser(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
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 {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
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) {
|
||||
@ -608,11 +657,11 @@ func (h *WebUiHandler) deleteUser(w http.ResponseWriter, r *http.Request, tokenD
|
||||
err := h.api.DeleteUser(tokenData, r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), "/#/users")
|
||||
h.alertDialog(w, r, err.Error(), "/users")
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/#/users", 303)
|
||||
http.Redirect(w, r, "/users", 303)
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
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 {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
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) {
|
||||
@ -649,25 +696,24 @@ func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request, token
|
||||
err := h.api.DeleteToken(tokenData, r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), "/#/tokens")
|
||||
h.alertDialog(w, r, err.Error(), "/tokens")
|
||||
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 {
|
||||
tmpl, err := h.loadTemplate("alert.tmpl")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpl.Execute(w, &AlertData{
|
||||
err := h.tmpl.ExecuteTemplate(w, "alert.tmpl", &AlertData{
|
||||
Head: h.headHtml,
|
||||
Message: message,
|
||||
RedirectUrl: redirectUrl,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -675,7 +721,7 @@ func (h *WebUiHandler) handleLoading(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method != "GET" {
|
||||
w.WriteHeader(405)
|
||||
h.alertDialog(w, r, "Invalid method for users", "/#/tunnels")
|
||||
h.alertDialog(w, r, "Invalid method for users", "/tunnels")
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
@ -697,18 +743,3 @@ func (h *WebUiHandler) handleLoading(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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
|
||||
}
|
||||
|
281
webui/index.tmpl
281
webui/index.tmpl
@ -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>
|
Loading…
Reference in New Issue
Block a user