Compare commits

..

70 Commits

Author SHA1 Message Date
Girish Ramakrishnan 9bb4c8127e docker based tests 2024-09-22 11:13:26 +02:00
Girish Ramakrishnan 27f7bcd040 test: add simple gitlab-ci file 2024-09-21 09:46:38 +02:00
Girish Ramakrishnan 76dc856dbf test: fix system test 2024-09-20 15:37:34 +02:00
Vladimir D 227fdf10dd OIDC: id_token added to client response types 2024-09-20 14:16:40 +02:00
Girish Ramakrishnan 19c744b17d unbound-anchor is now part of ExecStartPre
it seems unbound-anchor is not a dep of unbound in ubuntu 24. some
installations are thus missing this package.

in any case, ignore unbound-anchor exit status
2024-09-20 10:00:01 +02:00
Vladimir D 3ce74d04d0 OIDC: groups claim added to make groups provisioned 2024-09-19 13:08:20 +02:00
Johannes Zellner 87b8fc6a1b dashboard: remove box-shadow on form-controls to be inline with buttons 2024-09-19 13:04:03 +02:00
Johannes Zellner 9012badfb8 dashbaord: Fix form-control align in filter bars 2024-09-19 12:19:09 +02:00
Girish Ramakrishnan 3b6e5d8ed1 cloudron-support: ipv6 checks 2024-09-19 12:11:56 +02:00
Girish Ramakrishnan 1148724613 boxerror: handle AggregateError 2024-09-19 11:44:47 +02:00
Girish Ramakrishnan f526695aae cloudron-support: enable-ssh has an alias enable-remote-support 2024-09-19 08:38:59 +02:00
Girish Ramakrishnan e8850eeac2 8.0.6 changelog 2024-09-18 15:33:42 +02:00
Girish Ramakrishnan 777834d790 dig: set tries parameter 2024-09-18 15:25:48 +02:00
Girish Ramakrishnan dca9246450 Fix AdGuard resolving dashboard to docker bridge IP
Issue 1: DO droplet when given the name my.blah.com , will put an entry
in /etc/hosts with `127.0.1.1 my.blah.com` . When app containers use
system DNS, they get this IP address which does not work inside a container.

An idea is to remove this entry when running cloudron-setup, but maybe this
causes trouble later.

Issue 2: Some networks seem to lack loopback networking. With OIDC changes,
we want the apps to access my.blah.com even if hairpin nat is not working.

Solution: make my.blah.com to resolve to the docker bridge IP (172.18.0.1)
where nginx also listens to. This means that such requests never go outside the server

Caveats:
* This breaks AdGuard which now starts resolving it to 172.18.0.1 for
the entire network! So, we skip ExtraHosts configuration for adguard

* Maybe ExtraHosts should be scoped to OIDC apps only. But the thought here is
that it will help apps like say n8n which are querying dasahboard.
2024-09-18 14:42:11 +02:00
Girish Ramakrishnan 767f7ab40e capitalize view name 2024-09-18 13:10:26 +02:00
Johannes Zellner 1b810ec74f Only add unchecked checklist items on fresh installs for the moment 2024-09-16 13:46:19 +02:00
Johannes Zellner f59b9e1b5f frontend: adjust filemanager to new pankow api 2024-09-16 13:28:30 +02:00
Johannes Zellner 398dbe802e frontend: remove another unused css rule 2024-09-16 12:21:14 +02:00
Johannes Zellner 8b5fa0fe76 frontend: purge unwanted css styles 2024-09-16 12:08:10 +02:00
Johannes Zellner 99042a47f3 frontend: Fix all toolbuttons 2024-09-16 12:05:41 +02:00
Johannes Zellner 46e600abe9 frontend: fixup LogsViewer 2024-09-16 11:50:20 +02:00
Johannes Zellner 051dd8b58f frontend: update dependencies 2024-09-16 11:50:20 +02:00
Girish Ramakrishnan 067b02dba1 dashboard: reconfigure all apps on location change
continuation of 1b5fee233e

all containers have ExtraHosts , so we have to reconfigure everything
2024-09-16 11:23:06 +02:00
Girish Ramakrishnan 22a0874188 grammar 2024-09-16 10:37:01 +02:00
Girish Ramakrishnan 0e25809158 settings: do not overflow the schedule 2024-09-16 10:29:35 +02:00
Girish Ramakrishnan 305d877896 operator: fix resource view
app resources view requires the cpu and memory information
2024-09-13 16:47:13 +02:00
Girish Ramakrishnan a932a5251a update: all operators to update an app
previously, the update info was restricted to admins. this can now be queried
by any authenticated user. update information can be gathered from listing apps and
then checking against appstore anyway.
2024-09-13 16:46:58 +02:00
Girish Ramakrishnan 7b58fccb9f app info: fix overflow of manifest id 2024-09-13 11:34:30 +02:00
Johannes Zellner 859fef62d4 Revert "Make unbound prefer ipv4 to avoid using ipv6 for spam checking"
This reverts commit aedf55dba0.
2024-09-12 17:41:12 +02:00
Girish Ramakrishnan 0647a3a233 unbound: prefer ip4 on ubuntu 24 and above
ip6 queries seems to be blocked by spamhaus
2024-09-12 17:13:50 +02:00
Johannes Zellner aedf55dba0 Make unbound prefer ipv4 to avoid using ipv6 for spam checking 2024-09-12 16:43:34 +02:00
Girish Ramakrishnan e9a422b657 logs: handle logs not found (logrotated)
we show an error message in the UI now
2024-09-12 10:32:00 +02:00
Girish Ramakrishnan 23df6bdfbf add to changes 2024-09-11 17:55:35 +02:00
Girish Ramakrishnan 1b5fee233e docker: use the system dns for app containers
take 2 after failed attempt with 92bce26e22

this makes the dashboard domain resolve internally to nginx

can test with `getent ahosts my.domain.com` inside the container.
2024-09-11 17:52:25 +02:00
Girish Ramakrishnan 63457d2de4 Revert "docker: use the system dns for app containers"
This reverts commit 92bce26e22.
2024-09-10 19:37:39 +02:00
Girish Ramakrishnan 732c944e98 changelog: update release version 2024-09-10 17:43:18 +02:00
Girish Ramakrishnan 86c4db8f22 bugs in syslog parsing 2024-09-10 13:46:13 +02:00
Girish Ramakrishnan 8c0c9981de remove usage of nsyslog-parser-2
this module is somehow parsing the syslog incorrectly causing
incorrect directories being created in the logs directory
(since appName got parsed incorrectly)
2024-09-10 13:09:43 +02:00
Girish Ramakrishnan e5dcf78ceb unbound: setup anchor on service restart 2024-09-10 09:48:10 +02:00
Girish Ramakrishnan 92bce26e22 docker: use the system dns for app containers 2024-09-10 09:42:31 +02:00
Girish Ramakrishnan a72c038435 cloudron-support: also need to be remove any corrupt containerd 2024-09-09 18:42:08 +02:00
Girish Ramakrishnan 6742cdf373 backups: remount remote if not mounted before a backup 2024-09-09 18:15:49 +02:00
Girish Ramakrishnan ea72cef7f9 storage: remove getProviderStatus 2024-09-09 17:36:51 +02:00
Girish Ramakrishnan 565ad83399 add to changes 2024-09-09 09:29:54 +02:00
Girish Ramakrishnan 43f795c9e4 remove use of "Cloudron" in various descriptions 2024-09-08 19:17:35 +02:00
Girish Ramakrishnan 1589cfb639 tz: add note in backup and update UI 2024-09-08 18:20:15 +02:00
Girish Ramakrishnan a9b9931aa8 backups: do not overflow the schedule timings 2024-09-08 15:51:07 +02:00
Girish Ramakrishnan 1cd577cc65 filesystem: remove debug warning 2024-09-08 15:25:49 +02:00
Johannes Zellner 13d8db3daa For the moment new checklist items on update are acknowledged 2024-09-07 09:37:39 +02:00
Girish Ramakrishnan 40c4a01bc0 cloudron-support: ipv6 check 2024-09-06 17:20:52 +02:00
Girish Ramakrishnan 4301c70ba7 exoscale: add sos AT-VIE-2 region 2024-09-02 22:01:29 +02:00
Girish Ramakrishnan d5e9e556ab digitalocean: add LON1 region 2024-09-02 20:58:14 +02:00
Girish Ramakrishnan bdf9e04963 memory: ensure slider is always usable 2024-08-30 12:07:55 +02:00
Girish Ramakrishnan b95285365d 8.1.0 changes 2024-08-28 11:51:01 +02:00
Girish Ramakrishnan abf445e969 docker: fix rounding
toFixed() returns a string!
2024-08-28 11:45:53 +02:00
Girish Ramakrishnan e988e3a303 storage: fix noop test 2024-08-27 15:16:18 +02:00
Girish Ramakrishnan dca548b8a0 apptask: better progress message 2024-08-26 17:26:23 +02:00
Girish Ramakrishnan 56ecfdb4eb Fix crash on missing translation 2024-08-26 17:26:12 +02:00
Johannes Zellner 7640851aa9 dashboard: notification items need more padding on mobile 2024-08-23 19:48:04 +02:00
Johannes Zellner d9301160e1 dashboard: give notification header more horizontal space 2024-08-23 19:45:27 +02:00
Johannes Zellner 3656d7f631 frontend: fix translation resolver to actually fallback to english 2024-08-23 19:41:58 +02:00
Johannes Zellner 9f89b07777 frontend: ensure API_ORIGIN is always set 2024-08-23 19:28:26 +02:00
Johannes Zellner 199dbff7b1 frontend: rework i18n and replace all superagent calls with pankow fetcher 2024-08-23 19:17:23 +02:00
Johannes Zellner 88b8cb48fc Deliver translation files as content type json 2024-08-23 18:34:53 +02:00
Johannes Zellner e8b3232966 frontend: replace more superagent with pankow fetcher 2024-08-23 18:34:53 +02:00
Johannes Zellner 5de7537c71 frontend: replace superagent with pankow fetcher in DirectoryModel 2024-08-23 12:19:47 +02:00
Johannes Zellner 4706313239 frontend: update dependencies 2024-08-23 12:19:47 +02:00
Girish Ramakrishnan d32819da4e i18n: fix crash if language file is missing 2024-08-23 10:20:35 +02:00
Girish Ramakrishnan b6becae396 make TRANSLATIONS_DIR a constant 2024-08-23 10:09:21 +02:00
Johannes Zellner d310c5746e dashboard: improve admin checklist display in postinstall dialog 2024-08-20 19:00:19 +02:00
88 changed files with 1020 additions and 1307 deletions
+24
View File
@@ -0,0 +1,24 @@
run_tests:
stage: test
image: cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4
services:
- name: mysql:8.0
alias: mysql
variables:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: box
BOX_ENV: ci
DATABASE_URL: mysql://root:password@mysql/box
script:
- echo "Running tests..."
- mysql -hmysql -uroot -ppassword -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'password';"
- mysql -hmysql -uroot -ppassword -e "CREATE DATABASE IF NOT EXISTS box"
- npm install
- node_modules/.bin/db-migrate up
- ln -s /usr/local/node-18.18.0/bin/node /usr/bin/node
- node_modules/.bin/mocha --no-timeouts --bail src/test/tokens-test.js
- echo "Done!"
stages:
- test
+21
View File
@@ -2830,3 +2830,24 @@
* sftp: restore mode and owner
* dashboard: also render checklist items in apps.html
[8.0.5]
* cpu quota: fix rounding error
* frontend: fix translation resolver to actually fallback to english
* i18n: fix crash if language file is missing
* memory: fix slider UI where max was incorrectly set
* digitalocean: add LON1 Spaces region
* exoscale: add sos AT-VIE-2 region
* i18n: remove use of "Cloudron"
* tz: add note in backup and update UI
* backups: do not overflow the schedule timings
* checklist: new checklist items on update are acknowledged
* backups: automatically trigger a remount if mount is not active
* logs: rework the syslog parser
* docker: use system dns for app containers
* logs: show error message in UI when log rotated
* unbound: prefer ip4 for dns queries (only on ubuntu 24 and above)
* apps: allow operators to update apps
[8.0.6]
* Fix AdGuard resolving dashboard to docker bridge IP
+5 -4
View File
@@ -171,6 +171,7 @@ const REGIONS_WASABI = [
const REGIONS_DIGITALOCEAN = [
{ name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' },
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
{ name: 'LON1', value: 'https://lon1.digitaloceanspaces.com' },
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
@@ -181,6 +182,7 @@ const REGIONS_DIGITALOCEAN = [
// https://www.exoscale.com/datacenters/
const REGIONS_EXOSCALE = [
{ name: 'Vienna (AT-VIE-1)', value: 'https://sos-at-vie-1.exo.io' },
{ name: 'Vienna (AT-VIE-2)', value: 'https://sos-at-vie-2.exo.io' },
{ name: 'Sofia (BG-SOF-1)', value: 'https://sos-bg-sof-1.exo.io' },
{ name: 'Zurich (CH-DK-2)', value: 'https://sos-ch-dk-2.exo.io' },
{ name: 'Geneva (CH-GVA-2)', value: 'https://sos-ch-gva-2.exo.io' },
@@ -1398,8 +1400,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
};
Client.prototype.getUpdateInfo = function (callback) {
if (!this._userInfo.isAtLeastAdmin) return callback(new Error('Not allowed'));
get('/api/v1/updater/updates', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -2642,9 +2642,10 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
this.config(function (error, result) {
if (error) return callback(error);
that.getUpdateInfo(function (error, info) { // note: non-admin users may get access denied for this
if (!error) result.update = info.update; // attach update information to config object
that.getUpdateInfo(function (error, info) {
if (error) return callback(error);
result.update = info.update;
that.setConfig(result);
callback(null);
});
+7
View File
@@ -135,6 +135,10 @@ select.form-control {
background-position-y: 5px;
}
.form-control {
box-shadow: none;
}
input[type="checkbox"], input[type="radio"] {
margin-top: 2px;
}
@@ -730,6 +734,7 @@ multiselect {
.form-control {
display: inline-block;
width: 200px;
vertical-align: middle;
}
}
@@ -1745,6 +1750,7 @@ div:hover > .picture-edit-indicator {
.form-control {
display: inline-block;
width: 200px;
vertical-align: middle;
}
}
@@ -1759,6 +1765,7 @@ div:hover > .picture-edit-indicator {
.notification-item {
cursor: pointer;
padding: 10px 15px;
&:hover {
box-shadow: 0 2px 27px rgba(0,0,0,.1);
+84 -31
View File
@@ -22,13 +22,17 @@
"auth": {
"sso": "Log ind med Cloudron-oplysninger",
"nosso": "Log ind med en dedikeret konto",
"email": "Log ind med din e-mailadresse"
"email": "Log ind med din e-mailadresse",
"openid": "Log ind med Cloudron OpenID"
},
"addAppAction": "Tilføj app",
"addAppproxyAction": "Tilføj app-proxy",
"addApplinkAction": "Tilføj app-link",
"filter": {
"clearAll": "Ryd alt"
},
"apps": {
"count": "Antal apps: {{ count }}"
}
},
"main": {
@@ -80,7 +84,8 @@
"justNow": "lige nu",
"yeserday": "I går",
"minutesAgo": "{{ m }} minutter siden",
"hoursAgo": "{{ h }} timer siden"
"hoursAgo": "{{ h }} timer siden",
"never": "Aldrig"
},
"navbar": {
"users": "Brugere"
@@ -165,7 +170,10 @@
"loginAction": "Login",
"createAccountAction": "Opret konto",
"switchToSignUpAction": "Har du ikke en konto endnu? Tilmeld dig",
"switchToLoginAction": "Har du allerede en konto? Log ind"
"switchToLoginAction": "Har du allerede en konto? Log ind",
"setupWithTokenAction": "Opsætning",
"setupToken": "Opsætningstoken",
"titleToken": "Tilmeld dig med installationstoken"
},
"title": "App Store",
"searchPlaceholder": "Søg efter alternativer som Github, Dropbox, Slack, Trello, …",
@@ -180,7 +188,7 @@
"users": {
"externalLdap": {
"title": "Tilslut en ekstern mappe",
"description": "Cloudron synkroniserer brugere og grupper fra en ekstern LDAP- eller ActiveDirectory-server. Adgangskodebekræftelse til autentificering af disse brugere foretages mod den eksterne server. Synkroniseringen køres ikke automatisk, men skal udløses manuelt.",
"description": "Denne indstilling synkroniserer og godkender brugere og grupper fra en ekstern LDAP- eller Active Directory-server. Synkroniseringen køres med jævne mellemrum, men kan også udløses manuelt.",
"bindUsername": "Bind DN/Benyttelsesnavn (valgfrit)",
"subscriptionRequiredAction": "Oprettelse af abonnement nu",
"noopInfo": "LDAP-godkendelse er ikke konfigureret.",
@@ -195,14 +203,15 @@
"groupFilter": "Gruppefilter",
"groupnameField": "Groupname Felt",
"auth": "Auth",
"autocreateUsersOnLogin": "Opret automatisk brugere, når de logger ind på Cloudron",
"autocreateUsersOnLogin": "Opret automatisk brugere ved login",
"showLogsAction": "Vis logs",
"syncAction": "Synkroniser",
"configureAction": "Konfigurer",
"bindPassword": "Bind adgangskode (valgfrit)",
"errorSelfSignedCert": "Serveren bruger et ugyldigt eller selvsigneret certifikat.",
"providerOther": "Andre",
"providerDisabled": "Deaktiveret"
"providerDisabled": "Deaktiveret",
"disableWarning": "Godkendelseskilden for alle eksisterende brugere bliver nulstillet til at godkende mod den lokale adgangskodedatabase."
},
"addUserDialog": {
"sendInviteCheckbox": "Send en e-mail med en invitation nu",
@@ -227,7 +236,9 @@
"primaryEmail": "Primær e-mail",
"errorDisplayNameRequired": "Navn er påkrævet",
"activeCheckbox": "Brugeren er aktiv",
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen"
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen",
"external2FA": "2FA-opsætning styres af ekstern godkendelseskilde",
"ldapGroups": "LDAP-grupper"
},
"invitationDialog": {
"descriptionLink": "Kopier link til invitation",
@@ -255,10 +266,11 @@
"description": "Cloudron kan fungere som en central brugerkatalogserver for eksterne programmer.",
"enabled": "Aktiveret",
"ipRestriction": {
"description": "Mappeserveren kan begrænses til bestemte IP'er eller områder.",
"description": "Begræns adgang til Directory Server til specifikke IP'er eller områder. Linjer, der starter med <code>#</code>, behandles som kommentarer.",
"placeholder": "Linjeadskilt IP-adresse eller undernet",
"label": "Begræns adgang"
}
},
"cloudflarePortWarning": "Cloudflare-proxying skal deaktiveres på dashboard-domænet for at få adgang til LDAP-serveren"
},
"userImportDialog": {
"description": "Upload en JSON- eller CSV-fil med det skema, der er beskrevet i vores <a href=\"{{ docsLink }}\" target=\"_blank\">dokumentation</a>",
@@ -482,7 +494,10 @@
"changeEmail": {
"title": "Ændre primær e-mailadresse",
"errorEmailInvalid": "E-mail-adressen er ikke gyldig",
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet"
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet",
"email": "Ny e-mailadresse",
"password": "Adgangskode til bekræftelse",
"errorWrongPassword": "Forkert adgangskode"
},
"changeDisplayName": {
"title": "Ændre dit visningsnavn",
@@ -609,7 +624,7 @@
},
"check": {
"noop": "Cloudron-backups er deaktiveret. Sørg for, at der tages backup af denne server ved hjælp af alternative midler. Se https://docs.cloudron.io/backups/#storage-providers for flere oplysninger.",
"sameDisk": "Cloudron-backups er i øjeblikket på den samme disk som Cloudron-serverinstansen. Dette er farligt og kan føre til fuldstændigt tab af data, hvis disken fejler. Se https://docs.cloudron.io/backups/#storage-providers for lagring af sikkerhedskopier på en ekstern placering."
"sameDisk": "Sikkerhedskopierne ligger i øjeblikket på den samme disk som Cloudron selv. Hvis disken fyldes op med disse sikkerhedskopier, vil Cloudron ikke fungere. En diskfejl kan også føre til fuldstændigt datatab. Se https://docs.cloudron.io/backups/#storage-providers for at gemme sikkerhedskopier på et eksternt sted."
},
"title": "Sikkerhedskopiering",
"logs": {
@@ -642,7 +657,9 @@
"logo": "Logo",
"changeLogo": {
"title": "Vælg Cloudron Avatar"
}
},
"backgroundImage": "Baggrundsbillede af login-side",
"clearBackgroundImage": "Klar"
},
"emails": {
"domains": {
@@ -771,7 +788,7 @@
"ip": {
"interfaceDescription": "Liste over tilgængelige enheder på serveren med:",
"title": "IP-adresse",
"description": "Cloudron bruger denne IP-adresse, når der oprettes DNS-poster.",
"description": "Cloudron bruger denne IPv4-adresse til at oprette DNS A-poster.",
"provider": "Udbyder",
"interface": "Navn på netværksgrænseflade",
"configure": "Konfigurer",
@@ -795,7 +812,7 @@
},
"title": "Netværk",
"configureIp": {
"title": "Konfigurer IP-provider",
"title": "Konfigurer IPv4-provider",
"providerGenericDescription": "Serverens offentlige IP-adresse registreres automatisk."
},
"ipv4": {
@@ -838,14 +855,12 @@
},
"settings": {
"timezone": {
"description": "Den aktuelle tidszoneindstilling er <b>{{{{ timeZone }}}</b>.\nDenne indstilling bruges til planlægning af backup- og opdateringsopgaver.",
"description": "Den aktuelle tidszoneindstilling er <b>{{ timeZone }}</b>. Denne indstilling bruges til at planlægge backup- og opdateringsopgaver. Tidsstempler i brugergrænsefladen vises altid i browserens tidszone.",
"title": "Tidszone"
},
"updates": {
"updateAvailableAction": "Opdatering tilgængelig",
"title": "Opdateringer",
"autoUpdateDisabled": "Automatisk opdatering af platformen og apps er<b>deaktiveret</b>.",
"currentSchedule": "Den nuværende tidsplan for automatisk opdatering af platform og apps er",
"version": "Platform version",
"showLogsAction": "Vis logs",
"changeScheduleAction": "Ændre tidsplan",
@@ -940,7 +955,11 @@
"disableAction": "Deaktivere SSH-støtteadgang",
"enableAction": "Aktiver SSH-støtteadgang"
},
"title": "Støtte"
"title": "Støtte",
"help": {
"title": "Hjælp",
"description": "Brug venligst følgende ressourcer til hjælp og support:\n* [Cloudron Forum]({{ forumLink }}) - Brug venligst de support- og app-specifikke kategorier til spørgsmål.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
}
},
"system": {
"diskUsage": {
@@ -964,7 +983,19 @@
"title": "System Memory",
"graphSubtext": "Kun apps, der bruger mere end {{ threshold }} af memory, vises"
},
"selectPeriodLabel": "Vælg periode"
"selectPeriodLabel": "Vælg periode",
"info": {
"platformVersion": "Platformsversion",
"title": "Info",
"vendor": "Leverandør",
"product": "Produtk",
"memory": "Memory",
"uptime": "Driftstid",
"activationTime": "Cloudrons skabelsestidspunkt"
},
"graphs": {
"title": "Diagrammer"
}
},
"domains": {
"renewCerts": {
@@ -1025,7 +1056,13 @@
"porkbunSecretapikey": "Hemmelig API-nøgle",
"cloudflareDefaultProxyStatus": "Aktiver proxying for nye DNS-poster",
"porkbunApikey": "API-nøgle",
"bunnyAccessKey": "Bunny Access Key"
"bunnyAccessKey": "Bunny Access Key",
"deSecToken": "deSEC Token",
"dnsimpleAccessToken": "Adgangstoken",
"ovhEndpoint": "Endepunkt",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
},
"title": "Domæner og certs",
"addDomain": "Tilføj domæne",
@@ -1089,7 +1126,8 @@
"copy": "Kopier",
"clear": "Klar",
"pasteInfo": "Brug Ctrl+v til at indsætte for at indsætte"
}
},
"uploadTo": "Upload til {{ path }}"
},
"filemanager": {
"newFileDialog": {
@@ -1121,7 +1159,8 @@
"renameDialog": {
"title": "Omdøb {{ fileName }}",
"newName": "Nyt navn",
"rename": "Omdøb"
"rename": "Omdøb",
"reallyOverwrite": "Der findes allerede en fil med det navn. Overskrive eksisterende fil?"
},
"extractDialog": {
"title": "Udpakning af {{ fileName }}",
@@ -1278,7 +1317,7 @@
},
"enableEmailDialog": {
"description": "Dette vil konfigurere Cloudron til at modtage e-mails for<b>{{ domain }}</b>Se dokumentationen for åbning af de <a href=\"{{{ requiredPortsDocsLink }}\" target=\"_blank\">forpligtede porte</a> for Cloudron Email.",
"cloudflareInfo": "Domænet <code>{{{ adminDomain }}</code> administreres af Cloudflare. Kontroller venligst, at Cloudflare-proxying er deaktiveret for <code>{{{ mailFqdn }}</code> og indstillet til <code>Kun DNS</code>. Dette er påkrævet, fordi Cloudflare ikke giver proxy for e-mail.",
"cloudflareInfo": "Mailserverens domæne <code>{{ adminDomain }}</code> administreres af Cloudflare. Kontrollér, at Cloudflare-proxy er deaktiveret for <code>{{ mailFqdn }}</code> og indstillet til <code>kun DNS</code>. Dette er nødvendigt, fordi Cloudflare ikke proxy'er e-mail.",
"title": "Aktiver e-mail for {{ domain }}?",
"noProviderInfo": "Der er ikke oprettet nogen DNS-udbyder. De DNS-poster, der er anført i fanen Status, skal oprettes manuelt.",
"setupDnsCheckbox": "Opsæt Mail DNS-poster nu",
@@ -1431,7 +1470,7 @@
},
"memory": {
"title": "Memory graense",
"description": "Cloudron tildeler 50 % af denne værdi som RAM og 50 % som swap.",
"description": "Maksimal arbejdshastighed, som appen kan bruge",
"error": "Kan ikke indstille memory limit, prøv mindre.",
"resizeAction": "Ændre størrelse"
}
@@ -1492,11 +1531,12 @@
"packageVersion": "Pakkeversion",
"lastUpdated": "Sidst opdateret",
"checkForUpdatesAction": "Tjek for opdateringer",
"customAppUpdateInfo": "Opdateringer er ikke tilgængelige for brugerdefinerede apps",
"updateAvailableAction": "Opdatering tilgængelig"
"customAppUpdateInfo": "Automatisk opdatering er ikke tilgængelig for brugerdefinerede apps.",
"updateAvailableAction": "Opdatering tilgængelig",
"installedAt": "Installeret på"
},
"auto": {
"description": "Cloudron kontrollerer jævnligt App Store for opdateringer. Hvis du deaktiverer automatiske opdateringer, skal du sørge for at anvende opdateringerne manuelt.",
"description": "Cloudron tjekker med jævne mellemrum <a href=»{{ appStoreLink }}« target=»_blank«>App Store</a> for opdateringer.",
"title": "Automatiske opdateringer",
"enabled": "Automatiske opdateringer er i øjeblikket aktiveret.",
"disabled": "Automatiske opdateringer er i øjeblikket deaktiveret.",
@@ -1569,7 +1609,8 @@
"openAction": "Åbn {{ app }}",
"firstTimeTitle": "Første gang du bruger det",
"firstTimeCollapseHeader": "Første gangs opsætningsvejledning",
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app."
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app.",
"checklist": "Administrativ tjekliste"
},
"restoreDialog": {
"warning": "Alle data, der er genereret mellem nu og den sidst kendte sikkerhedskopi, vil uigenkaldeligt gå tabt. Det anbefales at oprette en sikkerhedskopi af de aktuelle data, før du forsøger at gendanne dem.",
@@ -1718,6 +1759,12 @@
"title": "Redis-konfiguration",
"enable": "Konfigurer appen til at bruge Redis",
"disable": "Deaktiver Redis"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Administrative noter"
}
}
},
"passwordReset": {
@@ -1821,7 +1868,11 @@
"mountStatus": "Status for montering",
"type": "Type",
"localDirectory": "Lokal vejviser",
"remountActionTooltip": "Genmonter"
"remountActionTooltip": "Genmonter",
"editVolumeDialog": {
"title": "Rediger volumen {{ name }}"
},
"editActionTooltip": "Rediger volumen"
},
"newLoginEmail": {
"topic": "Vi har bemærket et nyt login på din Cloudron-konto.",
@@ -1859,7 +1910,8 @@
"signInAction": "Log ind",
"resetPasswordAction": "Nulstil adgangskode",
"errorIncorrect2FAToken": "2FA-token er ugyldig",
"errorInternal": "Intern fejl, prøv igen senere"
"errorInternal": "Intern fejl, prøv igen senere",
"loginWith": "Log ind med Cloudron"
},
"lang": {
"en": "English",
@@ -1874,7 +1926,8 @@
"es": "Spansk",
"ru": "Russisk",
"pt": "Portugisisk",
"da": "Dansk"
"da": "Dansk",
"id": "Indonesisk"
},
"supportConfig": {
"emailNotVerified": "Du bedes først bekræfte e-mailen på cloudron.io-kontoen for at sikre, at vi kan kontakte dig."
+1 -3
View File
@@ -167,13 +167,11 @@
"updates": {
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
"title": "Aktualisierungen",
"currentSchedule": "Die Einstellungen für die automatische Aktualisierung für System und Anwendungen lautet:",
"version": "Systemversion",
"changeScheduleAction": "Zeitplan ändern",
"stopUpdateAction": "Aktualisierung abbrechen",
"updateAvailableAction": "Aktualisierung verfügbar",
"showLogsAction": "Logfiles anzeigen",
"autoUpdateDisabled": "Die automatische Aktualisierung des Systems und der Anwendungen ist <b>deaktiviert</b>."
"showLogsAction": "Logfiles anzeigen"
},
"appstoreAccount": {
"title": "Cloudron.io-Konto",
+15 -13
View File
@@ -521,7 +521,7 @@
"title": "Backups",
"location": {
"title": "Location",
"description": "Cloudron makes a complete backup of your system at the configured location.",
"description": "A complete backup of your system is saved to the storage location with the configured format.",
"disabledList": "The following apps have automatic backups disabled:",
"provider": "Provider",
"location": "Location",
@@ -532,7 +532,7 @@
},
"schedule": {
"title": "Schedule and Retention",
"description": "Cloudron makes a complete backup of your system based on this scheduled interval and keeps backups with the specified retention policy.",
"description": "A complete backup of the system is created based on the specified Schedule in the <a href=\"/#/settings\">System Time Zone</a>. Old backups are removed based on the Retention Policy.",
"schedule": "Schedule",
"retentionPolicy": "Retention Policy",
"configure": "Configure"
@@ -788,7 +788,7 @@
"title": "Network",
"ip": {
"title": "IPv4",
"description": "Cloudron uses this IPv4 address to setup DNS A records.",
"description": "This IPv4 address is used to set up DNS A records.",
"provider": "Provider",
"interface": "Network Interface Name",
"configure": "Configure",
@@ -821,7 +821,7 @@
"ipv6": {
"address": "IPv6 Address",
"title": "IPv6",
"description": "Cloudron uses this IPv6 address to setup DNS AAAA records.\n"
"description": "This IPv6 address is used to set up DNS AAAA records."
},
"configureIpv6": {
"title": "Configure IPv6 Provider"
@@ -835,7 +835,7 @@
},
"services": {
"title": "Services",
"description": "Cloudron services implement functionality such as databases, email and authentication.",
"description": "Services implement functionality such as databases, email and authentication.",
"service": "Service",
"memoryUsage": "Memory Usage",
"memoryLimit": "Memory Limit",
@@ -869,19 +869,20 @@
"emailNotVerified": "Email not yet verified"
},
"timezone": {
"title": "Time Zone",
"title": "System Time Zone",
"description": "The current timezone setting is <b>{{ timeZone }}</b>. This setting is used for scheduling backup and update tasks. Timestamps in the UI are always displayed using the browser's timezone."
},
"updates": {
"title": "Updates",
"autoUpdateDisabled": "Automatic update for the platform and apps is <b>disabled</b>.",
"currentSchedule": "The current automatic update schedule for platform and apps is",
"version": "Platform version",
"showLogsAction": "Show Logs",
"changeScheduleAction": "Change Schedule",
"checkForUpdatesAction": "Check for Updates",
"updateAvailableAction": "Update Available",
"stopUpdateAction": "Stop Update"
"stopUpdateAction": "Stop Update",
"disabled": "Disabled",
"schedule": "Schedule",
"description": "Platform and App Updates are automatically applied based on the Schedule in the <a href=\"/#/settings\">System Time Zone</a>."
},
"privateDockerRegistry": {
"title": "Private Docker Registry",
@@ -1016,7 +1017,7 @@
"tooltipRemove": "Remove Domain",
"renewCerts": {
"title": "Renew certificates",
"description": "Cloudron renews Let's Encrypt certificates automatically. Use this option to trigger a renewal immediately.",
"description": "Let's Encrypt certificates are renewed automatically. Use this option to trigger a renewal immediately.",
"renewAllAction": "Renew All Certs",
"showLogsAction": "Show Logs"
},
@@ -1301,7 +1302,7 @@
"outbound": {
"tabTitle": "Outbound",
"title": "Email Relay",
"description": "Cloudron will use this mail server (Smart host) to send the outbound mails of apps installed under this domain.",
"description": "This mail server (Smart host) will be used to send the outbound mails of apps installed under this domain.",
"noopAdminDomainWarning": "Cloudron cannot send user invites, password reset and other notifications when email is disabled on the primary domain",
"noopNonAdminDomainWarning": "Cloudron cannot provide email sending for apps hosted under this domain when email is disabled.",
"mailRelay": {
@@ -1627,7 +1628,7 @@
},
"auto": {
"title": "Automatic Backups",
"description": "Cloudron periodically creates a backup based on the <a href=\"{{ backupLink }}\">backup</a> settings.",
"description": "Backups are periodically created based on the <a href=\"{{ backupLink }}\">Backup Schedule</a>.",
"enabled": "Automatic Backups is currently enabled.",
"disabled": "Automatic Backups is currently disabled.",
"disableAction": "Disable Automatic Backups",
@@ -1672,7 +1673,8 @@
"openAction": "Open {{ app }}",
"firstTimeTitle": "First Time Usage",
"firstTimeCollapseHeader": "First time setup instructions",
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app."
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app.",
"checklist": "Admin Checklist"
},
"uninstallDialog": {
"title": "Uninstall {{ app }}",
-2
View File
@@ -871,8 +871,6 @@
"changeScheduleAction": "Cambiar Programación",
"showLogsAction": "Mostrar Registros",
"version": "Versión de la Plataforma",
"currentSchedule": "El programa actual de actualización automática para la plataforma y las aplicaciones es",
"autoUpdateDisabled": "La actualización automática de la plataforma y las aplicaciones está <b> desactivada </b>.",
"title": "Actualizaciones"
},
"language": {
-2
View File
@@ -791,8 +791,6 @@
"changeScheduleAction": "Modifier la fréquence",
"showLogsAction": "Afficher les journaux",
"version": "Version de la plateforme",
"currentSchedule": "La mise à jour automatique de la plateforme et des application a lieu",
"autoUpdateDisabled": "La mise à jour automatique de la plateforme et des applications est <b>désactivée</b>.",
"title": "Mises à jour"
},
"timezone": {
-2
View File
@@ -1151,8 +1151,6 @@
"changeScheduleAction": "Cambia Pianificazione",
"showLogsAction": "Visualizza Logs",
"version": "Versione piattaforma",
"currentSchedule": "L'attuale programma di aggiornamento automatico per piattaforma e app è",
"autoUpdateDisabled": "L'aggiornamento automatico per la piattaforma e le app è <b>disabilitato</b>.",
"title": "Aggiornamenti"
},
"timezone": {
+3 -4
View File
@@ -1117,7 +1117,8 @@
"customAppUpdateWarning": "Dit is een aangepaste app en niet geïnstalleerd vanuit de App Store, het krijgt hierdoor geen updates. Lees de <a target=\"_blank\" href=\"{{ docsLink }}\">documentatie</a> over hoe je een aangepaste app kunt updaten.",
"appDocsUrl": "Bekijk de <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} documentatie</a> voor informatie en tips over deze app. Indien je meer hulp nodig hebt ga dan naar Cloudron's <a target=\"_blank\" href=\"{{ forumUrl }}\">{{ title }} forum</a>.",
"sso": "Deze app is ingesteld voor authenticatie via het Cloudron gebuikersadresboek. Cloudron gebruikers kunnen inloggen en het direct gebruiken.",
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox."
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox.",
"checklist": "Admin Controlelijst"
},
"uninstallDialog": {
"uninstallAction": "De-installeer",
@@ -1315,12 +1316,10 @@
},
"timezone": {
"title": "Tijdzone",
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>.\nDeze instelling wordt gebruikt voor backup planning en update taken."
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>. Deze instelling wordt gebruikt voor backup planning en update taken. Tijdseenheden in de gebruikersschermen zijn op basis van de browers tijdzone."
},
"updates": {
"title": "Updates",
"autoUpdateDisabled": "Automatische update voor het platform en apps is <b>uitgeschakeld</b>.",
"currentSchedule": "De huidige automatische update planning voor het platform en de apps is",
"showLogsAction": "Toon logbestanden",
"changeScheduleAction": "Planning aanpassen",
"checkForUpdatesAction": "Controleer op updates",
+1 -3
View File
@@ -1228,9 +1228,7 @@
"checkForUpdatesAction": "Проверить обновления",
"updateAvailableAction": "Обновление доступно",
"version": "Версия платформы",
"stopUpdateAction": "Остановить обновление",
"autoUpdateDisabled": "Автоматические обновления для платформы и приложений <b>выключены</b>.",
"currentSchedule": "Текущее расписание автоматических обновлений для платформы и приложений"
"stopUpdateAction": "Остановить обновление"
},
"privateDockerRegistry": {
"title": "Частный реестр Docker",
+117 -55
View File
@@ -22,14 +22,18 @@
"auth": {
"email": "Đăng nhập bằng email",
"sso": "Đăng nhập với tên & mật khẩu trên Cloudron",
"nosso": "Đăng nhập vào tài khoản riêng"
"nosso": "Đăng nhập bằng tài khoản riêng",
"openid": "Đăng nhập bằng Cloudron OpenID"
},
"addAppAction": "Thêm App",
"addApplinkAction": "Thêm đường link App",
"addApplinkAction": "Thêm link App",
"filter": {
"clearAll": "Xoá tất cả"
},
"addAppproxyAction": "Thêm proxy cho app"
"addAppproxyAction": "Thêm proxy cho app",
"apps": {
"count": "Tổng số app: {{ count }}"
}
},
"main": {
"logout": "Thoát",
@@ -80,7 +84,8 @@
"justNow": "mới đây",
"yeserday": "Hôm qua",
"minutesAgo": "{{ m }} phút trước",
"hoursAgo": "{{ h }} tiếng trước"
"hoursAgo": "{{ h }} tiếng trước",
"never": "Chưa lần nào"
},
"statusEnabled": "Đã bật",
"statusDisabled": "Đã tắt",
@@ -107,7 +112,7 @@
"finance": "Tài chính",
"git": "Chạy code",
"email": "Email",
"game": "Game",
"game": "Trò chơi",
"hosting": "Chạy web",
"media": "Hình ảnh",
"learning": "Học tập",
@@ -130,7 +135,7 @@
"manualWarning": "Thêm A record cho <b>{{ nơi cài đặt }}</b> vào địa chỉ IP công cộng của Cloudron này",
"userManagement": "Quản lý người dùng",
"userManagementMailbox": "Tất cả người dùng với hộp thư trên Cloudron này có quyền truy cập app.",
"userManagementLeaveToApp": "Để phần quản lý người dùng cho app",
"userManagementLeaveToApp": "Để app quản lý người dùng",
"userManagementAllUsers": "Cho phép tất cả người dùng trên Cloudron truy cập",
"errorUserManagementSelectAtLeastOne": "Chọn ít nhất một người dùng hay nhóm",
"users": "Người dùng",
@@ -138,10 +143,10 @@
"lowOnResources": "Cloudron này đang chạy gần hết bộ nhớ.",
"pleaseUpgradeServer": "Hãy nâng cấp server có bộ nhớ nhiều hơn. Hoặc, xoá những app không dùng đến để có thêm chỗ trống.",
"setupSubscriptionAction": "Cài đặt gói đăng ký",
"installAnywayAction": "Vẫn tải về luôn",
"installAnywayAction": "Vẫn tải về",
"installAction": "Tải về",
"subscriptionRequired": "Để cài đặt thêm app, hãy đăng ký gói trả phí.",
"userManagementNone": "App này có phần quản lý người dùng riêng. Phần cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
"userManagementNone": "App này có phần quản lý người dùng riêng. Cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
"userManagementSelectUsers": "Chỉ cho phép người dùng và nhóm sau",
"configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron Email</a>.",
"doInstallAction": "Tải về {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
@@ -167,7 +172,10 @@
"switchToLoginAction": "Đã có tài khoản rồi? Đăng nhập",
"switchToSignUpAction": "Chưa có tài khoản? Hãy đăng ký nhé",
"description": "Tài khoản này được dùng để truy cập Cửa hàng App và quản lý gói đăng ký của bạn",
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron"
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron",
"setupWithTokenAction": "Cài đặt",
"titleToken": "Đăng ký với Mã cài đặt",
"setupToken": "Cài đặt Mã"
},
"searchPlaceholder": "Tìm kiếm app thay thế cho Github, Dropbox, Slack, Trello, …",
"appMissing": "Thiếu app nào đó? Hãy nhắn cho chúng tôi.",
@@ -205,7 +213,9 @@
"username": "Tên đăng nhập",
"fullName": "Họ tên",
"fallbackEmailPlaceholder": "Không bắt buộc. Nếu không được xác định, email chính sẽ được sử dụng",
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký"
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký",
"external2FA": "Nguồn xác thực ngoài đang quản lý cài đặt Mã xác minh 2 Bước",
"ldapGroups": "Nhóm LDAP"
},
"addUserDialog": {
"addUserAction": "Thêm người dùng",
@@ -223,7 +233,7 @@
"configureAction": "Cấu hình",
"syncAction": "Đồng bộ",
"showLogsAction": "Hiển thị log",
"autocreateUsersOnLogin": "Tự động tạo tài khoản người dùng khi họ đăng nhập vào Cloudron",
"autocreateUsersOnLogin": "Tự động tạo người dùng khi họ đăng ",
"auth": "Xác minh",
"groupnameField": "Vùng tên nhóm",
"groupFilter": "Lọc nhóm",
@@ -237,10 +247,11 @@
"provider": "Nhà cung cấp",
"noopInfo": "Xác thực LDAP chưa được thiết lập.",
"subscriptionRequiredAction": "Cài đặt gói đăng ký ngay",
"description": "Cloudron sẽ đồng bộ người dùng và nhóm từ server LDAP hay ActiveDirectory bên ngoài. Xác minh mật khẩu cho người dùng được dựa trên server ngoài. Việc đồng bộ hoá không được chạy tự động mà cần được khởi động bằng tay.",
"description": "Cài đặt này đồng bộ và xác thực người dùng và nhóm từ một server LDAP hay ActiveDirectory bên ngoài. Sự đồng bộ hóa này được chạy theo chu kỳ nhưng cũng có thể được khởi động bằng tay.",
"title": "Kết nối thư mục ngoài",
"providerOther": "Khác",
"providerDisabled": "Đã tắt"
"providerDisabled": "Đã tắt",
"disableWarning": "Nguồn mã xác minh cho tất cả người dùng hiện hữu sẽ được cài đặt lại dựa trên cơ sở dữ liệu mật khẩu nội bộ trên server."
},
"users": {
"inactiveTooltip": "Người dùng không hoạt động",
@@ -250,12 +261,12 @@
"notActivatedYetTooltip": "Người dùng chưa được kích hoạt",
"externalLdapTooltip": "Từ thư mục LDAP ngoài",
"usermanagerTooltip": "Người dùng này có thể quản lý nhóm và những người dùng khác",
"adminTooltip": "Người dùng này có vai trò admin",
"superadminTooltip": "Người dùng này có vai trò superadmin",
"adminTooltip": "Người dùng này admin",
"superadminTooltip": "Người dùng này superadmin",
"empty": "Không tìm thấy người dùng",
"groups": "Nhóm",
"user": "Người dùng",
"transferOwnershipTooltip": "Chuyển đổi quyền sở hữu",
"transferOwnershipTooltip": "Chuyển nhượng quyền sở hữu",
"invitationTooltip": "Mời Người dùng",
"setGhostTooltip": "Nhập vai",
"count": "Tổng ng dùng: {{ count }}",
@@ -334,7 +345,7 @@
"enabled": "Đã bật",
"title": "Máy chủ chỉ mục",
"ipRestriction": {
"description": "Máy chủ chỉ mục có thể được giới hạn cho những địa chỉ IP hoặc khoảng vùng cụ thể.",
"description": "Giới hạn quyền truy cập máy chủ chỉ mục cho những địa chỉ IP hoặc khoảng vùng cụ thể. Những dòng bắt đầu bằng dấu <code>#</code> được xem như ghi chú thêm.",
"placeholder": "Viết xuống dòng những địa chỉ IP hoặc Subnet",
"label": "Giới hạn quyền truy cập"
},
@@ -342,7 +353,8 @@
"label": "Mật khẩu bind",
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>",
"url": "URL máy chủ"
}
},
"cloudflarePortWarning": "Cần tắt proxy Cloudflare cho tên miền dashboard để truy cập LDAP server"
},
"userImportDialog": {
"success": "{{ count }} người dùng đã được nhập vào.",
@@ -478,7 +490,10 @@
"changeEmail": {
"title": "Thay đổi email chính",
"errorEmailInvalid": "Email không hợp lệ",
"errorEmailRequired": "Bạn cần nhập một email hợp lệ"
"errorEmailRequired": "Bạn cần nhập một email hợp lệ",
"email": "Thêm địa chỉ mail mới",
"password": "Mật khẩu để xác nhận",
"errorWrongPassword": "Sai mật khẩu"
},
"disable2FAAction": "Tắt xác minh hai bước",
"changeFallbackEmail": {
@@ -499,7 +514,8 @@
"passwordResetNotification": {
"title": "Đã đặt lại mật khẩu thành công",
"body": "Email đã được gửi đến {{ email }}"
}
},
"enable2FANotAvailable": "Không cài được cho người dùng từ nguồn xác minh ngoài"
},
"backups": {
"location": {
@@ -617,7 +633,7 @@
},
"check": {
"noop": "Tính năng sao lưu Cloudron đã tắt. Hãy chắc rằng server được sao lưu bằng một biện pháp khác. Xem thông tin thêm tại https://docs.cloudron.io/backups/#storage-providers.",
"sameDisk": "Các bản sao lưu Cloudron đang ở trên cùng ổ đĩa với server chạy Cloudron. Việc này sẽ nguy hiểm và có thể dẫn đến mất dữ liệu nếu ổ đĩa bị trục trặc. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
"sameDisk": "Các bản sao lưu Cloudron hiện đang ở trên cùng ổ đĩa với server chạy Cloudron. Nếu ổ đĩa chứa đầy các bản sao lưu, Cloudron sẽ không hoạt động được. Sự c trục trặc ổ đĩa cũng có thể làm mất dữ liệu hoàn toàn. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
},
"backupEdit": {
"preserved": {
@@ -625,7 +641,8 @@
"description": "Vẫn giữ bản sao lưu mặc kệ chính sách lưu giữ được định thế nào"
},
"title": "Chỉnh sửa Bản sao lưu",
"label": "Nhãn"
"label": "Nhãn",
"remotePath": "Đường dẫn"
}
},
"login": {
@@ -637,7 +654,8 @@
"errorIncorrectCredentials": "Không đúng tên đăng nhập hoặc mật khẩu",
"loginTo": "Đăng nhập vào",
"errorIncorrect2FAToken": "Mã bảo mật 2 Bước không đúng",
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau"
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau",
"loginWith": "Đăng nhập bằng Cloudron"
},
"setupAccount": {
"username": "Tên đăng nhập",
@@ -670,7 +688,7 @@
"enableAction": "Bật",
"setupDnsInfo": "Sử dụng lựa chọn này để cài đặt những bản ghi có liên quan đến email. Để trống lựa chọn này sẽ hữu ích cho việc tạo ra các hộp thư và <a href=\"{{ importEmailDocsLink }}\">nhập dữ liệu các mail đã có sẵn</a> trước khi đưa vào sử dụng.",
"setupDnsCheckbox": "Cài đặt các bản ghi DNS ngay",
"cloudflareInfo": "Tên miền <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Xin chắc rằng proxy qua Cloudflare đã được tắt cho <code>{{ mailFqdn }}</code> và được chỉnh về chế độ<code>DNS only</code>. Việc này là cần thiết vì Cloudflare không proxy được email.",
"cloudflareInfo": "Tên miền cho mail server <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Hãy nhớ tắt proxy qua Cloudflare cho <code>{{ mailFqdn }}</code> và chỉnh về chế độ <code>DNS only</code>. Cần làm vậy vì Cloudflare không proxy được email.",
"noProviderInfo": "Chưa cài đặt nhà cung cấp DNS. Những bản ghi DNS trong phần Trạng thái cần được cài đặt thủ công.",
"description": "Lựa chọn này sẽ cấu hình Cloudron để nhận mail cho <b>{{ domain }}</b>. Xem hướng dẫn để mở <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">những cổng cần thiết</a> cho Email Cloudron.",
"title": "Bật chế độ email cho {{ domain }}?"
@@ -856,7 +874,7 @@
"network": {
"configureIp": {
"providerGenericDescription": "Địa chỉ IP công cộng của server này sẽ được tự động dò tìm ra.",
"title": "Cấu hình nhà cung cấp IP"
"title": "Cấu hình nhà cung cấp IPv4"
},
"dyndns": {
"description": "Bật lựa chọn này để đồng bộ các bản ghi DNS với một địa chỉ IP thường xuyên thay đổi. Việc này hữu ích khi Cloudron chạy trên hệ thống mạng với địa chỉ IP hay thay đổi như kết nối mạng ở nhà.",
@@ -879,8 +897,8 @@
"configure": "Cấu hình",
"interface": "Tên giao diện mạng",
"provider": "Nhà cung cấp",
"description": "Cloudron dùng địa chỉ IP này để cài đặt các bản ghi DNS.",
"title": "Địa chỉ IP",
"description": "Cloudron dùng địa chỉ IPv4 này để cài đặt các bản ghi A của DNS.",
"title": "IPv4",
"address": "Địa chỉ IP"
},
"title": "Mạng",
@@ -981,7 +999,8 @@
"info": "Các cài đặt này áp dụng cho tất cả các tên miền.",
"title": "Cài đặt",
"acl": "Danh sách quản lý truy cập mail",
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL"
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL",
"virtualAllMail": "Thư mực \"Tất cả Thư\""
},
"domains": {
"testEmailTooltip": "Gửi mail thử",
@@ -1018,6 +1037,10 @@
},
"action": {
"queue": "Cho vào hàng chờ gửi sau"
},
"changeVirtualAllMailDialog": {
"description": "Thư mục \"Tất cả Thư\" là một thư mục chứa tất cả thư trong hộp thư của bạn. Thư mục này hữu dụng cho những mail client mà không hỗ trợ chức năng tìm kiếm thư mục xoay vòng.",
"title": "Thư mực \"Tất cả Thư\""
}
},
"branding": {
@@ -1032,7 +1055,9 @@
},
"logo": "Logo",
"cloudronName": "Tên cho Cloudron",
"title": "Thương hiệu"
"title": "Thương hiệu",
"backgroundImage": "Hình nền trang đăng nhập",
"clearBackgroundImage": "Xoá"
},
"eventlog": {
"time": "Thời gian",
@@ -1064,7 +1089,19 @@
"uninstalledApp": "App đã xoá",
"diskSpeed": "Tốc độ: {{ speed }} MB/s"
},
"title": "Hệ thống"
"title": "Hệ thống",
"info": {
"activationTime": "Ngày tạo Cloudron",
"platformVersion": "Phiên bản hệ thống",
"title": "Thông tin",
"vendor": "Nhà cung cấp",
"product": "Sản phẩm",
"memory": "Bộ nhớ",
"uptime": "Thời gian online"
},
"graphs": {
"title": "Biểu đồ"
}
},
"support": {
"remoteSupport": {
@@ -1093,9 +1130,14 @@
"subscriptionRequired": "Phiếu hỗ trợ chỉ có trong những gói trả phí.",
"title": "Phiếu hỗ trợ",
"emailNotVerified": "Email tài khoản cloudron.io của bạn {{ email }} chưa được xác minh. Xin hãy xác minh mail trước để tạo phiếu hỗ trợ.",
"emailVerifyAction": "Xác minh ngay"
"emailVerifyAction": "Xác minh ngay",
"typeBilling": "Vấn đề Hóa đơn"
},
"title": "Hỗ trợ"
"title": "Hỗ trợ",
"help": {
"description": "Xin dùng những nguồn lực sau để được trợ giúp và hỗ trợ\n* [Diễn dàn Cloudron]({{ forumLink }}) - Vui lòng vào Mục Hỗ trợ & App cụ thể để đặt câu hỏi.\n* [HDSD & Kho kiến thức Cloudron]({{ docsLink }})\n* [Đóng gói App tùy chỉnh & API]({{ packagingLink }})\n",
"title": "Hỗ trợ"
}
},
"settings": {
"registryConfig": {
@@ -1148,17 +1190,15 @@
"changeScheduleAction": "Thay đổi lịch cập nhật",
"showLogsAction": "Hiển thị log",
"version": "Phiên bản hệ thống",
"currentSchedule": "Lịch cập nhật tự động hiện tại cho hệ thống và các app là",
"autoUpdateDisabled": "Cập nhật tự động cho hệ thống và các app <b>đã tắt</b>.",
"title": "Cập nhật"
},
"timezone": {
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>.\nMúi giờ này được dùng cho việc lên lịch sao lưu và cập nhật hệ thống.",
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>. Cài đặt này được dùng cho tác vụ sao lưu và cập nhật. Dấu thời gian hiện ở giao diện được hiển thị theo múi giờ của trình duyệt hiện dùng.",
"title": "Múi giờ"
},
"appstoreAccount": {
"subscriptionReactivateAction": "Kích hoạt lại gói đăng ký",
"subscriptionChangeAction": "Thay đổi gói đăng ký",
"subscriptionChangeAction": "Quản lý gói đăng ký",
"subscriptionSetupAction": "Nâng cấp Gói Cao cấp",
"subscriptionEndsAt": "Đã huỷ đăng ký và kết thúc vào",
"cloudronId": "Mã Cloudron ID",
@@ -1264,7 +1304,8 @@
"renameDialog": {
"rename": "Đổi tên",
"newName": "Tên mới",
"title": "Đổi tên {{ fileName }}"
"title": "Đổi tên {{ fileName }}",
"reallyOverwrite": "Trùng tên tập tin hiện có. Ghi đè lên tập tin cũ?"
},
"newFileDialog": {
"create": "Tạo",
@@ -1315,7 +1356,8 @@
"filePath": "Đường chỉ đến tập tin hay thư mục",
"title": "Tải xuống từ {{ name }}"
},
"title": "Màn hình terminal"
"title": "Màn hình terminal",
"uploadTo": "Tải lên {{ path }}"
},
"logs": {
"download": "Tải xuống tất cả log",
@@ -1384,7 +1426,13 @@
"cloudflareDefaultProxyStatus": "Bật tính năng proxy cho những bản ghi DNS mới",
"porkbunSecretapikey": "Mã bí mật API",
"bunnyAccessKey": "Mã truy cập Bunny",
"porkbunApikey": "Key API"
"porkbunApikey": "Key API",
"deSecToken": "Mã deSEC",
"dnsimpleAccessToken": "Mã truy cập",
"ovhAppSecret": "Mã bí mật App",
"ovhEndpoint": "Điểm Endpoint",
"ovhConsumerKey": "Mã Khách hàng",
"ovhAppKey": "Mã App"
},
"subscriptionRequired": {
"description": "Để thêm tên miền, hãy đăng ký gói trả phí.",
@@ -1432,13 +1480,14 @@
"firstTimeCollapseHeader": "Hướng dẫn cho lần cài đặt đầu tiên",
"openAction": "Mở {{ app }}",
"postInstallConfirmCheckbox": "Đã xem hướng dẫn",
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>."
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>.",
"checklist": "Danh sách kiểm tra cho Admin"
},
"uninstall": {
"uninstall": {
"uninstallAction": "Xoá",
"backupWarning": "Các bản sao lưu app sẽ không được xoá ngay mà sẽ dựa vào lịch trình sao lưu được định sẵn. Bạn có thể hồi sinh app từ một bản sao lưu hiện có bằng những <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">hướng dẫn sau đây</a>.",
"description": "Lựa chọn này sẽ gỡ cài đặt app ngay lập tức và xoá hết tất cả những dữ liệu liên quan. Trang web sẽ không còn truy cập được sau đó.",
"description": "Việc này sẽ xóa app ngay lập tức và tất cả dữ liệu. Trang sẽ không còn truy cập được sau khi xóa.",
"title": "Xoá"
},
"startStop": {
@@ -1491,23 +1540,24 @@
},
"updates": {
"auto": {
"enableAction": "Bật chế độ cập nhật tự động",
"disableAction": "Tắt chế độ cập nhật tự động",
"enableAction": "Bật cập nhật tự động",
"disableAction": "Tắt cập nhật tự động",
"disabled": "Cập nhật tự động hiện đang tắt.",
"enabled": "Cập nhật tự dộng đang được mở.",
"description": "Cloudron định kỳ kiểm tra Cửa hàng app cho các phiên bản cập nhật mới. Nếu bạn tắt chế độ cập nhật tự động, xin chắc rằng bạn cài đặt thủ công các cập nhật phiên bản mới.",
"description": "Cloudron định kỳ kiểm tra <a href=\"{{ appStoreLink }}\" target=\"_blank\">Cửa hàng App </a> cho phiên bản app mới.",
"title": "Cập nhật tự động"
},
"info": {
"updateAvailableAction": "Có phiên bản cập nhật mới",
"customAppUpdateInfo": "Phiên bản mới không có sẵn cho các app tuỳ chỉnh",
"customAppUpdateInfo": "Tự động cập nhật không có sẵn cho các app tùy chỉnh.",
"checkForUpdatesAction": "Kiểm tra cập nhật",
"lastUpdated": "Lần cuối cập nhật",
"packageVersion": "Phiên bản đóng gói",
"appId": "ID của app",
"description": "Tên app và phiên bản",
"title": "Thông tin app",
"repository": "Repo của bản đống gói"
"repository": "Repo của bản đống gói",
"installedAt": "Được cài lúc"
},
"noUpdates": "Không có phiên bản mới"
},
@@ -1586,14 +1636,14 @@
},
"resources": {
"cpu": {
"description": "Phần trăm thời gian CPU dành cho app khi hệ thống đang chịu tải nặng.",
"title": "Chia phần trong CPU",
"setAction": "Cài đặt"
"description": "Phần trăm CPU tối đa app có thể dùng",
"title": "Giới hạn CPU",
"setAction": "Nâng lên"
},
"memory": {
"resizeAction": "Chỉnh lại",
"error": "Hệ thống không chỉnh được giới hạn bộ nhớ này, hãy thử một giá trị thấp hơn.",
"description": "Cloudron dành 50% giá trị này cho RAM và 50% còn lại cho swap.",
"description": "Bộ nhớ tối đa app có thể dùng",
"title": "Giới hạn bộ nhớ"
}
},
@@ -1747,7 +1797,8 @@
},
"redis": {
"title": "Thiết lập Redis",
"enable": "Thiết lập app sử dụng Redis"
"enable": "Thiết lập app sử dụng Redis",
"disable": "Tắt Redis"
},
"addApplinkDialog": {
"title": "Thêm link app bên ngoài"
@@ -1761,6 +1812,12 @@
"upstreamUri": "Đường dẫn bên ngoài",
"label": "Nhãn",
"clearIconAction": "Xoá biểu tượng"
},
"infoTabTitle": "Thông tin",
"info": {
"notes": {
"title": "Ghi chú của Admin"
}
}
},
"volumes": {
@@ -1795,10 +1852,14 @@
},
"mountStatus": "Trạng thái mount",
"type": "Dạng",
"tooltipEdit": "Chỉnh sửa Volume",
"tooltipEdit": "Chỉnh Volume",
"localDirectory": "Thư mục trên máy",
"remountActionTooltip": "Mount Volume lại",
"mountType": "Dạng mount"
"mountType": "Dạng mount",
"editVolumeDialog": {
"title": "Chỉnh volume {{ name }}"
},
"editActionTooltip": "Chỉnh Volume"
},
"welcomeEmail": {
"inviteLinkAction": "Bắt đầu tạo tải khoản",
@@ -1822,7 +1883,8 @@
"es": "Tiếng Tây Ban Nha",
"ru": "Tiếng Nga",
"da": "Tiếng Đan Mạch",
"pt": "Tiếng Bồ Đào Nha"
"pt": "Tiếng Bồ Đào Nha",
"id": "Tiếng Indonesia"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Đặt lại mật khẩu",
@@ -1859,7 +1921,7 @@
"topic": "Chúng tôi nhận thấy có một đăng nhập mới vào tài khoản Cloudron của bạn.",
"salutation": "Xin chào <%= user %>,",
"subject": "[<%= cloudron %>] Có đăng nhập mới vào tài khoản của bạn",
"notice": "Chhungs tôi nhận thấy một đăng nhập trên tài khoản Cloudron của bạn từ một thiết bị mới.",
"notice": "Có một đăng nhập vào tài khoản Cloudron của bạn từ một thiết bị mới.",
"action": "Nếu người đó là bạn, bạn có thể thoải mái bỏ qua email này. Nếu đó không phải là bạn, bạn nên đổi mật khẩu của bạn ngay bây giờ."
},
"supportConfig": {
-2
View File
@@ -798,8 +798,6 @@
},
"updates": {
"title": "更新",
"autoUpdateDisabled": "平台和应用的自动更新已 <b>停用</b>。",
"currentSchedule": "当前平台和应用的自动更新计划是",
"version": "平台版本",
"showLogsAction": "显示日志",
"changeScheduleAction": "修改计划",
+8 -8
View File
@@ -775,38 +775,38 @@
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.installedAt' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span>{{ app.creationTime | prettyDate }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
</div>
</div>
+4 -1
View File
@@ -629,9 +629,12 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var nearest256m = Math.ceil(Math.max(result.memory, $scope.resources.currentMemoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
var startTick = app.manifest.memoryLimit || (256 * 1024 * 1024);
for (var i = startTick; i <= nearest256m; i *= 2) {
// code below ensure we atleast have 2 ticks to keep the slider usable
$scope.resources.memoryTicks.push(startTick); // start tick
for (var i = startTick * 2; i < nearest256m; i *= 2) {
$scope.resources.memoryTicks.push(i);
}
$scope.resources.memoryTicks.push(nearest256m); // end tick
});
// for firefox widget update
+4
View File
@@ -19,6 +19,10 @@
-->
<div ng-bind-html="appPostInstallConfirm.message | markdown2html"></div>
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
<div style="margin-top: 10px; margin-bottom: 5px;" ng-show="pendingChecklistItems(appPostInstallConfirm.app)">
<label class="control-label">{{ 'app.appInfo.checklist' | tr }}</label>
</div>
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
<span ng-bind-html="item.message | markdown2html"></span>
+5 -5
View File
@@ -542,20 +542,20 @@
</div>
<div class="card" style="margin-bottom: 15px;">
<p>{{ 'backups.schedule.description' | tr }}</p>
<p ng-bind-html=" 'backups.schedule.description' | tr "></p>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'backups.schedule.schedule' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
<span>{{ prettyBackupSchedule(backupPolicy.currentPolicy.schedule) }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'backups.schedule.retentionPolicy' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span>{{ prettyBackupRetention(backupPolicy.currentPolicy.retention) }}</span>
</div>
</div>
+3 -1
View File
@@ -2,6 +2,7 @@
/* global $, angular, TASK_TYPES, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, APP_TYPES */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR , REGIONS_CONTABO */
/* global document, window, FileReader */
angular.module('Application').controller('BackupsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', function ($scope, $location, $rootScope, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
@@ -15,6 +16,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.memory = null; // { memory, swap }
$scope.mountStatus = null; // { state, message }
$scope.manualBackupApps = [];
$scope.currentTimeZone = '';
$scope.backupConfig = {};
$scope.backups = [];
@@ -785,7 +787,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.backups.forEach(function (backup) {
backup.contents = []; // { id, label, fqdn }
backup.dependsOn.forEach(function (appBackupId) {
let match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
const match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
if (!match) return; // for example, 'mail'
const app = appsById[match[1]];
if (app) {
+3 -3
View File
@@ -1,4 +1,4 @@
<div class="content">
<div class="content content-large">
<div class="text-left">
<h1>{{ 'notifications.title' | tr }}
@@ -15,7 +15,7 @@
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="card" ng-hide="busy || notifications.length">
<div class="card card-large" ng-hide="busy || notifications.length">
<div class="row">
<div class="col-xs-12">
<h3 class="text-center" style="margin: 20px;">{{ 'notifications.nonePending' | tr }}</h3>
@@ -23,7 +23,7 @@
</div>
</div>
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
<div class="card card-large notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
<div class="row">
<div class="col-xs-12" ng-class="{ 'notification-details': notification.detailsShown }">
<span class="notification-title">{{ notification.title }}</span> <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>
+13 -4
View File
@@ -272,22 +272,31 @@
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ 'settings.updates.currentSchedule' | tr }} <b>{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</b></span>
<span ng-show="updateSchedule.currentPattern === 'never'" ng-bind-html=" 'settings.updates.autoUpdateDisabled' | tr "></span>
<span ng-bind-html=" 'settings.updates.description' | tr "></span>
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'settings.updates.version' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
v{{ config.version }} ({{ config.ubuntuVersion }})
</div>
</div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'settings.updates.schedule' | tr }}</span>
</div>
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</span>
<span ng-show="updateSchedule.currentPattern === 'never'">{{ 'settings.updates.disabled' | tr }}</span>
</div>
</div>
<div class="row">
<br/>
<div ng-if="update.busy" class="col-md-12" style="margin-bottom: 10px;">
+270 -561
View File
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -9,27 +9,26 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-sans": "^5.0.22",
"@fontsource/noto-sans": "^5.1.0",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.1.1",
"combokeys": "^3.0.1",
"filesize": "^10.1.4",
"marked": "^13.0.3",
"filesize": "^10.1.6",
"marked": "^14.1.2",
"moment": "^2.30.1",
"pankow": "^1.7.3",
"pankow-viewers": "^1.0.4",
"superagent": "^9.0.2",
"vue": "^3.4.38",
"vue-i18n": "^9.14.0",
"vue-router": "^4.4.3"
"pankow": "^2.2.1",
"pankow-viewers": "^1.0.7",
"vue": "^3.5.6",
"vue-i18n": "^10.0.1",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@vitejs/plugin-vue": "^5.1.2",
"eslint": "^9.9.0",
"eslint-plugin-vue": "^9.27.0",
"vite": "^5.4.1"
"@eslint/js": "^9.10.0",
"@vitejs/plugin-vue": "^5.1.3",
"eslint": "^9.10.0",
"eslint-plugin-vue": "^9.28.0",
"vite": "^5.4.5"
}
}
+7 -8
View File
@@ -11,9 +11,9 @@
<Button icon="fa-solid fa-eraser" @click="onClear()" style="margin-right: 5px">{{ $t('logs.clear') }}</Button>
<Button :href="downloadUrl" target="_blank" icon="fa-solid fa-download">{{ $t('logs.download') }}</Button>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
</template>
</TopBar>
</template>
@@ -31,7 +31,7 @@ import { Button, TopBar, MainLayout } from 'pankow';
import LogsModel from '../models/LogsModel.js';
import AppModel from '../models/AppModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin ;
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin;
export default {
name: 'LogsViewer',
@@ -43,7 +43,6 @@ export default {
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
logsModel: null,
appModel: null,
busyRestart: false,
@@ -113,10 +112,10 @@ export default {
return;
}
this.logsModel = LogsModel.create(this.apiOrigin, this.accessToken, this.type, this.id);
this.logsModel = LogsModel.create(API_ORIGIN, this.accessToken, this.type, this.id);
if (this.type === 'app') {
this.appModel = AppModel.create(this.apiOrigin, this.accessToken, this.id);
this.appModel = AppModel.create(API_ORIGIN, this.accessToken, this.id);
try {
const app = await this.appModel.get();
@@ -160,7 +159,7 @@ export default {
this.logsModel.stream((time, html) => {
newLogLines.push({ time, html });
}, function (error) {
console.error('Failed to start log stream:', error);
newLogLines.push({ time: error.time, html: error.html });
})
}
};
+10 -13
View File
@@ -27,9 +27,9 @@
<Button style="margin-left: 20px;" :disabled="!connected" @click="onUpload" icon="fa-solid fa-upload">{{ $t('terminal.uploadTo', { path: '/app/data/' }) }}</Button>
<Button :disabled="!connected" @click="onDownload" icon="fa-solid fa-download">{{ $t('terminal.downloadAction') }}</Button>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
</template>
</TopBar>
</template>
@@ -48,9 +48,7 @@
<script>
import superagent from 'superagent';
import { Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
import { fetcher, Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
import '@xterm/xterm/css/xterm.css';
import { Terminal } from '@xterm/xterm';
@@ -75,7 +73,6 @@ export default {
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
appModel: null,
directoryModel: null,
fatalError: false,
@@ -117,7 +114,7 @@ export default {
if (!downloadFileName) return;
try {
const result = await superagent.head(`${this.apiOrigin}/api/v1/apps/${this.id}/download`).query({
await fetcher.head(`${API_ORIGIN}/api/v1/apps/${this.id}/download`, {
file: downloadFileName,
access_token: this.accessToken
});
@@ -128,7 +125,7 @@ export default {
return;
}
this.downloadFileDownloadUrl = `${this.apiOrigin}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
this.downloadFileDownloadUrl = `${API_ORIGIN}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
// we have to click the link to make the browser do the download
// don't know how to prevent the browsers
@@ -199,7 +196,7 @@ export default {
let execId;
try {
const result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.id}/exec`).query({ access_token: this.accessToken }).send({ cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' });
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.id}/exec`, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, { access_token: this.accessToken });
execId = result.body.id;
} catch (error) {
console.error('Cannot create socket.', error);
@@ -222,7 +219,7 @@ export default {
});
// websocket cannot use relative urls
const url = `${this.apiOrigin.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
const url = `${API_ORIGIN.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
this.socket = new WebSocket(url);
this.terminal.loadAddon(new AttachAddon(this.socket));
@@ -263,8 +260,8 @@ export default {
this.id = id;
this.name = id;
this.appModel = create(this.apiOrigin, this.accessToken, this.id);
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, `apps/${id}`);
this.appModel = create(API_ORIGIN, this.accessToken, this.id);
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, `apps/${id}`);
try {
const app = await this.appModel.get();
+3 -48
View File
@@ -1,14 +1,12 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import superagent from 'superagent';
import { createRouter, createWebHashHistory } from 'vue-router';
import i18n from './i18n.js';
import FileManager from './FileManager.vue';
import Home from './views/Home.vue';
import Viewer from './views/Viewer.vue';
@@ -25,53 +23,10 @@ const router = createRouter({
routes,
});
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
(async function loadLanguages() {
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
async function loadLanguage(lang) {
try {
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
// we do not deliver as application/json :/
translations[lang] = JSON.parse(result.text);
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
(async function init() {
const app = createApp(FileManager);
app.use(i18n);
app.use(await i18n());
app.use(router);
app.mount('#app');
+51
View File
@@ -0,0 +1,51 @@
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
import { createI18n } from 'vue-i18n';
import { fetcher } from 'pankow';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: function (keys, key) {
const message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// if not found return null to fallback to resolving for english
if (message === null) return null;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
async function loadLanguage(lang) {
try {
const result = await fetcher.get(`${API_ORIGIN}/translation/${lang}.json`);
translations[lang] = result.body;
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
async function main() {
// load at least fallback english
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
return i18n;
}
export default main;
+3 -48
View File
@@ -1,61 +1,16 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import superagent from 'superagent';
import i18n from './i18n.js';
import LogsViewer from './components/LogsViewer.vue';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
(async function loadLanguages() {
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
async function loadLanguage(lang) {
try {
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
// we do not deliver as application/json :/
translations[lang] = JSON.parse(result.text);
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
(async function init() {
const app = createApp(LogsViewer);
app.use(i18n);
app.use(await i18n());
app.mount('#app');
})();
+9 -9
View File
@@ -1,6 +1,6 @@
import superagent from 'superagent';
import { ISTATES } from '../constants.js';
import { fetcher } from 'pankow';
import { sleep } from 'pankow/utils';
export function create(origin, accessToken, id) {
@@ -9,13 +9,13 @@ export function create(origin, accessToken, id) {
async get() {
let error, result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid app ${id}`, error || result.statusCode);
if (error || result.status !== 200) {
console.error(`Invalid app ${id}`, error || result.status);
this.fatalError = `Invalid app ${id}`;
return;
}
@@ -25,25 +25,25 @@ export function create(origin, accessToken, id) {
async restart() {
let error, result;
try {
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
result = await fetcher.post(`${origin}/api/v1/apps/${id}/restart`, null, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
if (error || result.status !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.status);
return;
}
while(true) {
let result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
} catch (e) {
console.error(e);
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
+14 -21
View File
@@ -1,5 +1,5 @@
import superagent from 'superagent';
import { fetcher } from 'pankow';
import { sanitize } from 'pankow/utils';
const BASE_URL = import.meta.env.BASE_URL || '/';
@@ -35,15 +35,15 @@ export function createDirectoryModel(origin, accessToken, api) {
async listFiles(path) {
let error, result;
try {
result = await superagent.get(`${origin}/api/v1/${api}/files/${path}`).query({ access_token: accessToken });
result = await fetcher.get(`${origin}/api/v1/${api}/files/${path}`, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
if (error || result.status !== 200) {
if (error.status === 404) return [];
console.error('Failed to list files', error || result.statusCode);
console.error('Failed to list files', error || result.status);
return [];
}
@@ -59,6 +59,8 @@ export function createDirectoryModel(origin, accessToken, api) {
// if we have an image, attach previewUrl
if (item.mimeType.indexOf('image/') === 0) {
item.previewUrl = `${origin}/api/v1/${api}/files/${encodeURIComponent(path + '/' + item.fileName)}?access_token=${accessToken}`;
} else {
item.previewUrl = '';
}
item.owner = item.uid;
@@ -109,31 +111,22 @@ export function createDirectoryModel(origin, accessToken, api) {
await this.save(filePath, '');
},
async newFolder(folderPath) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}?access_token=${accessToken}&directory=true`);
await fetcher.post(`${origin}/api/v1/${api}/files/${folderPath}`, { access_token: accessToken, directory: true });
},
async remove(filePath) {
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken });
await fetcher.del(`${origin}/api/v1/${api}/files/${filePath}`, { access_token: accessToken });
},
async rename(fromFilePath, toFilePath, overwrite = false) {
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'rename', newFilePath: sanitize(toFilePath), overwrite })
.query({ access_token: accessToken });
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'rename', newFilePath: sanitize(toFilePath), overwrite }, { access_token: accessToken });
},
async copy(fromFilePath, toFilePath) {
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'copy', newFilePath: sanitize(toFilePath) })
.query({ access_token: accessToken });
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'copy', newFilePath: sanitize(toFilePath) }, { access_token: accessToken });
},
async chown(filePath, uid) {
await superagent.put(`${origin}/api/v1/${api}/files/${filePath}`)
.send({ action: 'chown', uid: uid, recursive: true })
.query({ access_token: accessToken });
await fetcher.put(`${origin}/api/v1/${api}/files/${filePath}`, { action: 'chown', uid: uid, recursive: true }, { access_token: accessToken });
},
async extract(path) {
await superagent.put(`${origin}/api/v1/${api}/files/${path}`)
.send({ action: 'extract' })
.query({ access_token: accessToken });
await fetcher.put(`${origin}/api/v1/${api}/files/${path}`, { action: 'extract' }, { access_token: accessToken });
},
async download(path) {
window.open(`${origin}/api/v1/${api}/files/${path}?download=true&access_token=${accessToken}`);
@@ -169,7 +162,7 @@ export function createDirectoryModel(origin, accessToken, api) {
xhr.send(file);
});
const res = await req;
await req;
},
async getFile(path) {
let result;
@@ -185,7 +178,7 @@ export function createDirectoryModel(origin, accessToken, api) {
},
async paste(targetDir, action, files) {
// this will not overwrite but tries to find a new unique name to past to
for (let f in files) {
for (const f in files) {
let done = false;
let targetPath = targetDir + '/' + files[f].name;
while (!done) {
+12 -49
View File
@@ -1,9 +1,6 @@
import moment from 'moment';
import superagent from 'superagent';
import { ansiToHtml } from 'anser';
import { ISTATES } from '../constants.js';
import { sleep } from 'pankow/utils';
// https://github.com/janl/mustache.js/blob/master/mustache.js#L60
const entityMap = {
@@ -58,7 +55,17 @@ export function create(origin, accessToken, type, id) {
name: 'LogsModel',
stream(lineHandler, errorHandler) {
eventSource = new EventSource(`${origin}${streamApi}?lines=${INITIAL_STREAM_LINES}&access_token=${accessToken}`);
eventSource.onerror = errorHandler;
eventSource._lastMessage = null;
eventSource.onerror = function ( /* uselessError */) {
if (eventSource.readyState === EventSource.CLOSED) {
// eventSource does not give us the HTTP error code. We have to resort to message count check and guess the reason
const msg = eventSource._lastMessage === null ? `Logs unavailable. Maybe the logs were logrotated.` : `Connection closed.`;
const e = new Error(msg);
e.time = moment().format('MMM DD HH:mm:ss');
e.html = ansiToHtml(e.message);
errorHandler(e);
}
};
// eventSource.onopen = function () { console.log('stream is open'); };
eventSource.onmessage = function (message) {
var data;
@@ -72,56 +79,12 @@ export function create(origin, accessToken, type, id) {
const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
eventSource._lastMessage = { time, html };
lineHandler(time, html);
};
},
getDownloadUrl() {
return `${origin}${downloadApi}?access_token=${accessToken}&format=short&lines=-1`;
},
// TODO maybe move this into AppsModel.js
async getApp() {
let error, result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid app ${id}`, error || result.statusCode);
this.fatalError = `Invalid app ${id}`;
return;
}
return result.body;
},
async restartApp() {
if (type !== 'app') return;
let error, result;
try {
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
return;
}
while(true) {
let result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
} catch (e) {
console.error(e);
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
}
};
}
-19
View File
@@ -9,19 +9,6 @@ html, body {
color: var(--pankow-text-color);
}
h1 {
font-weight: 300 !important;
}
a {
color: #2196f3;
text-decoration: none;
}
a:hover, a:focus {
color: #0a6ebd;
}
.shadow {
box-shadow: 0 2px 5px rgba(0,0,0,.1);
}
@@ -29,9 +16,3 @@ a:hover, a:focus {
#app {
height: 100%;
}
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}
+3 -48
View File
@@ -1,61 +1,16 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import superagent from 'superagent';
import i18n from './i18n.js';
import Terminal from './components/Terminal.vue';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
(async function loadLanguages() {
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
async function loadLanguage(lang) {
try {
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
// we do not deliver as application/json :/
translations[lang] = JSON.parse(result.text);
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
(async function init() {
const app = createApp(Terminal);
app.use(i18n);
app.use(await i18n());
app.mount('#app');
})();
+23 -48
View File
@@ -28,7 +28,7 @@
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain/>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool/>
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
</template>
<template #right>
@@ -37,9 +37,9 @@
<Button icon="fa-solid fa-upload" @click="onUploadMenu">{{ $t('filemanager.toolbar.upload') }}</Button>
<Menu ref="uploadMenu" :model="uploadMenuModel"/>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
</template>
</TopBar>
</template>
@@ -57,8 +57,6 @@
:delete-handler="deleteHandler"
:rename-handler="renameHandler"
:change-owner-handler="changeOwnerHandler"
:copy-handler="copyHandler"
:cut-handler="cutHandler"
:paste-handler="pasteHandler"
:download-handler="downloadHandler"
:extract-handler="extractHandler"
@@ -68,7 +66,6 @@
:upload-folder-handler="onUploadFolder"
:drop-handler="onDrop"
:items="items"
:clipboard="clipboard"
:owners-model="ownersModel"
:fallback-icon="fallbackIcon"
:tr="$t"
@@ -100,10 +97,9 @@
<script>
import superagent from 'superagent';
import { marked } from 'marked';
import { Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
import Icon from 'pankow/components/Icon.vue';
import { sanitize, sleep } from 'pankow/utils';
@@ -112,7 +108,7 @@ import { ISTATES } from '../constants.js';
import PreviewPanel from '../components/PreviewPanel.vue';
import { createDirectoryModel } from '../models/DirectoryModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
const BASE_URL = import.meta.env.BASE_URL || '/';
const beforeUnloadListener = (event) => {
@@ -150,12 +146,7 @@ export default {
activeDirectoryItem: {},
items: [],
selectedItems: [],
clipboard: {
action: '', // copy or cut
files: []
},
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
title: 'Cloudron',
appLink: '',
resourceType: '',
@@ -222,13 +213,13 @@ export default {
if (type === 'app') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${resourceId}`, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
if (error || result.status !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
@@ -237,13 +228,13 @@ export default {
} else if (type === 'volume') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/volumes/${resourceId}`, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
if (error || result.status !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
@@ -253,7 +244,7 @@ export default {
}
try {
const result = await superagent.get(`${this.apiOrigin}/api/v1/dashboard/config`).query({ access_token: this.accessToken });
const result = await fetcher.get(`${API_ORIGIN}/api/v1/dashboard/config`, { access_token: this.accessToken });
this.footerContent = marked.parse(result.body.footer);
} catch (e) {
console.error('Failed to fetch Cloudron config.', e);
@@ -265,7 +256,7 @@ export default {
this.resourceType = type;
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
@@ -277,7 +268,7 @@ export default {
this.resourceType = toParams.type;
this.resourceId = toParams.resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
}
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
@@ -488,31 +479,15 @@ export default {
await this.loadCwd();
},
async copyHandler(files) {
if (!files) return;
this.clipboard = {
action: 'copy',
files
};
},
async cutHandler(files) {
if (!files) return;
this.clipboard = {
action: 'cut',
files
};
},
async pasteHandler(target) {
if (!this.clipboard.files || !this.clipboard.files.length) return;
async pasteHandler(action, files, target) {
if (!files || !files.length) return;
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.open();
await this.directoryModel.paste(targetPath, this.clipboard.action, this.clipboard.files);
await this.directoryModel.paste(targetPath, action, files);
this.clipboard = {};
await this.loadCwd();
@@ -565,25 +540,25 @@ export default {
let error, result;
try {
result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.resourceId}/restart`).query({ access_token: this.accessToken });
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.resourceId}/restart`, null, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.statusCode);
if (error || result.status !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.status);
return;
}
while(true) {
let result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${this.resourceId}`, { access_token: this.accessToken });
} catch (e) {
console.error('Failed to fetch app status.', e);
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
+1 -1
View File
@@ -16,7 +16,7 @@ import { TextViewer, ImageViewer } from 'pankow-viewers';
import { createDirectoryModel } from '../models/DirectoryModel.js';
import { sanitize } from 'pankow/utils';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
export default {
name: 'Viewer',
-6
View File
@@ -37,7 +37,6 @@
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.9.13",
"nsyslog-parser-2": "^0.9.11",
"oidc-provider": "^8.4.6",
"ovh": "^2.0.3",
"qrcode": "^1.5.3",
@@ -4215,11 +4214,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nsyslog-parser-2": {
"version": "0.9.11",
"resolved": "https://registry.npmjs.org/nsyslog-parser-2/-/nsyslog-parser-2-0.9.11.tgz",
"integrity": "sha512-QF13YP12BAA38NOWescMjiEoyJtnRV5k++fYOP8kNqKFtCubv1w73W9UhjCeER4l87M+4CWlm3MJcD5ZbgDJAg=="
},
"node_modules/nwsapi": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
-1
View File
@@ -45,7 +45,6 @@
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.9.13",
"nsyslog-parser-2": "^0.9.11",
"oidc-provider": "^8.4.6",
"ovh": "^2.0.3",
"qrcode": "^1.5.3",
+6 -37
View File
@@ -2,40 +2,11 @@
set -eu
readonly source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly DATA_DIR="${HOME}/.cloudron_test"
readonly DEFAULT_TESTS="./src/test/*-test.js ./src/routes/test/*-test.js"
! "${source_dir}/src/test/check-install" && exit 1
# cleanup old data dirs some of those docker container data requires sudo to be removed
echo "=> Provide root password to purge any leftover data in ${DATA_DIR} and load apparmor profile:"
sudo rm -rf ${DATA_DIR}
# archlinux does not have apparmor
if hash apparmor_parser 2>/dev/null; then
echo "=> Loading app armor profile"
sudo apparmor_parser --replace --write-cache ./setup/start/docker-cloudron-app.apparmor
fi
# create dir structure
mkdir -p ${DATA_DIR}
cd ${DATA_DIR}
mkdir -p appsdata
mkdir -p boxdata/box boxdata/mail boxdata/certs boxdata/mail/dkim/localhost boxdata/mail/dkim/foobar.com
mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications/dashboard platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks platformdata/sftp/ssh platformdata/firewall platformdata/update
sudo mkdir -p /mnt/cloudron-test-music /media/cloudron-test-music # volume test
# put cert
echo "=> Generating a localhost selfsigned cert"
openssl req -x509 -newkey rsa:2048 -keyout platformdata/nginx/cert/host.key -out platformdata/nginx/cert/host.cert -days 3650 -subj '/CN=localhost' -nodes -config <(cat /etc/ssl/openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:*.localhost"))
cd "${source_dir}"
# clear out any containers if FAST is unset
if [[ -z ${FAST+x} ]]; then
echo "=> Delete all docker containers first"
docker ps -qa --filter "label=isCloudronManaged" | xargs --no-run-if-empty docker rm -f
echo "=> Delete mysql server"
docker rm -f mysql-server
echo "==> To skip this run with: FAST=1 ./run-tests"
else
@@ -62,11 +33,8 @@ while ! mysqladmin ping -h"${MYSQL_IP}" --silent; do
sleep 1
done
echo "=> Ensure local base image"
docker pull cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4
echo "=> Create iptables blocklist"
sudo ipset create cloudron_blocklist hash:net || true
# echo "=> Create iptables blocklist"
# sudo ipset create cloudron_blocklist hash:net || true
echo "=> Ensure database"
mysql -h"${MYSQL_IP}" -uroot -ppassword -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'password';"
@@ -80,5 +48,6 @@ if [[ $# -gt 0 ]]; then
TESTS="$*"
fi
echo "=> Run tests with mocha"
BOX_ENV=test ./node_modules/.bin/mocha --bail --no-timeouts --exit -R spec ${TESTS}
echo "=> Run tests"
docker run -e BOX_ENV=test -e DATABASE_HOSTNAME=${MYSQL_IP} -v `pwd`:/home/yellowtent/box:ro -v `which node`:/usr/bin/node:ro -v /var/run/docker.sock:/var/run/docker.sock -t cloudron/boxtest node_modules/.bin/mocha --bail --no-timeouts --colors --exit -R spec src/test
+46 -3
View File
@@ -247,8 +247,7 @@ function check_dns() {
function check_unbound() {
if ! systemctl is-active -q unbound; then
info "unbound is down. updating root anchor to see if it fixes it"
unbound-anchor -a /var/lib/unbound/root.key
info "unbound is down. restarting to see if it fixes it" # unbound-anchor is part of ExecStartPre
systemctl restart unbound
if ! systemctl is-active -q unbound; then
@@ -361,6 +360,48 @@ function check_node() {
success "node version is correct"
}
function print_ipv6_disable_howto() {
echo "Instead of disabling IPv6 globally, you can disable it at an interface level."
for iface in $(ls /sys/class/net | grep -vE '^(lo|veth|docker|virbr|br|vmnet|tun|tap|wl|we)'); do
echo -e "\tsysctl -w net.ipv6.conf.${iface}.disable_ipv6=1"
done
echo "For above configuration to persist reboots, you have to add below to /etc/sysctl.conf"
for iface in $(ls /sys/class/net | grep -vE '^(lo|veth|docker|virbr|br|vmnet|tun|tap|wl|we)'); do
echo -e "\tnet.ipv6.conf.${iface}.disable_ipv6=1"
done
}
function check_ipv6() {
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
if [[ "${ipv6_disable}" == "1" ]]; then
fail "IPv6 is disabled in kernel. Cloudron requires IPv6 in kernel"
print_ipv6_disable_howto
exit 1
fi
# check if server has IPv6 address
has_ipv6_address=0
for iface in $(ls /sys/class/net | grep -vE '^(lo|veth|docker|virbr|br|vmnet|tun|tap|wl|we)'); do
if ipv6=$(ip -6 addr show dev ${iface} | grep -o 'inet6 [^ ]*' | awk '{print $2}' | grep -v '^fe80'); then
[[ -n "${ipv6}" ]] && has_ipv6_address=1
fi
done
if [[ "${has_ipv6_address}" == "0" ]]; then
success "IPv6 is enabled. No public IPv6 address"
return
fi
if ! ping6 -q -c 1 api.cloudron.io; then
fail "Server has an IPv6 address but api.cloudron.io is unreachable via IPv6"
print_ipv6_disable_howto
exit 1
fi
success "IPv6 is enabled and public IPv6 address is working"
}
function check_docker() {
if ! systemctl is-active -q docker; then
info "Docker is down. Trying to restart docker ..."
@@ -503,6 +544,7 @@ function troubleshoot() {
# note: disk space test has already been run globally
print_system
check_node
check_ipv6
check_docker
check_host_mysql
check_nginx # requires mysql to be checked
@@ -591,6 +633,7 @@ function ask_reboot() {
function recreate_docker() {
readonly logfile="/home/yellowtent/platformdata/logs/box.log"
readonly stagefile="/home/yellowtent/platformdata/recreate-docker-stage"
readonly containerd_root="/var/lib/containerd"
if ! docker_root=$(docker info -f '{{ .DockerRootDir }}' 2>/dev/null); then
warning "Unable to detect docker root. Assuming /var/lib/docker"
@@ -616,7 +659,7 @@ function recreate_docker() {
if grep -q "clearing_storage" "${stagefile}"; then
info "Clearing docker storage at ${docker_root}"
if ! rm -rf "${docker_root}/"*; then
if ! rm -rf "${docker_root}/"* "${containerd_root}/"*; then
echo -e "\nThe server has to be rebooted to clear the docker storage. After reboot,"
echo -e "run 'cloudron-support --recreate-docker' again.\n"
ask_reboot
+10 -4
View File
@@ -22,6 +22,11 @@ readonly MAIL_DATA_DIR="${HOME_DIR}/boxdata/mail"
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly ubuntu_version=$(lsb_release -rs)
vergte() {
greater_version=$(echo -e "$1\n$2" | sort -rV | head -n1)
[[ "$1" == "${greater_version}" ]] && return 0 || return 1
}
cp -f "${script_dir}/../scripts/cloudron-support" /usr/bin/cloudron-support
cp -f "${script_dir}/../scripts/cloudron-translation-update" /usr/bin/cloudron-translation-update
rm -f /usr/bin/cloudron-logs # legacy script
@@ -109,11 +114,12 @@ systemctl restart systemd-journald
usermod -a -G adm ${USER}
log "Setting up unbound"
cp -f "${script_dir}/start/unbound.conf" /etc/unbound/unbound.conf.d/cloudron-network.conf
cp -f "${script_dir}/start/unbound/unbound.conf" /etc/unbound/unbound.conf.d/cloudron-network.conf
unbound_version=$(unbound -V | sed -n 's/^Version \([0-9.]*\)/\1/p')
if vergte "${unbound_version}" "1.19.2"; then
cp "${script_dir}/start/unbound/prefer-ip4.conf" /etc/unbound/unbound.conf.d/cloudron-prefer-ip4.conf
fi
rm -f /etc/unbound/unbound.conf.d/remote-control.conf # on ubuntu 24
# update the root anchor after a out-of-disk-space situation (see #269)
# it returns 1 even on fail, it's not clear - https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound-anchor.html#exit-code
unbound-anchor -v -a /var/lib/unbound/root.key || log "unbound-anchor failed, but it probably worked"
log "Adding systemd services"
cp -r "${script_dir}/start/systemd/." /etc/systemd/system/
+4 -1
View File
@@ -8,10 +8,13 @@ Wants=network-online.target nss-lookup.target
[Service]
PIDFile=/run/unbound.pid
# https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html ("Special Excecutable Prefixes")
# update the root anchor after a out-of-disk-space situation (see #269)
# it returns 1 even on fail, it's not clear - https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound-anchor.html#exit-code
ExecStartPre=-/usr/sbin/unbound-anchor -a /var/lib/unbound/root.key
ExecStart=/usr/sbin/unbound -d
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
# On ubuntu 16, this doesn't work for some reason
Type=notify
[Install]
+6
View File
@@ -0,0 +1,6 @@
# Prefer IPv4 outbound queries. Spamhaus often rejects queries from IPv6 addresses
# This setting is in a separate file since it only works from Ubuntu 24 , unbound 1.19.2
server:
prefer-ip4: yes
+1 -1
View File
@@ -90,7 +90,7 @@ async function detectMetaInfo(applink) {
});
const [jsdomError, dom] = await safe(jsdom.JSDOM.fromURL(applink.upstreamUri, { virtualConsole }));
if (jsdomError) console.error('detectMetaInfo: jsdomError', jsdomError);
if (jsdomError) debug('detectMetaInfo: jsdomError', jsdomError);
if (!applink.icon && dom) {
let favicon = '';
+11 -11
View File
@@ -103,7 +103,7 @@ async function login(email, password, totpToken) {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS, response.body.message);
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Login error. status code: ${response.status}`);
if (!response.body.accessToken) throw new BoxError(BoxError.EXTERNAL_ERROR, `Login error. invalid response: ${response.text}`);
@@ -120,7 +120,7 @@ async function registerUser(email, password) {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 409) throw new BoxError(BoxError.ALREADY_EXISTS, 'Registration error: account already exists');
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Registration error. invalid response: ${response.status}`);
}
@@ -134,7 +134,7 @@ async function getSubscription() {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (response.status === 502) throw new BoxError(BoxError.EXTERNAL_ERROR, `Stripe error: ${response.status} ${JSON.stringify(response.body)}`);
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${response.status} ${JSON.stringify(response.body)}`);
@@ -165,7 +165,7 @@ async function purchaseApp(data) {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND); // appstoreId does not exist
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message);
@@ -188,7 +188,7 @@ async function unpurchaseApp(appId, data) {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 404) return; // was never purchased
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App unpurchase failed to get app. status:${response.status}`);
@@ -199,7 +199,7 @@ async function unpurchaseApp(appId, data) {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (response.status !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, `App unpurchase failed. status:${response.status}`);
}
@@ -221,7 +221,7 @@ async function getBoxUpdate(options) {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (response.status === 204) return; // no update
if (response.status !== 200 || !response.body) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`);
@@ -296,7 +296,7 @@ async function registerCloudron(data) {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 401) throw new BoxError(BoxError.LICENSE_ERROR, 'Setup token invalid');
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${response.statusCode} ${error.message}`);
@@ -402,7 +402,7 @@ async function createTicket(info, auditSource) {
}
const [error, response] = await safe(request);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`);
@@ -444,7 +444,7 @@ async function getApps() {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App listing failed. ${response.status} ${JSON.stringify(response.body)}`);
if (!response.body.apps) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`);
@@ -467,7 +467,7 @@ async function getAppVersion(appId, version) {
.timeout(60 * 1000)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 403 || response.statusCode === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND);
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App fetch failed. ${response.status} ${JSON.stringify(response.body)}`);
+12 -11
View File
@@ -243,9 +243,10 @@ async function downloadImage(manifest) {
await docker.downloadImage(manifest);
}
async function updateChecklist(app, newChecks) {
async function updateChecklist(app, newChecks, acknowledged = false) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof newChecks, 'object');
assert.strictEqual(typeof acknowledged, 'boolean');
// add new checklist items depending on sso state
const checklist = app.checklist || {};
@@ -253,7 +254,7 @@ async function updateChecklist(app, newChecks) {
if (app.checklist[k]) continue;
const item = {
acknowledged: false,
acknowledged: acknowledged,
sso: newChecks[k].sso,
appVersion: app.version,
message: newChecks[k].message,
@@ -296,7 +297,7 @@ async function install(app, args, progressCallback) {
await verifyManifest(app.manifest);
// teardown for re-installs
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old containers' });
await reverseProxy.unconfigureApp(app);
await deleteContainers(app, { managedOnly: true });
@@ -324,7 +325,7 @@ async function install(app, args, progressCallback) {
await downloadIcon(app);
await progressCallback({ percent: 25, message: 'Updating checklist' });
await updateChecklist(app, app.manifest.checklist || {});
await updateChecklist(app, app.manifest.checklist || {}, restoreConfig ? true : false);
if (!skipDnsSetup) {
await progressCallback({ percent: 30, message: 'Registering subdomains' });
@@ -406,7 +407,7 @@ async function create(app, args, progressCallback) {
assert.strictEqual(typeof args, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old container' });
await deleteContainers(app, { managedOnly: true });
// FIXME: re-setup addons only because sendmail addon to re-inject env vars on mailboxName change
@@ -432,7 +433,7 @@ async function changeLocation(app, args, progressCallback) {
const skipDnsSetup = args.skipDnsSetup;
const overwriteDns = args.overwriteDns;
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Unregistering old domains' });
await reverseProxy.unconfigureApp(app);
await deleteContainers(app, { managedOnly: true });
@@ -493,7 +494,7 @@ async function changeServices(app, args, progressCallback) {
assert.strictEqual(typeof args, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old containers' });
await deleteContainers(app, { managedOnly: true });
const unusedAddons = {};
@@ -526,7 +527,7 @@ async function migrateDataDir(app, args, progressCallback) {
assert(newStorageVolumeId === null || typeof newStorageVolumeId === 'string');
assert(newStorageVolumePrefix === null || typeof newStorageVolumePrefix === 'string');
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old containers' });
await deleteContainers(app, { managedOnly: true });
await progressCallback({ percent: 45, message: 'Ensuring app data directory' });
@@ -556,7 +557,7 @@ async function configure(app, args, progressCallback) {
assert.strictEqual(typeof args, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old containers' });
await reverseProxy.unconfigureApp(app);
await deleteContainers(app, { managedOnly: true });
@@ -616,7 +617,7 @@ async function update(app, args, progressCallback) {
}
await progressCallback({ percent: 20, message: 'Updating checklist' });
await updateChecklist(app, app.manifest.checklist || {});
await updateChecklist(app, app.manifest.checklist || {}, true /* new state acked */);
// download new image before app is stopped. this is so we can reduce downtime
// and also not remove the 'common' layers when the old image is deleted
@@ -625,7 +626,7 @@ async function update(app, args, progressCallback) {
// note: we cleanup first and then backup. this is done so that the app is not running should backup fail
// we cannot easily 'recover' from backup failures because we have to revert manfest and portBindings
await progressCallback({ percent: 35, message: 'Cleaning up old install' });
await progressCallback({ percent: 35, message: 'Deleting old containers' });
await deleteContainers(app, { managedOnly: true });
if (app.manifest.dockerImage !== updateConfig.manifest.dockerImage) await docker.deleteImage(app.manifest);
+1 -1
View File
@@ -279,7 +279,7 @@ async function run(progressCallback) {
const { retention } = await backups.getPolicy();
debug(`run: retention is ${JSON.stringify(retention)}`);
const status = await storage.api(backupConfig.provider).getProviderStatus(backupConfig);
const status = await backups.ensureMounted();
debug(`run: mount point status is ${JSON.stringify(status)}`);
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not mounted: ${status.message}`);
+11 -4
View File
@@ -39,6 +39,7 @@ exports = module.exports = {
remount,
getMountStatus,
ensureMounted,
BACKUP_IDENTIFIER_BOX: 'box',
BACKUP_IDENTIFIER_MAIL: 'mail',
@@ -359,9 +360,7 @@ function managedBackupMountObject(backupConfig) {
};
}
async function remount(auditSource) {
assert.strictEqual(typeof auditSource, 'object');
async function remount() {
const backupConfig = await getConfig();
if (mounts.isManagedProvider(backupConfig.provider)) {
@@ -380,12 +379,20 @@ async function getMountStatus() {
} else if (backupConfig.provider === 'filesystem') {
hostPath = backupConfig.backupFolder;
} else {
throw new BoxError(BoxError.BAD_STATE, 'Backup location is not a mount');
return { state: 'active' };
}
return await mounts.getStatus(backupConfig.provider, hostPath); // { state, message }
}
async function ensureMounted() {
const status = await getMountStatus();
if (status.state === 'active') return status;
await remount();
return await getMountStatus();
}
async function getPolicy() {
const result = await settings.getJson(settings.BACKUP_POLICY_KEY);
return result || {
+1 -1
View File
@@ -52,7 +52,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
// check mount status before uploading
const status = await storage.api(backupConfig.provider).getProviderStatus(backupConfig);
const status = await backups.ensureMounted();
debug(`upload: mount point status is ${JSON.stringify(status)}`);
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not active: ${status.message}`);
+5
View File
@@ -24,6 +24,11 @@ function BoxError(reason, errorOrMessage, override) {
this.message = reason;
} else if (typeof errorOrMessage === 'string') {
this.message = errorOrMessage;
} else if (errorOrMessage instanceof AggregateError) { //
const messages = errorOrMessage.errors.map(e => e.message);
this.message = `${errorOrMessage.message} messages: ${messages.join(',')}`;
this.nestedError = errorOrMessage;
Object.assign(this, override); // copy enumerable properies
} else { // error object
this.message = errorOrMessage.message;
this.nestedError = errorOrMessage;
+1 -6
View File
@@ -25,7 +25,7 @@ const assert = require('assert'),
let gConnectionPool = null;
const gDatabase = {
hostname: '127.0.0.1',
hostname: constants.TEST ? process.env.DATABASE_HOSTNAME : '127.0.0.1',
username: 'root',
password: 'password',
port: 3306,
@@ -35,11 +35,6 @@ const gDatabase = {
async function initialize() {
if (gConnectionPool !== null) return;
if (constants.TEST) {
// see setupTest script how the mysql-server is run
gDatabase.hostname = require('child_process').execSync('docker inspect -f "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" mysql-server').toString().trim();
}
// https://github.com/mysqljs/mysql#pool-options
gConnectionPool = mysql.createPool({
connectionLimit: 5,
+1 -1
View File
@@ -16,7 +16,7 @@ async function resolve(hostname, rrtype, options) {
assert.strictEqual(typeof rrtype, 'string');
assert(options && typeof options === 'object'); // { server, timeout }
const resolver = new dns.promises.Resolver({ timeout: options.timeout || 10000 });
const resolver = new dns.promises.Resolver({ timeout: options.timeout || 10000, tries: options.tries || 2 });
if (constants.CLOUDRON) resolver.setServers([ options.server || '127.0.0.150' ]); // unbound runs here
+1 -1
View File
@@ -50,7 +50,7 @@ async function isChangeSynced(hostname, type, value, nameserver) {
const status = [];
for (let i = 0; i < nsIPs.length; i++) {
const nsIp = nsIPs[i];
const resolveOptions = { server: nsIp, timeout: 5000 };
const resolveOptions = { server: nsIp, timeout: 5000, tries: 1 };
const resolver = type === 'A' || type === 'AAAA' ? resolveIp(hostname, type, resolveOptions) : dig.resolve(hostname, 'TXT', resolveOptions);
const [error, answer] = await safe(resolver);
+7 -4
View File
@@ -288,7 +288,9 @@ async function createSubcontainer(app, name, cmd, options) {
const { hostPort, type:portType, count:portCount } = app.portBindings[portName];
const portSpec = portType == 'tcp' ? manifest.tcpPorts : manifest.udpPorts;
const containerPort = portSpec[portName].containerPort || hostPort;
const hostIps = hostPort === 53 ? await getAddressesForPort53() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
// port 53 is special. systemd-resolved is listening on 127.0.0.x port 53 and another process cannot listen to 0.0.0.0 port 53
// for port 53 alone, we listen explicitly on the server's interface IP
const hostIps = hostPort === 53 ? await getAddressesForPort53() : [ '0.0.0.0', '::0' ];
portEnv.push(`${portName}=${hostPort}`);
if (portCount > 1) portEnv.push(`${portName}_COUNT=${portCount}`);
@@ -355,7 +357,7 @@ async function createSubcontainer(app, name, cmd, options) {
},
// CpuPeriod (100000 microseconds) and CpuQuota(app.cpuQuota% of CpuPeriod)
// 1000000000 is one core https://github.com/moby/moby/issues/24713#issuecomment-233167619 and https://stackoverflow.com/questions/52391877/set-the-number-of-cpu-cores-of-a-container-using-docker-engine-api
NanoCPUs: app.cpuQuota === 100 ? 0 : (os.cpus().length * app.cpuQuota/100).toFixed(2) * 1000000000,
NanoCPUs: app.cpuQuota === 100 ? 0 : Math.round(os.cpus().length * app.cpuQuota/100 * 1000000000),
VolumesFrom: isAppContainer ? null : [ app.containerId + ':rw' ],
SecurityOpt: [ 'apparmor=docker-cloudron-app' ],
CapAdd: [],
@@ -373,8 +375,9 @@ async function createSubcontainer(app, name, cmd, options) {
if (isAppContainer) {
containerOptions.Hostname = app.id;
containerOptions.HostConfig.NetworkMode = 'cloudron'; // user defined bridge network
containerOptions.HostConfig.Dns = ['172.18.0.1']; // use internal dns
containerOptions.HostConfig.DnsSearch = ['.']; // use internal dns
// Do not inject for AdGuard. It ends up resolving the dashboard domain as the docker bridge IP
if (manifest.id !== 'com.adguard.home.cloudronapp') containerOptions.HostConfig.ExtraHosts = [ `${dashboardFqdn}:172.18.0.1` ];
containerOptions.NetworkingConfig = {
EndpointsConfig: {
+2
View File
@@ -118,6 +118,8 @@ function process(req, res, next) {
async function start() {
assert(gHttpServer === null, 'Already started');
if (constants.TEST) return;
const json = express.json({ strict: true });
// we protect container create as the app/admin can otherwise mount random paths (like the ghost file)
+1 -1
View File
@@ -6,7 +6,7 @@
exports = module.exports = {
// a version change recreates all containers with latest docker config
'version': '49.7.0',
'version': '49.8.0',
// a major version bump in the db containers will trigger the restore logic that uses the db dumps
// docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256
+1 -1
View File
@@ -151,7 +151,7 @@ async function getStatus(mountType, hostPath) {
if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' };
const [error] = await safe(shell.execArgs('getVolumeStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
const [error] = await safe(shell.execArgs('getStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
const state = error ? 'inactive' : 'active';
if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' };
+4
View File
@@ -288,6 +288,10 @@ server {
location ~ ^/translation/ {
root <%= sourceDir %>/dashboard/dist;
add_header "Access-Control-Allow-Origin" "*";
types {
application/json json;
}
}
# Cross domain webfont access for proxy auth login page https://github.com/h5bp/server-configs/issues/85
+13 -3
View File
@@ -44,6 +44,7 @@ const assert = require('assert'),
translations = require('./translations.js'),
url = require('url'),
users = require('./users.js'),
groups = require('./groups.js'),
util = require('util');
const OIDC_CLIENTS_TABLE_NAME = 'oidcClients';
@@ -719,6 +720,9 @@ async function claims(userId/*, use, scope*/) {
const [error, user] = await safe(users.get(userId));
if (error) return { error: 'user not found' };
const [groupsError, allGroups] = await safe(groups.listWithMembers());
if (groupsError) return { error: groupsError.message }
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
@@ -735,7 +739,8 @@ async function claims(userId/*, use, scope*/) {
locale: 'en-US',
name: user.displayName,
picture: `https://${dashboardFqdn}/api/v1/profile/avatar/${user.id}`,
preferred_username: user.username
preferred_username: user.username,
groups: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `${g.name}`; })
};
return claims;
@@ -817,12 +822,17 @@ async function start() {
},
claims: {
email: ['email', 'email_verified'],
profile: [ 'family_name', 'given_name', 'locale', 'name', 'preferred_username', 'picture' ]
profile: [ 'family_name', 'given_name', 'locale', 'name', 'preferred_username', 'picture' ],
groups: [ 'groups' ]
},
features: {
rpInitiatedLogout: { enabled: false },
devInteractions: { enabled: false }
},
clientDefaults: {
response_types: ['code', 'id_token'],
grant_types: ['authorization_code', 'implicit']
},
responseTypes: [
'code',
'id_token', 'id_token token',
@@ -860,7 +870,7 @@ async function start() {
accountId: ctx.oidc.session.accountId,
});
grant.addOIDCScope('openid email profile');
grant.addOIDCScope('openid email profile groups');
// grant.addOIDCClaims(['first_name']);
await grant.save();
+2 -4
View File
@@ -4,10 +4,7 @@ const constants = require('./constants.js'),
path = require('path');
function baseDir() {
const homeDir = process.env.HOME;
if (constants.CLOUDRON) return homeDir;
if (constants.TEST) return path.join(homeDir, '.cloudron_test');
// cannot reach
return process.env.HOME;
}
// keep these values in sync with start.sh
@@ -18,6 +15,7 @@ exports = module.exports = {
INFRA_VERSION_FILE: path.join(baseDir(), 'platformdata/INFRA_VERSION'),
CRON_SEED_FILE: path.join(baseDir(), 'platformdata/CRON_SEED'),
DASHBOARD_DIR: constants.TEST ? path.join(__dirname, '../dashboard/src') : path.join(baseDir(), 'box/dashboard/dist'),
TRANSLATIONS_DIR: constants.TEST ? path.join(__dirname, '../dashboard/src/translation') : path.join(baseDir(), 'box/dashboard/dist/translation'),
PROVIDER_FILE: '/etc/cloudron/PROVIDER',
SETUP_TOKEN_FILE: '/etc/cloudron/SETUP_TOKEN',
+2 -2
View File
@@ -266,9 +266,9 @@ async function onDashboardLocationSet(subdomain, domain) {
async function onDashboardLocationChanged(auditSource) {
assert.strictEqual(typeof auditSource, 'object');
// mark apps using oidc addon to be reconfigured
// mark all apps to be reconfigured, all have ExtraHosts injected
const [, installedApps] = await safe(apps.list());
await safe(apps.configureApps(installedApps.filter((a) => !!a.manifest.addons?.oidc), { scheduleNow: true }, auditSource), { debug });
await safe(apps.configureApps(installedApps, { scheduleNow: true }, auditSource), { debug });
await safe(services.rebuildService('turn', auditSource), { debug }); // to update the realm variable
}
+1 -1
View File
@@ -66,7 +66,7 @@ async function cleanup(req, res, next) {
}
async function remount(req, res, next) {
const [error] = await safe(backups.remount(AuditSource.fromRequest(req)));
const [error] = await safe(backups.remount());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
+1 -1
View File
@@ -41,7 +41,7 @@ async function enableRemoteSupport(req, res, next) {
if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enabled is required'));
const [error] = await safe(support.enableRemoteSupport(req.body.enable, AuditSource.fromRequest(req)));
if (error) return next(new HttpError(503, 'Error enabling remote support. Try running "cloudron-support --enable-ssh" on the server'));
if (error) return next(new HttpError(503, 'Error enabling remote support. Try running "cloudron-support --enable-remote-support" on the server'));
next(new HttpSuccess(202, {}));
}
+5 -4
View File
@@ -118,12 +118,13 @@ describe('System', function () {
expect(response.body.swap).to.be.a('number');
});
it('fails (non-admin)', async function () {
it('succeeds (admin)', async function () {
const response = await superagent.get(`${serverUrl}/api/v1/system/memory`)
.query({ access_token: user.token })
.ok(() => true);
.query({ access_token: user.token });
expect(response.statusCode).to.equal(403);
expect(response.statusCode).to.equal(200);
expect(response.body.memory).to.eql(os.totalmem());
expect(response.body.swap).to.be.a('number');
});
});
+1 -1
View File
@@ -23,7 +23,7 @@ host_path="$1"
mount_filename=$(systemd-escape -p --suffix=mount "$host_path")
mount_file="/etc/systemd/system/${mount_filename}"
# stop and start will do the reumount
# stop and start will do the remount
systemctl stop "${mount_filename}"
systemctl start "${mount_filename}"
-1
View File
@@ -22,7 +22,6 @@ fi
service="$1"
if [[ "${service}" == "unbound" ]]; then
unbound-anchor -a /var/lib/unbound/root.key
systemctl restart --no-block unbound
elif [[ "${service}" == "nginx" ]]; then
if systemctl -q is-active nginx; then
+11 -4
View File
@@ -46,11 +46,18 @@ fi
# DEBUG has to be hardcoded because it is not set in the tests. --setenv is required for ubuntu 16 (-E does not work)
# NODE_OPTIONS is used because env -S does not work in ubuntu 16/18.
# it seems systemd-run does not return the exit status of the process despite --wait
if ! systemd-run --unit "${service_name}" --nice "${nice}" --uid=${id} --gid=${id} ${options} --setenv HOME=${HOME} --setenv USER=${SUDO_USER} --setenv DEBUG=box:* --setenv BOX_ENV=${BOX_ENV} --setenv NODE_ENV=production --setenv NODE_OPTIONS=--unhandled-rejections=strict --setenv AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE=1 "${task_worker}" "${task_id}" "${logfile}"; then
echo "Service ${service_name} failed to run" # this only happens if the path to task worker itself is wrong
fi
if [[ "$CI" == "1" ]]; then
if ! DEBUG=box:* NODE_ENV=production NODE_OPTIONS=--unhandled-rejections=strict gosu $id:$id "${task_worker}" "${task_id}" "${logfile}"; then
echo "Service ${service_name} failed to run" # this only happens if the path to task worker itself is wrong
fi
exit_code=$?
else
if ! systemd-run --unit "${service_name}" --nice "${nice}" --uid=${id} --gid=${id} ${options} --setenv HOME=${HOME} --setenv USER=${SUDO_USER} --setenv DEBUG=box:* --setenv BOX_ENV=${BOX_ENV} --setenv NODE_ENV=production --setenv NODE_OPTIONS=--unhandled-rejections=strict --setenv AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE=1 "${task_worker}" "${task_id}" "${logfile}"; then
echo "Service ${service_name} failed to run" # this only happens if the path to task worker itself is wrong
fi
exit_code=$(systemctl show "${service_name}" -p ExecMainCode | sed 's/ExecMainCode=//g')
exit_code=$(systemctl show "${service_name}" -p ExecMainCode | sed 's/ExecMainCode=//g')
fi
echo "Service ${service_name} finished with exit code ${exit_code}"
exit "${exit_code}"
+5 -4
View File
@@ -113,23 +113,24 @@ async function initializeExpressSync() {
router.post('/api/v1/dashboard/location', json, token, authorizeAdmin, routes.dashboard.changeLocation);
// system (vm/server)
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo);
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo); // vendor, product name etc
router.post('/api/v1/system/reboot', json, token, authorizeAdmin, routes.system.reboot);
router.get ('/api/v1/system/graphs', token, authorizeAdmin, routes.system.getSystemGraphs);
router.get ('/api/v1/system/cpus', token, authorizeAdmin, routes.system.getCpus);
router.get ('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.getDiskUsage);
router.post('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.updateDiskUsage);
router.get ('/api/v1/system/block_devices', token, authorizeAdmin, routes.system.getBlockDevices);
router.get ('/api/v1/system/memory', token, authorizeAdmin, routes.system.getMemory);
router.get ('/api/v1/system/logs/:unit', token, authorizeAdmin, routes.system.getLogs);
router.get ('/api/v1/system/logstream/:unit', token, authorizeAdmin, routes.system.getLogStream);
// app operators require cpu and memory info for the Resources UI
router.get ('/api/v1/system/cpus', token, authorizeUser, routes.system.getCpus);
router.get ('/api/v1/system/memory', token, authorizeUser, routes.system.getMemory);
// eventlog
router.get ('/api/v1/eventlog', token, authorizeAdmin, routes.eventlog.list);
router.get ('/api/v1/eventlog/:eventId', token, authorizeAdmin, routes.eventlog.get);
// updater
router.get ('/api/v1/updater/updates', token, authorizeAdmin, routes.updater.getUpdateInfo);
router.get ('/api/v1/updater/updates', token, authorizeUser, routes.updater.getUpdateInfo);
router.post('/api/v1/updater/update', json, token, authorizeAdmin, routes.updater.update);
router.post('/api/v1/updater/check_for_updates', json, token, authorizeAdmin, routes.updater.checkForUpdates);
router.get ('/api/v1/updater/autoupdate_pattern', token, authorizeAdmin, routes.updater.getAutoupdatePattern);
+2 -2
View File
@@ -111,10 +111,10 @@ function sudo(tag, args, options, callback) {
cp.stdout.on('data', (data) => {
if (options.captureStdout) stdoutResult += data.toString('utf8');
if (!options.quiet) process.stdout.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
if (!options.quiet) process.stdout.write(data + '\r'); // do not use debug to avoid double timestamps when calling backupupload.js
});
cp.stderr.on('data', (data) => {
process.stderr.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
process.stderr.write(data + '\r'); // do not use debug to avoid double timestamps when calling backupupload.js
});
cp.on('exit', function (code, signal) {
+2 -13
View File
@@ -1,7 +1,6 @@
'use strict';
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -35,23 +34,12 @@ const assert = require('assert'),
debug = require('debug')('box:storage/filesystem'),
df = require('../df.js'),
fs = require('fs'),
mounts = require('../mounts.js'),
path = require('path'),
paths = require('../paths.js'),
readdirp = require('readdirp'),
safe = require('safetydance'),
shell = require('../shell.js');
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
// Check filesystem is mounted so we don't write into the actual folder on disk
if (!mounts.isManagedProvider(apiConfig.provider) && apiConfig.provider !== 'mountpoint') return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
const hostPath = mounts.isManagedProvider(apiConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : apiConfig.mountPoint;
return await mounts.getStatus(apiConfig.provider, hostPath); // { state, message }
}
// the du call in the function below requires root
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
@@ -88,11 +76,12 @@ async function upload(apiConfig, backupFilePath) {
const [mkdirError] = await safe(fs.promises.mkdir(path.dirname(backupFilePath), { recursive: true }));
if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `Error creating directory ${backupFilePath}: ${mkdirError.message}`);
await safe(fs.promises.unlink(backupFilePath), { debug }); // remove any hardlink
await safe(fs.promises.unlink(backupFilePath)); // remove any hardlink
return {
stream: fs.createWriteStream(backupFilePath, { autoClose: true }),
async finish() {
console.log('OK CHOWNNIG!!!!!', process.env.SUDO_UID, process.getuid());
const backupUid = parseInt(process.env.SUDO_UID, 10) || process.getuid(); // in test, upload() may or may not be called via sudo script
if (hasChownSupportSync(apiConfig)) {
if (!safe.fs.chownSync(backupFilePath, backupUid, backupUid)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to chown ${backupFilePath}: ${safe.error.message}`);
-7
View File
@@ -1,7 +1,6 @@
'use strict';
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -61,12 +60,6 @@ function getBucket(apiConfig) {
return new GCS(gcsConfig).bucket(apiConfig.bucket);
}
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
-7
View File
@@ -11,7 +11,6 @@
// for the other API calls we leave it to the backend to retry. this allows
// them to tune the concurrency based on failures/rate limits accordingly
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -44,12 +43,6 @@ function injectPrivateFields(newConfig, currentConfig) {
// in-place injection of tokens and api keys which came in with constants.SECRET_PLACEHOLDER
}
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
-7
View File
@@ -1,7 +1,6 @@
'use strict';
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -24,12 +23,6 @@ const assert = require('assert'),
debug = require('debug')('box:storage/noop'),
fs = require('fs');
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
-7
View File
@@ -1,7 +1,6 @@
'use strict';
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -92,12 +91,6 @@ function getS3Config(apiConfig) {
return credentials;
}
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
+1 -1
View File
@@ -97,7 +97,7 @@ async function getDisks() {
const DISK_TYPES = [ 'ext4', 'xfs', 'cifs', 'nfs', 'fuse.sshfs' ]; // we don't show size of contents in untracked disk types
for (const disk of dfEntries) {
if (!DISK_TYPES.includes(disk.type)) continue;
if (!DISK_TYPES.includes(disk.type) && disk.mountpoint !== '/') continue;
if (disk.mountpoint === '/') rootDisk = disk;
disks[disk.filesystem] = {
filesystem: disk.filesystem,
+1 -1
View File
@@ -169,7 +169,7 @@ function startTask(id, options, onTaskFinished) {
let killTimerId = null, timedOut = false;
const sudoOptions = { preserveEnv: true, logStream: null };
if (constants.TEST) sudoOptions.logStream = fs.createWriteStream('/dev/null'); // without this output is messed up, not sure why
//if (constants.TEST) sudoOptions.logStream = fs.createWriteStream('/dev/null'); // without this output is messed up, not sure why
gTasks[id] = shell.sudo('startTask', [ START_TASK_CMD, id, logFile, options.nice || 0, options.memoryLimit || 400, options.oomScoreAdjust || 0 ], sudoOptions, async function (sudoError) {
if (!gTasks[id]) return; // ignore task exit since we are shutting down. see stopAllTasks
+3 -1
View File
@@ -14,6 +14,8 @@ const applinks = require('../applinks.js'),
describe('Applinks', function () {
const { setup, cleanup } = common;
this.timeout(10000);
before(setup);
after(cleanup);
@@ -30,7 +32,7 @@ describe('Applinks', function () {
};
const APPLINK_2 = {
upstreamUri: 'https://google.com'
upstreamUri: 'https://google.de'
};
const APPLINK_3 = {
-56
View File
@@ -1,56 +0,0 @@
#!/bin/bash
set -eu
readonly source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
readonly sudo_scripts_dir="${source_dir}/src/scripts"
if [[ ! -f /usr/bin/node ]]; then
echo "node is not in root user's environment. '/usr/bin/env node' will not work"
exit 1
fi
# checks if all scripts are sudo access
readarray -d '' scripts < <(find ${sudo_scripts_dir} -type f -print0)
declare -a missing_scripts=()
for script in "${scripts[@]}"; do
# sudo -k ignores a cached sudo session for the command
if [[ $(sudo -k -n "${script}" --check 2>/dev/null) != "OK" ]]; then
missing_scripts+=("${script}")
fi
done
if [[ ${#missing_scripts[@]} -gt 0 ]]; then
echo "The following script(s) have no sudo access: ${missing_scripts[*]} . Try 'sudo -n ${missing_scripts[0]} --check'"
echo -e "\nYou have to add the lines below to /etc/sudoers.d/yellowtent\n\n"
for missing_script in "${missing_scripts[@]}"; do
echo "Defaults!${missing_script} env_keep=\"HOME BOX_ENV\""
echo "${USER} ALL=(ALL) NOPASSWD: ${missing_script}"
echo ""
done
exit 1
fi
setenv_scripts=(starttask.sh backupupload.js)
for script in "${setenv_scripts[@]}"; do
if ! grep -q ":SETENV:.*${script}" "/etc/sudoers.d/yellowtent"; then
echo "SETENV missing for ${script} in /etc/sudoers.d/yellowtent"
exit 1
fi
done
if ! grep -q "backupupload.js closefrom_override" "/etc/sudoers.d/yellowtent"; then
echo "backupupload.js needs closefrom_override in /etc/sudoers.d/yellowtent"
exit 1
fi
images=$(node -e "const i = require('${source_dir}/src/infra_version.js'); console.log(Object.keys(i.images).map(x => i.images[x]).join(' '));")
for image in ${images}; do
if ! docker inspect "${image}" >/dev/null 2>/dev/null; then
docker pull ${image%@sha256:*}
fi
done
+1 -1
View File
@@ -11,7 +11,7 @@ const BoxError = require('../boxerror.js'),
expect = require('expect.js'),
safe = require('safetydance');
describe('Settings', function () {
describe('Cloudron', function () {
const { setup, cleanup } = common;
before(setup);
+1 -7
View File
@@ -17,20 +17,14 @@ describe('System', function () {
after(cleanup);
it('can get disks', async function () {
// does not work on archlinux 8!
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
const disks = await df.disks();
expect(disks).to.be.ok();
expect(disks.some(d => d.mountpoint === '/')).to.be.ok();
});
it('can get file', async function () {
// does not work on archlinux 8!
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
const disks = await df.file(__dirname);
expect(disks).to.be.ok();
expect(disks.mountpoint).to.be('/home');
expect(disks.mountpoint).to.be('/home/yellowtent/box');
});
});
+2 -5
View File
@@ -160,11 +160,8 @@ describe('Storage', function () {
format: 'tgz'
};
it('upload works', function (done) {
noop.upload(gBackupConfig, 'file', { }, function (error) {
expect(error).to.be(null);
done();
});
it('upload works', async function () {
await noop.upload(gBackupConfig, 'file', {});
});
it('can download file', async function () {
-9
View File
@@ -17,27 +17,18 @@ describe('System', function () {
after(cleanup);
it('can get disks', async function () {
// does not work on archlinux 8!
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
const disks = await system.getDisks();
expect(disks).to.be.ok();
expect(Object.keys(disks).some(fs => disks[fs].mountpoint === '/')).to.be.ok();
});
it('can get swaps', async function () {
// does not work on archlinux 8!
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
const swaps = await system.getSwaps();
expect(swaps).to.be.ok();
expect(Object.keys(swaps).some(n => swaps[n].type === 'partition' || swaps[n].type === 'file')).to.be.ok();
});
it('can check for disk space', async function () {
// does not work on archlinux 8!
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
await system.checkDiskSpace();
});
+9 -9
View File
@@ -14,8 +14,6 @@ const assert = require('assert'),
paths = require('./paths.js'),
safe = require('safetydance');
const TRANSLATION_FOLDER = path.join(paths.DASHBOARD_DIR, 'translation');
// to be used together with getTranslations() => { translations, fallback }
function translate(input, assets) {
assert.strictEqual(typeof input, 'string');
@@ -51,21 +49,23 @@ function translate(input, assets) {
}
async function getTranslations() {
const fallback = safe.JSON.parse(fs.readFileSync(path.join(TRANSLATION_FOLDER, 'en.json'), 'utf8'));
if (!fallback) debug(`getTranslations: Fallback language en not found. ${safe.error.message}`);
const fallbackData = fs.readFileSync(path.join(paths.TRANSLATIONS_DIR, 'en.json'), 'utf8');
if (!fallbackData) debug(`getTranslations: Fallback language en not found. ${safe.error.message}`);
const fallback = safe.JSON.parse(fallbackData) || {};
const lang = await cloudron.getLanguage();
const translations = safe.JSON.parse(fs.readFileSync(path.join(TRANSLATION_FOLDER, lang + '.json'), 'utf8'));
if (!translations) debug(`getTranslations: Requested language ${lang} not found. ${safe.error.message}`);
const translationData = safe.fs.readFileSync(path.join(paths.TRANSLATIONS_DIR, `${lang}.json`), 'utf8');
if (!translationData) debug(`getTranslations: Requested language ${lang} not found. ${safe.error.message}`);
const translations = safe.JSON.parse(translationData) || {};
return { translations: translations || {}, fallback: fallback || {} };
return { translations, fallback };
}
async function listLanguages() {
const [error, result] = await safe(fs.promises.readdir(TRANSLATION_FOLDER));
const [error, result] = await safe(fs.promises.readdir(paths.TRANSLATIONS_DIR));
if (error) {
debug('listLanguages: Failed to list translations. %o', error);
debug(`listLanguages: Failed to list translations. %${error.message}`);
return [ 'en' ]; // we always return english to avoid dashboard breakage
}
+1 -1
View File
@@ -84,7 +84,7 @@ async function checkBoxUpdates(options) {
debug(`checkBoxUpdates: ${updateInfo.version} is available. renotification: ${!!state.box}`);
const changelog = updateInfo.changelog.map((m) => `* ${m}\n`).join('');
const message = `Changelog:\n${changelog}\n\nGo to the settings view to update.\n\n`;
const message = `Changelog:\n${changelog}\n\nGo to the Settings view to update.\n\n`;
await notifications.alert(notifications.ALERT_BOX_UPDATE, `Cloudron v${updateInfo.version} is available`, message, { persist: false });
+36 -23
View File
@@ -12,11 +12,33 @@ const debug = require('debug')('syslog:server'),
net = require('net'),
path = require('path'),
paths = require('./src/paths.js'),
parser = require('nsyslog-parser-2'),
util = require('util');
let gServer = null;
// https://docs.docker.com/engine/logging/drivers/syslog/
// example: <34>1 2023-09-07T14:33:22Z myhost myapp 1234 5678 [exampleSDID@32473 iut="3" eventSource="Application"] An example message
function parseRFC5424Message(rawMessage) {
const syslogRegex = /^<(\d+)>(\d+) (\S+) (\S+) (\S+) (\S+) (\S+) (?:\[(.*?)\])?(.*)$/s; // /s means .* will match newline
const match = rawMessage.match(syslogRegex);
if (!match) return null;
const [, pri, version, timestamp, hostname, appName, procId, msgId, structuredData, message] = match;
return {
pri: parseInt(pri, 10), // priority
version: parseInt(version, 10), // version
timestamp, // timestamp
hostname, // hostname
appName, // app name
procId, // process ID
msgId, // message ID
structuredData: structuredData ? structuredData : null, // structured data (if present)
message // message
};
}
async function start() {
debug('==========================================');
debug(' Cloudron Syslog Daemon ');
@@ -29,33 +51,24 @@ async function start() {
});
gServer.on('connection', function (socket) {
socket.on('data', function (msg) {
const lines = msg.toString().split('\n'); // may be multiline data
socket.on('data', function (data) {
const msg = data.toString('utf8');
const info = parseRFC5424Message(msg);
if (!info) return debug('Unable to parse:', msg);
if (!info.appName) return debug('Ignore unknown app:', msg);
lines.forEach(function (msg) {
if (!msg) return;
const appLogDir = path.join(paths.LOG_DIR, info.appName);
const info = parser(msg);
if (!info || !info.appName) return debug('Ignore unknown app log:', msg);
// remove line breaks to avoid holes in the log file
// we do not ignore empty log lines, to allow gaps for potential ease of readability
const message = info.message.replace(/[\n\r]+/g, '');
const appLogDir = path.join(paths.LOG_DIR, info.appName);
try {
fs.mkdirSync(appLogDir, { recursive: true });
fs.appendFileSync(`${appLogDir}/app.log`, info.ts.toISOString() + ' ' + message + '\n');
} catch (error) {
console.error(error);
}
});
try {
fs.mkdirSync(appLogDir, { recursive: true });
fs.appendFileSync(`${appLogDir}/app.log`, `${info.timestamp} ${info.message.trim()}\n`);
} catch (error) {
debug(error);
}
});
socket.on('error', function (error) {
console.error(`socket error: ${error}`);
debug(`socket error: ${error}`);
});
});
+33
View File
@@ -0,0 +1,33 @@
FROM ubuntu:jammy-20230816@sha256:b492494d8e0113c4ad3fe4528a4b5ff89faa5331f7d52c5c138196f69ce176a6
RUN apt update && \
apt install -y openssl mysql-client-8.0 sudo lsb-release vim gosu curl
RUN useradd --system --uid 808 --comment "Cloudron Box" --create-home --shell /usr/bin/bash yellowtent
RUN mkdir -p /mnt/cloudron-test-music /media/cloudron-test-music # volume test
# https://download.docker.com/linux/static/stable/x86_64/
RUN cd /usr/bin && curl -L https://download.docker.com/linux/static/stable/x86_64/docker-25.0.5.tgz | tar -zxvf - --strip-components=1 docker/docker
COPY setup/start/sudoers /etc/sudoers.d/cloudron
COPY test/cloak /usr/bin/cloak
RUN ln -s /usr/bin/cloak /usr/bin/systemd-run && \
ln -s /usr/bin/cloak /usr/bin/systemctl
COPY test/entrypoint.sh /usr/bin/entrypoint.sh
WORKDIR /home/yellowtent
USER yellowtent
RUN mkdir -p appsdata
RUN mkdir -p boxdata/box boxdata/mail boxdata/certs boxdata/mail/dkim/localhost boxdata/mail/dkim/foobar.com
RUN mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications/dashboard platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks platformdata/sftp/ssh platformdata/firewall platformdata/update
RUN bash -c 'openssl req -x509 -newkey rsa:2048 -keyout platformdata/nginx/cert/host.key -out platformdata/nginx/cert/host.cert -days 3650 -subj '/CN=localhost' -nodes -config <(cat /etc/ssl/openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:*.localhost"))'
WORKDIR /home/yellowtent/box
USER root
ENTRYPOINT [ "/usr/bin/entrypoint.sh" ]
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
set -eu
cmd=$(basename $BASH_SOURCE)
# echo $cmd "$@"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
set -eu
docker_gid=$(stat -c "%g" /run/docker.sock)
addgroup --gid ${docker_gid} --system docker
usermod -aG docker yellowtent
exec su yellowtent --command "$@"