aboutsummaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/common.go75
-rw-r--r--internal/handlers/events.go36
-rw-r--r--internal/handlers/ipxemenu.go64
-rw-r--r--internal/handlers/middleware.go110
-rw-r--r--internal/handlers/polling.go161
-rw-r--r--internal/handlers/static.go127
-rw-r--r--internal/handlers/templates.go83
7 files changed, 656 insertions, 0 deletions
diff --git a/internal/handlers/common.go b/internal/handlers/common.go
new file mode 100644
index 0000000..99511e2
--- /dev/null
+++ b/internal/handlers/common.go
@@ -0,0 +1,75 @@
+// Copyright 2018 ThousandEyes Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+
+ "github.com/thousandeyes/shoelaces/internal/environment"
+ "github.com/thousandeyes/shoelaces/internal/ipxe"
+ "github.com/thousandeyes/shoelaces/internal/mappings"
+)
+
+// DefaultTemplateRenderer holds information for rendering a template based
+// on its name. It implements the http.Handler interface.
+type DefaultTemplateRenderer struct {
+ templateName string
+}
+
+// RenderDefaultTemplate renders a template by the given name
+func RenderDefaultTemplate(name string) *DefaultTemplateRenderer {
+ return &DefaultTemplateRenderer{templateName: name}
+}
+
+func (t *DefaultTemplateRenderer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ env := envFromRequest(r)
+ tpl := env.StaticTemplates
+ // XXX: Probably not ideal as it's doing the directory listing on every request
+ ipxeScripts := ipxe.ScriptList(env)
+ tplVars := struct {
+ BaseURL string
+ HostnameMaps *[]mappings.HostnameMap
+ NetworkMaps *[]mappings.NetworkMap
+ Scripts *[]ipxe.Script
+ }{
+ env.BaseURL,
+ &env.HostnameMaps,
+ &env.NetworkMaps,
+ &ipxeScripts,
+ }
+ renderTemplate(w, tpl, "header", tplVars)
+ renderTemplate(w, tpl, t.templateName, tplVars)
+ renderTemplate(w, tpl, "footer", tplVars)
+}
+
+func renderTemplate(w http.ResponseWriter, tpl *template.Template, tmpl string, d interface{}) {
+ err := tpl.ExecuteTemplate(w, tmpl, d)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func envFromRequest(r *http.Request) *environment.Environment {
+ return r.Context().Value(ShoelacesEnvCtxID).(*environment.Environment)
+}
+
+func envNameFromRequest(r *http.Request) string {
+ e := r.Context().Value(ShoelacesEnvNameCtxID)
+ if e != nil {
+ return e.(string)
+ }
+ return ""
+}
diff --git a/internal/handlers/events.go b/internal/handlers/events.go
new file mode 100644
index 0000000..f794343
--- /dev/null
+++ b/internal/handlers/events.go
@@ -0,0 +1,36 @@
+// Copyright 2018 ThousandEyes Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+)
+
+// ListEvents returns a JSON list of the logged events.
+func ListEvents(w http.ResponseWriter, r *http.Request) {
+ // Get Environment and convert the EventLog to JSON
+ env := envFromRequest(r)
+ eventList, err := json.Marshal(env.EventLog.Events)
+ if err != nil {
+ env.Logger.Error("component", "handler", "err", err)
+ os.Exit(1)
+ }
+
+ //Write the EventLog and send the HTTP response
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(eventList)
+}
diff --git a/internal/handlers/ipxemenu.go b/internal/handlers/ipxemenu.go
new file mode 100644
index 0000000..0ce8a5a
--- /dev/null
+++ b/internal/handlers/ipxemenu.go
@@ -0,0 +1,64 @@
+// Copyright 2018 ThousandEyes Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+
+ "github.com/thousandeyes/shoelaces/internal/ipxe"
+)
+
+const menuHeader = "#!ipxe\n" +
+ "chain /poll/1/${netX/mac:hexhyp}\n" +
+ "menu Choose target to boot\n"
+
+const menuFooter = "\n" +
+ "choose target\n" +
+ "echo -n Enter hostname or none:\n" +
+ "read hostname\n" +
+ "set baseurl %s\n" +
+ "# Boot it as intended.\n" +
+ "chain ${target}\n"
+
+// IPXEMenu serves the ipxe menu with list of all available scripts
+func IPXEMenu(w http.ResponseWriter, r *http.Request) {
+ env := envFromRequest(r)
+
+ scripts := ipxe.ScriptList(env)
+ if len(scripts) == 0 {
+ http.Error(w, "No Scripts Found", http.StatusInternalServerError)
+ return
+ }
+
+ var bootItemsBuffer bytes.Buffer
+ //Creates the top portion of the iPXE menu
+ bootItemsBuffer.WriteString(menuHeader)
+ for _, s := range scripts {
+ //Formats the bootable scripts separated by newlines into a single string
+ var desc string
+ if len(s.Env) > 0 {
+ desc = fmt.Sprintf("%s [%s]", s.Name, s.Env)
+ } else {
+ desc = string(s.Name)
+ }
+ bootItem := fmt.Sprintf("item %s%s %s\n", s.Path, s.Name, desc)
+ bootItemsBuffer.WriteString(bootItem)
+ }
+ //Creates the bottom portion of the iPXE menu
+ bootItemsBuffer.WriteString(fmt.Sprintf(menuFooter, env.BaseURL))
+ w.Write(bootItemsBuffer.Bytes())
+}
diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go
new file mode 100644
index 0000000..9525efb
--- /dev/null
+++ b/internal/handlers/middleware.go
@@ -0,0 +1,110 @@
+// Copyright 2018 ThousandEyes Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "context"
+ "github.com/justinas/alice"
+ "net/http"
+ "regexp"
+
+ "github.com/thousandeyes/shoelaces/internal/environment"
+)
+
+// ShoelacesCtxID Shoelaces Specific Request Context ID.
+type ShoelacesCtxID int
+
+// ShoelacesEnvCtxID is the context id key for the shoelaces.Environment.
+const ShoelacesEnvCtxID ShoelacesCtxID = 0
+
+// ShoelacesEnvNameCtxID is the context ID key for the chosen environment.
+const ShoelacesEnvNameCtxID ShoelacesCtxID = 1
+
+var envRe = regexp.MustCompile(`^(:?/env\/([a-zA-Z0-9_-]+))?(\/.*)`)
+
+// environmentMiddleware Rewrites the URL in case it was an environment
+// specific and sets the environment in the context.
+func environmentMiddleware(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var reqEnv string
+ m := envRe.FindStringSubmatch(r.URL.Path)
+ if len(m) > 0 && m[2] != "" {
+ r.URL.Path = m[3]
+ reqEnv = m[2]
+ }
+ ctx := context.WithValue(r.Context(), ShoelacesEnvNameCtxID, reqEnv)
+ h.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// loggingMiddleware adds an entry to the logger each time the HTTP service
+// receives a request.
+func loggingMiddleware(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ logger := envFromRequest(r).Logger
+
+ logger.Info("component", "http", "type", "request", "src", r.RemoteAddr, "method", r.Method, "url", r.URL)
+ h.ServeHTTP(w, r)
+ })
+}
+
+// SecureHeaders adds secure headers to the responses
+func secureHeadersMiddleware(h http.Handler) http.Handler {
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Add X-XSS-Protection header
+ w.Header().Add("X-XSS-Protection", "1; mode=block")
+
+ // Add X-Content-Type-Options header
+ w.Header().Add("X-Content-Type-Options", "nosniff")
+
+ // Prevent page from being displayed in an iframe
+ w.Header().Add("X-Frame-Options", "DENY")
+
+ // Prevent page from being displayed in an iframe
+ w.Header().Add("Content-Security-Policy", "script-src 'self'")
+
+ h.ServeHTTP(w, r)
+ })
+}
+
+// disableCacheMiddleware sets a header for disabling HTTP caching
+func disableCacheMiddleware(h http.Handler) http.Handler {
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Cache-Control", "no-cache, no-store, must-revalidate")
+
+ h.ServeHTTP(w, r)
+ })
+}
+
+// MiddlewareChain receives a Shoelaces environment and returns a chains of
+// middlewares to apply to every request.
+func MiddlewareChain(env *environment.Environment) alice.Chain {
+ // contextMiddleware sets the environment key in the request Context.
+ contextMiddleware := func(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := context.WithValue(r.Context(), ShoelacesEnvCtxID, env)
+ h.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+
+ return alice.New(
+ secureHeadersMiddleware,
+ disableCacheMiddleware,
+ environmentMiddleware,
+ contextMiddleware,
+ loggingMiddleware)
+}
diff --git a/internal/handlers/polling.go b/internal/handlers/polling.go
new file mode 100644
index 0000000..12f36e2
--- /dev/null
+++ b/internal/handlers/polling.go
@@ -0,0 +1,161 @@
+// Copyright 2018 ThousandEyes Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+
+ "github.com/gorilla/mux"
+ "github.com/thousandeyes/shoelaces/internal/log"
+ "github.com/thousandeyes/shoelaces/internal/polling"
+ "github.com/thousandeyes/shoelaces/internal/server"
+ "github.com/thousandeyes/shoelaces/internal/utils"
+)
+
+// PollHandler is called by iPXE boot agents. It returns the boot script
+// specified on the configuration or, if the host is unknown, it makes it
+// retry for a while until the user specifies alternative IPXE boot script.
+func PollHandler(w http.ResponseWriter, r *http.Request) {
+ env := envFromRequest(r)
+
+ ip, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ vars := mux.Vars(r)
+ // iPXE MAC addresses come with dashes instead of colons
+ mac := utils.MacDashToColon(vars["mac"])
+ host := r.FormValue("host")
+
+ err = validateMACAndIP(env.Logger, mac, ip)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if host == "" {
+ host = resolveHostname(env.Logger, ip)
+ }
+
+ server := server.New(mac, ip, host)
+ script, err := polling.Poll(
+ env.Logger, env.ServerStates, env.HostnameMaps, env.NetworkMaps,
+ env.EventLog, env.Templates, env.BaseURL, server)
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Write([]byte(script))
+}
+
+// ServerListHandler provides a list of the servers that tried to boot
+// but did not match the hostname regex or network mappings.
+func ServerListHandler(w http.ResponseWriter, r *http.Request) {
+ env := envFromRequest(r)
+
+ servers, err := json.Marshal(polling.ListServers(env.ServerStates))
+ if err != nil {
+ env.Logger.Error("component", "handler", "err", err)
+ os.Exit(1)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(servers)
+}
+
+// UpdateTargetHandler is a POST endpoint that receives parameters for
+// booting manually.
+func UpdateTargetHandler(w http.ResponseWriter, r *http.Request) {
+ env := envFromRequest(r)
+
+ ip, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ mac, scriptName, environment, params := parsePostForm(r.PostForm)
+ if mac == "" || scriptName == "" {
+ http.Error(w, "MAC address and target must not be empty", http.StatusBadRequest)
+ return
+ }
+
+ server := server.New(mac, ip, "")
+ inputErr, err := polling.UpdateTarget(
+ env.Logger, env.ServerStates, env.Templates, env.EventLog, env.BaseURL, server,
+ scriptName, environment, params)
+
+ if err != nil {
+ if inputErr {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ } else {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusFound)
+}
+
+func parsePostForm(form map[string][]string) (mac, scriptName, environment string, params map[string]interface{}) {
+ params = make(map[string]interface{})
+ for k, v := range form {
+ if k == "mac" {
+ mac = utils.MacDashToColon(v[0])
+ } else if k == "target" {
+ scriptName = v[0]
+ } else if k == "environment" {
+ environment = v[0]
+ } else {
+ params[k] = v[0]
+ }
+ }
+ return
+}
+
+func validateMACAndIP(logger log.Logger, mac string, ip string) (err error) {
+ if !utils.IsValidMAC(mac) {
+ logger.Error("component", "polling", "msg", "Invalid MAC", "mac", mac)
+ return fmt.Errorf("%s", "Invalid MAC")
+ }
+
+ if !utils.IsValidIP(ip) {
+ logger.Error("component", "polling", "msg", "Invalid IP", "ip", ip)
+ return fmt.Errorf("%s", "Invalid IP")
+ }
+
+ logger.Debug("component", "polling", "msg", "MAC and IP validated", "mac", mac, "ip", ip)
+
+ return nil
+}
+
+func resolveHostname(logger log.Logger, ip string) string {
+ host := utils.ResolveHostname(ip)
+ if host == "" {
+ logger.Info("component", "polling", "msg", "Can't resolve IP", "ip", ip)
+ }
+
+ return host
+}
diff --git a/internal/handlers/static.go b/internal/handlers/static.go
new file mode 100644
index 0000000..b27fa1a
--- /dev/null
+++ b/internal/handlers/static.go
@@ -0,0 +1,127 @@
+// Copyright 2018 ThousandEyes Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+)
+
+// StaticConfigFileHandler handles static config files
+type StaticConfigFileHandler struct{}
+
+func (s *StaticConfigFileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ env := envFromRequest(r)
+ envName := envNameFromRequest(r)
+ basePath := path.Join(env.DataDir, "static")
+ if envName == "" {
+ http.FileServer(http.Dir(basePath)).ServeHTTP(w, r)
+ return
+ }
+ envPath := filepath.Join(env.DataDir, env.EnvDir, envName, "static")
+ OverlayFileServer(envPath, basePath).ServeHTTP(w, r)
+}
+
+// StaticConfigFileServer returns a StaticConfigFileHandler instance implementing http.Handler
+func StaticConfigFileServer() *StaticConfigFileHandler {
+ return &StaticConfigFileHandler{}
+}
+
+// OverlayFileServerHandler handles request for overlayer directories
+type OverlayFileServerHandler struct {
+ upper string
+ lower string
+}
+
+// OverlayFileServer serves static content from two overlayed directories
+func OverlayFileServer(upper, lower string) *OverlayFileServerHandler {
+ return &OverlayFileServerHandler{
+ upper: upper,
+ lower: lower,
+ }
+}
+
+func (o *OverlayFileServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+
+ fp := filepath.Clean(r.URL.Path)
+ upper := filepath.Clean(path.Join(o.upper, fp))
+ lower := filepath.Clean(path.Join(o.lower, fp))
+
+ // TODO: try to avoid stat()-ing both if not necessary
+ infoUpper, errUpper := os.Stat(upper)
+ infoLower, errLower := os.Stat(lower)
+
+ // If both upper and lower files/dirs do not exist, return 404
+ if errUpper != nil && os.IsNotExist(errUpper) &&
+ errLower != nil && os.IsNotExist(errLower) {
+ http.NotFound(w, r)
+ return
+ }
+
+ isDir := false
+ fileList := make(map[string]os.FileInfo)
+
+ if errUpper == nil && infoUpper.IsDir() {
+ files, _ := ioutil.ReadDir(upper)
+ for _, f := range files {
+ fileList[f.Name()] = f
+ }
+ isDir = true
+ }
+ if errLower == nil && infoLower.IsDir() {
+ files, _ := ioutil.ReadDir(lower)
+ for _, f := range files {
+ if _, ok := fileList[f.Name()]; !ok {
+ fileList[f.Name()] = f
+ }
+ }
+ isDir = true
+ }
+
+ // Generate HTML directory index
+ if isDir {
+ fileListIndex := []string{}
+ for i := range fileList {
+ fileListIndex = append(fileListIndex, i)
+ }
+ sort.Strings(fileListIndex)
+ w.Write([]byte("<pre>\n"))
+ for _, i := range fileListIndex {
+ f := fileList[i]
+ name := f.Name()
+ if f.IsDir() {
+ name = name + "/"
+ }
+ l := fmt.Sprintf("<a href=\"%s\">%s</a>\n", name, name)
+ w.Write([]byte(l))
+ }
+ w.Write([]byte("</pre>\n"))
+ return
+ }
+
+ // Serve the file from the upper layer if it exists.
+ if errUpper == nil {
+ http.ServeFile(w, r, upper)
+ // If not serve it from the lower
+ } else if errLower == nil {
+ http.ServeFile(w, r, lower)
+ }
+ http.NotFound(w, r)
+}
diff --git a/internal/handlers/templates.go b/internal/handlers/templates.go
new file mode 100644
index 0000000..df1bc58
--- /dev/null
+++ b/internal/handlers/templates.go
@@ -0,0 +1,83 @@
+// Copyright 2018 ThousandEyes Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/thousandeyes/shoelaces/internal/utils"
+)
+
+// TemplateHandler is the dynamic configuration provider endpoint. It
+// receives a key and maybe an environment.
+func TemplateHandler(w http.ResponseWriter, r *http.Request) {
+ variablesMap := map[string]interface{}{}
+ configName := mux.Vars(r)["key"]
+
+ if configName == "" {
+ http.Error(w, "No template name provided", http.StatusNotFound)
+ return
+ }
+
+ for key, val := range r.URL.Query() {
+ variablesMap[key] = val[0]
+ }
+
+ env := envFromRequest(r)
+ envName := envNameFromRequest(r)
+ variablesMap["baseURL"] = utils.BaseURLforEnvName(env.BaseURL, envName)
+
+ configString, err := env.Templates.RenderTemplate(env.Logger, configName, variablesMap, envName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ } else {
+ io.WriteString(w, configString)
+ }
+}
+
+// GetTemplateParams receives a script name and returns the parameters
+// required for completing that template.
+func GetTemplateParams(w http.ResponseWriter, r *http.Request) {
+ var vars []string
+ env := envFromRequest(r)
+
+ filterBlacklist := func(s string) bool {
+ return !utils.StringInSlice(s, env.ParamsBlacklist)
+ }
+
+ script := r.URL.Query().Get("script")
+ if script == "" {
+ http.Error(w, "Required script parameter", http.StatusInternalServerError)
+ return
+ }
+
+ envName := r.URL.Query().Get("environment")
+ if envName == "" {
+ envName = "default"
+ }
+
+ vars = utils.Filter(env.Templates.ListVariables(script, envName), filterBlacklist)
+
+ marshaled, err := json.Marshal(vars)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(marshaled)
+}
nihil fit ex nihilo