mirror of
https://github.com/tailscale/tailscale.git
synced 2025-06-09 01:08:34 +00:00
326 lines
7.8 KiB
Go
326 lines
7.8 KiB
Go
![]() |
// Copyright (c) Tailscale Inc & AUTHORS
|
||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||
|
|
||
|
package main
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
_ "embed"
|
||
|
"html/template"
|
||
|
"log"
|
||
|
"net/http"
|
||
|
"net/url"
|
||
|
"sort"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"tailscale.com/util/rands"
|
||
|
)
|
||
|
|
||
|
//go:embed ui-header.html
|
||
|
var headerHTML string
|
||
|
|
||
|
//go:embed ui-list.html
|
||
|
var listHTML string
|
||
|
|
||
|
//go:embed ui-edit.html
|
||
|
var editHTML string
|
||
|
|
||
|
//go:embed ui-style.css
|
||
|
var styleCSS string
|
||
|
|
||
|
var headerTmpl = template.Must(template.New("header").Parse(headerHTML))
|
||
|
var listTmpl = template.Must(headerTmpl.New("list").Parse(listHTML))
|
||
|
var editTmpl = template.Must(headerTmpl.New("edit").Parse(editHTML))
|
||
|
|
||
|
var processStart = time.Now()
|
||
|
|
||
|
func (s *idpServer) handleUI(w http.ResponseWriter, r *http.Request) {
|
||
|
if isFunnelRequest(r) {
|
||
|
http.Error(w, "tsidp: UI not available over Funnel", http.StatusNotFound)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
switch r.URL.Path {
|
||
|
case "/":
|
||
|
s.handleClientsList(w, r)
|
||
|
return
|
||
|
case "/new":
|
||
|
s.handleNewClient(w, r)
|
||
|
return
|
||
|
case "/style.css":
|
||
|
http.ServeContent(w, r, "ui-style.css", processStart, strings.NewReader(styleCSS))
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if strings.HasPrefix(r.URL.Path, "/edit/") {
|
||
|
s.handleEditClient(w, r)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
http.Error(w, "tsidp: not found", http.StatusNotFound)
|
||
|
}
|
||
|
|
||
|
func (s *idpServer) handleClientsList(w http.ResponseWriter, r *http.Request) {
|
||
|
s.mu.Lock()
|
||
|
clients := make([]clientDisplayData, 0, len(s.funnelClients))
|
||
|
for _, c := range s.funnelClients {
|
||
|
clients = append(clients, clientDisplayData{
|
||
|
ID: c.ID,
|
||
|
Name: c.Name,
|
||
|
RedirectURI: c.RedirectURI,
|
||
|
HasSecret: c.Secret != "",
|
||
|
})
|
||
|
}
|
||
|
s.mu.Unlock()
|
||
|
|
||
|
sort.Slice(clients, func(i, j int) bool {
|
||
|
if clients[i].Name != clients[j].Name {
|
||
|
return clients[i].Name < clients[j].Name
|
||
|
}
|
||
|
return clients[i].ID < clients[j].ID
|
||
|
})
|
||
|
|
||
|
var buf bytes.Buffer
|
||
|
if err := listTmpl.Execute(&buf, clients); err != nil {
|
||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
|
return
|
||
|
}
|
||
|
buf.WriteTo(w)
|
||
|
}
|
||
|
|
||
|
func (s *idpServer) handleNewClient(w http.ResponseWriter, r *http.Request) {
|
||
|
if r.Method == "GET" {
|
||
|
if err := s.renderClientForm(w, clientDisplayData{IsNew: true}); err != nil {
|
||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if r.Method == "POST" {
|
||
|
if err := r.ParseForm(); err != nil {
|
||
|
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||
|
redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
|
||
|
|
||
|
baseData := clientDisplayData{
|
||
|
IsNew: true,
|
||
|
Name: name,
|
||
|
RedirectURI: redirectURI,
|
||
|
}
|
||
|
|
||
|
if errMsg := validateRedirectURI(redirectURI); errMsg != "" {
|
||
|
s.renderFormError(w, baseData, errMsg)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
clientID := rands.HexString(32)
|
||
|
clientSecret := rands.HexString(64)
|
||
|
newClient := funnelClient{
|
||
|
ID: clientID,
|
||
|
Secret: clientSecret,
|
||
|
Name: name,
|
||
|
RedirectURI: redirectURI,
|
||
|
}
|
||
|
|
||
|
s.mu.Lock()
|
||
|
if s.funnelClients == nil {
|
||
|
s.funnelClients = make(map[string]*funnelClient)
|
||
|
}
|
||
|
s.funnelClients[clientID] = &newClient
|
||
|
err := s.storeFunnelClientsLocked()
|
||
|
s.mu.Unlock()
|
||
|
|
||
|
if err != nil {
|
||
|
log.Printf("could not write funnel clients db: %v", err)
|
||
|
s.renderFormError(w, baseData, "Failed to save client")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
successData := clientDisplayData{
|
||
|
ID: clientID,
|
||
|
Name: name,
|
||
|
RedirectURI: redirectURI,
|
||
|
Secret: clientSecret,
|
||
|
IsNew: true,
|
||
|
}
|
||
|
s.renderFormSuccess(w, successData, "Client created successfully! Save the client secret - it won't be shown again.")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
|
}
|
||
|
|
||
|
func (s *idpServer) handleEditClient(w http.ResponseWriter, r *http.Request) {
|
||
|
clientID := strings.TrimPrefix(r.URL.Path, "/edit/")
|
||
|
if clientID == "" {
|
||
|
http.Error(w, "Client ID required", http.StatusBadRequest)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
s.mu.Lock()
|
||
|
client, exists := s.funnelClients[clientID]
|
||
|
s.mu.Unlock()
|
||
|
|
||
|
if !exists {
|
||
|
http.Error(w, "Client not found", http.StatusNotFound)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if r.Method == "GET" {
|
||
|
data := createEditBaseData(client, client.Name, client.RedirectURI)
|
||
|
if err := s.renderClientForm(w, data); err != nil {
|
||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if r.Method == "POST" {
|
||
|
action := r.FormValue("action")
|
||
|
|
||
|
if action == "delete" {
|
||
|
s.mu.Lock()
|
||
|
delete(s.funnelClients, clientID)
|
||
|
err := s.storeFunnelClientsLocked()
|
||
|
s.mu.Unlock()
|
||
|
|
||
|
if err != nil {
|
||
|
log.Printf("could not write funnel clients db: %v", err)
|
||
|
s.mu.Lock()
|
||
|
s.funnelClients[clientID] = client
|
||
|
s.mu.Unlock()
|
||
|
|
||
|
baseData := createEditBaseData(client, client.Name, client.RedirectURI)
|
||
|
s.renderFormError(w, baseData, "Failed to delete client. Please try again.")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if action == "regenerate_secret" {
|
||
|
newSecret := rands.HexString(64)
|
||
|
s.mu.Lock()
|
||
|
s.funnelClients[clientID].Secret = newSecret
|
||
|
err := s.storeFunnelClientsLocked()
|
||
|
s.mu.Unlock()
|
||
|
|
||
|
baseData := createEditBaseData(client, client.Name, client.RedirectURI)
|
||
|
baseData.HasSecret = true
|
||
|
|
||
|
if err != nil {
|
||
|
log.Printf("could not write funnel clients db: %v", err)
|
||
|
s.renderFormError(w, baseData, "Failed to regenerate secret")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
baseData.Secret = newSecret
|
||
|
s.renderFormSuccess(w, baseData, "New client secret generated! Save it - it won't be shown again.")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if err := r.ParseForm(); err != nil {
|
||
|
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||
|
redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
|
||
|
baseData := createEditBaseData(client, name, redirectURI)
|
||
|
|
||
|
if errMsg := validateRedirectURI(redirectURI); errMsg != "" {
|
||
|
s.renderFormError(w, baseData, errMsg)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
s.mu.Lock()
|
||
|
s.funnelClients[clientID].Name = name
|
||
|
s.funnelClients[clientID].RedirectURI = redirectURI
|
||
|
err := s.storeFunnelClientsLocked()
|
||
|
s.mu.Unlock()
|
||
|
|
||
|
if err != nil {
|
||
|
log.Printf("could not write funnel clients db: %v", err)
|
||
|
s.renderFormError(w, baseData, "Failed to update client")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
s.renderFormSuccess(w, baseData, "Client updated successfully!")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
|
}
|
||
|
|
||
|
type clientDisplayData struct {
|
||
|
ID string
|
||
|
Name string
|
||
|
RedirectURI string
|
||
|
Secret string
|
||
|
HasSecret bool
|
||
|
IsNew bool
|
||
|
IsEdit bool
|
||
|
Success string
|
||
|
Error string
|
||
|
}
|
||
|
|
||
|
func (s *idpServer) renderClientForm(w http.ResponseWriter, data clientDisplayData) error {
|
||
|
var buf bytes.Buffer
|
||
|
if err := editTmpl.Execute(&buf, data); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
if _, err := buf.WriteTo(w); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (s *idpServer) renderFormError(w http.ResponseWriter, data clientDisplayData, errorMsg string) {
|
||
|
data.Error = errorMsg
|
||
|
if err := s.renderClientForm(w, data); err != nil {
|
||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (s *idpServer) renderFormSuccess(w http.ResponseWriter, data clientDisplayData, successMsg string) {
|
||
|
data.Success = successMsg
|
||
|
if err := s.renderClientForm(w, data); err != nil {
|
||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func createEditBaseData(client *funnelClient, name, redirectURI string) clientDisplayData {
|
||
|
return clientDisplayData{
|
||
|
ID: client.ID,
|
||
|
Name: name,
|
||
|
RedirectURI: redirectURI,
|
||
|
HasSecret: client.Secret != "",
|
||
|
IsEdit: true,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func validateRedirectURI(redirectURI string) string {
|
||
|
if redirectURI == "" {
|
||
|
return "Redirect URI is required"
|
||
|
}
|
||
|
|
||
|
u, err := url.Parse(redirectURI)
|
||
|
if err != nil {
|
||
|
return "Invalid URL format"
|
||
|
}
|
||
|
|
||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||
|
return "Redirect URI must be a valid HTTP or HTTPS URL"
|
||
|
}
|
||
|
|
||
|
if u.Host == "" {
|
||
|
return "Redirect URI must include a valid host"
|
||
|
}
|
||
|
|
||
|
return ""
|
||
|
}
|