diff --git a/.gitignore b/.gitignore index dab6b83732..7f60a02ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ logs node_modules dist npm-debug.log +bin web/static/js/bundle*.js web/static/js/bundle*.js.map diff --git a/Makefile b/Makefile index 9fd74b959f..49bf7dc2d9 100644 --- a/Makefile +++ b/Makefile @@ -239,6 +239,7 @@ clean: stop-docker rm -f .prepare-go .prepare-jsx nuke: | clean clean-docker + rm -rf bin rm -rf data .prepare-go: @@ -266,6 +267,9 @@ run: start-docker .prepare-go .prepare-jsx jq -s '.[0] * .[1]' ./config/config.json $(ENTERPRISE_DIR)/config/enterprise-config-additions.json > config.json.tmp; \ mv config.json.tmp ./config/config.json; \ sed -e '/\/\/ENTERPRISE_IMPORTS/ {' -e 'r $(ENTERPRISE_DIR)/imports' -e 'd' -e '}' -i'.bak' mattermost.go; \ + sed -i'.bak' 's|_BUILD_ENTERPRISE_READY_|true|g' ./model/version.go; \ + else \ + sed -i'.bak' 's|_BUILD_ENTERPRISE_READY_|false|g' ./model/version.go; \ fi @echo Starting go web server @@ -299,6 +303,7 @@ stop: @if [ "$(BUILD_ENTERPRISE)" = "true" ] && [ -d "$(ENTERPRISE_DIR)" ]; then \ mv ./config/config.json.bak ./config/config.json 2> /dev/null || true; \ mv ./mattermost.go.bak ./mattermost.go 2> /dev/null || true; \ + mv ./model/version.go.bak ./model/version.go 2> /dev/null || true; \ fi setup-mac: diff --git a/api/api.go b/api/api.go index a6bb229823..f29063fe1f 100644 --- a/api/api.go +++ b/api/api.go @@ -46,6 +46,7 @@ func InitApi() { InitOAuth(r) InitWebhook(r) InitPreference(r) + InitLicense(r) templatesDir := utils.FindDir("api/templates") l4g.Debug("Parsing server templates at %v", templatesDir) diff --git a/api/context.go b/api/context.go index 561884c145..e8ec6576d0 100644 --- a/api/context.go +++ b/api/context.go @@ -35,6 +35,7 @@ type Page struct { TemplateName string Props map[string]string ClientCfg map[string]string + ClientLicense map[string]string User *model.User Team *model.Team Channel *model.Channel diff --git a/api/file.go b/api/file.go index d023515af1..46e81691e3 100644 --- a/api/file.go +++ b/api/file.go @@ -541,12 +541,8 @@ func writeFile(f []byte, path string) *model.AppError { return model.NewAppError("writeFile", "Encountered an error writing to S3", err.Error()) } } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL { - if err := os.MkdirAll(filepath.Dir(utils.Cfg.FileSettings.Directory+path), 0774); err != nil { - return model.NewAppError("writeFile", "Encountered an error creating the directory for the new file", err.Error()) - } - - if err := ioutil.WriteFile(utils.Cfg.FileSettings.Directory+path, f, 0644); err != nil { - return model.NewAppError("writeFile", "Encountered an error writing to local server storage", err.Error()) + if err := writeFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil { + return err } } else { return model.NewAppError("writeFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") @@ -555,6 +551,18 @@ func writeFile(f []byte, path string) *model.AppError { return nil } +func writeFileLocally(f []byte, path string) *model.AppError { + if err := os.MkdirAll(filepath.Dir(path), 0774); err != nil { + return model.NewAppError("writeFile", "Encountered an error creating the directory for the new file", err.Error()) + } + + if err := ioutil.WriteFile(path, f, 0644); err != nil { + return model.NewAppError("writeFile", "Encountered an error writing to local server storage", err.Error()) + } + + return nil +} + func readFile(path string) ([]byte, *model.AppError) { if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 { diff --git a/api/license.go b/api/license.go new file mode 100644 index 0000000000..9ed2d2afbe --- /dev/null +++ b/api/license.go @@ -0,0 +1,95 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "bytes" + l4g "code.google.com/p/log4go" + "github.com/gorilla/mux" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + "io" + "net/http" + "strings" +) + +func InitLicense(r *mux.Router) { + l4g.Debug("Initializing license api routes") + + sr := r.PathPrefix("/license").Subrouter() + sr.Handle("/add", ApiAdminSystemRequired(addLicense)).Methods("POST") + sr.Handle("/remove", ApiAdminSystemRequired(removeLicense)).Methods("POST") +} + +func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { + c.LogAudit("attempt") + err := r.ParseMultipartForm(model.MAX_FILE_SIZE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + m := r.MultipartForm + + fileArray, ok := m.File["license"] + if !ok { + c.Err = model.NewAppError("addLicense", "No file under 'license' in request", "") + c.Err.StatusCode = http.StatusBadRequest + return + } + + if len(fileArray) <= 0 { + c.Err = model.NewAppError("addLicense", "Empty array under 'license' in request", "") + c.Err.StatusCode = http.StatusBadRequest + return + } + + fileData := fileArray[0] + + file, err := fileData.Open() + defer file.Close() + if err != nil { + c.Err = model.NewAppError("addLicense", "Could not open license file", err.Error()) + return + } + + buf := bytes.NewBuffer(nil) + io.Copy(buf, file) + + data := buf.Bytes() + + var license *model.License + if success, licenseStr := utils.ValidateLicense(data); success { + license = model.LicenseFromJson(strings.NewReader(licenseStr)) + + if ok := utils.SetLicense(license); !ok { + c.LogAudit("failed - expired or non-started license") + c.Err = model.NewAppError("addLicense", "License is either expired or has not yet started.", "") + return + } + + go func() { + if err := writeFileLocally(data, utils.LICENSE_FILE_LOC); err != nil { + l4g.Error("Could not save license file") + } + }() + } else { + c.LogAudit("failed - invalid license") + c.Err = model.NewAppError("addLicense", "Invalid license file", "") + return + } + + c.LogAudit("success") + w.Write([]byte(license.ToJson())) +} + +func removeLicense(c *Context, w http.ResponseWriter, r *http.Request) { + c.LogAudit("") + + utils.RemoveLicense() + + rdata := map[string]string{} + rdata["status"] = "ok" + w.Write([]byte(model.MapToJson(rdata))) +} diff --git a/mattermost.go b/mattermost.go index f86af76e36..f6abb90194 100644 --- a/mattermost.go +++ b/mattermost.go @@ -67,6 +67,8 @@ func main() { api.InitApi() web.InitWeb() + utils.LoadLicense() + if flagRunCmds { runCmds() } else { diff --git a/model/license.go b/model/license.go new file mode 100644 index 0000000000..7a955e1f82 --- /dev/null +++ b/model/license.go @@ -0,0 +1,68 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type License struct { + Id string `json:"id"` + IssuedAt int64 `json:"issued_at"` + StartsAt int64 `json:"starts_at"` + ExpiresAt int64 `json:"expires_at"` + Customer *Customer `json:"customer"` + Features *Features `json:"features"` +} + +type Customer struct { + Id string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + PhoneNumber string `json:"phone_number"` +} + +type Features struct { + Users int `json:"users"` + LDAP bool `json:"ldap"` + GoogleSSO bool `json:"google_sso"` +} + +func (l *License) IsExpired() bool { + now := GetMillis() + if l.ExpiresAt < now { + return true + } + return false +} + +func (l *License) IsStarted() bool { + now := GetMillis() + if l.StartsAt < now { + return true + } + return false +} + +func (l *License) ToJson() string { + b, err := json.Marshal(l) + if err != nil { + return "" + } else { + return string(b) + } +} + +func LicenseFromJson(data io.Reader) *License { + decoder := json.NewDecoder(data) + var o License + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/utils/license.go b/utils/license.go new file mode 100644 index 0000000000..1f8e24f32f --- /dev/null +++ b/utils/license.go @@ -0,0 +1,152 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package utils + +import ( + "bytes" + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "io" + "os" + "strconv" + "strings" + + l4g "code.google.com/p/log4go" + + "github.com/mattermost/platform/model" +) + +const ( + LICENSE_FILE_LOC = "./data/active.dat" +) + +var IsLicensed bool = false +var License *model.License = &model.License{} +var ClientLicense map[string]string = make(map[string]string) + +// test public key +var publicKey []byte = []byte(`-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3/k3Al9q1Xe+xngQ/yGn +0suaJopea3Cpf6NjIHdO8sYTwLlxqt0Mdb+qBR9LbCjZfcNmqc5mZONvsyCEoN/5 +VoLdlv1m9ao2BSAWphUxE2CPdUWdLOsDbQWliSc5//UhiYeR+67Xxon0Hg0LKXF6 +PumRIWQenRHJWqlUQZ147e7/1v9ySVRZksKpvlmMDzgq+kCH/uyM1uVP3z7YXhlN +K7vSSQYbt4cghvWQxDZFwpLlsChoY+mmzClgq+Yv6FLhj4/lk94twdOZau/AeZFJ +NxpC+5KFhU+xSeeklNqwCgnlOyZ7qSTxmdJHb+60SwuYnnGIYzLJhY4LYDr4J+KR +1wIDAQAB +-----END PUBLIC KEY-----`) + +func LoadLicense() { + file, err := os.Open(LICENSE_FILE_LOC) + if err != nil { + l4g.Warn("Unable to open/find license file") + return + } + defer file.Close() + + buf := bytes.NewBuffer(nil) + io.Copy(buf, file) + + if success, licenseStr := ValidateLicense(buf.Bytes()); success { + license := model.LicenseFromJson(strings.NewReader(licenseStr)) + if !license.IsExpired() && license.IsStarted() && license.StartsAt > License.StartsAt { + License = license + IsLicensed = true + ClientLicense = getClientLicense(license) + return + } + } + + l4g.Warn("No valid enterprise license found") +} + +func SetLicense(license *model.License) bool { + if !license.IsExpired() && license.IsStarted() { + License = license + IsLicensed = true + ClientLicense = getClientLicense(license) + return true + } + + return false +} + +func RemoveLicense() { + License = &model.License{} + IsLicensed = false + ClientLicense = getClientLicense(License) + + if err := os.Remove(LICENSE_FILE_LOC); err != nil { + l4g.Error("Unable to remove license file, err=%v", err.Error()) + } +} + +func ValidateLicense(signed []byte) (bool, string) { + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(signed))) + + _, err := base64.StdEncoding.Decode(decoded, signed) + if err != nil { + l4g.Error("Encountered error decoding license, err=%v", err.Error()) + return false, "" + } + + if len(decoded) <= 256 { + l4g.Error("Signed license not long enough") + return false, "" + } + + // remove null terminator + if decoded[len(decoded)-1] == byte(0) { + decoded = decoded[:len(decoded)-1] + } + + plaintext := decoded[:len(decoded)-256] + signature := decoded[len(decoded)-256:] + + block, _ := pem.Decode(publicKey) + + public, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + l4g.Error("Encountered error signing license, err=%v", err.Error()) + return false, "" + } + + rsaPublic := public.(*rsa.PublicKey) + + h := sha256.New() + h.Write(plaintext) + d := h.Sum(nil) + + err = rsa.VerifyPKCS1v15(rsaPublic, crypto.SHA256, d, signature) + if err != nil { + l4g.Error("Invalid signature, err=%v", err.Error()) + return false, "" + } + + return true, string(plaintext) +} + +func getClientLicense(l *model.License) map[string]string { + props := make(map[string]string) + + props["IsLicensed"] = strconv.FormatBool(IsLicensed) + + if IsLicensed { + props["Users"] = strconv.Itoa(l.Features.Users) + props["LDAP"] = strconv.FormatBool(l.Features.LDAP) + props["GoogleSSO"] = strconv.FormatBool(l.Features.GoogleSSO) + props["IssuedAt"] = strconv.FormatInt(l.IssuedAt, 10) + props["StartsAt"] = strconv.FormatInt(l.StartsAt, 10) + props["ExpiresAt"] = strconv.FormatInt(l.ExpiresAt, 10) + props["Name"] = l.Customer.Name + props["Email"] = l.Customer.Email + props["Company"] = l.Customer.Company + props["PhoneNumber"] = l.Customer.PhoneNumber + } + + return props +} diff --git a/web/react/components/about_build_modal.jsx b/web/react/components/about_build_modal.jsx index 3143bec224..d542146320 100644 --- a/web/react/components/about_build_modal.jsx +++ b/web/react/components/about_build_modal.jsx @@ -15,6 +15,19 @@ export default class AboutBuildModal extends React.Component { render() { const config = global.window.mm_config; + const license = global.window.mm_license; + + let title = 'Team Edition'; + let licensee; + if (config.BuildEnterpriseReady === 'true' && license.IsLicensed === 'true') { + title = 'Enterprise Edition'; + licensee = ( +
{'If a user attribute changes on the LDAP server it will be updated the next time the user enters their credentials to log in to Mattermost. This includes if a user is made inactive or removed from an LDAP server. Synchronization with LDAP servers is planned in a future release.'}
+ {'LDAP is an enterprise feature. Your current license does not support LDAP. Click '} + + {'here'} + + {' for information and pricing on enterprise licenses.'} +
++ {'This compiled release of Mattermost platform is provided under a '} + + {'commercial license'} + + {' from Mattermost, Inc. based on your subscription level and is subject to the '} + + {'Terms of Service.'} + +
+{'Your subscription details are as follows:'}
+ {'Name: ' + global.window.mm_license.Name} ++ {'If you’re migrating servers you may need to remove your license key from this server in order to install it on a new server. To start, '} + + {'disable all Enterprise Edition features on this server'} + + {'. This will enable the ability to remove the license key and downgrade this server from Enterprise Edition to Team Edition.'} +
+{'This compiled release of Mattermost platform is offered under an MIT license.'}
+{'See MIT-COMPILED-LICENSE.txt in your root install directory for details. See NOTICES.txt for information about open source software used in this system.'}
+ + ); + + licenseKey = ( ++ {'Upload a license key for Mattermost Enterprise Edition to upgrade this server. '} + + {'Visit us online'} + + {' to learn more about the benefits of Enterprise Edition or to purchase a key.'} +
+