package io.fathom.cloud.dns.services;
import io.fathom.cloud.Clock;
import io.fathom.cloud.CloudException;
import io.fathom.cloud.dns.DnsService;
import io.fathom.cloud.dns.backend.DnsBackend;
import io.fathom.cloud.dns.model.DnsRecordset;
import io.fathom.cloud.dns.model.DnsZone;
import io.fathom.cloud.dns.state.DnsRepository;
import io.fathom.cloud.lifecycle.LifecycleListener;
import io.fathom.cloud.protobuf.DnsModel.BackendData;
import io.fathom.cloud.protobuf.DnsModel.DnsRecordData;
import io.fathom.cloud.protobuf.DnsModel.DnsRecordsetData;
import io.fathom.cloud.protobuf.DnsModel.DnsSuffixData;
import io.fathom.cloud.protobuf.DnsModel.DnsZoneData;
import io.fathom.cloud.server.model.Project;
import io.fathom.cloud.state.DuplicateValueException;
import io.fathom.cloud.state.NamedItemCollection;
import io.fathom.cloud.state.NumberedItemCollection;
import io.fathom.cloud.state.StoreOptions;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fathomdb.Configuration;
import com.google.common.base.CharMatcher;
import com.google.common.base.Function;
import com.google.common.base.Strings;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;
import com.google.common.net.InetAddresses;
import com.google.inject.persist.Transactional;
@Singleton
@Transactional
public class DnsServiceImpl implements DnsService, LifecycleListener {
private static final Logger log = LoggerFactory.getLogger(DnsServiceImpl.class);
@Inject
DnsRepository repository;
@Inject
WellKnownTlds wellKnownTlds;
@Inject
DnsBackends dnsBackends;
private final boolean canCreateInTld;
@Inject
public DnsServiceImpl(Configuration config) {
// TODO: Add @Configured?
this.canCreateInTld = config.lookup("dns.allowCreateInTld", true);
}
@Override
public List<DnsZone> listZones(Project project) throws CloudException {
List<DnsZone> records = Lists.newArrayList();
for (DnsZoneData data : repository.getDnsZones(project.getId()).list()) {
DnsZone record = new DnsZone(data);
records.add(record);
}
return records;
}
@Override
public DnsZone createZone(Project project, DnsZoneSpec spec) throws CloudException, DuplicateValueException {
String zoneName = spec.name;
zoneName = zoneName.toLowerCase();
DnsSuffixData suffixData = findMaximalSuffix(zoneName);
if (suffixData == null) {
throw new IllegalArgumentException("Unsupported suffix: " + zoneName);
}
if (suffixData.getTld() && !canCreateInTld) {
throw new IllegalArgumentException("Creation of domains blocked (under TLDs)");
}
String backendKey = null;
String zone;
// TODO: Is there any point in allowing a shared domain but then
// blocking creation under it?
// if (suffixData.getSharedDomain() && !canCreateInShared) {
// throw new
// IllegalArgumentException("Creation of domains blocked (under shared domains)");
// }
String suffix = suffixData.getKey();
String prefix = zoneName.substring(0, zoneName.length() - suffix.length());
prefix = CharMatcher.is('.').trimTrailingFrom(prefix);
if (prefix.isEmpty()) {
throw new IllegalArgumentException("Unsupported domain (TLD or marked as shared): " + zoneName);
} else {
int dotIndex = prefix.indexOf('.');
String prefixTail;
if (dotIndex != -1) {
prefixTail = prefix.substring(dotIndex + 1);
} else {
prefixTail = prefix;
}
// If this is a shared domain, try to assign it to the user
if (suffixData.getSharedDomain()) {
NamedItemCollection<DnsSuffixData> store = repository.getSharedSubdomains(suffixData.getKey());
DnsSuffixData allocation = store.find(prefixTail);
if (allocation == null) {
DnsSuffixData.Builder b = DnsSuffixData.newBuilder();
b.setKey(prefixTail);
b.setOwnerProject(project.getId());
allocation = store.create(b);
} else {
if (allocation.getOwnerProject() != project.getId()) {
// Already assigned to someone else
throw new DuplicateValueException();
}
}
}
if (suffixData.hasBackend()) {
backendKey = suffixData.getBackend();
}
zone = prefixTail + "." + suffix;
}
if (spec.backend != null) {
if (backendKey != null && !backendKey.equals(spec.backend)) {
throw new IllegalArgumentException();
}
}
if (backendKey == null) {
if (spec.backend != null) {
backendKey = spec.backend;
} else {
// TODO: Cache ?
for (BackendData backend : repository.getBackends().list()) {
if (backend.getDefault()) {
backendKey = backend.getKey();
}
}
}
}
DnsBackend backend = getBackend(backendKey);
String backendId = backend.createZone(project, zoneName, zone, suffixData);
DnsZoneData.Builder data = DnsZoneData.newBuilder();
data.setProjectId(project.getId());
data.setName(zoneName);
if (backendKey != null) {
data.setBackend(backendKey);
}
if (backendId != null) {
data.setBackendId(backendId);
}
DnsZoneData created = repository.getDnsZones(project.getId()).create(data);
DnsZone dnsDomain = new DnsZone(created);
backend.updateDomain(project, dnsDomain);
return dnsDomain;
}
public DnsSuffixData findMaximalSuffix(String domain) throws CloudException {
NamedItemCollection<DnsSuffixData> store = repository.getDnsSuffixes();
domain = domain.toLowerCase();
while (true) {
if (Strings.isNullOrEmpty(domain)) {
return null;
}
DnsSuffixData data = store.find(domain);
if (data != null) {
return data;
}
int dotIndex = domain.indexOf('.');
domain = domain.substring(dotIndex + 1);
}
}
private boolean isTld(String domain) throws CloudException {
// TODO: Caching
NamedItemCollection<DnsSuffixData> store = repository.getDnsSuffixes();
domain = domain.toLowerCase();
DnsSuffixData data = store.find(domain);
if (data != null) {
return data.hasTld() && data.getTld();
}
return false;
// tlds.add("com");
// tlds.add("net");
// tlds.add("org");
// tlds.add("edu");
// tlds.add("gov");
// tlds.add("mil");
// tlds.add("io");
// tlds.add("co.uk");
//
// domain = domain.toLowerCase();
// if (tlds.contains(domain)) {
// return true;
// }
//
// boolean found = false;
// for (String tld : tlds) {
// if (domain.endsWith("." + tld)) {
// found = true;
// break;
// }
// }
//
// if (!found) {
// log.warn("Did not recognize TLD for: {}", domain);
// }
// return false;
}
// public String findHost(String fqdn, String domain) {
// if (!fqdn.endsWith(domain)) {
// throw new IllegalArgumentException();
// }
//
// String host = fqdn.substring(0, fqdn.length() - domain.length());
// // if (host.isEmpty()) {
// // host = ".";
// // }
//
// if (host.endsWith(".")) {
// host = host.substring(0, host.length() - 1);
// }
// return host;
// }
@Override
public DnsZone findZoneByName(Project project, String zoneName) throws CloudException {
NumberedItemCollection<DnsZoneData> dnsDomains = repository.getDnsZones(project.getId());
DnsZoneData data = dnsDomains.findByKey(zoneName);
if (data == null) {
return null;
}
return new DnsZone(data);
}
@Override
public DnsZone findDomain(Project project, long id) throws CloudException {
DnsZoneData data = repository.getDnsZones(project.getId()).find(id);
if (data == null) {
return null;
}
return new DnsZone(data);
}
@Override
public Recordset findRecordset(Project project, Zone zone, long recordsetId) throws CloudException {
DnsRecordsetData data = repository.getDnsRecordsets(project.getId(), zone.getId()).find(recordsetId);
if (data == null) {
return null;
}
return new DnsRecordset((DnsZone) zone, data);
}
// public DnsRecord findRecord(Project project, DnsDomain domain, String
// name) throws CloudException {
// long projectId = project.getId();
// if (domain.getProjectId() != projectId) {
// throw new IllegalArgumentException();
// }
//
// DnsRecordData data = repository.getDnsRecords(projectId,
// domain.getName()).find(name);
// if (data == null) {
// return null;
// }
// return new DnsRecord(domain, data);
// }
public DnsRecordset createRecordset(Project project, DnsZone zone, DnsRecordsetData.Builder data)
throws CloudException {
long projectId = project.getId();
if (zone.getProjectId() != projectId) {
throw new IllegalArgumentException();
}
data.setProjectId(projectId);
DnsBackend backend = findBackend(zone);
data.getStateBuilder().setCreatedAt(Clock.getTimestamp());
DnsRecordsetData created = repository.getDnsRecordsets(projectId, zone.getId()).create(data);
backend.updateDomain(project, zone);
return new DnsRecordset(zone, created);
}
private DnsBackend findBackend(DnsZone zone) throws CloudException {
String backendKey = null;
if (zone.getData().hasBackend()) {
backendKey = zone.getData().getBackend();
}
return getBackend(backendKey);
}
private DnsBackend getBackend(String backendKey) throws CloudException {
if (backendKey == null) {
return dnsBackends.getGenericBackend();
}
BackendData backend = repository.getBackends().find(backendKey);
if (backend == null) {
throw new IllegalArgumentException("Cannot find backend");
}
return dnsBackends.getBackend(backend);
}
String findDomain(String host) throws CloudException {
int firstDot = host.indexOf('.');
if (firstDot == -1) {
log.warn("Suspicious domain; likely won't validate: " + host);
return host;
}
// String prefix = host.substring(0, firstDot);
String suffix = host.substring(firstDot + 1);
if (isTld(suffix)) {
return host;
}
return suffix;
}
// static class RecordImpl implements DnsService.Record {
// private final String type;
// private final String fqdn;
// private final InetAddress ip;
//
// public RecordImpl(String type, String fqdn, InetAddress ip) {
// this.type = type;
// this.fqdn = fqdn;
// this.ip = ip;
// }
//
// }
// @Override
// public Record buildAddress(String fqdn, InetAddress ip) {
// String type = (ip instanceof Inet6Address ? DnsService.TYPE_AAAA :
// DnsService.TYPE_A);
// return new RecordImpl(type, fqdn, ip);
// }
@Override
public void setDnsRecordsets(String systemKey, Project project, List<DnsRecordsetSpec> recordsetSpecs)
throws CloudException {
List<DnsRecordsetData> recordsets = Lists.newArrayList();
for (DnsRecordsetSpec recordsetSpec : recordsetSpecs) {
// DnsRecord recordImpl = (DnsRecord) record;
String domainName = findDomain(recordsetSpec.fqdn);
// String name = findHost(recordset.fqdn, domainName);
DnsZoneSpec zoneSpec = new DnsZoneSpec();
zoneSpec.name = domainName;
DnsZone zone = findOrCreateDomain(project, zoneSpec);
DnsRecordsetData.Builder data = DnsRecordsetData.newBuilder();
data.setProjectId(project.getId());
for (DnsRecordSpec record : recordsetSpec.records) {
// Sanity check
InetAddress address = InetAddresses.forString(record.address);
String ip = InetAddresses.toAddrString(address);
DnsRecordData.Builder recordBuilder = data.addRecordBuilder();
recordBuilder.setTarget(ip);
}
data.setType(recordsetSpec.type);
data.setFqdn(recordsetSpec.fqdn);
data.setZoneId(zone.getId());
data.setSystemKey(systemKey);
recordsets.add(data.build());
}
setDnsRecords(project, recordsets);
}
private void setDnsRecords(Project project, List<DnsRecordsetData> recordsets) throws CloudException {
// This is where a database would be awesome!
long projectId = project.getId();
Set<Long> idsInUse = Sets.newHashSet();
// TODO: This is inefficient
Multimap<String, DnsRecordsetData> dbState = HashMultimap.create();
for (DnsZoneData domain : repository.getDnsZones(projectId).list()) {
for (DnsRecordsetData record : repository.getDnsRecordsets(projectId, domain.getId()).list()) {
idsInUse.add(record.getId());
if (!record.hasSystemKey()) {
continue;
}
dbState.put(record.getSystemKey(), record);
}
}
ImmutableListMultimap<String, DnsRecordsetData> requestedState = Multimaps.index(recordsets,
new Function<DnsRecordsetData, String>() {
@Override
public String apply(DnsRecordsetData input) {
return input.getSystemKey();
}
});
Set<Long> dirtyDomains = Sets.newHashSet();
for (String systemKey : requestedState.keySet()) {
Set<DnsRecordsetData> requested = Sets.newHashSet(requestedState.get(systemKey));
Map<DnsRecordsetData, Long> current = Maps.newHashMap();
for (DnsRecordsetData db : dbState.get(systemKey)) {
long id = db.getId();
DnsRecordsetData.Builder b = DnsRecordsetData.newBuilder(db);
b.clearId();
b.clearState();
current.put(b.build(), id);
}
SetView<DnsRecordsetData> add = Sets.difference(requested, current.keySet());
SetView<DnsRecordsetData> remove = Sets.difference(current.keySet(), requested);
for (DnsRecordsetData a : add) {
log.debug("Add: {}", a);
}
for (DnsRecordsetData r : remove) {
log.debug("Remove: {}", r);
}
for (DnsRecordsetData a : add) {
DnsRecordsetData.Builder b = DnsRecordsetData.newBuilder(a);
repository.getDnsRecordsets(projectId, a.getZoneId()).create(b);
dirtyDomains.add(b.getZoneId());
}
for (DnsRecordsetData r : remove) {
long id = current.get(r);
repository.getDnsRecordsets(projectId, r.getZoneId()).delete(id);
dirtyDomains.add(r.getZoneId());
}
}
for (Long domainId : dirtyDomains) {
DnsZone zone = findDomain(project, domainId);
DnsBackend backend = findBackend(zone);
backend.updateDomain(project, zone);
}
}
private DnsZone findOrCreateDomain(Project project, DnsZoneSpec zoneSpec) throws CloudException {
DnsZone zone = findZoneByName(project, zoneSpec.name);
if (zone == null) {
try {
zone = createZone(project, zoneSpec);
} catch (DuplicateValueException e) {
zone = findZoneByName(project, zoneSpec.name);
}
if (zone == null) {
throw new IllegalStateException();
}
}
return zone;
}
@Override
public List<DnsRecordset> listRecordsets(Project project, Zone zone) throws CloudException {
return listRecordsets(project, zone, new StoreOptions[0]);
}
public List<DnsRecordset> listRecordsets(Project project, Zone zone, StoreOptions... options) throws CloudException {
long projectId = project.getId();
if (zone.getProjectId() != projectId) {
throw new IllegalArgumentException();
}
List<DnsRecordset> records = Lists.newArrayList();
for (DnsRecordsetData data : repository.getDnsRecordsets(projectId, zone.getId()).list(options)) {
DnsRecordset record = new DnsRecordset((DnsZone) zone, data);
records.add(record);
}
return records;
}
@Override
public DnsRecordset createRecordset(Project project, Zone zone, String fqdn, String type, List<String> ips)
throws CloudException {
if (ips == null || ips.isEmpty()) {
throw new IllegalArgumentException();
}
if (Strings.isNullOrEmpty(type)) {
throw new IllegalArgumentException();
}
type = type.trim().toUpperCase();
if (Strings.isNullOrEmpty(type)) {
throw new IllegalArgumentException();
}
DnsRecordsetData.Builder data = DnsRecordsetData.newBuilder();
data.setZoneId(zone.getId());
data.setFqdn(fqdn);
data.setType(type);
for (String ip : ips) {
// Sanity check
InetAddress address = InetAddresses.forString(ip);
ip = InetAddresses.toAddrString(address);
DnsRecordData.Builder recordBuilder = data.addRecordBuilder();
recordBuilder.setTarget(ip);
}
data.setProjectId(project.getId());
return createRecordset(project, (DnsZone) zone, data);
}
// private DnsRecordset toRecord(DnsZone zone, DnsRecordsetData data) {
// if (data == null) {
// return null;
// }
// return new DnsRecordset(zone, data);
// }
@Override
public void start() throws Exception {
wellKnownTlds.create();
}
public List<DnsSuffixData> listSuffix() throws CloudException {
NamedItemCollection<DnsSuffixData> store = repository.getDnsSuffixes();
return store.list();
}
public DnsSuffixData ensureTld(String tld) throws CloudException, DuplicateValueException {
NamedItemCollection<DnsSuffixData> store = repository.getDnsSuffixes();
tld = tld.trim();
while (tld.startsWith(".")) {
tld = tld.substring(1);
}
tld = tld.toLowerCase();
DnsSuffixData data = store.find(tld);
if (data != null) {
if (!data.getTld()) {
throw new IllegalArgumentException("Suffix is not marked as TLD: " + tld);
}
return data;
}
DnsSuffixData.Builder b = DnsSuffixData.newBuilder();
b.setKey(tld);
b.setTld(true);
return store.create(b);
}
public DnsSuffixData createShared(Zone zone) throws CloudException, DuplicateValueException {
DnsZoneData data = ((DnsZone) zone).getData();
NamedItemCollection<DnsSuffixData> store = repository.getDnsSuffixes();
String key = zone.getName();
key = key.toLowerCase();
DnsSuffixData.Builder b = DnsSuffixData.newBuilder();
b.setKey(key);
b.setSharedDomain(true);
b.setOwnerProject(zone.getProjectId());
if (data.hasBackend()) {
b.setBackend(data.getBackend());
}
return store.create(b);
}
public DnsSuffixData deleteTld(String tld) throws CloudException {
NamedItemCollection<DnsSuffixData> store = repository.getDnsSuffixes();
DnsSuffixData ret = store.find(tld);
store.delete(tld);
return ret;
}
public boolean deleteZone(Project project, DnsZone zone) throws CloudException {
// TODO: Verify no children
// TODO: Delete from backend?
NumberedItemCollection<DnsZoneData> store = repository.getDnsZones(project.getId());
DnsZoneData found = store.find(zone.getId());
if (found == null) {
return false;
}
store.delete(zone.getId());
return true;
}
public boolean deleteRecordset(Project project, DnsZone zone, long recordsetId) throws CloudException {
NumberedItemCollection<DnsRecordsetData> store = repository.getDnsRecordsets(project.getId(), zone.getId());
DnsRecordsetData found = store.find(recordsetId);
if (found == null) {
return false;
}
DnsBackend backend = findBackend(zone);
store.delete(recordsetId);
backend.updateDomain(project, zone);
return true;
}
public DnsZone findMaximalZone(Project project, String name) throws CloudException {
NumberedItemCollection<DnsZoneData> store = repository.getDnsZones(project.getId());
name = name.toLowerCase();
while (true) {
if (Strings.isNullOrEmpty(name)) {
return null;
}
DnsZoneData data = store.findByKey(name);
if (data != null) {
return new DnsZone(data);
}
int dotIndex = name.indexOf('.');
if (dotIndex == -1) {
return null;
}
name = name.substring(dotIndex + 1);
}
}
}