feat: initial commit

This commit is contained in:
2023-12-13 20:07:22 +01:00
commit 5d0311b731
79 changed files with 3143 additions and 0 deletions

191
pkg/server/http.go Normal file
View File

@ -0,0 +1,191 @@
package server
import (
"context"
"fmt"
"html/template"
"net"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
)
var (
//go:embed templates/idle.html.gotmpl
rawIdleTemplate []byte
idleTemplate *template.Template
)
func init() {
tmpl, err := template.New("").Parse(string(rawIdleTemplate))
if err != nil {
panic(errors.Wrap(err, "could not parse idle template"))
}
idleTemplate = tmpl
}
func (s *Server) startHTTPServer(ctx context.Context) error {
router := chi.NewRouter()
router.Get("/", s.handleHome)
router.Post("/api/v1/cast", s.handleCast)
router.Delete("/api/v1/cast", s.handleReset)
router.Get("/api/v1/status", s.handleStatus)
server := http.Server{
Addr: s.address,
Handler: router,
}
listener, err := net.Listen("tcp", s.address)
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 tcp connections", logger.F("port", port), logger.F("host", host))
s.port = int(port)
go func() {
logger.Debug(ctx, "starting http server")
if err := server.Serve(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 http server")
if err := server.Close(); err != nil {
logger.Error(ctx, "could not close http server", logger.CapturedE(errors.WithStack(err)))
}
}()
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
}
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,
})
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct {
IPs []string
Port int
ID string
}
ips, err := 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
}
d := templateData{
ID: s.instanceID,
IPs: ips,
Port: s.port,
}
if err := idleTemplate.Execute(w, d); err != nil {
logger.Error(r.Context(), "could not render idle page", logger.CapturedE(errors.WithStack(err)))
}
}

43
pkg/server/mdns.go Normal file
View File

@ -0,0 +1,43 @@
package server
import (
"context"
"github.com/grandcat/zeroconf"
"github.com/pkg/errors"
"github.com/wlynxg/anet"
"gitlab.com/wpetit/goweb/logger"
)
const (
MDNSService = "_arcast._http._tcp"
MDNSDomain = "local."
)
func (s *Server) startMDNServer(ctx context.Context) error {
logger.Debug(ctx, "starting mdns server")
ifaces, err := anet.Interfaces()
if err != nil {
return errors.WithStack(err)
}
ips, err := getLANIPv4Addrs()
if err != nil {
return errors.WithStack(err)
}
logger.Debug(ctx, "advertising ips", logger.F("ips", ips))
server, err := zeroconf.RegisterProxy(s.instanceID, MDNSService, MDNSDomain, s.port, s.instanceID, ips, []string{}, ifaces)
if err != nil {
return errors.WithStack(err)
}
go func() {
<-ctx.Done()
logger.Debug(ctx, "closing mdns server")
server.Shutdown()
}()
return nil
}

60
pkg/server/network.go Normal file
View File

@ -0,0 +1,60 @@
package server
import (
"net"
"github.com/pkg/errors"
"github.com/wlynxg/anet"
)
var (
_, lanA, _ = net.ParseCIDR("10.0.0.0/8")
_, lanB, _ = net.ParseCIDR("172.16.0.0/12")
_, lanC, _ = net.ParseCIDR("192.168.0.0/16")
)
func getLANIPv4Addrs() ([]string, error) {
ips := make([]string, 0)
addrs, err := anet.InterfaceAddrs()
if err != nil {
return nil, errors.WithStack(err)
}
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
ipv4 := ipnet.IP.To4()
if ipv4 == nil {
continue
}
isLAN := lanA.Contains(ipv4) || lanB.Contains(ipv4) || lanC.Contains(ipv4)
if !isLAN {
continue
}
ips = append(ips, ipv4.String())
}
}
return ips, nil
}
func FindPreferredLocalAddress(ips ...net.IP) (string, error) {
localAddrs, err := anet.InterfaceAddrs()
if err != nil {
return "", errors.WithStack(err)
}
for _, addr := range localAddrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
for _, ip := range ips {
if ipnet.Contains(ip) {
return ip.String(), nil
}
}
}
}
return ips[0].String(), nil
}

61
pkg/server/options.go Normal file
View File

@ -0,0 +1,61 @@
package server
import (
"github.com/jaevor/go-nanoid"
"github.com/pkg/errors"
)
var newRandomInstanceID func() string
func init() {
generator, err := nanoid.Standard(21)
if err != nil {
panic(errors.Wrap(err, "could not generate random instance id"))
}
newRandomInstanceID = generator
}
type Options struct {
InstanceID string
Address string
DisableServiceDiscovery bool
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
InstanceID: NewRandomInstanceID(),
Address: ":",
DisableServiceDiscovery: false,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.Address = addr
}
}
func WithInstanceID(id string) OptionFunc {
return func(opts *Options) {
opts.InstanceID = id
}
}
func WithServiceDiscoveryDisabled(disabled bool) OptionFunc {
return func(opts *Options) {
opts.DisableServiceDiscovery = disabled
}
}
func NewRandomInstanceID() string {
return newRandomInstanceID()
}

76
pkg/server/server.go Normal file
View File

@ -0,0 +1,76 @@
package server
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Server struct {
browser browser.Browser
instanceID string
address string
port int
disableServiceDiscovery bool
ctx context.Context
cancel context.CancelFunc
}
func (s *Server) Start() error {
serverCtx, cancelServer := context.WithCancel(context.Background())
s.cancel = cancelServer
s.ctx = serverCtx
httpServerCtx, cancelHTTPServer := context.WithCancel(serverCtx)
if err := s.startHTTPServer(httpServerCtx); err != nil {
cancelHTTPServer()
return errors.WithStack(err)
}
if !s.disableServiceDiscovery {
mdnsServerCtx, cancelMDNSServer := context.WithCancel(serverCtx)
if err := s.startMDNServer(mdnsServerCtx); err != nil {
cancelHTTPServer()
cancelMDNSServer()
return errors.WithStack(err)
}
} else {
logger.Info(serverCtx, "service discovery disabled")
}
return nil
}
func (s *Server) Stop() error {
if s.cancel != nil {
s.cancel()
}
return nil
}
func (s *Server) Wait() error {
<-s.ctx.Done()
if err := s.ctx.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
func New(browser browser.Browser, funcs ...OptionFunc) *Server {
opts := NewOptions(funcs...)
return &Server{
browser: browser,
instanceID: opts.InstanceID,
address: opts.Address,
disableServiceDiscovery: opts.DisableServiceDiscovery,
}
}

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Idle</title>
<style>
html {
box-sizing: border-box;
font-size: 16px;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
width: 100%;
height: 100%;
background-color: #e1e1e1;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
ol,
ul {
list-style: none;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.panel {
display: block;
background-color: #fff;
border-radius: 5px;
padding: 10px 20px;
box-shadow: 2px 2px #3333331d;
}
.panel p, .panel ul {
margin-top: 10px;
}
.text-centered {
text-align: center;
}
.text-italic {
font-style: italic;
}
.text-small {
font-size: 0.8em;
}
</style>
</head>
<body>
<div class="container">
<div class="panel">
<h1>Arcast</h1>
<p>Instance ID:</p>
<p class="text-centered text-small">
<code>{{ .ID }}</code>
</p>
<p>Addresses:</p>
<ul class="text-italic text-small">
{{ $port := .Port }}
{{range .IPs}}
<li><code>{{ . }}:{{ $port }}</code></li>
{{end}}
</ul>
</div>
</div>
</body>
</html>