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}