Parent directory

server.go

7700 bytes
  1package serve
  2
  3import (
  4	"context"
  5	"crypto/tls"
  6	"errors"
  7	"fmt"
  8	"net/http"
  9
 10	"charm.land/log/v2"
 11
 12	"charm.land/ssh"
 13	"github.com/charmbracelet/soft-serve/pkg/access"
 14	"github.com/charmbracelet/soft-serve/pkg/backend"
 15	"github.com/charmbracelet/soft-serve/pkg/config"
 16	"github.com/charmbracelet/soft-serve/pkg/cron"
 17	"github.com/charmbracelet/soft-serve/pkg/daemon"
 18	"github.com/charmbracelet/soft-serve/pkg/db"
 19	"github.com/charmbracelet/soft-serve/pkg/jobs"
 20	"github.com/charmbracelet/soft-serve/pkg/proto"
 21	sshsrv "github.com/charmbracelet/soft-serve/pkg/ssh"
 22	"github.com/charmbracelet/soft-serve/pkg/stats"
 23	"github.com/charmbracelet/soft-serve/pkg/utils"
 24	"github.com/charmbracelet/soft-serve/pkg/web"
 25	"golang.org/x/sync/errgroup"
 26)
 27
 28// Server is the Soft Serve server.
 29type Server struct {
 30	SSHServer   *sshsrv.SSHServer
 31	GitDaemon   *daemon.GitDaemon
 32	HTTPServer  *web.HTTPServer
 33	StatsServer *stats.StatsServer
 34	CertLoader  *CertReloader
 35	Cron        *cron.Scheduler
 36	Config      *config.Config
 37	Backend     *backend.Backend
 38	DB          *db.DB
 39
 40	logger *log.Logger
 41	ctx    context.Context
 42}
 43
 44// NewServer returns a new *Server configured to serve Soft Serve. The SSH
 45// server key-pair will be created if none exists.
 46// It expects a context with *backend.Backend, *db.DB, *log.Logger, and
 47// *config.Config attached.
 48func NewServer(ctx context.Context) (*Server, error) {
 49	var err error
 50	cfg := config.FromContext(ctx)
 51	be := backend.FromContext(ctx)
 52	db := db.FromContext(ctx)
 53	logger := log.FromContext(ctx).WithPrefix("server")
 54	srv := &Server{
 55		Config:  cfg,
 56		Backend: be,
 57		DB:      db,
 58		logger:  log.FromContext(ctx).WithPrefix("server"),
 59		ctx:     ctx,
 60	}
 61
 62	// Add cron jobs.
 63	sched := cron.NewScheduler(ctx)
 64	for n, j := range jobs.List() {
 65		id, err := sched.AddFunc(j.Runner.Spec(ctx), j.Runner.Func(ctx))
 66		if err != nil {
 67			logger.Warn("error adding cron job", "job", n, "err", err)
 68		}
 69
 70		j.ID = id
 71	}
 72
 73	srv.Cron = sched
 74
 75	ensureDefaultRepo(ctx, cfg, be, logger)
 76
 77	srv.SSHServer, err = sshsrv.NewSSHServer(ctx)
 78	if err != nil {
 79		return nil, fmt.Errorf("create ssh server: %w", err)
 80	}
 81
 82	srv.GitDaemon, err = daemon.NewGitDaemon(ctx)
 83	if err != nil {
 84		return nil, fmt.Errorf("create git daemon: %w", err)
 85	}
 86
 87	srv.HTTPServer, err = web.NewHTTPServer(ctx)
 88	if err != nil {
 89		return nil, fmt.Errorf("create http server: %w", err)
 90	}
 91
 92	srv.StatsServer, err = stats.NewStatsServer(ctx)
 93	if err != nil {
 94		return nil, fmt.Errorf("create stats server: %w", err)
 95	}
 96
 97	if cfg.HTTP.TLSKeyPath != "" && cfg.HTTP.TLSCertPath != "" {
 98		srv.CertLoader, err = NewCertReloader(cfg.HTTP.TLSCertPath, cfg.HTTP.TLSKeyPath, logger)
 99		if err != nil {
100			return nil, fmt.Errorf("create cert reloader: %w", err)
101		}
102
103		srv.HTTPServer.SetTLSConfig(&tls.Config{
104			GetCertificate: srv.CertLoader.GetCertificateFunc(),
105		})
106	}
107
108	warnIfAnonAdminAccess(ctx, be, logger)
109
110	return srv, nil
111}
112
113// warnIfAnonAdminAccess logs a loud warning if the server's effective,
114// post-override settings grant unauthenticated (keyless) connections admin
115// access. This checks effective runtime state via the backend, not just the
116// new config-override fields, since the same risk exists whether the
117// dangerous combination came from config or was set via `ssh soft settings`
118// on a previous run.
119func warnIfAnonAdminAccess(ctx context.Context, be *backend.Backend, logger *log.Logger) {
120	if !be.AllowKeyless(ctx) || be.AnonAccess(ctx) < access.AdminAccess {
121		return
122	}
123
124	logger.Warn("################################################################")
125	logger.Warn("# WARNING: anonymous keyless connections have ADMIN access.    #")
126	logger.Warn("# Anyone who can reach this server has full control, no auth.  #")
127	logger.Warn("# This is intended for local/dev use only. Do not expose this  #")
128	logger.Warn("# server to an untrusted network.                              #")
129	logger.Warn("################################################################")
130}
131
132// ensureDefaultRepo creates the repo named by cfg.DefaultRepo if it does not
133// already exist. It never fails startup: it logs invalid names and creation
134// errors, then returns.
135func ensureDefaultRepo(ctx context.Context, cfg *config.Config, be *backend.Backend, logger *log.Logger) {
136	if cfg.DefaultRepo == "" {
137		return
138	}
139
140	name := utils.SanitizeRepo(cfg.DefaultRepo)
141	if err := utils.ValidateRepo(name); err != nil {
142		logger.Warn("invalid default_repo, skipping", "name", cfg.DefaultRepo, "err", err)
143		return
144	}
145
146	if _, err := be.Repository(ctx, name); err == nil {
147		return
148	} else if !errors.Is(err, proto.ErrRepoNotFound) {
149		logger.Warn("failed to look up default repo", "name", name, "err", err)
150		return
151	}
152
153	// The migration always inserts a user at ID 1. The repos table requires
154	// a non-null owner, so we attribute the repo to that account.
155	owner, err := be.UserByID(ctx, 1)
156	if err != nil {
157		logger.Warn("failed to look up default repo owner, skipping", "name", name, "err", err)
158		return
159	}
160
161	if _, err := be.CreateRepository(ctx, name, owner, proto.RepositoryOptions{}); err != nil && !errors.Is(err, proto.ErrRepoExist) {
162		logger.Warn("failed to create default repo", "name", name, "err", err)
163		return
164	}
165
166	logger.Info("created default repo", "name", name)
167}
168
169// ReloadCertificates reloads the TLS certificates for the HTTP server.
170func (s *Server) ReloadCertificates() error {
171	if s.CertLoader == nil {
172		return nil
173	}
174	return s.CertLoader.Reload()
175}
176
177// Start starts the SSH server.
178func (s *Server) Start() error {
179	errg, _ := errgroup.WithContext(s.ctx)
180
181	// optionally start the SSH server
182	if s.Config.SSH.Enabled {
183		errg.Go(func() error {
184			s.logger.Print("Starting SSH server", "addr", s.Config.SSH.ListenAddr)
185			if err := s.SSHServer.ListenAndServe(); !errors.Is(err, ssh.ErrServerClosed) {
186				return err
187			}
188			return nil
189		})
190	}
191
192	// optionally start the git daemon
193	if s.Config.Git.Enabled {
194		errg.Go(func() error {
195			s.logger.Print("Starting Git daemon", "addr", s.Config.Git.ListenAddr)
196			if err := s.GitDaemon.ListenAndServe(); !errors.Is(err, daemon.ErrServerClosed) {
197				return err
198			}
199			return nil
200		})
201	}
202
203	// optionally start the HTTP server
204	if s.Config.HTTP.Enabled {
205		errg.Go(func() error {
206			s.logger.Print("Starting HTTP server", "addr", s.Config.HTTP.ListenAddr)
207			if err := s.HTTPServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
208				return err
209			}
210			return nil
211		})
212	}
213
214	// optionally start the Stats server
215	if s.Config.Stats.Enabled {
216		errg.Go(func() error {
217			s.logger.Print("Starting Stats server", "addr", s.Config.Stats.ListenAddr)
218			if err := s.StatsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
219				return err
220			}
221			return nil
222		})
223	}
224
225	errg.Go(func() error {
226		s.Cron.Start()
227		return nil
228	})
229	return errg.Wait()
230}
231
232// Shutdown lets the server gracefully shutdown.
233func (s *Server) Shutdown(ctx context.Context) error {
234	errg, ctx := errgroup.WithContext(ctx)
235	errg.Go(func() error {
236		return s.GitDaemon.Shutdown(ctx)
237	})
238	errg.Go(func() error {
239		return s.HTTPServer.Shutdown(ctx)
240	})
241	errg.Go(func() error {
242		return s.SSHServer.Shutdown(ctx)
243	})
244	errg.Go(func() error {
245		return s.StatsServer.Shutdown(ctx)
246	})
247	errg.Go(func() error {
248		for _, j := range jobs.List() {
249			s.Cron.Remove(j.ID)
250		}
251		s.Cron.Stop()
252		return nil
253	})
254	// defer s.DB.Close() // nolint: errcheck
255	return errg.Wait()
256}
257
258// Close closes the SSH server.
259func (s *Server) Close() error {
260	var errg errgroup.Group
261	errg.Go(s.GitDaemon.Close)
262	errg.Go(s.HTTPServer.Close)
263	errg.Go(s.SSHServer.Close)
264	errg.Go(s.StatsServer.Close)
265	errg.Go(func() error {
266		s.Cron.Stop()
267		return nil
268	})
269	// defer s.DB.Close() // nolint: errcheck
270	return errg.Wait()
271}