feat: embed optional apps in player server

This commit is contained in:
2024-01-16 09:27:04 +01:00
parent acd71c84f6
commit 8d46ff7ff8
32 changed files with 1285 additions and 113 deletions

View File

@ -1,4 +1,4 @@
package server
package network
import (
"net"
@ -13,7 +13,7 @@ var (
_, lanC, _ = net.ParseCIDR("192.168.0.0/16")
)
func getLANIPv4Addrs() ([]string, error) {
func GetLANIPv4Addrs() ([]string, error) {
ips := make([]string, 0)
addrs, err := anet.InterfaceAddrs()

120
pkg/selfsigned/cert.go Normal file
View File

@ -0,0 +1,120 @@
package selfsigned
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"time"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/pkg/errors"
)
func NewLANCert() (*tls.Certificate, error) {
hosts, err := network.GetLANIPv4Addrs()
if err != nil {
return nil, errors.WithStack(err)
}
hosts = append(hosts, "127.0.0.1")
rawCert, rawKey, err := NewCertKeyPair(hosts...)
if err != nil {
return nil, errors.WithStack(err)
}
cert, err := tls.X509KeyPair(rawCert, rawKey)
if err != nil {
return nil, errors.WithStack(err)
}
return &cert, nil
}
func NewCertKeyPair(hosts ...string) ([]byte, []byte, error) {
priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, nil, errors.WithStack(err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, errors.WithStack(err)
}
keyUsage := x509.KeyUsageDigitalSignature
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Arcast Org"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, h)
}
}
template.IsCA = true
template.KeyUsage |= x509.KeyUsageCertSign
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv)
if err != nil {
return nil, nil, errors.WithStack(err)
}
var cert bytes.Buffer
if err := pem.Encode(&cert, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return nil, nil, errors.WithStack(err)
}
var key bytes.Buffer
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, nil, errors.WithStack(err)
}
if err := pem.Encode(&key, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
return nil, nil, errors.WithStack(err)
}
return cert.Bytes(), key.Bytes(), nil
}
func publicKey(priv any) any {
switch k := priv.(type) {
case *rsa.PrivateKey:
return &k.PublicKey
case *ecdsa.PrivateKey:
return &k.PublicKey
case ed25519.PrivateKey:
return k.Public().(ed25519.PublicKey)
default:
return nil
}
}

118
pkg/server/api.go Normal file
View File

@ -0,0 +1,118 @@
package server
import (
"fmt"
"net/http"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type InfoResponse struct {
IPs []string `json:"ips"`
Port int `json:"port"`
TLSPort int `json:"tlsPort"`
InstanceID string `json:"instanceId"`
AppsEnabled bool `json:"appsEnabled"`
ServiceDiscoveryEnabled bool `json:"serviceDiscoveryEnabled"`
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
ips, err := network.GetLANIPv4Addrs()
if err != nil {
logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
api.DataResponse(w, http.StatusOK, &InfoResponse{
IPs: ips,
TLSPort: s.tlsPort,
Port: s.port,
InstanceID: s.instanceID,
AppsEnabled: s.appsEnabled,
ServiceDiscoveryEnabled: s.serviceDiscoveryEnabled,
})
}
type CastRequest struct {
URL string `json:"url" validate:"required"`
}
func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) {
req := &CastRequest{}
if ok := api.Bind(w, r, req); !ok {
return
}
if err := s.browser.Load(req.URL); err != nil {
logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if err := s.resetBrowser(); err != nil {
logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) resetBrowser() error {
idleURL := fmt.Sprintf("http://localhost:%d", s.port)
if err := s.browser.Reset(idleURL); err != nil {
return errors.WithStack(err)
}
return nil
}
type StatusResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Status string `json:"status"`
Title string `json:"title"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
url, err := s.browser.URL()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
status, err := s.browser.Status()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
title, err := s.browser.Title()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, &StatusResponse{
ID: s.instanceID,
URL: url,
Status: status.String(),
Title: title,
})
}

63
pkg/server/apps.go Normal file
View File

@ -0,0 +1,63 @@
package server
import (
"io/fs"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"gitlab.com/wpetit/goweb/api"
_ "embed"
)
type App struct {
ID string `json:"id"`
Title map[string]string `json:"title"`
Description map[string]string `json:"description"`
Icon string `json:"icon"`
FS fs.FS `json:"-"`
Hidden bool `json:"hidden"`
}
func (s *Server) handleDefaultApp(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/apps/"+s.defaultApp+"/", http.StatusTemporaryRedirect)
}
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
api.DataResponse(w, http.StatusOK, struct {
DefaultApp string `json:"defaultApp"`
Apps []App `json:"apps"`
}{
DefaultApp: s.defaultApp,
Apps: s.apps,
})
}
var (
indexedAppFilesystems map[string]fs.FS
indexAppsOnce sync.Once
)
func (s *Server) handleAppFilesystem(w http.ResponseWriter, r *http.Request) {
indexAppsOnce.Do(func() {
indexedAppFilesystems = make(map[string]fs.FS, len(s.apps))
for _, app := range s.apps {
indexedAppFilesystems[app.ID] = app.FS
}
})
appID := chi.URLParam(r, "appID")
fs, exists := indexedAppFilesystems[appID]
if !exists {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
name := strings.TrimPrefix(r.URL.Path, "/apps/"+appID)
http.ServeFileFS(w, r, fs, name)
}

106
pkg/server/broadcast.go Normal file
View File

@ -0,0 +1,106 @@
package server
import (
"context"
"log"
"net/http"
"sync"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
var (
upgrader = websocket.Upgrader{}
channels = &channelMap{
index: make(map[string]map[*websocket.Conn]struct{}),
}
)
func (s *Server) handleBroadcast(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("upgrade:", err)
return
}
channelID := chi.URLParam(r, "channelID")
channels.Add(channelID, c)
defer func() {
channels.Remove(channelID, c)
if err := c.Close(); err != nil && !websocket.IsCloseError(err, 1001) {
logger.Error(ctx, "could not close connection", logger.E(errors.WithStack(err)))
}
}()
for {
messageType, message, err := c.ReadMessage()
if err != nil {
logger.Error(ctx, "could not read message", logger.E(errors.WithStack(err)))
break
}
logger.Debug(ctx, "broadcasting message", logger.F("message", message), logger.F("messageType", messageType))
channels.Send(ctx, channelID, messageType, message, c)
}
}
type channelMap struct {
mutex sync.RWMutex
index map[string]map[*websocket.Conn]struct{}
}
func (m *channelMap) Remove(channelID string, conn *websocket.Conn) {
m.mutex.Lock()
defer m.mutex.Unlock()
conns, exists := m.index[channelID]
if !exists {
return
}
delete(conns, conn)
if len(conns) == 0 {
delete(m.index, channelID)
}
}
func (m *channelMap) Add(channelID string, conn *websocket.Conn) {
m.mutex.Lock()
defer m.mutex.Unlock()
conns, exists := m.index[channelID]
if !exists {
conns = make(map[*websocket.Conn]struct{})
}
conns[conn] = struct{}{}
m.index[channelID] = conns
}
func (m *channelMap) Send(ctx context.Context, channelID string, messageType int, message []byte, except *websocket.Conn) {
m.mutex.RLock()
defer m.mutex.RUnlock()
conns, exists := m.index[channelID]
if !exists {
return
}
for c := range conns {
if except == c {
continue
}
if err := c.WriteMessage(messageType, message); err != nil {
logger.Error(ctx, "could not write message", logger.E(errors.WithStack(err)))
}
}
}

View File

@ -2,15 +2,17 @@ package server
import (
"context"
"crypto/tls"
"fmt"
"html/template"
"net"
"net/http"
"strconv"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
@ -31,14 +33,61 @@ func init() {
idleTemplate = tmpl
}
func (s *Server) startHTTPServer(ctx context.Context) error {
func (s *Server) startWebServers(ctx context.Context) error {
router := chi.NewRouter()
if s.appsEnabled {
ips, err := network.GetLANIPv4Addrs()
if err != nil {
return errors.WithStack(err)
}
allowedOrigins := make([]string, len(ips))
for idx, ip := range ips {
allowedOrigins[idx] = fmt.Sprintf("http://%s:%d", ip, s.port)
}
router.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: false,
}))
}
router.Get("/", s.handleHome)
router.Get("/api/v1/info", s.handleInfo)
router.Post("/api/v1/cast", s.handleCast)
router.Delete("/api/v1/cast", s.handleReset)
router.Get("/api/v1/status", s.handleStatus)
if s.appsEnabled {
router.Get("/apps", s.handleDefaultApp)
router.Get("/api/v1/apps", s.handleApps)
router.Handle("/apps/{appID}/*", http.HandlerFunc(s.handleAppFilesystem))
router.Handle("/api/v1/broadcast/{channelID}", http.HandlerFunc(s.handleBroadcast))
}
if err := s.startHTTPServer(ctx, router); err != nil {
return errors.WithStack(err)
}
if s.tlsCert != nil {
if err := s.startHTTPSServer(ctx, router); err != nil {
return errors.WithStack(err)
}
} else {
logger.Info(ctx, "no tls certificate configured, not starting https server")
}
if err := s.resetBrowser(); err != nil {
return errors.WithStack(err)
}
return nil
}
func (s *Server) startHTTPServer(ctx context.Context, router chi.Router) error {
server := http.Server{
Addr: s.address,
Handler: router,
@ -78,101 +127,67 @@ func (s *Server) startHTTPServer(ctx context.Context) error {
}
}()
if err := s.resetBrowser(); err != nil {
return errors.WithStack(err)
}
return nil
}
type CastRequest struct {
URL string `json:"url" validate:"required"`
}
func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) {
req := &CastRequest{}
if ok := api.Bind(w, r, req); !ok {
return
func (s *Server) startHTTPSServer(ctx context.Context, router chi.Router) error {
server := http.Server{
Addr: s.address,
Handler: router,
TLSConfig: &tls.Config{
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return s.tlsCert, nil
},
},
}
if err := s.browser.Load(req.URL); err != nil {
logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if err := s.resetBrowser(); err != nil {
logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) resetBrowser() error {
idleURL := fmt.Sprintf("http://localhost:%d", s.port)
if err := s.browser.Reset(idleURL); err != nil {
listener, err := net.Listen("tcp", s.tlsAddress)
if err != nil {
return errors.WithStack(err)
}
host, rawPort, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
return errors.WithStack(err)
}
port, err := strconv.ParseInt(rawPort, 10, 32)
if err != nil {
return errors.Wrapf(err, "could not parse listening port '%v'", rawPort)
}
logger.Debug(ctx, "listening for tls tcp connections", logger.F("port", port), logger.F("host", host))
s.tlsPort = int(port)
go func() {
logger.Debug(ctx, "starting https server")
if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error(ctx, "could not listen", logger.CapturedE(errors.WithStack(err)))
}
}()
go func() {
<-ctx.Done()
logger.Debug(ctx, "closing https server")
if err := server.Close(); err != nil {
logger.Error(ctx, "could not close https server", logger.CapturedE(errors.WithStack(err)))
}
}()
return nil
}
type StatusResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Status string `json:"status"`
Title string `json:"title"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
url, err := s.browser.URL()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
status, err := s.browser.Status()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
title, err := s.browser.Title()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, &StatusResponse{
ID: s.instanceID,
URL: url,
Status: status.String(),
Title: title,
})
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct {
IPs []string
Port int
ID string
IPs []string
Port int
TLSPort int
ID string
Apps bool
}
ips, err := getLANIPv4Addrs()
ips, err := network.GetLANIPv4Addrs()
if err != nil {
logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -180,9 +195,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
}
d := templateData{
ID: s.instanceID,
IPs: ips,
Port: s.port,
ID: s.instanceID,
IPs: ips,
Port: s.port,
TLSPort: s.tlsPort,
Apps: s.appsEnabled,
}
if err := idleTemplate.Execute(w, d); err != nil {

View File

@ -3,6 +3,7 @@ package server
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/network"
"github.com/grandcat/zeroconf"
"github.com/pkg/errors"
"github.com/wlynxg/anet"
@ -21,7 +22,7 @@ func (s *Server) startMDNServer(ctx context.Context) error {
return errors.WithStack(err)
}
ips, err := getLANIPv4Addrs()
ips, err := network.GetLANIPv4Addrs()
if err != nil {
return errors.WithStack(err)
}

View File

@ -1,6 +1,8 @@
package server
import (
"crypto/tls"
"github.com/jaevor/go-nanoid"
"github.com/pkg/errors"
)
@ -17,18 +19,27 @@ func init() {
}
type Options struct {
InstanceID string
Address string
DisableServiceDiscovery bool
InstanceID string
Address string
TLSAddress string
TLSCertificate *tls.Certificate
EnableServiceDiscovery bool
EnableApps bool
DefaultApp string
Apps []App
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
InstanceID: NewRandomInstanceID(),
Address: ":",
DisableServiceDiscovery: false,
InstanceID: NewRandomInstanceID(),
Address: ":",
TLSAddress: ":",
EnableServiceDiscovery: true,
EnableApps: false,
DefaultApp: "",
Apps: make([]App, 0),
}
for _, fn := range funcs {
@ -38,21 +49,51 @@ func NewOptions(funcs ...OptionFunc) *Options {
return opts
}
func WithAppsEnabled(enabled bool) OptionFunc {
return func(opts *Options) {
opts.EnableApps = enabled
}
}
func WithDefaultApp(defaultApp string) OptionFunc {
return func(opts *Options) {
opts.DefaultApp = defaultApp
}
}
func WithApps(apps ...App) OptionFunc {
return func(opts *Options) {
opts.Apps = apps
}
}
func WithAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.Address = addr
}
}
func WithTLSAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.TLSAddress = addr
}
}
func WithTLSCertificate(cert *tls.Certificate) OptionFunc {
return func(opts *Options) {
opts.TLSCertificate = cert
}
}
func WithInstanceID(id string) OptionFunc {
return func(opts *Options) {
opts.InstanceID = id
}
}
func WithServiceDiscoveryDisabled(disabled bool) OptionFunc {
func WithServiceDiscoveryEnabled(enabled bool) OptionFunc {
return func(opts *Options) {
opts.DisableServiceDiscovery = disabled
opts.EnableServiceDiscovery = enabled
}
}

View File

@ -2,6 +2,7 @@ package server
import (
"context"
"crypto/tls"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/pkg/errors"
@ -11,10 +12,20 @@ import (
type Server struct {
browser browser.Browser
instanceID string
address string
port int
disableServiceDiscovery bool
instanceID string
address string
port int
tlsAddress string
tlsPort int
tlsCert *tls.Certificate
serviceDiscoveryEnabled bool
appsEnabled bool
defaultApp string
apps []App
ctx context.Context
cancel context.CancelFunc
@ -26,16 +37,16 @@ func (s *Server) Start() error {
s.cancel = cancelServer
s.ctx = serverCtx
httpServerCtx, cancelHTTPServer := context.WithCancel(serverCtx)
if err := s.startHTTPServer(httpServerCtx); err != nil {
cancelHTTPServer()
webServersCtx, cancelWebServers := context.WithCancel(serverCtx)
if err := s.startWebServers(webServersCtx); err != nil {
cancelWebServers()
return errors.WithStack(err)
}
if !s.disableServiceDiscovery {
if s.serviceDiscoveryEnabled {
mdnsServerCtx, cancelMDNSServer := context.WithCancel(serverCtx)
if err := s.startMDNServer(mdnsServerCtx); err != nil {
cancelHTTPServer()
cancelWebServers()
cancelMDNSServer()
return errors.WithStack(err)
}
@ -71,6 +82,11 @@ func New(browser browser.Browser, funcs ...OptionFunc) *Server {
browser: browser,
instanceID: opts.InstanceID,
address: opts.Address,
disableServiceDiscovery: opts.DisableServiceDiscovery,
tlsAddress: opts.TLSAddress,
tlsCert: opts.TLSCertificate,
appsEnabled: opts.EnableApps,
defaultApp: opts.DefaultApp,
apps: opts.Apps,
serviceDiscoveryEnabled: opts.EnableServiceDiscovery,
}
}

View File

@ -76,13 +76,18 @@
.text-small {
font-size: 0.8em;
}
.mt {
margin-top: 1em;
display: block;
}
</style>
</head>
<body>
<div class="container">
<div class="panel">
<h1>Arcast</h1>
<h1>Arcast - Idle</h1>
<p>Instance ID:</p>
<p class="text-centered text-small">
<code>{{ .ID }}</code>
@ -94,6 +99,15 @@
<li><code>{{ . }}:{{ $port }}</code></li>
{{end}}
</ul>
{{if .Apps }}
<p>Apps:</p>
<ul class="text-italic text-small">
{{ $tlsPort := .TLSPort }}
{{range .IPs}}
<li><a href="https://{{ . }}:{{ $tlsPort }}/apps">https://{{ . }}:{{ $tlsPort }}/apps</a></li>
{{end}}
</ul>
{{end}}
</div>
</div>
</body>