diff --git a/pkg/providers/hetzner/hetzner.go b/pkg/providers/hetzner/hetzner.go index f7bd07fa..6026650c 100644 --- a/pkg/providers/hetzner/hetzner.go +++ b/pkg/providers/hetzner/hetzner.go @@ -7,7 +7,7 @@ import ( "github.com/projectdiscovery/cloudlist/pkg/schema" ) -var Services = []string{"instance"} +var Services = []string{"instance", "loadbalancer", "floatingip", "primaryip"} const ( authToken = "auth_token" @@ -53,9 +53,21 @@ func (p *Provider) Services() []string { // Resources returns the provider for an resource func (p *Provider) Resources(ctx context.Context) (*schema.Resources, error) { finalResources := schema.NewResources() - if p.services.Has("instance") { - provider := &instanceProvider{client: p.client, id: p.id} - if resources, err := provider.GetResource(ctx); err == nil { + providers := map[string]interface { + GetResource(ctx context.Context) (*schema.Resources, error) + }{ + "instance": &instanceProvider{client: p.client, id: p.id}, + "loadbalancer": &loadBalancerProvider{client: p.client, id: p.id}, + "floatingip": &floatingIPProvider{client: p.client, id: p.id}, + "primaryip": &primaryIPProvider{client: p.client, id: p.id}, + } + // Address services are merged before instances. The first writer owns a + // shared address, so an assigned primary IP stays a primaryip resource. + for _, service := range []string{"loadbalancer", "floatingip", "primaryip", "instance"} { + if !p.services.Has(service) { + continue + } + if resources, err := providers[service].GetResource(ctx); err == nil { finalResources.Merge(resources) } } diff --git a/pkg/providers/hetzner/hetzner_test.go b/pkg/providers/hetzner/hetzner_test.go new file mode 100644 index 00000000..26806267 --- /dev/null +++ b/pkg/providers/hetzner/hetzner_test.go @@ -0,0 +1,89 @@ +package hetzner + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + hetzner "github.com/hetznercloud/hcloud-go/hcloud" + "github.com/projectdiscovery/cloudlist/pkg/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var fakeResponses = map[string]string{ + "/servers": `{"servers": [ + {"id": 42, "name": "web", "public_net": { + "ipv4": {"ip": "95.217.30.1"}, + "ipv6": {"ip": "2a01:4f9:c010:9::/64", "dns_ptr": []} + }} + ]}`, + "/load_balancers": `{"load_balancers": [ + {"id": 1, "name": "public-lb", "public_net": {"enabled": true, + "ipv4": {"ip": "95.217.10.1"}, "ipv6": {"ip": "2a01:4f9:c010:1::1"}}}, + {"id": 2, "name": "private-lb", "public_net": {"enabled": false, + "ipv4": {"ip": "95.217.10.2"}, "ipv6": {"ip": ""}}} + ]}`, + "/floating_ips": `{"floating_ips": [ + {"id": 1, "type": "ipv4", "ip": "95.217.20.1", "server": 42}, + {"id": 2, "type": "ipv6", "ip": "2a01:4f9:c010:2::/64", "server": null} + ]}`, + "/primary_ips": `{"primary_ips": [ + {"id": 1, "type": "ipv4", "ip": "95.217.30.1", "assignee_id": 42, "assignee_type": "server"}, + {"id": 2, "type": "ipv4", "ip": "95.217.30.2", "assignee_id": null, "assignee_type": "server"} + ]}`, +} + +func newTestProvider(t *testing.T, services ...string) *Provider { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, ok := fakeResponses[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + client := hetzner.NewClient(hetzner.WithToken("test"), hetzner.WithEndpoint(server.URL)) + block := schema.OptionBlock{} + if len(services) > 0 { + block["services"] = services[0] + } + return &Provider{id: "test", client: client, services: block.ResolveServices(Services)} +} + +func resourcesByService(resources *schema.Resources) map[string][]string { + byService := make(map[string][]string) + for _, r := range resources.Items { + for _, v := range []string{r.PublicIPv4, r.PublicIPv6} { + if v != "" { + byService[r.Service] = append(byService[r.Service], v) + } + } + } + return byService +} + +func TestResources(t *testing.T) { + resources, err := newTestProvider(t).Resources(context.Background()) + require.NoError(t, err) + + byService := resourcesByService(resources) + assert.ElementsMatch(t, []string{"95.217.10.1", "2a01:4f9:c010:1::1"}, byService["loadbalancer"], "load balancers with public net disabled must be skipped") + assert.ElementsMatch(t, []string{"95.217.20.1", "2a01:4f9:c010:2::"}, byService["floatingip"]) + assert.ElementsMatch(t, []string{"95.217.30.1", "95.217.30.2"}, byService["primaryip"], "unassigned primary IPs must be included") + assert.ElementsMatch(t, []string{"2a01:4f9:c010:9::"}, byService["instance"], "an assigned primary IPv4 keeps the primaryip service") +} + +func TestResourcesServiceFilter(t *testing.T) { + resources, err := newTestProvider(t, "primaryip").Resources(context.Background()) + require.NoError(t, err) + + byService := resourcesByService(resources) + assert.Len(t, byService, 1) + assert.Len(t, byService["primaryip"], 2) +} diff --git a/pkg/providers/hetzner/ips.go b/pkg/providers/hetzner/ips.go new file mode 100644 index 00000000..8df5cbd3 --- /dev/null +++ b/pkg/providers/hetzner/ips.go @@ -0,0 +1,83 @@ +package hetzner + +import ( + "context" + "net" + + hetzner "github.com/hetznercloud/hcloud-go/hcloud" + "github.com/projectdiscovery/cloudlist/pkg/schema" +) + +// floatingIPProvider is a floating IP provider for Hetzner Cloud API +type floatingIPProvider struct { + id string + client *hetzner.Client +} + +func (p *floatingIPProvider) name() string { + return "floatingip" +} + +// GetResource returns all the floating IPs, assigned or not. +func (p *floatingIPProvider) GetResource(ctx context.Context) (*schema.Resources, error) { + list := schema.NewResources() + + floatingIPs, err := p.client.FloatingIP.All(ctx) + if err != nil { + return nil, err + } + + for _, ip := range floatingIPs { + list.Append(publicIPResource(p.id, p.name(), ip.IP)) + } + return list, nil +} + +// primaryIPProvider is a primary IP provider for Hetzner Cloud API +type primaryIPProvider struct { + id string + client *hetzner.Client +} + +func (p *primaryIPProvider) name() string { + return "primaryip" +} + +// GetResource returns all the primary IPs, including unassigned ones that +// the server listing cannot surface. +func (p *primaryIPProvider) GetResource(ctx context.Context) (*schema.Resources, error) { + list := schema.NewResources() + + primaryIPs, err := p.client.PrimaryIP.All(ctx) + if err != nil { + return nil, err + } + + for _, ip := range primaryIPs { + list.Append(publicIPResource(p.id, p.name(), ip.IP)) + } + return list, nil +} + +func publicIPResource(id, service string, ip net.IP) *schema.Resource { + resource := &schema.Resource{ + Provider: providerName, + ID: id, + Public: true, + Service: service, + } + if ip.To4() != nil { + resource.PublicIPv4 = ip.String() + } else { + resource.PublicIPv6 = ipString(ip) + } + return resource +} + +// ipString avoids net.IP's "" rendering for unset addresses. +func ipString(ip net.IP) string { + if ip == nil { + return "" + } + return ip.String() +} diff --git a/pkg/providers/hetzner/loadbalancers.go b/pkg/providers/hetzner/loadbalancers.go new file mode 100644 index 00000000..9825ad7b --- /dev/null +++ b/pkg/providers/hetzner/loadbalancers.go @@ -0,0 +1,43 @@ +package hetzner + +import ( + "context" + + hetzner "github.com/hetznercloud/hcloud-go/hcloud" + "github.com/projectdiscovery/cloudlist/pkg/schema" +) + +// loadBalancerProvider is a load balancer provider for Hetzner Cloud API +type loadBalancerProvider struct { + id string + client *hetzner.Client +} + +func (p *loadBalancerProvider) name() string { + return "loadbalancer" +} + +// GetResource returns all the load balancers in the store for a provider. +func (p *loadBalancerProvider) GetResource(ctx context.Context) (*schema.Resources, error) { + list := schema.NewResources() + + loadBalancers, err := p.client.LoadBalancer.All(ctx) + if err != nil { + return nil, err + } + + for _, lb := range loadBalancers { + if !lb.PublicNet.Enabled { + continue + } + list.Append(&schema.Resource{ + Provider: providerName, + ID: p.id, + PublicIPv4: ipString(lb.PublicNet.IPv4.IP), + PublicIPv6: ipString(lb.PublicNet.IPv6.IP), + Public: true, + Service: p.name(), + }) + } + return list, nil +}