auth-proxy: proxies only authenticated requests to upstream, redirects to login page otherwise (#103)
* auth-proxy: inspects authenticated user
* ingress: chart and use in rpuppy
* auth-proxy: make it optional in rpuppy
* kratos: whitelist env pub/priv domains for auth return_to addr
* url-shortener: put behind auth-proxy
* pihole: replace oauth2-client with auth-proxy
* auth-proxy: fix upstream uri generation
* pihole: remove old chart using oauth2
* auth-proxy: remove temporary values file
* url-shortener: check x-user header for authentication
* auth: fix allowed_return_urls list
* auth-proxy: fix current address generation logic
---------
Co-authored-by: Giorgi Lekveishvili <lekva@gl-mbp-m1-max.local>
diff --git a/core/auth/proxy/Dockerfile b/core/auth/proxy/Dockerfile
new file mode 100644
index 0000000..6e8d93c
--- /dev/null
+++ b/core/auth/proxy/Dockerfile
@@ -0,0 +1,5 @@
+FROM gcr.io/distroless/static:nonroot
+
+ARG TARGETARCH
+
+COPY server_${TARGETARCH} /usr/bin/server
diff --git a/core/auth/proxy/Makefile b/core/auth/proxy/Makefile
new file mode 100644
index 0000000..053ab05
--- /dev/null
+++ b/core/auth/proxy/Makefile
@@ -0,0 +1,39 @@
+repo_name ?= giolekva
+podman ?= docker
+ifeq ($(podman), podman)
+manifest_dest=docker://docker.io/$(repo_name)/pcloud-installer:latest
+endif
+
+clean:
+ rm -f server server_*
+
+build: clean
+ go build -o server *.go
+
+build_arm64: export CGO_ENABLED=0
+build_arm64: export GO111MODULE=on
+build_arm64: export GOOS=linux
+build_arm64: export GOARCH=arm64
+build_arm64:
+ go build -o server_arm64 *.go
+
+build_amd64: export CGO_ENABLED=0
+build_amd64: export GO111MODULE=on
+build_amd64: export GOOS=linux
+build_amd64: export GOARCH=amd64
+build_amd64:
+ go build -o server_amd64 *.go
+
+push_arm64: clean build_arm64
+ $(podman) build --platform linux/arm64 --tag=$(repo_name)/auth-proxy:arm64 .
+ $(podman) push $(repo_name)/auth-proxy:arm64
+
+push_amd64: clean build_amd64
+ $(podman) build --platform linux/amd64 --tag=$(repo_name)/auth-proxy:amd64 .
+ $(podman) push $(repo_name)/auth-proxy:amd64
+
+
+push: push_arm64 push_amd64
+ $(podman) manifest create $(repo_name)/auth-proxy:latest $(repo_name)/auth-proxy:arm64 $(repo_name)/auth-proxy:amd64
+ $(podman) manifest push $(repo_name)/auth-proxy:latest $(manifest_dest)
+ $(podman) manifest rm $(repo_name)/auth-proxy:latest
diff --git a/core/auth/proxy/main.go b/core/auth/proxy/main.go
new file mode 100644
index 0000000..8b3d837
--- /dev/null
+++ b/core/auth/proxy/main.go
@@ -0,0 +1,156 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "strings"
+)
+
+var port = flag.Int("port", 3000, "Port to listen on")
+var whoAmIAddr = flag.String("whoami-addr", "", "Kratos whoami endpoint address")
+var loginAddr = flag.String("login-addr", "", "Login page address")
+var upstream = flag.String("upstream", "", "Upstream service address")
+
+type user struct {
+ Identity struct {
+ Traits struct {
+ Username string `json:"username"`
+ } `json:"traits"`
+ } `json:"identity"`
+}
+
+type authError struct {
+ Error struct {
+ Status string `json:"status"`
+ } `json:"error"`
+}
+
+func getAddr(r *http.Request) (*url.URL, error) {
+ return url.Parse(fmt.Sprintf(
+ "%s://%s%s",
+ r.Header["X-Forwarded-Scheme"][0],
+ r.Header["X-Forwarded-Host"][0],
+ r.URL.RequestURI()))
+}
+
+func handle(w http.ResponseWriter, r *http.Request) {
+ user, err := queryWhoAmI(r.Cookies())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if user == nil {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ curr, err := getAddr(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ addr := fmt.Sprintf("%s?return_to=%s", *loginAddr, curr.String())
+ http.Redirect(w, r, addr, http.StatusSeeOther)
+ return
+ }
+ rc := r.Clone(context.Background())
+ rc.Header.Add("X-User", user.Identity.Traits.Username)
+ ru, err := url.Parse(fmt.Sprintf("http://%s%s", *upstream, r.URL.RequestURI()))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ rc.URL = ru
+ rc.RequestURI = ""
+ client := &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ },
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ resp, err := client.Do(rc)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ for name, values := range resp.Header {
+ for _, value := range values {
+ w.Header().Add(name, value)
+ }
+ }
+ w.WriteHeader(resp.StatusCode)
+ if _, err := io.Copy(w, resp.Body); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func queryWhoAmI(cookies []*http.Cookie) (*user, error) {
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ return nil, err
+ }
+ client := &http.Client{
+ Jar: jar,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ },
+ }
+ addr, err := url.Parse(*whoAmIAddr)
+ if err != nil {
+ return nil, err
+ }
+ client.Jar.SetCookies(addr, cookies)
+ resp, err := client.Get(*whoAmIAddr)
+ if err != nil {
+ return nil, err
+ }
+ data := make(map[string]any)
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, err
+ }
+ // TODO(gio): remove debugging
+ b, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ fmt.Println(string(b))
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(data); err != nil {
+ return nil, err
+ }
+ tmp := buf.String()
+ if resp.StatusCode == http.StatusOK {
+ u := &user{}
+ if err := json.NewDecoder(strings.NewReader(tmp)).Decode(u); err != nil {
+ return nil, err
+ }
+ return u, nil
+ }
+ e := &authError{}
+ if err := json.NewDecoder(strings.NewReader(tmp)).Decode(e); err != nil {
+ return nil, err
+ }
+ if e.Error.Status == "Unauthorized" {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("Unknown error: %s", tmp)
+}
+
+func main() {
+ flag.Parse()
+ http.HandleFunc("/", handle)
+ fmt.Printf("Starting HTTP server on port: %d\n", *port)
+ log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}
diff --git a/core/auth/ui/Makefile b/core/auth/ui/Makefile
index f3b9b63..23ae76b 100644
--- a/core/auth/ui/Makefile
+++ b/core/auth/ui/Makefile
@@ -1,5 +1,8 @@
repo_name ?= dtabidze
podman ?= docker
+ifeq ($(podman), podman)
+manifest_dest=docker://docker.io/$(repo_name)/pcloud-installer:latest
+endif
clean:
rm -f server server_*
@@ -32,5 +35,5 @@
push: push_arm64 push_amd64
$(podman) manifest create $(repo_name)/auth-ui:latest $(repo_name)/auth-ui:arm64 $(repo_name)/auth-ui:amd64
- $(podman) manifest push $(repo_name)/auth-ui:latest
+ $(podman) manifest push $(repo_name)/auth-ui:latest $(manifest_dest)
$(podman) manifest rm $(repo_name)/auth-ui:latest
diff --git a/core/auth/ui/main.go b/core/auth/ui/main.go
index 7cb1f4d..3de264c 100644
--- a/core/auth/ui/main.go
+++ b/core/auth/ui/main.go
@@ -239,9 +239,14 @@
// HttpOnly: true,
// })
}
+ returnTo := r.Form.Get("return_to")
flow, ok := r.Form["flow"]
if !ok {
- http.Redirect(w, r, s.kratos+"/self-service/login/browser", http.StatusSeeOther)
+ addr := s.kratos + "/self-service/login/browser"
+ if returnTo != "" {
+ addr += fmt.Sprintf("?return_to=%s", returnTo)
+ }
+ http.Redirect(w, r, addr, http.StatusSeeOther)
return
}
csrfToken, err := getCSRFToken("login", flow[0], r.Cookies())
@@ -289,6 +294,32 @@
return resp, nil
}
+func postFormToKratos(flowType, flow string, cookies []*http.Cookie, data url.Values) (*http.Response, error) {
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ return nil, err
+ }
+ client := &http.Client{
+ Jar: jar,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ },
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ b, err := url.Parse(*kratos + "/self-service/" + flowType + "/browser")
+ if err != nil {
+ return nil, err
+ }
+ client.Jar.SetCookies(b, cookies)
+ resp, err := client.PostForm(fmt.Sprintf(*kratos+"/self-service/"+flowType+"?flow=%s", flow), data)
+ if err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
type logoutResp struct {
LogoutURL string `json:"logout_url"`
}
@@ -360,6 +391,7 @@
if err != nil {
return err
}
+ fmt.Printf("++ %s\n", respBody)
t, err := regogo.Get(string(respBody), "input.ui.messages[0].type")
if err != nil {
return err
@@ -384,21 +416,17 @@
http.Redirect(w, r, s.kratos+"/self-service/login/browser", http.StatusSeeOther)
return
}
- req := loginReq{
- CSRFToken: r.FormValue("csrf_token"),
- Method: "password",
- Password: r.FormValue("password"),
- Username: r.FormValue("username"),
+ req := url.Values{
+ "csrf_token": []string{r.FormValue("csrf_token")},
+ "method": []string{"password"},
+ "password": []string{r.FormValue("password")},
+ "identifier": []string{r.FormValue("username")},
}
- var reqBody bytes.Buffer
- if err := json.NewEncoder(&reqBody).Encode(req); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- resp, err := postToKratos("login", flow[0], r.Cookies(), &reqBody)
- if err == nil {
- err = extractError(resp.Body)
- }
+ resp, err := postFormToKratos("login", flow[0], r.Cookies(), req)
+ fmt.Printf("--- %d\n", resp.StatusCode)
+ var vv bytes.Buffer
+ io.Copy(&vv, resp.Body)
+ fmt.Println(vv.String())
if err != nil {
if challenge, _ := r.Cookie("login_challenge"); challenge != nil {
redirectTo, err := s.hydra.LoginRejectChallenge(challenge.Value, err.Error())
@@ -429,7 +457,11 @@
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
return
}
- http.Redirect(w, r, "/", http.StatusSeeOther)
+ if resp.StatusCode == http.StatusSeeOther {
+ http.Redirect(w, r, resp.Header.Get("Location"), http.StatusSeeOther)
+ } else {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ }
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
diff --git a/core/installer/app.go b/core/installer/app.go
index 6c83376..8cfb2b5 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -147,7 +147,7 @@
...
}
-helm: {
+helmValidate: {
for key, value in helm {
"\(key)": #Helm & value & {
name: key
@@ -184,7 +184,7 @@
}
output: {
- for name, r in helm {
+ for name, r in helmValidate {
"\(name)": #HelmRelease & {
_name: name
_chart: r.chart
diff --git a/core/installer/repoio.go b/core/installer/repoio.go
index 374f75f..5b90327 100644
--- a/core/installer/repoio.go
+++ b/core/installer/repoio.go
@@ -388,6 +388,7 @@
}
switch def.Kind() {
case KindBoolean:
+ ret[k] = v
case KindString:
ret[k] = v
case KindNetwork:
diff --git a/core/installer/values-tmpl/core-auth.cue b/core/installer/values-tmpl/core-auth.cue
index 99840ab..192f806 100644
--- a/core/installer/values-tmpl/core-auth.cue
+++ b/core/installer/values-tmpl/core-auth.cue
@@ -225,6 +225,10 @@
}
selfservice: {
default_browser_return_url: "https://accounts-ui.\(global.domain)"
+ allowed_return_urls: [
+ "https://*.\(global.domain)/",
+ "https://*.\(global.privateDomain)",
+ ]
methods: {
password: {
enabled: true
diff --git a/core/installer/values-tmpl/pihole.cue b/core/installer/values-tmpl/pihole.cue
index a1ec66a..35d4c51 100644
--- a/core/installer/values-tmpl/pihole.cue
+++ b/core/installer/values-tmpl/pihole.cue
@@ -1,6 +1,7 @@
input: {
network: #Network
subdomain: string
+ requireAuth: bool
}
_domain: "\(input.subdomain).\(input.network.domain)"
@@ -18,17 +19,15 @@
tag: "v5.8.1"
pullPolicy: "IfNotPresent"
}
+ authProxy: {
+ repository: "giolekva"
+ name: "auth-proxy"
+ tag: "latest"
+ pullPolicy: "Always"
+ }
}
charts: {
- oauth2Client: {
- chart: "charts/oauth2-client"
- sourceRef: {
- kind: "GitRepository"
- name: "pcloud"
- namespace: global.id
- }
- }
pihole: {
chart: "charts/pihole"
sourceRef: {
@@ -37,80 +36,110 @@
namespace: global.id
}
}
-}
-
-_oauth2ClientSecretName: "oauth2-client"
-
-helm: {
- "oauth2-client": {
- chart: charts.oauth2Client
- values: {
- name: "oauth2-client"
- secretName: _oauth2ClientSecretName
- grantTypes: ["authorization_code"]
- responseTypes: ["code"]
- scope: "openid profile email"
- redirectUris: ["https://\(_domain)/oauth2/callback"]
- hydraAdmin: "http://hydra-admin.\(global.namespacePrefix)core-auth.svc.cluster.local"
+ ingress: {
+ chart: "charts/ingress"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.id
}
}
+ authProxy: {
+ chart: "charts/auth-proxy"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.id
+ }
+ }
+}
+
+_piholeServiceName: "pihole-web"
+_authProxyServiceName: "auth-proxy"
+_httpPortName: "http"
+_serviceWebPort: 80
+
+helm: {
pihole: {
chart: charts.pihole
values: {
- domain: _domain
- pihole: {
- fullnameOverride: "pihole"
- persistentVolumeClaim: { // TODO(gio): create volume separately as a dependency
+ fullnameOverride: "pihole"
+ persistentVolumeClaim: { // TODO(gio): create volume separately as a dependency
+ enabled: true
+ size: "5Gi"
+ }
+ admin: {
+ enabled: false
+ }
+ ingress: {
+ enabled: false
+ }
+ serviceDhcp: {
+ enabled: false
+ }
+ serviceDns: {
+ type: "ClusterIP"
+ }
+ serviceWeb: {
+ type: "ClusterIP"
+ http: {
enabled: true
- size: "5Gi"
+ port: _serviceWebPort
}
- admin: {
+ https: {
enabled: false
}
- ingress: {
- enabled: false
+ }
+ virtualHost: _domain
+ resources: {
+ requests: {
+ cpu: "250m"
+ memory: "100M"
}
- serviceDhcp: {
- enabled: false
+ limits: {
+ cpu: "500m"
+ memory: "250M"
}
- serviceDns: {
- type: "ClusterIP"
- }
- serviceWeb: {
- type: "ClusterIP"
- http: {
- enabled: true
- }
- https: {
- enabled: false
- }
- }
- virtualHost: _domain
- resources: {
- requests: {
- cpu: "250m"
- memory: "100M"
- }
- limits: {
- cpu: "500m"
- memory: "250M"
- }
- }
+ }
+ image: {
+ repository: images.pihole.fullName
+ tag: images.pihole.tag
+ pullPolicy: images.pihole.pullPolicy
+ }
+ }
+ }
+ if input.requireAuth {
+ "auth-proxy": {
+ chart: charts.authProxy
+ values: {
image: {
- repository: images.pihole.fullName
- tag: images.pihole.tag
- pullPolicy: images.pihole.pullPolicy
+ repository: images.authProxy.fullName
+ tag: images.authProxy.tag
+ pullPolicy: images.authProxy.pullPolicy
}
+ upstream: "\(_piholeServiceName).\(release.namespace).svc.cluster.local"
+ whoAmIAddr: "https://accounts.\(global.domain)/sessions/whoami"
+ loginAddr: "https://accounts-ui.\(global.domain)/login"
+ portName: _httpPortName
}
- oauth2: {
- cookieSecret: "1234123443214321"
- secretName: _oauth2ClientSecretName
- issuer: "https://hydra.\(global.domain)"
- }
- configName: "oauth2-proxy"
- profileUrl: "https://accounts-ui.\(global.domain)"
+ }
+ }
+ ingress: {
+ chart: charts.ingress
+ values: {
+ domain: _domain
ingressClassName: input.network.ingressClass
certificateIssuer: input.network.certificateIssuer
+ service: {
+ if input.requireAuth {
+ name: _authProxyServiceName
+ port: name: _httpPortName
+ }
+ if !input.requireAuth {
+ name: _piholeServiceName
+ port: number: _serviceWebPort
+ }
+ }
}
}
}
diff --git a/core/installer/values-tmpl/rpuppy.cue b/core/installer/values-tmpl/rpuppy.cue
index 8950d6c..ff316a0 100644
--- a/core/installer/values-tmpl/rpuppy.cue
+++ b/core/installer/values-tmpl/rpuppy.cue
@@ -1,6 +1,7 @@
input: {
network: #Network
subdomain: string
+ requireAuth: bool
}
_domain: "\(input.subdomain).\(input.network.domain)"
@@ -18,6 +19,12 @@
tag: "latest"
pullPolicy: "Always"
}
+ authProxy: {
+ repository: "giolekva"
+ name: "auth-proxy"
+ tag: "latest"
+ pullPolicy: "Always"
+ }
}
charts: {
@@ -29,20 +36,71 @@
namespace: global.id
}
}
+ ingress: {
+ chart: "charts/ingress"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.id
+ }
+ }
+ authProxy: {
+ chart: "charts/auth-proxy"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.id
+ }
+ }
}
+_rpuppyServiceName: "rpuppy"
+_authProxyServiceName: "auth-proxy"
+_httpPortName: "http"
+
helm: {
rpuppy: {
chart: charts.rpuppy
values: {
- ingressClassName: input.network.ingressClass
- certificateIssuer: input.network.certificateIssuer
- domain: _domain
image: {
repository: images.rpuppy.fullName
tag: images.rpuppy.tag
pullPolicy: images.rpuppy.pullPolicy
}
+ portName: _httpPortName
+ }
+ }
+ if input.requireAuth {
+ "auth-proxy": {
+ chart: charts.authProxy
+ values: {
+ image: {
+ repository: images.authProxy.fullName
+ tag: images.authProxy.tag
+ pullPolicy: images.authProxy.pullPolicy
+ }
+ upstream: "\(_rpuppyServiceName).\(release.namespace).svc.cluster.local"
+ whoAmIAddr: "https://accounts.\(global.domain)/sessions/whoami"
+ loginAddr: "https://accounts-ui.\(global.domain)/login"
+ portName: _httpPortName
+ }
+ }
+ }
+ ingress: {
+ chart: charts.ingress
+ values: {
+ domain: _domain
+ ingressClassName: input.network.ingressClass
+ certificateIssuer: input.network.certificateIssuer
+ service: {
+ if input.requireAuth {
+ name: _authProxyServiceName
+ }
+ if !input.requireAuth {
+ name: _rpuppyServiceName
+ }
+ port: name: _httpPortName
+ }
}
}
}
diff --git a/core/installer/values-tmpl/url-shortener.cue b/core/installer/values-tmpl/url-shortener.cue
index a3f6d3b..7d854e8 100644
--- a/core/installer/values-tmpl/url-shortener.cue
+++ b/core/installer/values-tmpl/url-shortener.cue
@@ -1,6 +1,7 @@
input: {
network: #Network
subdomain: string
+ requireAuth: bool
}
_domain: "\(input.subdomain).\(input.network.domain)"
@@ -18,6 +19,12 @@
tag: "latest"
pullPolicy: "Always"
}
+ authProxy: {
+ repository: "giolekva"
+ name: "auth-proxy"
+ tag: "latest"
+ pullPolicy: "Always"
+ }
}
charts: {
@@ -29,15 +36,32 @@
namespace: global.id
}
}
+ ingress: {
+ chart: "charts/ingress"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.id
+ }
+ }
+ authProxy: {
+ chart: "charts/auth-proxy"
+ sourceRef: {
+ kind: "GitRepository"
+ name: "pcloud"
+ namespace: global.id
+ }
+ }
}
+_urlShortenerServiceName: "url-shortener"
+_authProxyServiceName: "auth-proxy"
+_httpPortName: "http"
+
helm: {
"url-shortener": {
chart: charts.urlShortener
values: {
- ingressClassName: input.network.ingressClass
- certificateIssuer: input.network.certificateIssuer
- domain: _domain
storage: {
size: "1Gi"
}
@@ -46,7 +70,40 @@
tag: images.urlShortener.tag
pullPolicy: images.urlShortener.pullPolicy
}
- port: 8080
+ portName: _httpPortName
}
}
+ if input.requireAuth {
+ "auth-proxy": {
+ chart: charts.authProxy
+ values: {
+ image: {
+ repository: images.authProxy.fullName
+ tag: images.authProxy.tag
+ pullPolicy: images.authProxy.pullPolicy
+ }
+ upstream: "\(_urlShortenerServiceName).\(release.namespace).svc.cluster.local"
+ whoAmIAddr: "https://accounts.\(global.domain)/sessions/whoami"
+ loginAddr: "https://accounts-ui.\(global.domain)/login"
+ portName: _httpPortName
+ }
+ }
+ }
+ ingress: {
+ chart: charts.ingress
+ values: {
+ domain: _domain
+ ingressClassName: input.network.ingressClass
+ certificateIssuer: input.network.certificateIssuer
+ service: {
+ if input.requireAuth {
+ name: _authProxyServiceName
+ }
+ if !input.requireAuth {
+ name: _urlShortenerServiceName
+ }
+ port: name: _httpPortName
+ }
+ }
+ }
}
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
index 4fa4766..aebbd39 100644
--- a/core/installer/welcome/appmanager-tmpl/app.html
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -7,7 +7,7 @@
<label for="{{ $name }}">
<span>{{ $name }}</span>
</label>
- <input type="checkbox" role="swtich" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} {{ if index $data $name }}checked{{ end }}/>
+ <input type="checkbox" role="swtich" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.checked)" {{ if $readonly }}disabled{{ end }} {{ if index $data $name }}checked{{ end }}/>
{{ else if eq $schema.Kind 1 }}
<label for="{{ $name }}">
<span>{{ $name }}</span>