@@ -130,6 +130,7 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> { | |||
throw invalidEx("err.accountPlan.stopNetworkBeforeDeleting"); | |||
} | |||
update(accountPlan.setDeleted(now()).setEnabled(false)); | |||
networkDAO.delete(accountPlan.getNetwork()); | |||
if (configuration.paymentsEnabled()) { | |||
refundService.processRefunds(); | |||
} | |||
@@ -41,7 +41,7 @@ public class CloudServiceDAO extends AccountOwnedTemplateDAO<CloudService> { | |||
} | |||
@Override public CloudService postCreate(CloudService cloud, Object context) { | |||
if (!cloud.delegated()) { | |||
if (!cloud.delegated() && !configuration.testMode()) { | |||
final ValidationResult errors = testDriver(cloud, configuration); | |||
if (errors.isInvalid()) throw invalidEx(errors); | |||
} | |||
@@ -19,8 +19,10 @@ import org.cobbzilla.wizard.validation.ValidationResult; | |||
import java.io.Serializable; | |||
import java.util.ArrayList; | |||
import java.util.Arrays; | |||
import java.util.Collection; | |||
import java.util.List; | |||
import java.util.function.Predicate; | |||
import java.util.stream.Collectors; | |||
import static bubble.ApiConstants.G_AUTH; | |||
import static java.util.UUID.randomUUID; | |||
@@ -284,8 +286,11 @@ public class AccountContact implements Serializable { | |||
} | |||
public AccountContact mask() { | |||
return new AccountContact(this) | |||
.setInfo(getType().mask(getInfo())); | |||
return new AccountContact(this).setInfo(getType().mask(getInfo())); | |||
} | |||
public static Collection<AccountContact> mask(Collection<AccountContact> contacts) { | |||
return empty(contacts) ? contacts : contacts.stream().map(c -> c.mask()).collect(Collectors.toList()); | |||
} | |||
public ValidationResult validate(ValidationResult errors) { | |||
@@ -76,6 +76,12 @@ public class AccountPolicy extends IdentifiableBase implements HasAccount { | |||
@Type(type=ENCRYPTED_STRING) @Column(columnDefinition="varchar("+(100000+ENC_PAD)+")") | |||
@JsonIgnore @Getter @Setter private String accountContactsJson; | |||
public boolean hasAccountContacts() { return accountContactsJson != null; } | |||
@JsonIgnore @Transient public List<AccountContact> getVerifiedContacts () { | |||
return hasVerifiedAccountContacts() | |||
? Arrays.stream(getAccountContacts()).filter(AccountContact::verified).collect(Collectors.toList()) | |||
: Collections.emptyList(); | |||
} | |||
public boolean hasVerifiedAccountContacts() { | |||
return hasAccountContacts() && Arrays.stream(getAccountContacts()).anyMatch(AccountContact::verified); | |||
} | |||
@@ -6,7 +6,7 @@ import static bubble.ApiConstants.enumFromString; | |||
public enum BubbleNetworkState { | |||
created, starting, restoring, running, stopping, stopped; | |||
created, starting, restoring, running, stopping, error_stopping, stopped; | |||
@JsonCreator public static BubbleNetworkState fromString(String v) { return enumFromString(BubbleNetworkState.class, v); } | |||
@@ -145,6 +145,7 @@ public class BubbleNode extends IdentifiableBase implements HasNetwork, HasBubbl | |||
@Enumerated(EnumType.STRING) | |||
@ECIndex @Column(length=20, nullable=false) | |||
@Getter @Setter private ComputeNodeSizeType sizeType; | |||
@Transient @JsonIgnore public boolean isLocalCompute () { return sizeType == ComputeNodeSizeType.local; } | |||
@ECSearchable @ECField(index=110) | |||
@Column(nullable=false) | |||
@@ -6,6 +6,10 @@ import lombok.Setter; | |||
import lombok.experimental.Accessors; | |||
import org.cobbzilla.util.collection.NameAndValue; | |||
import static org.cobbzilla.util.json.JsonUtil.json; | |||
import static org.cobbzilla.util.security.CryptoUtil.string_decrypt; | |||
import static org.cobbzilla.util.security.CryptoUtil.string_encrypt; | |||
@NoArgsConstructor @Accessors(chain=true) | |||
public class NetworkKeys { | |||
@@ -14,8 +18,22 @@ public class NetworkKeys { | |||
@Getter @Setter private NameAndValue[] keys; | |||
public NetworkKeys addKey (String name, String value) { | |||
return setKeys(NameAndValue.update(keys, name, value)); | |||
} | |||
public NetworkKeys addKey (String name, String value) { return setKeys(NameAndValue.update(keys, name, value)); } | |||
public EncryptedNetworkKeys encrypt(String password) { return new EncryptedNetworkKeys(this, password); } | |||
@NoArgsConstructor @Accessors(chain=true) | |||
public static class EncryptedNetworkKeys { | |||
public EncryptedNetworkKeys (NetworkKeys keys, String password) { | |||
setData(string_encrypt(json(keys), password)); | |||
} | |||
public NetworkKeys decrypt () { | |||
return json(string_decrypt(getData(), getPassword()), NetworkKeys.class); | |||
} | |||
@Getter @Setter private String data; | |||
@Getter @Setter private String password; | |||
} | |||
} |
@@ -46,8 +46,7 @@ import static bubble.model.cloud.BubbleNetwork.TAG_PARENT_ACCOUNT; | |||
import static bubble.model.cloud.notify.NotificationType.retrieve_backup; | |||
import static bubble.server.BubbleServer.getRestoreKey; | |||
import static java.util.concurrent.TimeUnit.SECONDS; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.now; | |||
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.system.Sleep.sleep; | |||
@@ -117,7 +116,7 @@ public class AuthResource { | |||
public Response restore(@Context Request req, | |||
@Context ContainerRequest ctx, | |||
@PathParam("restoreKey") String restoreKey, | |||
@Valid NetworkKeys keys) { | |||
@Valid NetworkKeys.EncryptedNetworkKeys encryptedKeys) { | |||
// ensure we have been initialized | |||
long start = now(); | |||
@@ -137,6 +136,14 @@ public class AuthResource { | |||
final BubbleNode sageNode = nodeDAO.findByUuid(thisNode.getSageNode()); | |||
if (sageNode == null) return invalid("err.sageNode.notFound"); | |||
final NetworkKeys keys; | |||
try { | |||
keys = encryptedKeys.decrypt(); | |||
} catch (Exception e) { | |||
log.warn("restore: error decrypting keys: "+shortError(e)); | |||
return invalid("err.networkKeys.invalid"); | |||
} | |||
restoreService.registerRestore(restoreKey, keys); | |||
final NotificationReceipt receipt = notificationService.notify(thisNode.getUuid(), sageNode, retrieve_backup, thisNode.setRestoreKey(getRestoreKey())); | |||
@@ -19,6 +19,8 @@ import bubble.service.backup.NetworkKeysService; | |||
import bubble.service.cloud.NodeProgressMeterTick; | |||
import bubble.service.cloud.StandardNetworkService; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.cobbzilla.util.collection.NameAndValue; | |||
import org.cobbzilla.wizard.validation.ConstraintViolationBean; | |||
import org.glassfish.grizzly.http.server.Request; | |||
import org.glassfish.jersey.server.ContainerRequest; | |||
import org.springframework.beans.factory.annotation.Autowired; | |||
@@ -29,6 +31,7 @@ import javax.ws.rs.core.Response; | |||
import java.util.List; | |||
import static bubble.ApiConstants.*; | |||
import static bubble.model.account.Account.validatePassword; | |||
import static bubble.model.cloud.BubbleNetwork.TAG_ALLOW_REGISTRATION; | |||
import static org.cobbzilla.util.http.HttpContentTypes.APPLICATION_JSON; | |||
import static org.cobbzilla.wizard.resources.ResourceUtil.*; | |||
@@ -115,14 +118,22 @@ public class NetworkActionsResource { | |||
return ok(); | |||
} | |||
@GET @Path(EP_KEYS+"/{uuid}") | |||
@POST @Path(EP_KEYS+"/{uuid}") | |||
public Response retrieveNetworkKeys(@Context Request req, | |||
@Context ContainerRequest ctx, | |||
@PathParam("uuid") String uuid) { | |||
@PathParam("uuid") String uuid, | |||
NameAndValue enc) { | |||
final Account caller = userPrincipal(ctx); | |||
if (!caller.admin()) return forbidden(); | |||
final String encryptionKey = enc == null ? null : enc.getValue(); | |||
final ConstraintViolationBean error = validatePassword(encryptionKey); | |||
if (error != null) return invalid(error); | |||
final NetworkKeys keys = keysService.retrieveKeys(uuid); | |||
return keys == null ? notFound(uuid) : ok(keys); | |||
return keys == null | |||
? invalid("err.retrieveNetworkKeys.notFound") | |||
: ok(keys.encrypt(encryptionKey)); | |||
} | |||
@POST @Path(EP_STOP) | |||
@@ -608,44 +608,68 @@ public class StandardNetworkService implements NetworkService { | |||
public boolean stopNetwork(BubbleNetwork network) { | |||
log.info("stopNetwork: stopping "+network.getNetworkDomain()); | |||
String lock = null; | |||
final String networkUuid = network.getUuid(); | |||
boolean stopped = false; | |||
try { | |||
lock = lockNetwork(networkUuid); | |||
network = networkDAO.findByUuid(networkUuid); | |||
if (network == null) throw notFoundEx(networkUuid); | |||
network = networkDAO.findByUuid(networkUuid); | |||
if (network == null) throw notFoundEx(networkUuid); | |||
// are any of them still alive? | |||
final List<BubbleNode> nodes = nodeDAO.findByNetwork(network.getUuid()); | |||
if (nodes.isEmpty()) { | |||
// nothing is running... what do we need to stop? | |||
log.warn("stopNetwork: no nodes running"); | |||
} | |||
// are any of them still alive? | |||
final List<BubbleNode> nodes = nodeDAO.findByNetwork(networkUuid); | |||
if (nodes.isEmpty()) { | |||
// nothing is running... what do we need to stop? | |||
log.warn("stopNetwork: no nodes running"); | |||
} | |||
if (nodes.size() == 1) { | |||
final BubbleNode n = nodes.get(0); | |||
if (n.isLocalCompute()) { | |||
throw invalidEx("err.node.cannotStopLocalNode", "Cannot stop local node: " + n.id(), n.id()); | |||
} | |||
} | |||
network.setState(BubbleNetworkState.stopping); | |||
networkDAO.update(network); | |||
network.setState(BubbleNetworkState.stopping); | |||
networkDAO.update(network); | |||
final ValidationResult validationResult = new ValidationResult(); | |||
final ValidationResult validationResult = new ValidationResult(); | |||
// todo: parallel shutdown? | |||
// stop all nodes in network | |||
nodes.forEach(node -> { | |||
try { | |||
stopNode(node); | |||
log.info("stopNetwork: stopped node " + node.id()); | |||
} catch (Exception e) { | |||
validationResult.addViolation("err.node.shutdownFailed", "Node shutdown failed: " + node.getUuid() + "/" + node.getIp4() + ": " + e); | |||
} | |||
}); | |||
// todo: parallel shutdown? | |||
// stop all nodes in network | |||
nodes.forEach(node -> { | |||
try { | |||
stopNode(node); | |||
log.info("stopNetwork: stopped node "+node.id()); | |||
} catch (Exception e) { | |||
validationResult.addViolation("err.node.shutdownFailed", "Node shutdown failed: " + node.getUuid() + "/" + node.getIp4() + ": " + e); | |||
if (validationResult.isInvalid()) { | |||
throw invalidEx(validationResult); | |||
} | |||
}); | |||
if (validationResult.isInvalid()) throw invalidEx(validationResult); | |||
// delete nodes in network | |||
nodes.forEach(node -> nodeDAO.delete(node.getUuid())); | |||
network.setState(BubbleNetworkState.stopped); | |||
networkDAO.update(network); | |||
log.info("stopNetwork: stopped " + network.getNetworkDomain()); | |||
network.setState(BubbleNetworkState.stopped); | |||
networkDAO.update(network); | |||
stopped = true; | |||
// delete nodes in network | |||
nodes.forEach(node -> nodeDAO.delete(node.getUuid())); | |||
} catch (RuntimeException e) { | |||
log.error("stopNetwork: error stopping: "+e); | |||
if (network != null) network.setState(BubbleNetworkState.error_stopping); | |||
networkDAO.update(network); | |||
throw e; | |||
log.info("stopNetwork: stopped " + network.getNetworkDomain()); | |||
return true; | |||
} finally { | |||
if (lock != null) unlockNetwork(networkUuid, lock); | |||
} | |||
return stopped; | |||
} | |||
protected CloudService findServiceOrDelegate(String cloudUuid) { | |||
@@ -166,12 +166,6 @@ label_field_networks_name=Name | |||
label_field_networks_locale=Locale | |||
label_field_networks_timezone=Time Zone | |||
label_field_networks_object_state=State | |||
label_field_networks_action_view=View | |||
label_field_networks_action_stop=Stop | |||
label_field_networks_action_delete=Delete | |||
table_row_networks_action_view=View | |||
table_row_networks_action_stop=Stop | |||
table_row_networks_action_delete=Delete | |||
button_label_new_network=Create Bubble | |||
message_empty_networks=Create your first Bubble! | |||
@@ -206,6 +200,24 @@ button_label_customize=Customize | |||
button_label_use_default=Use Default | |||
button_label_create_new_network=Create New Bubble | |||
# Network Page - Restore Keys | |||
link_network_action_request_keys=Request Bubble Restore Key | |||
message_network_action_keys_requested=Bubble Restore Key requested | |||
message_network_action_retrieve_keys=Download Bubble Restore Key | |||
message_network_keys=Bubble Restore Key | |||
message_network_keys_description=Save this to a secure location. Keep it separate from the password you used to encrypt it. | |||
field_network_key_download_code=Download Code | |||
field_network_key_download_password=Encrypt with password | |||
button_label_retrieve_keys=Download | |||
err.retrieveNetworkKeys.notFound=Download Code Not Found | |||
# Network Page - Danger Zone | |||
title_network_danger_zone=Danger Zone | |||
link_network_action_stop=Stop | |||
link_network_action_stop_description=Stop this Bubble. If you have downloaded your restore key, you can later restore it. | |||
link_network_action_delete=Delete | |||
link_network_action_delete_description=Delete this Bubble and all backups. You will not be able to restore this Bubble. This action cannot be undone. | |||
# Bubble Plans | |||
plan_name_bubble=Bubble Standard | |||
plan_description_bubble=Try this one first. Most users probably don't need anything more. | |||
@@ -263,7 +275,7 @@ meter_tick_apt_install_done=Installing system packages | |||
meter_tick_pip_install=Installing python packages | |||
meter_tick_pyyaml_pycparser=Continuing to install python packages | |||
meter_tick_playbook_start=Running configuration playbook | |||
meter_tick_role_common=Installing common Bubble functionality | |||
meter_tick_role_common=Installing core system packages | |||
meter_tick_role_common_packages=Installing Bubble packages | |||
meter_tick_role_firewall=Setting up firewall | |||
meter_tick_role_bubble=Installing Bubble API | |||
@@ -275,7 +287,7 @@ meter_tick_role_nginx=Setting up web server | |||
meter_tick_role_nginx_certbot=Installing SSL certificates | |||
meter_tick_role_mitmproxy=Setting up MITM server | |||
meter_tick_role_bubble_finalizer=Finalizing Bubble installation | |||
meter_tick_role_bubble_finalizer_touch=Turning on "first-time" setting to allow user to unlock Bubble | |||
meter_tick_role_bubble_finalizer_touch=Turning on "first-time" setting to allow you to unlock your Bubble | |||
meter_tick_role_bubble_finalizer_start=Starting Bubble API services | |||
meter_tick_install_complete=Bubble installation completed | |||
@@ -302,6 +314,7 @@ msg_network_state_starting=starting | |||
msg_network_state_restoring=restoring | |||
msg_network_state_running=running | |||
msg_network_state_stopping=stopping | |||
msg_network_state_error_stopping=error stopping | |||
msg_network_state_stopped=stopped | |||
# Node states | |||
@@ -398,6 +411,7 @@ err.name.invalid=Name is invalid | |||
err.name.networkNameAlreadyExists=Name is already in use | |||
err.name.regexFailed=Name must start with a letter and can only contain letters, numbers, hyphens, periods and underscores | |||
err.node.name.alreadyExists=A node already exists with the same FQDN | |||
err.node.cannotStopLocalNode=Cannot stop local Bubble | |||
err.nodeOperationTimeout.required=Node operation timeout is required | |||
err.nodeOperationTimeout.tooLong=Node operation timeout cannot be longer than 3 days | |||
err.nodeOperationTimeout.tooShort=Node operation timeout cannot be shorter than 1 minute | |||
@@ -408,6 +422,7 @@ err.netlocation.invalid=Must specify both cloud and region, or neither | |||
err.network.alreadyStarted=Network is already started | |||
err.network.exists=A plan already exists for this network | |||
err.networkKeys.noVerifiedContacts=Cannot download network keys, no account contact information has yet been verified | |||
err.networkKeys.invalid=Bubble Restore Key was not valid | |||
err.networkName.required=Network name is required | |||
err.network.cannotStartInCurrentState=Cannot proceed: network cannot be started in its current state | |||
err.network.required=Network is required | |||
@@ -5,7 +5,8 @@ | |||
"params": { | |||
"network": "_required", | |||
"rootEmail": "root@example.com", | |||
"networkKeysVar": "networkKeys" | |||
"networkKeysVar": "networkKeys", | |||
"networkKeysPassword": "Passw0rd!" | |||
} | |||
}, | |||
@@ -59,12 +60,13 @@ | |||
{ | |||
"comment": "use confirmation token in email to retrieve network keys", | |||
"request": { | |||
"uri": "me/networks/<<network>>/actions/keys/{{emailInbox.[0].ctx.message.requestId}}" | |||
"uri": "me/networks/<<network>>/actions/keys/{{emailInbox.[0].ctx.message.requestId}}", | |||
"entity": { "name": "password", "value": "<<networkKeysPassword>>" } | |||
}, | |||
"response": { | |||
"store": "<<networkKeysVar>>", | |||
"check": [ | |||
{"condition": "json.getKeys().length >= 1"} | |||
{"condition": "json.getData().length >= 1"} | |||
] | |||
} | |||
} |
@@ -147,7 +147,8 @@ | |||
"params": { | |||
"network": "{{bubbleNetwork.network}}", | |||
"rootEmail": "bubble-user@example.com", | |||
"networkKeysVar": "networkKeys" | |||
"networkKeysVar": "networkKeys", | |||
"networkKeysPassword": "Passw0rd!!" | |||
} | |||
}, | |||
@@ -195,7 +196,10 @@ | |||
"comment": "restore node using restoreKey", | |||
"request": { | |||
"uri": "auth/restore/{{restoreNN.restoreKey}}", | |||
"entityJson": "{{json networkKeys}}", | |||
"entity": { | |||
"data": "{{networkKeys.data}}", | |||
"password": "Passw0rd!!" | |||
}, | |||
"method": "put" | |||
}, | |||
"after": "sleep 240s" // give the restore some time to stop the server, restore and restart | |||
@@ -69,15 +69,44 @@ | |||
} | |||
}, | |||
{ | |||
"comment": "use confirmation token in email to retrieve network keys, fails because password is not supplied", | |||
"request": { | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{emailInbox.[0].ctx.message.requestId}}", | |||
"method": "post" | |||
}, | |||
"response": { | |||
"status": 422, | |||
"check": [ | |||
{"condition": "json.has('err.password.required')"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "use confirmation token in email to retrieve network keys, fails because password is too simple", | |||
"request": { | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{emailInbox.[0].ctx.message.requestId}}", | |||
"entity": { "name": "password", "value": "password" } | |||
}, | |||
"response": { | |||
"status": 422, | |||
"check": [ | |||
{"condition": "json.has('err.password.invalid')"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "use confirmation token in email to retrieve network keys", | |||
"request": { | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{emailInbox.[0].ctx.message.requestId}}" | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{emailInbox.[0].ctx.message.requestId}}", | |||
"entity": { "name": "password", "value": "Passw0rd!" } | |||
}, | |||
"response": { | |||
"store": "networkKeys", | |||
"check": [ | |||
{"condition": "json.getKeys().length >= 1"} | |||
{"condition": "json.getData().length >= 1"} | |||
] | |||
} | |||
}, | |||
@@ -85,10 +114,12 @@ | |||
{ | |||
"comment": "try to retrieve keys again, fails because it's a one-time operation", | |||
"request": { | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{emailInbox.[0].ctx.message.requestId}}" | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{emailInbox.[0].ctx.message.requestId}}", | |||
"entity": { "name": "password", "value": "Passw0rd!" } | |||
}, | |||
"response": { | |||
"status": 404 | |||
"status": 422, | |||
"check": [ {"condition": "json.has('err.retrieveNetworkKeys.notFound')"} ] | |||
} | |||
}, | |||
@@ -210,12 +241,13 @@ | |||
{ | |||
"comment": "use confirmation token in email to retrieve network keys", | |||
"request": { | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{emailInbox.[0].ctx.message.requestId}}" | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{emailInbox.[0].ctx.message.requestId}}", | |||
"entity": { "name": "password", "value": "Passw0rd!" } | |||
}, | |||
"response": { | |||
"store": "networkKeys", | |||
"check": [ | |||
{"condition": "json.getKeys().length >= 1"} | |||
{"condition": "json.getData().length >= 1"} | |||
] | |||
} | |||
}, | |||
@@ -223,10 +255,12 @@ | |||
{ | |||
"comment": "try to retrieve keys again, fails because it's a one-time operation", | |||
"request": { | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{smsInbox.[0].ctx.message.requestId}}" | |||
"uri": "me/networks/{{serverConfig.thisNetwork.uuid}}/actions/keys/{{smsInbox.[0].ctx.message.requestId}}", | |||
"entity": { "name": "password", "value": "Passw0rd!" } | |||
}, | |||
"response": { | |||
"status": 404 | |||
"status": 422, | |||
"check": [ {"condition": "json.has('err.retrieveNetworkKeys.notFound')"} ] | |||
} | |||
} | |||
] |
@@ -1 +1 @@ | |||
Subproject commit e8c5f6c80992169184d4a17c1c65bfc5424a0686 | |||
Subproject commit 54759b23204ed1019c71e1f5414cb92f184e4aaa |
@@ -1 +1 @@ | |||
Subproject commit 0316b9e68650414d2fd051d704aa411aca1a0568 | |||
Subproject commit 39ab28e818c43b75eca844ef41fb466eb5e5a114 |