@@ -6,7 +6,7 @@ | |||
{"name": "server_alias", "value": "[[network.networkDomain]]"}, | |||
{"name": "restore_key", "value": "[[restoreKey]]"}, | |||
{"name": "install_type", "value": "[[installType]]"}, | |||
{"name": "bubble_java_opts", "value": "-Xms[[expr nodeSize.memoryMB '//' 4]]m -Xmx[[expr nodeSize.memoryMB '//' 4]]m"} | |||
{"name": "bubble_java_opts", "value": "-XX:MaxRAM=[[expr nodeSize.memoryMB '//' '2.625']]m"} | |||
], | |||
"optionalConfigNames": ["restore_key"], | |||
"tgzB64": "" |
@@ -0,0 +1,5 @@ | |||
[program:nodemanager] | |||
stdout_logfile = /home/bubble/logs/nodemanager-out.log | |||
stderr_logfile = /home/bubble/logs/nodemanager-err.log | |||
command=/usr/sbin/bubble-nodemanager |
@@ -29,18 +29,32 @@ | |||
shell: /usr/local/bin/copy_certs_to_bubble.sh {{ server_alias }} | |||
when: install_type == 'node' | |||
- name: Install bubble-nodemanager | |||
copy: | |||
src: "bubble-nodemanager" | |||
dest: /usr/sbin/bubble-nodemanager | |||
owner: root | |||
group: root | |||
mode: 0500 | |||
- name: Install bubble-nodemanager supervisor conf file | |||
copy: | |||
src: "supervisor_bubble_nodemanager.conf" | |||
dest: /etc/supervisor/conf.d/nodemanager.conf | |||
- name: Install bubble supervisor conf file | |||
template: | |||
src: "supervisor_bubble.conf.j2" | |||
dest: /etc/supervisor/conf.d/bubble.conf | |||
# We cannot receive notifications until nginx is running, so start bubble API as the very last step | |||
- name: Ensure bubble is started | |||
- name: Ensure bubble and bubble_nodemanager are started | |||
supervisorctl: | |||
name: '{{ item }}' | |||
state: restarted | |||
with_items: | |||
- bubble | |||
- nodemanager | |||
- name: Ensure authorized SSH keys are up-to-date | |||
shell: su - bubble bash -c "touch /home/bubble/.refresh_ssh_keys" |
@@ -4,7 +4,7 @@ stdout_logfile = /home/bubble/logs/bubble-out.log | |||
stderr_logfile = /home/bubble/logs/bubble-err.log | |||
command=sudo -u bubble bash -c "/usr/bin/java \ | |||
-Dfile.encoding=UTF-8 -Djava.net.preferIPv4Stack=true \ | |||
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 {{ bubble_java_opts }} \ | |||
-XX:+UseG1GC -XX:MaxGCPauseMillis=400 {{ bubble_java_opts }} \ | |||
-cp /home/bubble/current/bubble.jar \ | |||
bubble.server.BubbleServer \ | |||
/home/bubble/current/bubble.env" |
@@ -21,6 +21,14 @@ server { | |||
proxy_set_header X-Forwarded-Proto https; | |||
} | |||
location /nodeman { | |||
proxy_pass http://127.0.0.1:7800/; | |||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |||
proxy_set_header X-Real-IP $remote_addr; | |||
proxy_set_header X-Forwarded-Host {{ server_name }}; | |||
proxy_set_header X-Forwarded-Proto https; | |||
} | |||
location ^~ /.well-known/acme-challenge/ { | |||
default_type "text/plain"; | |||
root /var/www/html; | |||
@@ -21,6 +21,14 @@ server { | |||
proxy_set_header X-Forwarded-Proto https; | |||
} | |||
location /nodeman { | |||
proxy_pass http://127.0.0.1:7800/; | |||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |||
proxy_set_header X-Real-IP $remote_addr; | |||
proxy_set_header X-Forwarded-Host {{ server_name }}; | |||
proxy_set_header X-Forwarded-Proto https; | |||
} | |||
location ^~ /.well-known/acme-challenge/ { | |||
default_type "text/plain"; | |||
root /var/www/html; | |||
@@ -246,6 +246,12 @@ | |||
<version>16.2.0</version> | |||
</dependency> | |||
<dependency> | |||
<groupId>net.lingala.zip4j</groupId> | |||
<artifactId>zip4j</artifactId> | |||
<version>2.5.1</version> | |||
</dependency> | |||
<dependency> | |||
<groupId>org.cobbzilla</groupId> | |||
<artifactId>restex</artifactId> | |||
@@ -92,6 +92,7 @@ public class ApiConstants { | |||
public static final String EP_APPROVE = "/approve"; | |||
public static final String EP_DENY = "/deny"; | |||
public static final String EP_AUTHENTICATOR = "/authenticator"; | |||
public static final String EP_PATCH = "/patch"; | |||
public static final String ACCOUNTS_ENDPOINT = "/users"; | |||
public static final String EP_POLICY = "/policy"; | |||
@@ -180,6 +181,7 @@ public class ApiConstants { | |||
public static final String EP_STATUS = "/status"; | |||
public static final String EP_PROMOTIONS = PROMOTIONS_ENDPOINT; | |||
public static final String EP_FORK = "/fork"; | |||
public static final String EP_NODE_MANAGER = "/nodeman"; | |||
public static final String DETECT_ENDPOINT = "/detect"; | |||
public static final String EP_LOCALE = "/locale"; | |||
@@ -27,6 +27,7 @@ import org.cobbzilla.wizard.model.IdentifiableBase; | |||
import org.cobbzilla.wizard.model.entityconfig.EntityFieldType; | |||
import org.cobbzilla.wizard.model.entityconfig.annotations.*; | |||
import org.cobbzilla.wizard.validation.HasValue; | |||
import org.hibernate.annotations.Type; | |||
import javax.persistence.*; | |||
import java.io.File; | |||
@@ -41,6 +42,8 @@ import static org.cobbzilla.util.network.NetworkUtil.isLocalIpv4; | |||
import static org.cobbzilla.util.reflect.ReflectionUtil.copy; | |||
import static org.cobbzilla.util.string.ValidationRegexes.IP4_MAXLEN; | |||
import static org.cobbzilla.util.string.ValidationRegexes.IP6_MAXLEN; | |||
import static org.cobbzilla.wizard.model.crypto.EncryptedTypes.ENCRYPTED_STRING; | |||
import static org.cobbzilla.wizard.model.crypto.EncryptedTypes.ENC_PAD; | |||
import static org.cobbzilla.wizard.model.entityconfig.annotations.ECForeignKeySearchDepth.shallow; | |||
@ECType(root=true) @ECTypeCreate(method="DISABLED") | |||
@@ -196,6 +199,10 @@ public class BubbleNode extends IdentifiableBase implements HasNetwork, HasBubbl | |||
@Embedded @Getter @Setter private BubbleTags tags; | |||
@Type(type=ENCRYPTED_STRING) @Column(columnDefinition="varchar("+(100+ENC_PAD)+")") | |||
@Getter @Setter private String nodeManagerPassword; | |||
public boolean hasNodeManagerPassword () { return !empty(nodeManagerPassword); } | |||
@Transient @JsonIgnore @Getter @Setter private transient Map<String, String> ephemeralTags; | |||
public boolean hasEphemeralTag (String name) { return ephemeralTags != null && ephemeralTags.containsKey(name); } | |||
@@ -31,6 +31,7 @@ import bubble.service.account.StandardAuthenticatorService; | |||
import bubble.service.backup.RestoreService; | |||
import bubble.service.bill.PromotionService; | |||
import bubble.service.boot.ActivationService; | |||
import bubble.service.boot.NodeManagerService; | |||
import bubble.service.boot.SageHelloService; | |||
import bubble.service.cloud.DeviceIdService; | |||
import bubble.service.notify.NotificationService; | |||
@@ -64,8 +65,7 @@ import static bubble.server.BubbleConfiguration.getDEFAULT_LOCALE; | |||
import static bubble.server.BubbleServer.getRestoreKey; | |||
import static java.util.concurrent.TimeUnit.SECONDS; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.*; | |||
import static org.cobbzilla.util.http.HttpContentTypes.APPLICATION_JSON; | |||
import static org.cobbzilla.util.http.HttpContentTypes.CONTENT_TYPE_ANY; | |||
import static org.cobbzilla.util.http.HttpContentTypes.*; | |||
import static org.cobbzilla.util.json.JsonUtil.COMPACT_MAPPER; | |||
import static org.cobbzilla.util.json.JsonUtil.json; | |||
import static org.cobbzilla.util.string.LocaleUtil.currencyForLocale; | |||
@@ -95,6 +95,7 @@ public class AuthResource { | |||
@Autowired private PromotionService promoService; | |||
@Autowired private DeviceIdService deviceIdService; | |||
@Autowired private BubbleNodeKeyDAO nodeKeyDAO; | |||
@Autowired private NodeManagerService nodeManagerService; | |||
public Account updateLastLogin(Account account) { return accountDAO.update(account.setLastLogin()); } | |||
@@ -575,4 +576,13 @@ public class AuthResource { | |||
return ok_empty(); | |||
} | |||
@GET @Path(EP_PATCH+"/{token}") | |||
@Produces(APPLICATION_OCTET_STREAM) | |||
public Response getPatchFile(@Context ContainerRequest ctx, | |||
@PathParam("token") String token) { | |||
final File patch = nodeManagerService.findPatch(token); | |||
if (patch == null) return notFound(token); | |||
return send(new FileSendableResource(patch)); | |||
} | |||
} |
@@ -0,0 +1,181 @@ | |||
package bubble.resources.cloud; | |||
import bubble.dao.cloud.BubbleNodeDAO; | |||
import bubble.model.account.Account; | |||
import bubble.model.cloud.BubbleNode; | |||
import bubble.model.cloud.notify.NotificationReceipt; | |||
import bubble.service.boot.NodeManagerService; | |||
import bubble.service.boot.SelfNodeService; | |||
import bubble.service.notify.NotificationService; | |||
import edu.emory.mathcs.backport.java.util.Arrays; | |||
import lombok.extern.slf4j.Slf4j; | |||
import net.lingala.zip4j.ZipFile; | |||
import net.lingala.zip4j.exception.ZipException; | |||
import org.cobbzilla.util.http.*; | |||
import org.cobbzilla.util.io.FileUtil; | |||
import org.cobbzilla.util.io.TempDir; | |||
import org.cobbzilla.wizard.auth.LoginRequest; | |||
import org.glassfish.jersey.media.multipart.FormDataParam; | |||
import org.glassfish.jersey.server.ContainerRequest; | |||
import org.springframework.beans.factory.annotation.Autowired; | |||
import javax.ws.rs.*; | |||
import javax.ws.rs.core.Context; | |||
import javax.ws.rs.core.Response; | |||
import java.io.File; | |||
import java.io.IOException; | |||
import java.io.InputStream; | |||
import java.util.HashSet; | |||
import java.util.Set; | |||
import static bubble.model.cloud.notify.NotificationType.hello_to_sage; | |||
import static javax.ws.rs.core.MediaType.APPLICATION_JSON; | |||
import static javax.ws.rs.core.MediaType.MULTIPART_FORM_DATA; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.*; | |||
import static org.cobbzilla.util.io.FileUtil.*; | |||
import static org.cobbzilla.wizard.resources.ResourceUtil.*; | |||
@Consumes(APPLICATION_JSON) | |||
@Produces(APPLICATION_JSON) | |||
@Slf4j | |||
public class NodeManagerResource { | |||
// these constants must match up with those defined in bubble-nodemanager | |||
public static final String BUBBLE_NODE_ADMIN = "bubble_node_admin"; | |||
public static final String ROOT_DIR_PREFIX = "root_dir/"; | |||
public static final String COMPONENT_ROOT = "root"; | |||
public static final Set<String> PATCH_COMPONENTS = new HashSet<>(Arrays.asList(new String[]{COMPONENT_ROOT, "bubble", "mitmproxy"})); | |||
private BubbleNode node; | |||
public NodeManagerResource (BubbleNode node) { this.node = node; } | |||
@Autowired private BubbleNodeDAO nodeDAO; | |||
@Autowired private NodeManagerService nodeManagerService; | |||
@Autowired private SelfNodeService selfNodeService; | |||
@Autowired private NotificationService notificationService; | |||
@POST @Path("/set_password") | |||
public Response setPassword (@Context ContainerRequest ctx, | |||
LoginRequest request) { | |||
final String password = request.getPassword(); | |||
if (empty(password)) return invalid("err.password.required"); | |||
if (password.length() < 10) return invalid("err.password.tooShort"); | |||
nodeManagerService.setPassword(password); | |||
final BubbleNode selfNode = selfNodeService.getThisNode(); | |||
if (selfNode != null && selfNode.hasSageNode() && !selfNode.getUuid().equals(selfNode.getSageNode())) { | |||
final BubbleNode sageNode = nodeDAO.findByUuid(selfNode.getSageNode()); | |||
if (sageNode == null) { | |||
log.warn("setPassword: error finding sage to notify: "+selfNode.getSageNode()); | |||
} else { | |||
selfNode.setNodeManagerPassword(password); | |||
final NotificationReceipt receipt = notificationService.notify(sageNode, hello_to_sage, selfNode); | |||
if (!receipt.isSuccess()) { | |||
log.warn("setPassword: error notifying sage of new nodemanager password: "+receipt); | |||
} | |||
selfNode.setNodeManagerPassword(null); // just in case the object gets sync'd to db | |||
} | |||
} | |||
return ok_empty(); | |||
} | |||
@POST @Path("/disable") | |||
public Response disable (@Context ContainerRequest ctx) { | |||
nodeManagerService.disable(); | |||
return ok_empty(); | |||
} | |||
public HttpRequestBean validateNodeManagerRequest(ContainerRequest ctx, String path) { | |||
final Account caller = userPrincipal(ctx); | |||
if (!caller.admin() && !caller.getUuid().equals(node.getAccount())) throw forbiddenEx(); | |||
final BubbleNode n = nodeDAO.findByUuid(node.getUuid()); | |||
if (!n.hasNodeManagerPassword()) throw invalidEx("err.nodemanager.noPasswordSet"); | |||
final String url = "https://" + node.getFqdn() + ":" + node.getAdminPort() + "/nodeman/" + path; | |||
return new HttpRequestBean(url) | |||
.setAuthType(HttpAuthType.basic) | |||
.setAuthUsername(BUBBLE_NODE_ADMIN) | |||
.setAuthPassword(n.getNodeManagerPassword()); | |||
} | |||
public Response callNodeManager(HttpRequestBean request, String prefix) { | |||
prefix = prefix + ": "; | |||
try { | |||
final HttpResponseBean response = HttpUtil.getResponse(request); | |||
if (response.isOk()) return ok(response.getEntityString()); | |||
log.error(prefix+response); | |||
} catch (IOException e) { | |||
log.error(prefix+shortError(e)); | |||
} | |||
throw invalidEx("err.nodemanager.error"); | |||
} | |||
@GET @Path("/stats/{stat}") | |||
public Response getStats (@Context ContainerRequest ctx, | |||
@PathParam("stat") String stat) { | |||
final HttpRequestBean request = validateNodeManagerRequest(ctx, "stats/"+stat); | |||
return callNodeManager(request, "getStats"); | |||
} | |||
@POST @Path("/cmd/{command}") | |||
public Response runCommand (@Context ContainerRequest ctx, | |||
@PathParam("command") String command) { | |||
final HttpRequestBean request = validateNodeManagerRequest(ctx, "cmd/"+command) | |||
.setMethod(HttpMethods.POST); | |||
return callNodeManager(request, "runCommand"); | |||
} | |||
@POST @Path("/service/{service}/{action}") | |||
public Response service (@Context ContainerRequest ctx, | |||
@PathParam("service") String service, | |||
@PathParam("action") String action) { | |||
final HttpRequestBean request = validateNodeManagerRequest(ctx, "service/"+service+"/"+action) | |||
.setMethod(HttpMethods.POST); | |||
return callNodeManager(request, "service"); | |||
} | |||
@POST @Path("/patch/file/{component}/{path : .+}") | |||
@Consumes(MULTIPART_FORM_DATA) | |||
public Response patchFile (@Context ContainerRequest ctx, | |||
@PathParam("component") String component, | |||
@PathParam("path") String path, | |||
@FormDataParam("file") InputStream in, | |||
@FormDataParam("name") String name) { | |||
if (PATCH_COMPONENTS.contains(component)) return notFound(component); | |||
final HttpRequestBean request = validateNodeManagerRequest(ctx, "patch/"+component) | |||
.setMethod(HttpMethods.POST); | |||
// create a zipfile containing the file at the proper path | |||
final File zipFile = buildPatchZip(component, path, in); | |||
// register patch with NodeManagerService, receive URL | |||
final String url = nodeManagerService.registerPatch(zipFile); | |||
request.setEntity(url); | |||
return callNodeManager(request, "patchFile"); | |||
} | |||
private File buildPatchZip(String component, String path, InputStream in) { | |||
final TempDir tempDir = new TempDir(); | |||
if (component.equals(COMPONENT_ROOT)) { | |||
path = ROOT_DIR_PREFIX + path; | |||
} | |||
if (path.startsWith("/")) path = path.substring(1); | |||
if (empty(path)) throw invalidEx("err.nodemanager.invalidPath"); | |||
final String fullPath = abs(tempDir)+"/"+path; | |||
final File parentDir = mkdirOrDie(dirname(fullPath)); | |||
FileUtil.toFileOrDie(new File(parentDir, basename(path)), in); | |||
final File zipFile = FileUtil.temp(".zip"); | |||
try { | |||
new ZipFile(zipFile).addFolder(tempDir); | |||
} catch (ZipException e) { | |||
return die("buildPatchZip: "+shortError(e)); | |||
} | |||
log.info("buildPatchZip: created patch file: "+abs(zipFile)); | |||
return zipFile; | |||
} | |||
} |
@@ -11,11 +11,16 @@ import bubble.model.cloud.BubbleNetwork; | |||
import bubble.model.cloud.BubbleNode; | |||
import bubble.resources.account.ReadOnlyAccountOwnedResource; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.glassfish.grizzly.http.server.Request; | |||
import org.glassfish.jersey.server.ContainerRequest; | |||
import javax.ws.rs.Path; | |||
import javax.ws.rs.PathParam; | |||
import javax.ws.rs.core.Context; | |||
import java.util.List; | |||
import static org.cobbzilla.wizard.resources.ResourceUtil.forbiddenEx; | |||
import static bubble.ApiConstants.EP_NODE_MANAGER; | |||
import static org.cobbzilla.wizard.resources.ResourceUtil.*; | |||
@Slf4j | |||
public class NodesResource extends ReadOnlyAccountOwnedResource<BubbleNode, BubbleNodeDAO> { | |||
@@ -67,4 +72,19 @@ public class NodesResource extends ReadOnlyAccountOwnedResource<BubbleNode, Bubb | |||
@Override protected BubbleNode setReferences(ContainerRequest ctx, Account caller, BubbleNode node) { throw forbiddenEx(); } | |||
@Override protected Object daoCreate(BubbleNode nodes) { throw forbiddenEx(); } | |||
@Path("/{id}"+EP_NODE_MANAGER) | |||
public NodeManagerResource getNodeManagerResource(@Context Request req, | |||
@Context ContainerRequest ctx, | |||
@PathParam("id") String id) { | |||
final Account caller = userPrincipal(ctx); | |||
final BubbleNode node = super.find(ctx, id); | |||
if (node == null) throw notFoundEx(id); | |||
if (!caller.admin() && !caller.getUuid().equals(node.getAccount())) throw forbiddenEx(); | |||
if (!node.hasNodeManagerPassword()) throw invalidEx("err.nodemanager.noPasswordSet"); | |||
return configuration.subResource(NodeManagerResource.class, node); | |||
} | |||
} |
@@ -0,0 +1,47 @@ | |||
package bubble.service.boot; | |||
import bubble.server.BubbleConfiguration; | |||
import lombok.AccessLevel; | |||
import lombok.Getter; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.cobbzilla.util.collection.ExpirationMap; | |||
import org.cobbzilla.util.security.bcrypt.BCryptUtil; | |||
import org.springframework.beans.factory.annotation.Autowired; | |||
import org.springframework.stereotype.Service; | |||
import java.io.File; | |||
import static bubble.ApiConstants.AUTH_ENDPOINT; | |||
import static bubble.ApiConstants.EP_PATCH; | |||
import static org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric; | |||
import static org.cobbzilla.util.io.FileUtil.toFileOrDie; | |||
@Service @Slf4j | |||
public class NodeManagerService { | |||
public static final File NODEMANAGER_PASSWORD_FILE = new File("/home/bubble/.nodemanager_pass"); | |||
@Autowired private BubbleConfiguration configuration; | |||
public String generatePasswordOrNull() { | |||
if (NODEMANAGER_PASSWORD_FILE.exists()) return null; | |||
final String password = randomAlphanumeric(20); | |||
setPassword(password); | |||
return password; | |||
} | |||
public void setPassword(String password) { toFileOrDie(NODEMANAGER_PASSWORD_FILE, BCryptUtil.hash(password)); } | |||
public void disable() { toFileOrDie(NODEMANAGER_PASSWORD_FILE, "__disabled__"); } | |||
@Getter(lazy=true, value=AccessLevel.PROTECTED) private final ExpirationMap<String, File> patches = new ExpirationMap<>(); | |||
public String registerPatch(File zipFile) { | |||
final String token = randomAlphanumeric(20); | |||
getPatches().put(token, zipFile); | |||
return configuration.getApiUriBase() + AUTH_ENDPOINT + EP_PATCH + "/" + token; | |||
} | |||
public File findPatch (String token) { return getPatches().get(token); } | |||
} |
@@ -39,6 +39,7 @@ public class SageHelloService extends SimpleDaemon { | |||
@Autowired private BubbleConfiguration configuration; | |||
@Autowired private StandardSelfNodeService selfNodeService; | |||
@Autowired private NotificationService notificationService; | |||
@Autowired private NodeManagerService nodeManagerService; | |||
private final AtomicBoolean sageHelloSent = new AtomicBoolean(false); | |||
public boolean sageHelloSuccessful () { return sageHelloSent.get(); } | |||
@@ -62,6 +63,9 @@ public class SageHelloService extends SimpleDaemon { | |||
if (sage == null) { | |||
log.error("hello_to_sage: sage node not found: " + c.getSageNode()); | |||
} else { | |||
// If we do not have a nodemanager password, generate one now and include it with our hello | |||
selfNode.setNodeManagerPassword(nodeManagerService.generatePasswordOrNull()); | |||
log.info("hello_to_sage: sending hello..."); | |||
final NotificationReceipt receipt = notificationService.notify(sage, hello_to_sage, selfNode); | |||
log.info("hello_to_sage: received reply from sage node: " + json(receipt, COMPACT_MAPPER)); | |||
@@ -74,6 +78,7 @@ public class SageHelloService extends SimpleDaemon { | |||
} | |||
} | |||
} | |||
selfNode.setNodeManagerPassword(null); // just in case the object gets sync'd to db | |||
} | |||
} | |||
} | |||
@@ -100,7 +100,7 @@ | |||
{"name": "server_alias", "value": "[[network.networkDomain]]"}, | |||
{"name": "restore_key", "value": "[[restoreKey]]"}, | |||
{"name": "install_type", "value": "[[installType]]"}, | |||
{"name": "bubble_java_opts", "value": "-Xms[[expr nodeSize.memoryMB '//' 2]]m -Xmx[[expr nodeSize.memoryMB '//' 2]]m"} | |||
{"name": "bubble_java_opts", "value": "-XX:MaxRAM=[[expr nodeSize.memoryMB '//' '2.625']]m"} | |||
], | |||
"optionalConfigNames": ["restore_key"], | |||
"tgzB64": "" | |||
@@ -372,7 +372,7 @@ meter_unknown_error=An unknown error occurred | |||
# Help text shown during launch | |||
title_launch_help_html=Next Steps | |||
message_launch_help_html=<p>Your Bubble will take about 20 minutes to launch and configure itself.</p><p>When your Bubble has completed its setup, it will send you an email message with a link to unlock it and start using it.</p><p>While you wait for your Bubble to be ready, please <a href="https://github.com/bubblev/bubble-docs/blob/master/wg_setup/README.md">install WireGuard</a> on each device that you will be connecting to your Bubble.</p><p>If you run into any trouble installing WireGuard or setting up your Bubble, please contact <a href="mailto:support@bubblev.com">support@bubblev.com</a></p> | |||
message_launch_success_help_html=<p>Congratulations! Your Bubble is now running.</p><p>Check your email for message from your Bubble. It should contain a link to unlock it and start using it.</p><p>Follow that link to begin using your Bubble, it will guide you through the process to connect your various devices to it.</p><p>If you run into any trouble setting up your Bubble, please contact <a href="mailto:support@bubblev.com">support@bubblev.com</a></p> | |||
message_launch_success_help_html=<p>Congratulations! Your Bubble is now running.</p><p>Check your email for a message from your Bubble. It should contain a link to unlock it and start using it.</p><p>Follow that link to begin using your Bubble, it will guide you through the process to connect your various devices to it.</p><p>If you run into any trouble setting up your Bubble, please contact <a href="mailto:support@bubblev.com">support@bubblev.com</a></p> | |||
# Network statuses | |||
msg_network_state_created=initialized | |||
@@ -774,3 +774,8 @@ err.addFeed.emptyFqdnList=Feed URL was not found or contained no data | |||
# analytics app errors | |||
err.addFilter.analyticsFilterRequired=Filter pattern is required | |||
# nodemanager errors | |||
err.nodemanager.error=Error calling nodemanager | |||
err.nodemanager.noPasswordSet=No nodemanager password is set | |||
err.nodemanager.invalidPath=Path is invalid |