diff --git a/build.sh b/build.sh index d9810230..afb01940 100755 --- a/build.sh +++ b/build.sh @@ -7,13 +7,36 @@ # amd64, arm64, armhf and i386. # # [--ups]: Build the ubuntu-personal-store snap as well. +# +# [--with-webconf]: Adds the new webconf service to the snapweb snap. +# set -e -if [ "$#" -eq 0 ] || ([ "$#" -eq 1 ] && [ "$1" = "--ups" ]); then +BUILD_UPS_SNAP= +WITH_WEBCONF= + +while [ "$1" != "" ]; do + case $1 in + --ups) + BUILD_UPS_SNAP=1 + ;; + --with-webconf) + WITH_WEBCONF=1 + ;; + amd64|i386|arm64|armhf) + architectures+=("$1") + ;; + *) + # print usage + head -13 $0 | tail -12 + exit + esac + shift +done + +if [ "$architectures" = "" ]; then architectures=( amd64 arm64 armhf i386 ) -else - architectures=( "$@" ) fi AVAHI_VERSION="0.6.31-4ubuntu4snap2" @@ -59,7 +82,10 @@ gobuild() { mkdir -p $output_dir cd $output_dir - GOARCH=$arch GOARM=7 CGO_ENABLED=1 CC=${plat_abi}-gcc go build -ldflags "-extld=${plat_abi}-gcc -X main.httpAddr=${httpAddr} -X main.httpsAddr=${httpsAddr}" github.com/snapcore/snapweb/cmd/snapweb + GOARCH=$arch GOARM=7 CGO_ENABLED=1 CC=${plat_abi}-gcc go build -ldflags "-extld=${plat_abi}-gcc" github.com/snapcore/snapweb/cmd/snapweb + if [ $WITH_WEBCONF ]; then + GOARCH=$arch GOARM=7 CGO_ENABLED=1 CC=${plat_abi}-gcc go build -ldflags "-extld=${plat_abi}-gcc" github.com/snapcore/snapweb/cmd/webconf + fi GOARCH=$arch GOARM=7 CGO_ENABLED=1 CC=${plat_abi}-gcc go build -o generate-token -ldflags "-extld=${plat_abi}-gcc" $srcdir/cmd/generate-token/main.go cp generate-token ../../ cd - > /dev/null @@ -112,7 +138,7 @@ for ARCH in "${architectures[@]}"; do snapcraft snap $builddir # TODO: We only support amd64 ups builds for now -Need a cross-compile fix for "after: [desktop-qt5]" - if [[ $* == *--ups* ]] && [ $ARCH = amd64 ]; then + if [ $BUILD_UPS_SNAP ] && [ $ARCH = amd64 ]; then HTTP_ADDR="127.0.0.1:5200" HTTPS_ADDR="127.0.0.1:5201" diff --git a/cmd/snapweb/cert.go b/cmd/snapweb/cert.go index a4ababb3..d52b9c34 100644 --- a/cmd/snapweb/cert.go +++ b/cmd/snapweb/cert.go @@ -32,8 +32,8 @@ import ( "time" ) -// DumpCertificate create the certificate & key files -func DumpCertificate() { +// CreateCertificateIfNeeded creates the certificate & key files, if they don't exist yet +func CreateCertificateIfNeeded() { certFilename := filepath.Join(os.Getenv("SNAP_DATA"), "cert.pem") keyFilename := filepath.Join(os.Getenv("SNAP_DATA"), "key.pem") diff --git a/cmd/snapweb/cert_test.go b/cmd/snapweb/cert_test.go index 56019501..6443c530 100644 --- a/cmd/snapweb/cert_test.go +++ b/cmd/snapweb/cert_test.go @@ -45,17 +45,17 @@ func (s *CertSuite) TearDownTest(c *C) { os.RemoveAll(s.snapdata) } -func (s *CertSuite) TestDumpCertificate(c *C) { +func (s *CertSuite) TestCreateCertificateIfNeeded(c *C) { c.Assert(ioutil.WriteFile(s.certFilename, nil, 0600), IsNil) c.Assert(ioutil.WriteFile(s.keyFilename, nil, 0600), IsNil) - DumpCertificate() + CreateCertificateIfNeeded() certData, err := ioutil.ReadFile(s.certFilename) c.Assert(err, IsNil) keyData, err := ioutil.ReadFile(s.keyFilename) c.Assert(err, IsNil) - DumpCertificate() + CreateCertificateIfNeeded() certData2, err := ioutil.ReadFile(s.certFilename) c.Assert(err, IsNil) keyData2, err := ioutil.ReadFile(s.keyFilename) diff --git a/cmd/snapweb/handlers.go b/cmd/snapweb/handlers.go index 23c087c6..3ffe214b 100644 --- a/cmd/snapweb/handlers.go +++ b/cmd/snapweb/handlers.go @@ -250,7 +250,7 @@ func initURLHandlers(log *log.Logger, config snappy.Config) http.Handler { handler.HandleFunc("/", makeMainPageHandler()) - return NewFilterHandlerFromConfig(handler, config) + return snappy.NewFilterHandlerFromConfig(handler, config) } // Name of the cookie transporting the access token @@ -341,31 +341,5 @@ func redirHandler(config snappy.Config) http.Handler { http.StatusSeeOther) }) - return NewFilterHandlerFromConfig(redir, config) -} - -// NewFilterHandlerFromConfig creates a new http.Handler with an integrated NetFilter -func NewFilterHandlerFromConfig(handler http.Handler, config snappy.Config) http.Handler { - if config.DisableIPFilter { - return handler - } - - f := snappy.NewFilter() - - for _, net := range config.AllowNetworks { - f.AllowNetwork(net) - } - - for _, ifname := range config.AllowInterfaces { - f.AddLocalNetworkForInterface(ifname) - } - - // if nothing was specified, default to allowing all local networks - if (len(config.AllowNetworks) == 0) && - (len(config.AllowInterfaces) == 0) { - logger.Println("Allowing local network access by default") - f.AddLocalNetworks() - } - - return f.FilterHandler(handler) + return snappy.NewFilterHandlerFromConfig(redir, config) } diff --git a/cmd/snapweb/main.go b/cmd/snapweb/main.go index 5b8dc1b8..0649a1af 100644 --- a/cmd/snapweb/main.go +++ b/cmd/snapweb/main.go @@ -22,6 +22,8 @@ import ( "net/http" "os" "path/filepath" + "strings" + "time" "github.com/snapcore/snapweb/avahi" "github.com/snapcore/snapweb/snappy/app" @@ -43,7 +45,26 @@ func init() { } } +func redir(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, + "https://"+strings.Replace(req.Host, httpAddr, httpsAddr, -1), + http.StatusSeeOther) +} + +type blockerFn func() bool + +func blockOn(managedCondition blockerFn) { + snappy.WritePidFile() + for managedCondition() == false { + snappy.WaitForSigHup() + // wait futher more, to let webconf release the 4200 port + time.Sleep(1000) + } +} + func main() { + blockOn(snappy.IsDeviceManaged) + // TODO set warning for too hazardous config? config, err := snappy.ReadConfig() if err != nil { @@ -58,7 +79,7 @@ func main() { logger.Println("Snapweb starting...") if !config.DisableHTTPS { - DumpCertificate() + CreateCertificateIfNeeded() go func() { certFile := filepath.Join(os.Getenv("SNAP_DATA"), "cert.pem") diff --git a/cmd/snapweb/main_test.go b/cmd/snapweb/main_test.go new file mode 100644 index 00000000..eae6f794 --- /dev/null +++ b/cmd/snapweb/main_test.go @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "os" + "time" + + "github.com/snapcore/snapweb/snappy/app" + + . "gopkg.in/check.v1" +) + +type MainSuite struct{} + +var _ = Suite(&MainSuite{}) + +var mockManagedState = false + +func mockIsDeviceManaged() bool { + return mockManagedState +} + +func (s *MainSuite) TestBlockUntilManaged(c *C) { + os.Setenv("SNAP_DATA", c.MkDir()) + + ready := make(chan bool) + done := make(chan bool) + + go func() { + ready <- true + blockOn(mockIsDeviceManaged) + done <- true + }() + + // a signal cannot unblock snapweb until the system is managed + mockManagedState = false + <-ready + time.Sleep(1000) // give time to install the signal handler + snappy.SendSignalToSnapweb() + select { + case <-done: + c.Fail() + default: + } + + // try to unblock once the system has changed + mockManagedState = true + snappy.SendSignalToSnapweb() + result := <-done + + c.Assert(result, Equals, true) +} diff --git a/cmd/webconf/main.go b/cmd/webconf/main.go new file mode 100644 index 00000000..1d9821df --- /dev/null +++ b/cmd/webconf/main.go @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "log" + "net" + "net/http" + "os" + "path/filepath" + "text/template" + "time" + + "github.com/snapcore/snapd/dirs" + + "github.com/snapcore/snapweb/avahi" + "github.com/snapcore/snapweb/snappy/app" +) + +var logger *log.Logger +var timer *time.Timer + +const ( + httpAddr string = ":4200" +) + +func init() { + logger = log.New(os.Stderr, "webconf: ", log.Ldate|log.Ltime|log.Lshortfile) +} + +type branding struct { + Name string + Subname string +} + +type templateData struct { + Branding branding + SnapdVersion string +} + +func makeMainPageHandler() http.HandlerFunc { + + layoutPath := filepath.Join(os.Getenv("SNAP"), "www", "templates", "webconf.html") + t, err := template.ParseFiles(layoutPath) + if err != nil { + logger.Fatalf("%v", err) + } + + data := templateData{ + Branding: branding{ + Name: "Ubuntu", + Subname: "", + }, + SnapdVersion: "snapd", + } + + return func(w http.ResponseWriter, r *http.Request) { + + // reset the timer, and give 3 minutes to complete the setup process + timer.Reset(time.Second * 180) + + err = t.Execute(w, &data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Println(err) + return + } + + } +} + +func doneHandler(server net.Listener) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + snappy.SendSignalToSnapweb() + if server != nil { + server.Close() + } + } +} + +func initURLHandlers(log *log.Logger, server net.Listener, config snappy.Config) http.Handler { + handler := http.NewServeMux() + + // API + handler.Handle("/api/v2/create-user", + snappy.MakePassthroughHandler(dirs.SnapdSocket, "/api/v2/create-user")) + handler.HandleFunc("/done", doneHandler(server)) + + // Resources + handler.Handle("/public/", snappy.LoggingHandler(http.FileServer(http.Dir(filepath.Join(os.Getenv("SNAP"), "www"))))) + + handler.HandleFunc("/", makeMainPageHandler()) + + return snappy.NewFilterHandlerFromConfig(handler, config) +} + +func main() { + if snappy.IsDeviceManaged() { + log.Println("webconf does not run on managed devices") + // this tells systemd to not restart this service + os.Exit(0) + } + + config, err := snappy.ReadConfig() + if err != nil { + logger.Fatal("Configuration error", err) + } + + go avahi.InitMDNS(logger) + + // open a plain HTTP end-point on the "usual" 4200 port + server, err := net.Listen("tcp", httpAddr) + if err != nil { + logger.Fatalf("%v", err) + } + + handler := initURLHandlers(logger, server, config) + + timer = time.NewTimer(time.Second * 120) + go func() { + <-timer.C + logger.Println("disabling webconf automatically after 2 minutes") + server.Close() + os.Exit(0) + }() + + http.Serve(server, handler) + + // prepare to exit, but let snapweb start before that + time.Sleep(2 * time.Second) +} diff --git a/cmd/webconf/main_test.go b/cmd/webconf/main_test.go new file mode 100644 index 00000000..d07b9b4d --- /dev/null +++ b/cmd/webconf/main_test.go @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "log" + "net" + "net/http" + "net/http/httptest" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapweb/snappy/app" +) + +func Test(t *testing.T) { TestingT(t) } + +type WebconfSuite struct{} + +var _ = Suite(&WebconfSuite{}) + +func (s *WebconfSuite) SetUpTest(c *C) { + cwd, err := os.Getwd() + c.Assert(err, IsNil) + os.Setenv("SNAP", filepath.Join(cwd, "..", "..")) +} + +func (s *WebconfSuite) TestURLHandlers(c *C) { + timer = time.NewTimer(time.Second * 120) + handler := initURLHandlers(log.New(os.Stdout, "", 0), nil, snappy.Config{}) + + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + req.RemoteAddr = "127.0.0.1:80" + c.Assert(err, IsNil) + + handler.ServeHTTP(rec, req) + c.Assert(rec.Code, Equals, http.StatusOK) + + body := rec.Body.String() + c.Check(strings.Contains(body, "'Ubuntu'"), Equals, true) +} + +func (s *WebconfSuite) TestDoneHandler(c *C) { + tmp := c.MkDir() + os.Setenv("SNAP_DATA", tmp) + snappy.WritePidFile() + + done := false + var sigchan chan os.Signal + sigchan = make(chan os.Signal, 1) + signal.Notify(sigchan, syscall.SIGHUP) + + server, _ := net.Listen("tcp", httpAddr) + handler := doneHandler(server) + + req, _ := http.NewRequest("GET", "/done", nil) + req.RemoteAddr = "127.0.0.1:80" + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + select { + case <-sigchan: + done = true + } + + c.Assert(done, Equals, true) +} diff --git a/gulpfile.js b/gulpfile.js index 453d439a..09a02216 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -18,15 +18,23 @@ var uglify = require('gulp-uglify'); var watchify = require('watchify'); gulp.task('js:build', ['js:clean', 'js:lint'], function() { - return createBundler(); + return createBundler('./www/src/js/app.js', 'snapweb.js', 'www/public/js'); }); gulp.task('js:clean', function(cb) { del(['www/public/js'], cb); }); -function createBundler(watch) { - var bundler = browserify('./www/src/js/app.js', { +gulp.task('js:build:webconf', ['js:clean:webconf', 'js:lint'], function() { + return createBundler('./www/src/js/webconf-app.js', 'webconf.js', 'www/public/js'); +}); + +gulp.task('js:clean:webconf', function(cb) { + del(['www/webconf/js'], cb); +}); + +function createBundler(appSrc, appJs, destDir, watch) { + var bundler = browserify(appSrc, { cache: {}, packageCache: {} }); @@ -43,22 +51,22 @@ function createBundler(watch) { } else { } - return bundleShared(bundler); + return bundleShared(bundler, appJs, destDir); } -function bundleShared(bundler) { +function bundleShared(bundler, appJs, destDir) { return bundler.bundle() .on('error', function(err) { gutil.log(gutil.colors.green('Browserify Error: ' + err)); this.emit('end'); process.exit(1); }) - .pipe(source('snapweb.js')) + .pipe(source(appJs)) .pipe(buffer()) .pipe(sourcemaps.init({loadMaps: true})) // loads map from browserify file .pipe(process.env.NODE_ENV === 'development'? gutil.noop() : uglify()) .pipe(sourcemaps.write('./')) // writes .map file - .pipe(gulp.dest('www/public/js/')); + .pipe(gulp.dest(destDir)); } gulp.task('js:lint', function() { @@ -133,4 +141,7 @@ gulp.task('install', ['default'], function() { .pipe(gulp.dest('../install')); }); -gulp.task('default', ['js:build', 'styles', 'images']); +gulp.task('default', + ['js:build', + process.env.WITH_WEBCONF === '1' ? 'js:build:webconf' : 'js:clean:webconf', + 'styles', 'images']); diff --git a/pkg/meta/snap.yaml b/pkg/meta/snap.yaml index 684dc196..1ec44236 100644 --- a/pkg/meta/snap.yaml +++ b/pkg/meta/snap.yaml @@ -16,5 +16,10 @@ apps: daemon: simple command: snapweb plugs: [network, network-bind, snapd-control, timeserver-control, timezone-control] + webconf: + daemon: simple + restart-condition: never + command: webconf + plugs: [network, network-bind, snapd-control, timeserver-control] generate-token: command: generate-token diff --git a/pkg/snapweb b/pkg/snapweb index 3b200cdf..c5e6bbec 100755 --- a/pkg/snapweb +++ b/pkg/snapweb @@ -2,7 +2,7 @@ set -e case $SNAP_ARCH in - amd64) + amd64|x86_64) plat_abi=x86_64-linux-gnu ;; armhf) diff --git a/pkg/webconf b/pkg/webconf new file mode 100755 index 00000000..7f6cdb67 --- /dev/null +++ b/pkg/webconf @@ -0,0 +1,26 @@ +#!/bin/sh +set -e + +case $SNAP_ARCH in + amd64|x86_64) + plat_abi=x86_64-linux-gnu + ;; + armhf) + plat_abi=arm-linux-gnueabihf + ;; + arm64) + plat_abi=aarch64-linux-gnu + ;; + i386) + plat_abi=i686-linux-gnu + ;; + *) + echo "unknown platform for snappy-magic: $SNAP_ARCH. remember to file a bug or better yet: fix it :)" + exit 1 + ;; +esac + +exec $SNAP/bin/$plat_abi/webconf + +# never reach this +exit 1 diff --git a/run-spread-tests.sh b/run-spread-tests.sh index 78b68718..981d8c3e 100755 --- a/run-spread-tests.sh +++ b/run-spread-tests.sh @@ -17,6 +17,7 @@ set -e image_name=ubuntu-core-16.img +image_name2=ubuntu-core-16-embryonic.img channel=candidate spread_opts= force_new_image=0 @@ -75,6 +76,12 @@ if [ ! -e $SPREAD_QEMU_PATH/$image_name ] || [ $force_new_image -eq 1 ] ; then mkdir -p $SPREAD_QEMU_PATH mv -f spread/image/ubuntu-core-16.img $SPREAD_QEMU_PATH/$image_name fi +if [ ! -e $SPREAD_QEMU_PATH/$image_name2 ] || [ $force_new_image -eq 1 ] ; then + echo "INFO: Creating new qemu test image(2) ..." + (cd spread/image ; sudo ./create-image-embryonic.sh $channel) + mkdir -p $SPREAD_QEMU_PATH + mv -f spread/image/$image_name2 $SPREAD_QEMU_PATH/$image_name2 +fi # We currently only run spread tests but we could do other things # here as well like running our snap-lintian tool etc. diff --git a/snappy/app/configuration.go b/snappy/app/configuration.go index 0fd562b4..2a80725e 100644 --- a/snappy/app/configuration.go +++ b/snappy/app/configuration.go @@ -36,6 +36,7 @@ type Config struct { DisableIPFilter bool `json:"disableIPFilter,omitempty"` AllowNetworks []string `json:"allowNetworks,omitempty"` AllowInterfaces []string `json:"allowInterfaces,omitempty"` + WebconfTimeout []string `json:"webconfTimeout,omitempty"` } var readFile = ioutil.ReadFile diff --git a/snappy/app/fake_snapd_client.go b/snappy/app/fake_snapd_client.go index 9cf16705..95744d7c 100644 --- a/snappy/app/fake_snapd_client.go +++ b/snappy/app/fake_snapd_client.go @@ -158,4 +158,13 @@ func (f *FakeSnapdClient) Abort(id string) (*client.Change, error) { return nil, nil } +// SysInfo returns system information +func (f *FakeSnapdClient) SysInfo() (*client.SysInfo, error) { + managed := &client.SysInfo{ + OnClassic: true, + Managed: true, + } + return managed, nil +} + var _ snapdclient.SnapdClient = (*FakeSnapdClient)(nil) diff --git a/snappy/app/helpers.go b/snappy/app/helpers.go new file mode 100644 index 00000000..741c2e70 --- /dev/null +++ b/snappy/app/helpers.go @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +/* This module gathers a set of helper functions and structures shared + by the various commands in snapweb +*/ + +package snappy + +import ( + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/snapcore/snapd/client" +) + +// IsDeviceManaged determines if the device is in the 'managed' state +func IsDeviceManaged() bool { + client := client.New(nil) + + sysInfo, err := client.SysInfo() + if err != nil { + return false + } + + if sysInfo.OnClassic || sysInfo.Managed { + return true + } + + return false +} + +func unixDialer(socketPath string) func(string, string) (net.Conn, error) { + file, err := os.OpenFile(socketPath, os.O_RDWR, 0666) + if err == nil { + file.Close() + } + + return func(_, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + } +} + +// MakePassthroughHandler maps a snapd API to a snapweb HTTP handler +func MakePassthroughHandler(socketPath string, prefix string) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := &http.Client{ + Transport: &http.Transport{Dial: unixDialer(socketPath)}, + } + + log.Println(r.Method, r.URL.Path) + + // need to remove the RequestURI field + // and remove the /api prefix from snapweb URLs + r.URL.Scheme = "http" + r.URL.Host = "localhost" + r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) + + outreq, err := http.NewRequest(r.Method, r.URL.String(), r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + resp, err := c.Do(outreq) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Note: the client.Do method above only returns JSON responses + // even if it doesn't say so + hdr := w.Header() + hdr.Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + + io.Copy(w, resp.Body) + + }) +} + +// LoggingHandler adds HTTP logs to a handler +func LoggingHandler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Println(r.Method, r.URL.Path) + h.ServeHTTP(w, r) + }) +} + +// WritePidFile writes the PID of the current process to snapweb.pid +func WritePidFile() { + var err error + + pidFilePath := filepath.Join(os.Getenv("SNAP_DATA"), "snapweb.pid") + + if f, err := os.OpenFile(pidFilePath, os.O_CREATE|os.O_RDWR, os.ModeTemporary|0640); err == nil { + fmt.Fprintf(f, "%d\n", os.Getpid()) + } + if err != nil { + log.Println(err) + } + +} + +// WaitForSigHup waits for the reception of the SIGHUP signal +func WaitForSigHup() { + var sigchan chan os.Signal + sigchan = make(chan os.Signal, 1) + signal.Notify(sigchan, syscall.SIGHUP) + defer signal.Stop(sigchan) + <-sigchan +} + +// SendSignalToSnapweb informs snapweb that the webconf process is finished, via a SIGHUP signal +func SendSignalToSnapweb() { + var pid int + + pidFilePath := filepath.Join(os.Getenv("SNAP_DATA"), "snapweb.pid") + + if f, err := os.Open(pidFilePath); err == nil { + if _, err = fmt.Fscanf(f, "%d\n", &pid); err == nil { + p, _ := os.FindProcess(pid) + err = p.Signal(syscall.Signal(syscall.SIGHUP)) + } else { + log.Println(err) + } + } else { + log.Println(err) + } +} diff --git a/snappy/app/helpers_test.go b/snappy/app/helpers_test.go new file mode 100644 index 00000000..b01cc2a2 --- /dev/null +++ b/snappy/app/helpers_test.go @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package snappy + +import ( + "bytes" + "fmt" + "log" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "time" + + . "gopkg.in/check.v1" +) + +type HelpersSuite struct{} + +var _ = Suite(&HelpersSuite{}) + +func (s *HelpersSuite) TestLoggingHandler(c *C) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + logged := LoggingHandler(handler) + + var output bytes.Buffer + log.SetOutput(&output) + defer func() { + log.SetOutput(os.Stdout) + }() + + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/foo", nil) + c.Assert(err, IsNil) + + logged.ServeHTTP(rec, req) + + c.Assert(output.String(), Matches, ".*GET /foo\n") +} + +func (s *HelpersSuite) TestPassthroughHandler(c *C) { + socketPath := "/tmp/snapd-test.socket" + c.Assert(os.MkdirAll(filepath.Dir(socketPath), 0755), IsNil) + l, err := net.Listen("unix", socketPath) + if err != nil { + c.Fatalf("unable to listen on %q: %v", socketPath, err) + } + + f := func(w http.ResponseWriter, r *http.Request) { + c.Check(r.URL.Path, Equals, "/v2/system-info") + c.Check(r.URL.RawQuery, Equals, "") + + fmt.Fprintln(w, `{"type":"sync", "result":{"series":"42"}}`) + } + + srv := &httptest.Server{ + Listener: l, + Config: &http.Server{Handler: http.HandlerFunc(f)}, + } + srv.Start() + defer srv.Close() + + handler := http.HandlerFunc(MakePassthroughHandler(socketPath, "/api")) + + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/api/v2/system-info", nil) + c.Assert(err, IsNil) + + // req.AddCookie(&http.Cookie{Name: SnapwebCookieName, Value: "1234"}) + + handler(rec, req) + body := rec.Body.String() + c.Assert(rec.Code, Equals, http.StatusOK) + c.Check(strings.Contains(body, "42"), Equals, true) + // TODO: check that we receive Content-Type: json/application +} + +func (s *HelpersSuite) TestSnapwebSignaling(c *C) { + os.Setenv("SNAP_DATA", c.MkDir()) + + WritePidFile() + + ready := make(chan bool) + done := make(chan bool) + + // the thread where we test the function + go func() { + for { + ready <- true + WaitForSigHup() + // say the test passed + done <- true + } + }() + + // send the signal, *once* the function is ready to be tested + <-ready + time.Sleep(1000) + SendSignalToSnapweb() + + c.Assert(<-done, Equals, true) + + // do it a second time + <-ready + time.Sleep(1000) + SendSignalToSnapweb() + + c.Assert(<-done, Equals, true) +} diff --git a/snappy/app/netfilter.go b/snappy/app/netfilter.go index c551948f..e7f46290 100644 --- a/snappy/app/netfilter.go +++ b/snappy/app/netfilter.go @@ -132,3 +132,29 @@ func (f *NetFilter) FilterHandler(handler http.Handler) http.Handler { }) } + +// NewFilterHandlerFromConfig creates a new http.Handler with an integrated NetFilter +func NewFilterHandlerFromConfig(handler http.Handler, config Config) http.Handler { + if config.DisableIPFilter { + return handler + } + + f := NewFilter() + + for _, net := range config.AllowNetworks { + f.AllowNetwork(net) + } + + for _, ifname := range config.AllowInterfaces { + f.AddLocalNetworkForInterface(ifname) + } + + // if nothing was specified, default to allowing all local networks + if (len(config.AllowNetworks) == 0) && + (len(config.AllowInterfaces) == 0) { + log.Println("Allowing local network access by default") + f.AddLocalNetworks() + } + + return f.FilterHandler(handler) +} diff --git a/spread.yaml b/spread.yaml index 9cfca85f..86193742 100644 --- a/spread.yaml +++ b/spread.yaml @@ -30,6 +30,9 @@ backends: - ubuntu-core-16: username: test password: test + - ubuntu-core-16-embryonic: + # root user access; not a managed system yet + password: test # Put this somewhere where we have read-write access path: /home/snapweb @@ -41,6 +44,7 @@ exclude: - key.pem - node_modules - snapweb + - web-conf - tests - releases - generate_token @@ -58,3 +62,13 @@ suites: . $TESTSLIB/prepare.sh restore-each: | . $TESTSLIB/restore-each.sh + spread/webconf/: + summary: Full-system tests for the webconf variant of snapweb + environment: + FIRSTBOOT: yes + systems: + - ubuntu-core-16-embryonic + prepare: + . $TESTSLIB/prepare.sh + restore-each: | + . $TESTSLIB/restore-each.sh diff --git a/spread/image/create-image-embryonic.sh b/spread/image/create-image-embryonic.sh new file mode 100755 index 00000000..b68ec05a --- /dev/null +++ b/spread/image/create-image-embryonic.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# +# Copyright (C) 2017 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +set -e + +if [ $(id -u) -ne 0 ] ; then + echo "ERROR: needs to be executed as root" + exit 1 +fi + +channel=candidate +if [ ! -z "$1" ] ; then + channel=$1 +fi + +snap= +if [ ! -z "$2" ] ; then + snap=$2 +fi + +model=pc +arch=amd64 +image_name=ubuntu-core-16-embryonic.img +ubuntu_image_extra_args= + +if [ ! -z "$snap" ] ; then + ubuntu_image_extra_args="--extra-snaps $snap" +fi + +ubuntu-image \ + --channel $channel \ + -o $image_name \ + --image-size 4G \ + $ubuntu_image_extra_args \ + $model.model + +kpartx -a $image_name +sleep 0.5 + +loop_path=`findfs LABEL=writable` +tmp_mount=`mktemp -d` + +mount $loop_path $tmp_mount + +core_snap=$(find $tmp_mount/system-data/var/lib/snapd/snaps -name "core_*.snap") +tmp_core=`mktemp -d` +mount $core_snap $tmp_core +# copy over all systemd units +mkdir -p $tmp_mount/system-data/etc/systemd +cp -rav $tmp_core/etc/systemd/* \ + $tmp_mount/system-data/etc/systemd/ +# copy some more etc files into the writable area +mkdir -p $tmp_mount/system-data/etc/ssh +cp -av $tmp_core/etc/passwd $tmp_mount/system-data/etc/ +cp -av $tmp_core/etc/shadow $tmp_mount/system-data/etc/ +cp -av $tmp_core/etc/ssh/sshd_config $tmp_mount/system-data/etc/ssh +# mkdir -p $tmp_mount/system-data/var/lib/system-image +# cp -av $tmp_core/etc/system-image/writable-paths $tmp_mount/system-data/var/lib/system-image +umount $tmp_core +rm -rf $tmp_core + +# allow root user to ssh into the system without password +# test_pass=`python -c 'import crypt; print crypt.crypt("test","Fx")'` +# ie, FxhZ/XVdPJNZE +sed -i 's/root:x:/root:FxhZ\/XVdPJNZE:/' $tmp_mount/system-data/etc/passwd +sed -i 's/root:x:/root:\!:/' $tmp_mount/system-data/etc/shadow +sed -i 's/\(PermitRootLogin\)\>.*/\1 yes/' $tmp_mount/system-data/etc/ssh/sshd_config +# sed -i 's/\(PermitEmptyPasswords\)\>.*/\1 yes/' $tmp_mount/system-data/etc/ssh/sshd_config + +if false; then + mkdir -p $tmp_mount/system-data/var/lib/extrausers + echo 'test:FxhZ/XVdPJNZE:800:800:spread test:/root:/bin/bash' > $tmp_mount/system-data/var/lib/extrausers/passwd + echo 'test:!:16891:0:99999:7:::' > $tmp_mount/system-data/var/lib/extrausers/shadow + echo 'adm:x:4:syslog,test' > $tmp_mount/system-data/var/lib/extrausers/group + echo 'test:x:800:' >> $tmp_mount/system-data/var/lib/extrausers/group +fi + +# Create systemd service to run on firstboot and install our passwd/group overrides +if true; then + mkdir -p $tmp_mount/system-data/etc/systemd/system + cat << 'EOF' > $tmp_mount/system-data/etc/systemd/system/spread-access.service +[Unit] +Description=Configure spread access +After=networking.service + +[Service] +Type=oneshot +ExecStart=/writable/system-data/var/lib/spread-access/run.sh +RemainAfterExit=no + +[Install] +WantedBy=multi-user.target +EOF + + mkdir $tmp_mount/system-data/var/lib/spread-access + cat << 'EOF' > $tmp_mount/system-data/var/lib/spread-access/run.sh +#!/bin/bash + +set -e + +echo "Start spread-access $(date -Iseconds --utc)" + +# mount our special passwd/shadow files to permit spread access as root +mount --bind /writable/system-data/etc/passwd /etc/passwd +mount --bind /writable/system-data/etc/shadow /etc/shadow + +EOF + + chmod +x $tmp_mount/system-data/var/lib/spread-access/run.sh + + # install the new service + mkdir -p $tmp_mount/system-data/etc/systemd/system/multi-user.target.wants + ln -sf /etc/systemd/system/spread-access.service \ + $tmp_mount/system-data/etc/systemd/system/multi-user.target.wants/spread-access.service +fi + +umount $tmp_mount +kpartx -d $image_name +rm -rf $tmp_mount diff --git a/spread/lib/restore-each.sh b/spread/lib/restore-each.sh index 94030e1c..80b7c556 100644 --- a/spread/lib/restore-each.sh +++ b/spread/lib/restore-each.sh @@ -15,21 +15,23 @@ for snap in /snap/*; do esac done +# Depending on what the test did both services are not meant to be +# running here. +systemctl stop snap.snapweb.snapweb.service || true + # Cleanup all configuration files from the snap so that we have # a fresh start for the next test rm -rf /var/snap/$SNAP_NAME/common/* rm -rf /var/snap/$SNAP_NAME/current/* -# Depending on what the test did both services are not meant to be -# running here. -systemctl stop snap.snapweb.snapweb.service || true - # Ensure we have the same state for snapd as we had before -systemctl stop snapd.service snapd.socket -rm -rf /var/lib/snapd/* -tar xzf $SPREAD_PATH/snapd-state.tar.gz -C / -rm -rf /root/.snap -systemctl start snapd.service snapd.socket +if [ -f $SPREAD_PATH/snapd-state.tar.gz ]; then + systemctl stop snapd.service snapd.socket + rm -rf /var/lib/snapd/* + tar xzf $SPREAD_PATH/snapd-state.tar.gz -C / + rm -rf /root/.snap + systemctl start snapd.service snapd.socket +fi # Start services again now that the system is restored systemctl start snap.snapweb.snapweb.service diff --git a/spread/main/netfilter/task.yaml b/spread/main/netfilter/task.yaml new file mode 100644 index 00000000..4d78d9ae --- /dev/null +++ b/spread/main/netfilter/task.yaml @@ -0,0 +1,25 @@ +summary: Control that remote IP connections are not accepted +execute: | + echo "setting up test interface" + modprobe dummy + sleep 1 + + echo "add a network filter to the configuration" + ip link set name eth10 dev dummy0 + sleep 1 + ip address add 192.168.100.199/24 dev eth10 + ip link set eth10 up + + echo "Verifying that a remote request is not accepted" + echo "{ \"allowNetworks\": [\"127.0.0.1/24\"] }" > /var/snap/snapweb/common/settings.json + systemctl restart snap.snapweb.snapweb.service + sleep 1 + RES=`printf "GET / HTTP/1.1\nHost:localhost\nUser-Agent:nc\n\n" | nc -s 192.168.100.199 127.0.0.1 4200 | grep "HTTP/1.1 403 Forbidden"` + test -n "$RES" + + echo "cleanup" + ip address del 192.168.100.199/24 dev eth10 + ip link set eth10 down + rm /var/snap/snapweb/common/settings.json + systemctl restart snap.snapweb.snapweb.service + \ No newline at end of file diff --git a/spread/main/webconf-not/task.yaml b/spread/main/webconf-not/task.yaml new file mode 100644 index 00000000..5fa8f33a --- /dev/null +++ b/spread/main/webconf-not/task.yaml @@ -0,0 +1,22 @@ +summary: Verify that firstboot won't run on a managed system +systems: [-ubuntu-core-16-64, -ubuntu-core-16-arm-64, -ubuntu-core-16-arm-32] +environment: + SEED_DIR: /var/lib/snapd/seed +prepare: | + systemctl stop snapd.service + rm -f /var/lib/snapd/state.json +restore: | + systemctl start snapd.service +execute: | + echo "Start the daemon with an empty state, this will make it import " + echo "assertions from the $SEED_DIR/assertions subdirectory." + systemctl start snapd.service + + echo "The system should be managed already" + test `snap managed` + + echo "Verifying that webconf is NOT running" + test -z "`ps h -C webconf`" + + echo "Verifying that snapweb is running as normal" + test -n "`ps h -C snapweb`" diff --git a/spread/webconf/netfilter/task.yaml b/spread/webconf/netfilter/task.yaml new file mode 100644 index 00000000..0a2e438e --- /dev/null +++ b/spread/webconf/netfilter/task.yaml @@ -0,0 +1,25 @@ +summary: Control that remote IP connections are not accepted +execute: | + echo "setting up test interface" + modprobe dummy + sleep 1 + + echo "add a network filter to the configuration" + ip link set name eth10 dev dummy0 + sleep 1 + ip address add 192.168.100.199/24 dev eth10 + ip link set eth10 up + + echo "Verifying that a remote request is not accepted" + echo "{ \"allowNetworks\": [\"127.0.0.1/24\"] }" > /var/snap/snapweb/common/settings.json + systemctl restart snap.snapweb.webconf.service + sleep 1 + RES=`printf "GET / HTTP/1.1\nHost:localhost\nUser-Agent:nc\n\n" | nc -s 192.168.100.199 127.0.0.1 4200 | grep "HTTP/1.1 403 Forbidden"` + test -n "$RES" + + echo "cleanup" + ip address del 192.168.100.199/24 dev eth10 + ip link set eth10 down + rm /var/snap/snapweb/common/settings.json + systemctl restart snap.snapweb.webconf.service + \ No newline at end of file diff --git a/spread/webconf/transition-to-snapweb/task.yaml b/spread/webconf/transition-to-snapweb/task.yaml new file mode 100644 index 00000000..f65ecdec --- /dev/null +++ b/spread/webconf/transition-to-snapweb/task.yaml @@ -0,0 +1,20 @@ +summary: Verify that webconf will transition to snapweb once done +execute: | + echo "The system should not be managed yet" + test `snap managed` = 'false' + + echo "Finish the configuration" + snap create-user david.barth@canonical.com + printf "GET /done HTTP/1.1\nHost:localhost\nUser-Agent:nc\n\n" | nc localhost 4200 + + # wait a bit + sleep 2 + + echo "Verifying that snapweb now serves on port 4201" + printf "GET / HTTP/1.1\nHost:localhost\nUser-Agent:nc\n\n" | nc localhost 4201 + + echo "The system should be managed now" + test `snap managed` = 'true' + + echo "And webconf does not run anymore" + test -z "`ps h -C webconf`" diff --git a/spread/webconf/webconf-runs/task.yaml b/spread/webconf/webconf-runs/task.yaml new file mode 100644 index 00000000..8be1261c --- /dev/null +++ b/spread/webconf/webconf-runs/task.yaml @@ -0,0 +1,11 @@ +summary: Verify that webconf will start on an un-managed system by default +execute: | + echo "The system should not be managed yet" + test `snap managed` = 'false' + + echo "Verifying that webconf is running" + test -n "`ps h -C webconf`" + + echo "Verifying that webconf is controlling port 4200" + printf "GET / HTTP/1.1\nHost:localhost\nUser-Agent:nc\n\n" | nc localhost 4200 | grep "script src" | grep webconf.js + diff --git a/tests/kvm-exec.sh b/tests/kvm-exec.sh index 4c111f90..635c4836 100755 --- a/tests/kvm-exec.sh +++ b/tests/kvm-exec.sh @@ -34,7 +34,7 @@ start_vm() { qemu-system-x86_64 \ -enable-kvm -snapshot \ -m 500 \ - -net nic -net user,hostfwd=tcp::$PORT_SSH-:22,hostfwd=tcp::4201-:4201 \ + -net nic -net user,hostfwd=tcp::$PORT_SSH-:22,hostfwd=tcp::4200-:4200,hostfwd=tcp::4201-:4201 \ -drive file=$IMAGE_BOOTABLE,format=raw \ -pidfile $FILE_PID \ -monitor unix:$FILE_MONITOR,server,nowait \ diff --git a/tests/remote-install-snap.sh b/tests/remote-install-snap.sh index 4a81da5b..8f132b7f 100755 --- a/tests/remote-install-snap.sh +++ b/tests/remote-install-snap.sh @@ -11,7 +11,9 @@ fi snap_name="${snap##*/}" -SSH_OPTS="-o StrictHostKeyChecking=no" +# ssh-keygen -f "~/.ssh/known_hosts" -R [localhost]:8022 + +SSH_OPTS="-o StrictHostKeyChecking=no -o PreferredAuthentications=\"password\"" SSH_OPTS="$SSH_OPTS -p $port $user@$host" ssh ${SSH_OPTS} "if [ -d tmpsnaps ]; then rm -rf tmpsnaps; fi; mkdir tmpsnaps;" diff --git a/www/src/css/styles.scss b/www/src/css/styles.scss index 504ea036..d1caddd8 100644 --- a/www/src/css/styles.scss +++ b/www/src/css/styles.scss @@ -67,3 +67,19 @@ hr { transform: rotate(360deg); } } +// webconf +.region-webconf .inner-wrapper { + background-color: $color-light; +} +.region-webconf .p-card--highlighted { + margin-bottom: 1em; +} + +.p-navigation { + margin-bottom: 0em; +} + +label { + margin-bottom: 0.5em; + display: inline-block; +} diff --git a/www/src/js/controllers/init.js b/www/src/js/controllers/webconf.js similarity index 89% rename from www/src/js/controllers/init.js rename to www/src/js/controllers/webconf.js index 113a8d97..02726dde 100644 --- a/www/src/js/controllers/init.js +++ b/www/src/js/controllers/webconf.js @@ -3,7 +3,7 @@ var Backbone = require('backbone'); Backbone.$ = $; var Marionette = require('backbone.marionette'); var Radio = require('backbone.radio'); -var InitLayoutView = require('../views/init.js'); +var InitLayoutView = require('../views/webconf.js'); var CreateUserModel = require('../models/create-user.js'); module.exports = { diff --git a/www/src/js/models/create-user.js b/www/src/js/models/create-user.js index bd2623ae..358faa87 100644 --- a/www/src/js/models/create-user.js +++ b/www/src/js/models/create-user.js @@ -2,10 +2,9 @@ var Backbone = require('backbone'); var Marionette = require('backbone.marionette'); -var CONFIG = require('../config.js'); module.exports = Backbone.Model.extend({ - url: CONFIG.CREATE_USER, + url: '/api/v2/create-user', // forces POST requests on every model update isNew: function() { @@ -21,5 +20,5 @@ module.exports = Backbone.Model.extend({ return 'Invalid email'; } }, - + }); diff --git a/www/src/js/routers/router.js b/www/src/js/routers/router.js index cb05d7df..51f393cf 100644 --- a/www/src/js/routers/router.js +++ b/www/src/js/routers/router.js @@ -4,7 +4,6 @@ var Backbone = require('backbone'); var Marionette = require('backbone.marionette'); var homeController = require('../controllers/home.js'); -// var initController = require('../controllers/init.js'); var searchController = require('../controllers/search.js'); var storeController = require('../controllers/store.js'); var settingsController = require('../controllers/settings.js'); diff --git a/www/src/js/routers/webconf-router.js b/www/src/js/routers/webconf-router.js new file mode 100644 index 00000000..e68f9bf8 --- /dev/null +++ b/www/src/js/routers/webconf-router.js @@ -0,0 +1,17 @@ +// webconf-router.js + +var Backbone = require('backbone'); +var Marionette = require('backbone.marionette'); + +var webconfController = require('../controllers/webconf.js'); + +module.exports = { + + home: new Marionette.AppRouter({ + controller: webconfController, + appRoutes: { + '': 'index' + } + }), + +}; diff --git a/www/src/js/templates/webconf-layout.hbs b/www/src/js/templates/webconf-layout.hbs new file mode 100644 index 00000000..a778096b --- /dev/null +++ b/www/src/js/templates/webconf-layout.hbs @@ -0,0 +1,17 @@ +
+
+ +
+
+ diff --git a/www/src/js/templates/first-boot.hbs b/www/src/js/templates/webconf.hbs similarity index 70% rename from www/src/js/templates/first-boot.hbs rename to www/src/js/templates/webconf.hbs index bae50ab7..ac84342d 100644 --- a/www/src/js/templates/first-boot.hbs +++ b/www/src/js/templates/webconf.hbs @@ -1,23 +1,16 @@ -
+
-

Ubuntu Core

-

- - Setup an administrator account on this all-snap Ubuntu Core system. After this setup process you will have secure web or command access to the system. -

+

Ubuntu Core

+

+ Setup an administrator account on this all-snap Ubuntu Core system. After this setup process you will have secure web or command access to the system. +

+
+

Create an account

-
-
-

- - -   - - -

+

Create a local system user with the username and SSH keys registered on the store account identified by the provided email address.

@@ -36,7 +29,6 @@

-
@@ -56,7 +48,7 @@ ssh {{username}}@{{ipaddress}}

- + Manage your device

diff --git a/www/src/js/views/init.js b/www/src/js/views/create-user.js similarity index 89% rename from www/src/js/views/init.js rename to www/src/js/views/create-user.js index ac0bf3d3..2afbce55 100644 --- a/www/src/js/views/init.js +++ b/www/src/js/views/create-user.js @@ -9,8 +9,8 @@ module.exports = Backbone.Marionette.LayoutView.extend({ className: 'b-layout__container', ui: { - statusmessage: '.statusmessage', - btncreate: '.btn-create', + statusmessage: ".statusmessage", + btncreate: ".btn-create", }, events: { @@ -30,8 +30,8 @@ module.exports = Backbone.Marionette.LayoutView.extend({ this.ui.statusmessage.show(); }, 'success': function(model, response) { - this.model.set({ipaddress: location.hostname}); - this.model.set({username: response.result.username}); + this.model.set({ ipaddress: location.hostname }); + this.model.set({ username: response.result.username }); this.$('#firstboot-step-1').hide(); this.$('#firstboot-step-2').show(); }, diff --git a/www/src/js/views/webconf-layout.js b/www/src/js/views/webconf-layout.js new file mode 100644 index 00000000..03723868 --- /dev/null +++ b/www/src/js/views/webconf-layout.js @@ -0,0 +1,53 @@ +// webconf layout view +var $ = require('jquery'); +var _ = require('lodash'); +var Backbone = require('backbone'); +Backbone.$ = $; +var Marionette = require('backbone.marionette'); +var React = require('react'); +var ReactDOM = require('react-dom'); +var Radio = require('backbone.radio'); +var FooterView = require('./layout-footer.js'); +var NotificationsView = require('./alerts.js'); +var template = require('../templates/webconf-layout.hbs'); +var chan = Radio.channel('root'); + +module.exports = Marionette.LayoutView.extend({ + + initialize: function() { + chan.comply('set:content', this.setContent, this); + chan.comply('alert:error', this.alertError, this); + }, + + el: '.b-layout', + + template : function() { + return template(); + }, + + onRender: function() { + this.showChildView('footerRegion', new FooterView()); + }, + + setContent: function(content) { + var reactElement = content.reactElement || null; + if (reactElement !== null) { + ReactDOM.render(reactElement, this.$('.b-layout__main').get(0)); + } else { + this.mainRegion.show(content.backboneView); + } + }, + + alertError: function(model) { + this.showChildView('alertsRegion', new NotificationsView({ + model: model + })); + }, + + regions: { + bannerRegion: '.b-layout__banner', + mainRegion: '.b-layout__main', + footerRegion: '.b-layout__footer', + alertsRegion: '.b-layout__alerts' + } +}); diff --git a/www/src/js/views/webconf.js b/www/src/js/views/webconf.js new file mode 100644 index 00000000..6dcfa644 --- /dev/null +++ b/www/src/js/views/webconf.js @@ -0,0 +1,73 @@ +var Backbone = require('backbone'); +var Marionette = require('backbone.marionette'); +var template = require('../templates/webconf.hbs'); + +module.exports = Backbone.Marionette.LayoutView.extend({ + + className: 'b-layout__container', + + ui: { + statusmessage: '.statusmessage', + btncreate: '.btn-create', + }, + + events: { + 'click #btn-create': 'handleCreate', + }, + + modelEvents: { + 'status-update': function(msg) { + this.ui.statusmessage.html(msg); + this.ui.statusmessage.removeClass('has-error'); + this.ui.statusmessage.removeClass('has-warning'); + this.ui.statusmessage.show(); + }, + 'invalid': function(model, error) { + this.ui.statusmessage.text(error); + this.ui.statusmessage.addClass('has-error'); + this.ui.statusmessage.show(); + }, + 'success': function(model, response) { + this.model.set({ipaddress: location.hostname}); + this.model.set({username: response.result.username}); + this.$('#firstboot-step-1').hide(); + this.$('#firstboot-step-2').show(); + }, + 'change': function() { + this.render(); + }, + }, + + handleCreate: function(event) { + event.preventDefault(); + this.model.set({ + email: this.$('#emailSSO').val(), + sudoer: true, + }); + if (this.model.isValid()) { + this.model.trigger('status-update', 'Contacting store...'); // via snapd... + this.model.save({}, { + success: function(model, response) { + model.trigger('success', model, response); + }, + error: function(model, response) { + var message = ""; + var resp = eval(response); + if (resp && resp.responseJSON && + resp.responseJSON.result) { + message = resp.responseJSON.result.message; + } else { + message = response.responseText; + } + + model.trigger('invalid', model, message); + } + }); + } + }, + + template : function(model) { + return template(model); + }, + +}); diff --git a/www/src/js/webconf-app.js b/www/src/js/webconf-app.js new file mode 100644 index 00000000..c1a343a9 --- /dev/null +++ b/www/src/js/webconf-app.js @@ -0,0 +1,26 @@ +// webconf-app.js +'use strict'; + +var $ = require('jquery'); +var Backbone = require('backbone'); +Backbone.$ = $; +var Marionette = require('backbone.marionette'); +var Radio = require('backbone.radio'); + +if (window.__agent) { + window.__agent.start(Backbone, Marionette); +} +var LayoutView = require('./views/webconf-layout.js'); +var router = require('./routers/webconf-router.js'); + +var webconf = new Marionette.Application(); +var layout = new LayoutView(); +layout.render(); + +$(document).ready(function() { + webconf.start(); +}); + +webconf.on('start', function() { + Backbone.history.start({pushState: true}); +}); diff --git a/www/templates/webconf.html b/www/templates/webconf.html new file mode 100644 index 00000000..60675f2d --- /dev/null +++ b/www/templates/webconf.html @@ -0,0 +1,24 @@ + + + + web-conf + + + + + + +
+
+
+ +
+ + + + diff --git a/www/tests/firstBootSpec.js b/www/tests/webconfSpec.js similarity index 77% rename from www/tests/firstBootSpec.js rename to www/tests/webconfSpec.js index eed45c3a..bb653b8f 100644 --- a/www/tests/firstBootSpec.js +++ b/www/tests/webconfSpec.js @@ -1,33 +1,27 @@ var _ = require('lodash'); var CreateUser = require('../src/js/models/create-user.js'); var Backbone = require('backbone'); -var InitView = require('../src/js/views/init.js'); -var CONF = require('../src/js/config.js'); +var MainView = require('../src/js/views/webconf.js'); -describe('FirstBoot', function() { +describe('WebConf', function() { describe('create-user model', function() { beforeEach(function() { - jasmine.Ajax.install(); this.model = new CreateUser({}); spyOn(this.model, 'save').and.callThrough(); spyOn(this.model, 'validate').and.callThrough(); }); afterEach(function() { - jasmine.Ajax.uninstall(); - delete this.model; }); it('should be an instance of Backbone.Model', function() { - expect(CreateUser).toBeDefined(); expect(this.model).toEqual(jasmine.any(Backbone.Model)); }); - it('should have urlRoot prop from config', function() { - var expectedPath = CONF.CREATE_USER; - expect(this.model.url).toBe(CONF.CREATE_USER); + it('should have urlRoot prop', function() { + expect(this.model.url).toBe('/api/v2/create-user'); }); it('should block empty or invalid email', function() { @@ -44,11 +38,11 @@ describe('FirstBoot', function() { }); }); - describe('InitView', function() { + describe('MainView', function() { beforeEach(function() { this.model = new CreateUser({}); - this.view = new InitView({ + this.view = new MainView({ model: this.model }); this.view.render(); @@ -65,7 +59,7 @@ describe('FirstBoot', function() { }); it('should be an instance of Backbone.View', function() { - expect(InitView).toBeDefined(); + expect(MainView).toBeDefined(); expect(this.view).toEqual(jasmine.any(Backbone.View)); });