tclip/cmd/web/main.go
Xe Iaso 6a3e79b8d0 cmd/web: only show authenticated header to tailnet users
Signed-off-by: Xe Iaso <xe@tailscale.com>
2023-03-17 15:52:33 +00:00

840 lines
19 KiB
Go

package main
import (
"context"
"crypto/md5"
"crypto/tls"
"database/sql"
"database/sql/driver"
"embed"
"errors"
"flag"
"fmt"
"html/template"
"log"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-enry/go-enry/v2"
"github.com/google/uuid"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday"
"github.com/tailscale/sqlite"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
)
var (
hostname = flag.String("hostname", envOr("TSNET_HOSTNAME", "paste"), "hostname to use on your tailnet, TSNET_HOSTNAME in the environment")
dataDir = flag.String("data-location", dataLocation(), "where data is stored, defaults to DATA_DIR or ~/.config/tailscale/paste")
tsnetLogVerbose = flag.Bool("tsnet-verbose", hasEnv("TSNET_VERBOSE"), "if set, have tsnet log verbosely to standard error")
useFunnel = flag.Bool("use-funnel", hasEnv("USE_FUNNEL"), "if set, expose individual pastes to the public internet with Funnel, USE_FUNNEL in the environment")
//go:embed schema.sql
sqlSchema string
//go:embed static
staticFiles embed.FS
//go:embed tmpl/*.tmpl
templateFiles embed.FS
)
func hasEnv(name string) bool {
_, ok := os.LookupEnv(name)
return ok
}
const formDataLimit = 64 * 1024 // 64 kilobytes (approx. 32 printed pages of text)
func dataLocation() string {
if dir, ok := os.LookupEnv("DATA_DIR"); ok {
return dir
}
dir, err := os.UserConfigDir()
if err != nil {
return os.Getenv("DATA_DIR")
}
return filepath.Join(dir, "tailscale", "paste")
}
func envOr(key, defaultVal string) string {
if result, ok := os.LookupEnv(key); ok {
return result
}
return defaultVal
}
type Server struct {
lc *tailscale.LocalClient // localclient to tsnet server
db *sql.DB // SQLite datastore
tmpls *template.Template // HTML templates
httpsURL string // the tailnet/public base URL of this service
}
func (s *Server) TailnetIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
s.NotFound(w, r)
return
}
ui, err := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
q := `
SELECT p.id
, p.filename
, p.created_at
, u.display_name
FROM pastes p
INNER JOIN users u
ON p.user_id = u.id
ORDER BY p.rowid DESC
LIMIT 5
`
jpis := make([]JoinedPasteInfo, 0, 5)
rows, err := s.db.QueryContext(r.Context(), q)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
jpi := JoinedPasteInfo{}
err := rows.Scan(&jpi.ID, &jpi.Filename, &jpi.CreatedAt, &jpi.PasterDisplayName)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
jpis = append(jpis, jpi)
}
err = s.tmpls.ExecuteTemplate(w, "create.tmpl", struct {
UserInfo *tailcfg.UserProfile
Title string
RecentPastes []JoinedPasteInfo
}{
UserInfo: ui.UserProfile,
Title: "Create new paste",
RecentPastes: jpis,
})
if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
}
func (s *Server) TailnetHelp(w http.ResponseWriter, r *http.Request) {
ui, err := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
err = s.tmpls.ExecuteTemplate(w, "help.tmpl", struct {
UserInfo *tailcfg.UserProfile
Title string
}{
UserInfo: ui.UserProfile,
Title: "Help",
})
if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
}
func (s *Server) NotFound(w http.ResponseWriter, r *http.Request) {
s.tmpls.ExecuteTemplate(w, "notfound.tmpl", struct {
UserInfo *tailcfg.UserProfile
Title string
}{
UserInfo: nil,
Title: "Not found",
})
}
func (s *Server) PublicIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
s.NotFound(w, r)
return
}
s.tmpls.ExecuteTemplate(w, "publicindex.tmpl", struct {
UserInfo *tailcfg.UserProfile
Title string
}{
UserInfo: nil,
Title: "Not found",
})
}
func (s *Server) TailnetSubmitPaste(w http.ResponseWriter, r *http.Request) {
userInfo, err := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data;") {
err = r.ParseMultipartForm(formDataLimit)
} else if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
err = r.ParseForm()
} else {
log.Printf("%s: unknown content type: %s", r.RemoteAddr, r.Header.Get("Content-Type"))
http.Error(w, "bad content-type, should be a form", http.StatusBadRequest)
return
}
if err != nil {
log.Printf("%s: bad form: %v", r.RemoteAddr, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !r.Form.Has("filename") && !r.Form.Has("content") {
log.Printf("%s", r.Form.Encode())
log.Printf("%s: posted form without filename and data", r.RemoteAddr)
http.Error(w, "include form values filename and data", http.StatusBadRequest)
return
}
fname := r.Form.Get("filename")
data := r.Form.Get("content")
id := uuid.NewString()
q := `
INSERT INTO pastes
( id
, created_at
, user_id
, filename
, data
)
VALUES
( ?1
, ?2
, ?3
, ?4
, ?5
)`
_, err = s.db.ExecContext(
r.Context(),
q,
id,
time.Now(),
userInfo.UserProfile.ID,
fname,
data,
)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
log.Printf("new paste: %s", id)
switch r.Header.Get("Accept") {
case "text/plain":
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "https://%s/paste/%s", s.httpsURL, id)
default:
http.Redirect(w, r, fmt.Sprintf("https://%s/paste/%s", s.httpsURL, id), http.StatusSeeOther)
}
}
type JoinedPasteInfo struct {
ID string `json:"id"`
Filename string `json:"fname"`
CreatedAt string `json:"created_at"`
PasterDisplayName string `json:"created_by"`
}
func (s *Server) TailnetPasteIndex(w http.ResponseWriter, r *http.Request) {
userInfo, err := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
_ = userInfo
q := `
SELECT p.id
, p.filename
, p.created_at
, u.display_name
FROM pastes p
INNER JOIN users u
ON p.user_id = u.id
ORDER BY p.rowid DESC
LIMIT 25
OFFSET ?1
`
uq := r.URL.Query()
page := uq.Get("page")
if page == "" {
page = "0"
}
pageNum, err := strconv.Atoi(page)
if err != nil {
log.Printf("%s: invalid ?page: %s: %v", r.RemoteAddr, page, err)
pageNum = 0
}
rows, err := s.db.Query(q, clampToZero(pageNum)*25)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
jpis := make([]JoinedPasteInfo, 0, 25)
defer rows.Close()
for rows.Next() {
jpi := JoinedPasteInfo{}
err := rows.Scan(&jpi.ID, &jpi.Filename, &jpi.CreatedAt, &jpi.PasterDisplayName)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
jpis = append(jpis, jpi)
}
if len(jpis) == 0 {
err = s.tmpls.ExecuteTemplate(w, "nopastes.tmpl", struct {
UserInfo *tailcfg.UserProfile
Title string
}{
UserInfo: userInfo.UserProfile,
Title: "Pastes",
})
if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
return
}
var prev, next *int
if pageNum != 0 {
i := pageNum - 1
prev = &i
}
if len(jpis) == 25 {
i := pageNum + 1
next = &i
}
err = s.tmpls.ExecuteTemplate(w, "listpaste.tmpl", struct {
UserInfo *tailcfg.UserProfile
Title string
Pastes []JoinedPasteInfo
Prev *int
Next *int
Page int
}{
UserInfo: userInfo.UserProfile,
Title: "Pastes",
Pastes: jpis,
Prev: prev,
Next: next,
Page: pageNum + 1,
})
if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
}
func (s *Server) ShowError(w http.ResponseWriter, r *http.Request, err error, code int) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(code)
log.Printf("%s: %v", r.RemoteAddr, err)
if err := s.tmpls.ExecuteTemplate(w, "error.tmpl", struct {
Title, Error string
UserInfo any
}{
Title: "Oh noes!",
Error: err.Error(),
}); err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
}
func clampToZero(i int) int {
if i <= 0 {
return 0
}
return i
}
func (s *Server) TailnetDeletePost(w http.ResponseWriter, r *http.Request) {
ui, err := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
if err != nil {
s.ShowError(w, r, err, http.StatusBadRequest)
return
}
if r.Method != http.MethodGet {
http.Error(w, "must GET", http.StatusMethodNotAllowed)
return
}
// /api/delete/{id}
sp := strings.Split(r.URL.Path, "/")
if len(sp) != 4 {
s.ShowError(w, r, errors.New("must be /api/delete/:id"), http.StatusBadRequest)
}
id := sp[3]
if len(sp) == 0 {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
q := `
SELECT p.user_id
FROM pastes p
WHERE p.id = ?1`
row := s.db.QueryRowContext(r.Context(), q, id)
var userIDOfPaste int64
if err := row.Scan(&userIDOfPaste); err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
if int64(ui.UserProfile.ID) != userIDOfPaste {
s.ShowError(w, r, errors.New("can only delete your pastes"), http.StatusForbidden)
return
}
q = `
DELETE FROM pastes
WHERE id = ?1 AND user_id = ?2
`
if _, err := s.db.ExecContext(r.Context(), q, id, ui.UserProfile.ID); err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
func (s *Server) ShowPost(w http.ResponseWriter, r *http.Request) {
ui, _ := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
var up *tailcfg.UserProfile
if ui != nil {
up = ui.UserProfile
}
if valAny := r.Context().Value(privacyKey); valAny != nil {
if val, ok := valAny.(mixedCriticalityHandlerCtxKey); ok {
if val == isFunnel {
up = nil
}
}
}
if r.Method != http.MethodGet {
http.Error(w, "must GET", http.StatusMethodNotAllowed)
return
}
pathComponents := strings.Split(r.URL.Path, "/")
pathComponents = pathComponents[2:]
if len(pathComponents) == 0 {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
id := pathComponents[0]
q := `
SELECT p.filename
, p.created_at
, p.data
, u.id
, u.login_name
, u.display_name
, u.profile_pic_url
FROM pastes p
INNER JOIN users u
ON p.user_id = u.id
WHERE p.id = ?1`
row := s.db.QueryRowContext(r.Context(), q, id)
var fname, data, userLoginName, userDisplayName, userProfilePicURL string
var userID int64
var createdAt string
err := row.Scan(&fname, &createdAt, &data, &userID, &userLoginName, &userDisplayName, &userProfilePicURL)
if err != nil {
s.ShowError(w, r, fmt.Errorf("can't find paste %s: %w", id, err), http.StatusInternalServerError)
return
}
lang, safe := enry.GetLanguageByFilename(fname)
if lang == "" || !safe {
lang, _ = enry.GetLanguageByContent(fname, []byte(data))
}
var rawHTML *template.HTML
// XXX(Xe): HACK around https://github.com/go-enry/go-enry/pull/154
if filepath.Ext(fname) == ".markdown" {
lang = "Markdown"
}
var cssClass string
if lang != "" {
cssClass = fmt.Sprintf("lang-%s", strings.ToLower(lang))
}
if lang == "Markdown" {
output := blackfriday.MarkdownCommon([]byte(data))
p := bluemonday.UGCPolicy()
p.AllowAttrs("class").Matching(regexp.MustCompile("^language-[a-zA-Z0-9]+$")).OnElements("code")
sanitized := p.SanitizeBytes(output)
raw := template.HTML(string(sanitized))
rawHTML = &raw
}
// If you specify a formatting option:
if len(pathComponents) != 1 {
switch pathComponents[1] {
// view file as plain text in browser
case "raw":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, data)
return
// download file to disk (plain text view plus download hint)
case "dl":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fname))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, data)
// view markdown file with a fancy HTML rendering step
case "md":
if lang != "Markdown" {
http.Redirect(w, r, "/paste/"+id, http.StatusTemporaryRedirect)
return
}
title, ok := strings.CutPrefix(strings.Split(strings.TrimSpace(data), "\n")[0], "#")
if !ok {
title = fname
}
err = s.tmpls.ExecuteTemplate(w, "fancypost.tmpl", struct {
Title string
CreatedAt string
PasterDisplayName string
PasterProfilePicURL string
RawHTML *template.HTML
}{
Title: title,
CreatedAt: createdAt,
PasterDisplayName: userDisplayName,
PasterProfilePicURL: userProfilePicURL,
RawHTML: rawHTML,
})
if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
return
// otherwise, throw a 404
default:
s.NotFound(w, r)
}
}
var remoteUserID = tailcfg.UserID(0)
if up != nil {
remoteUserID = up.ID
}
err = s.tmpls.ExecuteTemplate(w, "showpaste.tmpl", struct {
UserInfo *tailcfg.UserProfile
Title string
CreatedAt string
PasterDisplayName string
PasterProfilePicURL string
PasterUserID int64
UserID int64
ID string
Data string
RawHTML *template.HTML
CSSClass string
}{
UserInfo: up,
Title: fname,
CreatedAt: createdAt,
PasterDisplayName: userDisplayName,
PasterProfilePicURL: userProfilePicURL,
PasterUserID: userID,
UserID: int64(remoteUserID),
ID: id,
Data: data,
RawHTML: rawHTML,
CSSClass: cssClass,
})
if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
}
func main() {
flag.Parse()
os.MkdirAll(*dataDir, 0700)
os.MkdirAll(filepath.Join(*dataDir, "tsnet"), 0700)
s := &tsnet.Server{
Hostname: *hostname,
Dir: filepath.Join(*dataDir, "tsnet"),
Logf: func(string, ...any) {},
}
if *tsnetLogVerbose {
s.Logf = log.Printf
}
if err := s.Start(); err != nil {
log.Fatal(err)
}
db, err := openDB(*dataDir)
if err != nil {
log.Fatal(err)
}
defer db.Close()
lc, err := s.LocalClient()
if err != nil {
log.Fatal(err)
}
// wait for tailscale to start before trying to fetch cert names
for i := 0; i < 60; i++ {
st, err := lc.Status(context.Background())
if err != nil {
log.Printf("error retrieving tailscale status; retrying: %v", err)
} else {
if st.BackendState == "Running" {
break
}
}
time.Sleep(time.Second)
}
ctx := context.Background()
httpsURL, ok := lc.ExpandSNIName(ctx, *hostname)
if !ok {
log.Printf(httpsURL)
log.Fatal("HTTPS is not enabled in the admin panel")
}
ln, err := s.Listen("tcp", ":80")
if err != nil {
log.Fatal(err)
}
tmpls := template.Must(template.ParseFS(templateFiles, "tmpl/*.tmpl"))
srv := &Server{lc, db, tmpls, httpsURL}
tailnetMux := http.NewServeMux()
tailnetMux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
tailnetMux.HandleFunc("/paste/", srv.ShowPost)
tailnetMux.HandleFunc("/paste/list", srv.TailnetPasteIndex)
tailnetMux.HandleFunc("/api/post", srv.TailnetSubmitPaste)
tailnetMux.HandleFunc("/api/delete/", srv.TailnetDeletePost)
tailnetMux.HandleFunc("/", srv.TailnetIndex)
tailnetMux.HandleFunc("/help", srv.TailnetHelp)
funnelMux := http.NewServeMux()
funnelMux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
funnelMux.HandleFunc("/", srv.PublicIndex)
funnelMux.HandleFunc("/paste/", srv.ShowPost)
log.Printf("listening on http://%s", *hostname)
go func() { log.Fatal(http.Serve(ln, tailnetMux)) }()
if *useFunnel {
log.Println("trying to listen on funnel")
ln, err := s.ListenFunnel("tcp", ":443")
if err != nil {
log.Fatalf("can't listen on funnel: %v", err)
}
defer ln.Close()
log.Printf("listening on https://%s", httpsURL)
log.Fatal(MixedCriticalityHandler{
Public: funnelMux,
Private: tailnetMux,
}.Serve(ln))
} else {
ln, err := s.ListenTLS("tcp", ":443")
if err != nil {
log.Fatal(err)
}
defer ln.Close()
log.Printf("listening on https://%s", httpsURL)
log.Fatal(http.Serve(ln, tailnetMux))
}
}
type mixedCriticalityHandlerCtxKey int
const (
privacyKey mixedCriticalityHandlerCtxKey = iota
isFunnel
isTailnet
)
type MixedCriticalityHandler struct {
Public http.Handler
Private http.Handler
}
func (mch MixedCriticalityHandler) Serve(ln net.Listener) error {
srv := &http.Server{
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
tc, ok := c.(*tls.Conn)
if !ok {
return ctx
}
if _, ok := tc.NetConn().(*ipn.FunnelConn); ok {
return context.WithValue(ctx, privacyKey, isFunnel)
} else {
return context.WithValue(ctx, privacyKey, isTailnet)
}
},
Handler: mch,
}
return srv.Serve(ln)
}
func (mch MixedCriticalityHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
valAny := ctx.Value(privacyKey)
if valAny == nil {
panic("incorrect context stack (value is missing)")
}
val, ok := valAny.(mixedCriticalityHandlerCtxKey)
if !ok {
panic("incorrect context stack (value is of wrong type)")
}
switch val {
case isFunnel:
mch.Public.ServeHTTP(w, r)
return
case isTailnet:
mch.Private.ServeHTTP(w, r)
return
}
panic("unknown security level")
}
func openDB(dir string) (*sql.DB, error) {
db := sql.OpenDB(sqlite.Connector("file:"+filepath.Join(dir, "data.db"), func(ctx context.Context, conn driver.ConnPrepareContext) error {
return sqlite.ExecScript(conn.(sqlite.SQLConn), sqlSchema)
}, nil))
err := db.Ping()
if err != nil {
return nil, err
}
return db, nil
}
func md5Hash(inp string) string {
h := md5.New()
return fmt.Sprintf("%x", h.Sum([]byte(inp)))
}
func upsertUserInfo(ctx context.Context, db *sql.DB, lc *tailscale.LocalClient, remoteAddr string) (*apitype.WhoIsResponse, error) {
userInfo, err := lc.WhoIs(ctx, remoteAddr)
if err != nil {
return nil, err
}
if userInfo.UserProfile.LoginName == "tagged-devices" {
userInfo.UserProfile.ID = tailcfg.UserID(userInfo.Node.ID)
userInfo.UserProfile.LoginName = userInfo.Node.Hostinfo.Hostname()
userInfo.UserProfile.DisplayName = fmt.Sprintf("tagged node %s: %s", userInfo.Node.Hostinfo.Hostname(), userInfo.Node.Tags[0])
userInfo.UserProfile.ProfilePicURL = fmt.Sprintf("https://www.gravatar.com/avatar/%s", md5Hash(userInfo.Node.ComputedNameWithHost))
}
q := `
INSERT INTO users
( id
, login_name
, display_name
, profile_pic_url
)
VALUES
( ?1
, ?2
, ?3
, ?4
)
ON CONFLICT DO
UPDATE SET
login_name = ?2
, display_name = ?3
, profile_pic_url = ?4
`
_, err = db.ExecContext(
ctx,
q,
userInfo.UserProfile.ID,
userInfo.UserProfile.LoginName,
userInfo.UserProfile.DisplayName,
userInfo.UserProfile.ProfilePicURL,
)
if err != nil {
return nil, err
}
return userInfo, nil
}