16552: Fix default websocket external port and proxy config.
[arvados.git] / lib / install / init.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package install
6
7 import (
8         "context"
9         "crypto/rand"
10         "crypto/rsa"
11         "crypto/x509"
12         "encoding/pem"
13         "flag"
14         "fmt"
15         "io"
16         "os"
17         "os/exec"
18         "os/user"
19         "regexp"
20         "strconv"
21         "strings"
22         "text/template"
23
24         "git.arvados.org/arvados.git/lib/cmd"
25         "git.arvados.org/arvados.git/lib/config"
26         "git.arvados.org/arvados.git/sdk/go/arvados"
27         "git.arvados.org/arvados.git/sdk/go/ctxlog"
28         "github.com/lib/pq"
29 )
30
31 var InitCommand cmd.Handler = &initCommand{}
32
33 type initCommand struct {
34         ClusterID          string
35         Domain             string
36         PostgreSQLPassword string
37         Login              string
38         TLS                string
39         AdminEmail         string
40         Start              bool
41
42         LoginPAM                bool
43         LoginTest               bool
44         LoginGoogle             bool
45         LoginGoogleClientID     string
46         LoginGoogleClientSecret string
47 }
48
49 func (initcmd *initCommand) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
50         logger := ctxlog.New(stderr, "text", "info")
51         ctx := ctxlog.Context(context.Background(), logger)
52         ctx, cancel := context.WithCancel(ctx)
53         defer cancel()
54
55         var err error
56         defer func() {
57                 if err != nil {
58                         logger.WithError(err).Info("exiting")
59                 }
60         }()
61
62         hostname, err := os.Hostname()
63         if err != nil {
64                 err = fmt.Errorf("Hostname(): %w", err)
65                 return 1
66         }
67
68         flags := flag.NewFlagSet(prog, flag.ContinueOnError)
69         flags.SetOutput(stderr)
70         versionFlag := flags.Bool("version", false, "Write version information to stdout and exit 0")
71         flags.StringVar(&initcmd.ClusterID, "cluster-id", "", "cluster `id`, like x1234 for a dev cluster")
72         flags.StringVar(&initcmd.Domain, "domain", hostname, "cluster public DNS `name`, like x1234.arvadosapi.com")
73         flags.StringVar(&initcmd.Login, "login", "", "login `backend`: test, pam, 'google {client-id} {client-secret}', or ''")
74         flags.StringVar(&initcmd.AdminEmail, "admin-email", "", "give admin privileges to user with given `email`")
75         flags.StringVar(&initcmd.TLS, "tls", "none", "tls certificate `source`: acme, auto, insecure, or none")
76         flags.BoolVar(&initcmd.Start, "start", true, "start systemd service after creating config")
77         if ok, code := cmd.ParseFlags(flags, prog, args, "", stderr); !ok {
78                 return code
79         } else if *versionFlag {
80                 return cmd.Version.RunCommand(prog, args, stdin, stdout, stderr)
81         } else if !regexp.MustCompile(`^[a-z][a-z0-9]{4}`).MatchString(initcmd.ClusterID) {
82                 err = fmt.Errorf("cluster ID %q is invalid; must be an ASCII letter followed by 4 alphanumerics (try -help)", initcmd.ClusterID)
83                 return 1
84         }
85
86         if fields := strings.Fields(initcmd.Login); len(fields) == 3 && fields[0] == "google" {
87                 initcmd.LoginGoogle = true
88                 initcmd.LoginGoogleClientID = fields[1]
89                 initcmd.LoginGoogleClientSecret = fields[2]
90         } else if initcmd.Login == "test" {
91                 initcmd.LoginTest = true
92                 if initcmd.AdminEmail == "" {
93                         initcmd.AdminEmail = "admin@example.com"
94                 }
95         } else if initcmd.Login == "pam" {
96                 initcmd.LoginPAM = true
97         } else if initcmd.Login == "" {
98                 // none; login will show an error page
99         } else {
100                 err = fmt.Errorf("invalid argument to -login: %q: should be 'test', 'pam', 'google {client-id} {client-secret}', or empty")
101                 return 1
102         }
103
104         confdir := "/etc/arvados"
105         conffile := confdir + "/config.yml"
106         if _, err = os.Stat(conffile); err == nil {
107                 err = fmt.Errorf("config file %s already exists; delete it first if you really want to start over", conffile)
108                 return 1
109         }
110
111         wwwuser, err := user.Lookup("www-data")
112         if err != nil {
113                 err = fmt.Errorf("user.Lookup(%q): %w", "www-data", err)
114                 return 1
115         }
116         wwwgid, err := strconv.Atoi(wwwuser.Gid)
117         if err != nil {
118                 return 1
119         }
120         initcmd.PostgreSQLPassword = initcmd.RandomHex(32)
121
122         err = os.Mkdir("/var/lib/arvados/keep", 0600)
123         if err != nil && !os.IsExist(err) {
124                 err = fmt.Errorf("mkdir /var/lib/arvados/keep: %w", err)
125                 return 1
126         }
127         fmt.Fprintln(stderr, "created /var/lib/arvados/keep")
128
129         err = os.Mkdir(confdir, 0750)
130         if err != nil && !os.IsExist(err) {
131                 err = fmt.Errorf("mkdir %s: %w", confdir, err)
132                 return 1
133         }
134         err = os.Chown(confdir, 0, wwwgid)
135         if err != nil {
136                 err = fmt.Errorf("chown 0:%d %s: %w", wwwgid, confdir, err)
137                 return 1
138         }
139         f, err := os.OpenFile(conffile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
140         if err != nil {
141                 err = fmt.Errorf("open %s: %w", conffile, err)
142                 return 1
143         }
144         tmpl, err := template.New("config").Parse(`Clusters:
145   {{.ClusterID}}:
146     Services:
147       Controller:
148         InternalURLs:
149           "http://0.0.0.0:9000/": {}
150         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4440/" ) }}
151       RailsAPI:
152         InternalURLs:
153           "http://0.0.0.0:9001/": {}
154       Websocket:
155         InternalURLs:
156           "http://0.0.0.0:8005/": {}
157         ExternalURL: {{printf "%q" ( print "wss://" .Domain ":4446/" ) }}
158       Keepbalance:
159         InternalURLs:
160           "http://0.0.0.0:9019/": {}
161       GitHTTP:
162         InternalURLs:
163           "http://0.0.0.0:9005/": {}
164         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4445/" ) }}
165       DispatchCloud:
166         InternalURLs:
167           "http://0.0.0.0:9006/": {}
168       Keepproxy:
169         InternalURLs:
170           "http://0.0.0.0:9007/": {}
171         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4447/" ) }}
172       WebDAV:
173         InternalURLs:
174           "http://0.0.0.0:9008/": {}
175         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4448/" ) }}
176       WebDAVDownload:
177         InternalURLs:
178           "http://0.0.0.0:9009/": {}
179         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4449/" ) }}
180       Keepstore:
181         InternalURLs:
182           "http://0.0.0.0:9010/": {}
183       Composer:
184         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4459/composer" ) }}
185       Workbench1:
186         InternalURLs:
187           "http://0.0.0.0:9002/": {}
188         ExternalURL: {{printf "%q" ( print "https://" .Domain ":4442/" ) }}
189       Workbench2:
190         InternalURLs:
191           "http://0.0.0.0:9003/": {}
192         ExternalURL: {{printf "%q" ( print "https://" .Domain "/" ) }}
193       Health:
194         InternalURLs:
195           "http://0.0.0.0:9011/": {}
196     Collections:
197       BlobSigningKey: {{printf "%q" ( .RandomHex 50 )}}
198       {{if eq .TLS "insecure"}}
199       TrustAllContent: true
200       {{end}}
201     Containers:
202       DispatchPrivateKey: {{printf "%q" .GenerateSSHPrivateKey}}
203       CloudVMs:
204         Enable: true
205         Driver: loopback
206     ManagementToken: {{printf "%q" ( .RandomHex 50 )}}
207     PostgreSQL:
208       Connection:
209         dbname: arvados
210         host: localhost
211         user: arvados
212         password: {{printf "%q" .PostgreSQLPassword}}
213     SystemRootToken: {{printf "%q" ( .RandomHex 50 )}}
214     TLS:
215       {{if eq .TLS "insecure"}}
216       Insecure: true
217       {{else if eq .TLS "auto"}}
218       Automatic: true
219       {{else if eq .TLS "acme"}}
220       Certificate: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/cert")}}
221       Key: {{printf "%q" (print "/var/lib/acme/live/" .Domain "/privkey")}}
222       {{else}}
223       {}
224       {{end}}
225     Volumes:
226       {{.ClusterID}}-nyw5e-000000000000000:
227         Driver: Directory
228         DriverParameters:
229           Root: /var/lib/arvados/keep
230         Replication: 2
231     Workbench:
232       SecretKeyBase: {{printf "%q" ( .RandomHex 50 )}}
233     {{if .LoginPAM}}
234     Login:
235       PAM:
236         Enable: true
237     {{else if .LoginTest}}
238     Login:
239       Test:
240         Enable: true
241         Users:
242           admin:
243             Email: {{printf "%q" .AdminEmail}}
244             Password: admin
245     {{else if .LoginGoogle}}
246     Login:
247       Google:
248         Enable: true
249         ClientID: {{printf "%q" .LoginGoogleClientID}}
250         ClientSecret: {{printf "%q" .LoginGoogleClientSecret}}
251     {{end}}
252     Users:
253       AutoAdminUserWithEmail: {{printf "%q" .AdminEmail}}
254 `)
255         if err != nil {
256                 return 1
257         }
258         err = tmpl.Execute(f, initcmd)
259         if err != nil {
260                 err = fmt.Errorf("%s: tmpl.Execute: %w", conffile, err)
261                 return 1
262         }
263         err = f.Close()
264         if err != nil {
265                 err = fmt.Errorf("%s: close: %w", conffile, err)
266                 return 1
267         }
268         fmt.Fprintln(stderr, "created", conffile)
269
270         ldr := config.NewLoader(nil, logger)
271         ldr.SkipLegacy = true
272         cfg, err := ldr.Load()
273         if err != nil {
274                 err = fmt.Errorf("%s: %w", conffile, err)
275                 return 1
276         }
277         cluster, err := cfg.GetCluster("")
278         if err != nil {
279                 return 1
280         }
281
282         err = initcmd.createDB(ctx, cluster.PostgreSQL.Connection, stderr)
283         if err != nil {
284                 return 1
285         }
286
287         cmd := exec.CommandContext(ctx, "sudo", "-u", "www-data", "-E", "HOME=/var/www", "PATH=/var/lib/arvados/bin:"+os.Getenv("PATH"), "/var/lib/arvados/bin/bundle", "exec", "rake", "db:setup")
288         cmd.Dir = "/var/lib/arvados/railsapi"
289         cmd.Stdout = stderr
290         cmd.Stderr = stderr
291         err = cmd.Run()
292         if err != nil {
293                 err = fmt.Errorf("rake db:setup failed: %w", err)
294                 return 1
295         }
296         fmt.Fprintln(stderr, "initialized database")
297
298         if initcmd.Start {
299                 fmt.Fprintln(stderr, "starting systemd service")
300                 cmd := exec.CommandContext(ctx, "systemctl", "start", "--no-block", "arvados")
301                 cmd.Dir = "/"
302                 cmd.Stdout = stderr
303                 cmd.Stderr = stderr
304                 err = cmd.Run()
305                 if err != nil {
306                         err = fmt.Errorf("%v: %w", cmd.Args, err)
307                         return 1
308                 }
309         }
310
311         return 0
312 }
313
314 func (initcmd *initCommand) GenerateSSHPrivateKey() (string, error) {
315         privkey, err := rsa.GenerateKey(rand.Reader, 4096)
316         if err != nil {
317                 return "", err
318         }
319         err = privkey.Validate()
320         if err != nil {
321                 return "", err
322         }
323         return string(pem.EncodeToMemory(&pem.Block{
324                 Type:  "RSA PRIVATE KEY",
325                 Bytes: x509.MarshalPKCS1PrivateKey(privkey),
326         })), nil
327 }
328
329 func (initcmd *initCommand) RandomHex(chars int) string {
330         b := make([]byte, chars/2)
331         _, err := rand.Read(b)
332         if err != nil {
333                 panic(err)
334         }
335         return fmt.Sprintf("%x", b)
336 }
337
338 func (initcmd *initCommand) createDB(ctx context.Context, dbconn arvados.PostgreSQLConnection, stderr io.Writer) error {
339         for _, sql := range []string{
340                 `CREATE USER ` + pq.QuoteIdentifier(dbconn["user"]) + ` WITH SUPERUSER ENCRYPTED PASSWORD ` + pq.QuoteLiteral(dbconn["password"]),
341                 `CREATE DATABASE ` + pq.QuoteIdentifier(dbconn["dbname"]) + ` WITH TEMPLATE template0 ENCODING 'utf8'`,
342                 `CREATE EXTENSION IF NOT EXISTS pg_trgm`,
343         } {
344                 cmd := exec.CommandContext(ctx, "sudo", "-u", "postgres", "psql", "-c", sql)
345                 cmd.Dir = "/"
346                 cmd.Stdout = stderr
347                 cmd.Stderr = stderr
348                 err := cmd.Run()
349                 if err != nil {
350                         return fmt.Errorf("error setting up arvados user/database: %w", err)
351                 }
352         }
353         return nil
354 }