Skip to content

Commit

Permalink
Merge pull request #520 from yuzawa-san/proxy
Browse files Browse the repository at this point in the history
Add Proxy
  • Loading branch information
yuzawa-san authored Jun 12, 2024
2 parents 8d5b936 + 32120e4 commit b88782f
Show file tree
Hide file tree
Showing 14 changed files with 389 additions and 63 deletions.
45 changes: 26 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ by [@yuzawa-san](https://github.com/yuzawa-san/)
[![codecov](https://codecov.io/gh/yuzawa-san/googolplex-theater/branch/develop/graph/badge.svg)](https://codecov.io/gh/yuzawa-san/googolplex-theater)

Persistently maintain multiple Chromecast devices on you local network without using your browser.
Ideal for digital signage applications.
Originally developed to display statistics dashboards.
Ideal for digital signage applications: restaurant menus, notice boards.
Originally developed to display statistics dashboards (e.g. Grafana).

![Example](docs/example.jpg)

Expand All @@ -26,6 +26,7 @@ There is no backing database or database dependencies, rather there is a YAML fi
The YAML configuration is conveyed to the receiver application, which by default accepts a URL to display in an IFRAME.
The receiver application can be customized easily to suit your needs.
The application will try to reconnect if a session is ended for whatever reason.
(Optional) The application has a local HTTP proxy for advanced use cases (adding/removing headers for auth or frame breaking).
See [feature files](src/test/resources/features/) for more details.

## Requirements
Expand All @@ -43,7 +44,7 @@ There are certain requirements for networking which are beyond the realm of this
* The [Raspberry Pi](https://en.wikipedia.org/wiki/Raspberry_Pi) is a good, small, and cost-effective computer to use.
* The newer models with ARMv8 processors are most desirable. See the [models list](https://en.wikipedia.org/wiki/Raspberry_Pi#Specifications) for more details. Most models introduced after 2016 fulfill these recommendations.
* It is not advisable to use older models which use older processor architectures (ARMv6 or ARMv7), specifically the _original_ Raspberry Pi Zero or Zero W. See the linked specifications table in previous item for more details.
* IMPORTANT: URLs must be HTTPS and must not [deny framing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) This is a limit of using an IFRAME to display content.
* IMPORTANT: URLs must be HTTPS (unless you use an unpublished receiver app) and must not [deny framing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) This is a limit of using an IFRAME to display content. NOTE: the built-in proxy provides a way to attempt to circumvent this.

Development requirements:

Expand Down Expand Up @@ -150,21 +151,6 @@ The configuration is defined in `./conf/config.yml` and `./conf/devices.yml`.
The file is automatically watched for changes.
Some example use cases involve using cron and putting your config under version control and pulling from origin periodically, or downloading from S3/web, or updating using rsync/scp.

### Case Study: Grafana Dashboards

The maintainer has used this to show statistics dashboards in a software engineering context.

* Buy a new Raspberry Pi and install the default Raspberry Pi OS (Raspbian).
* Configure and name your Chromecasts.
* Install application Debian package and Java runtime.
* Create one Grafana playlist per device.
* Figure out how to use proper Grafana auth (proxy, token, etc).
* Make your devices.yml file with each playlist url per device.
* Place the devices.yml file under version control (git) or store it someplace accessible (http/s3/gcs).
* Add a cron job to pull the devices.yml file from wherever you stored it (alternatively configure something to push the file to the Raspberry Pi).
* devices.yml is updated periodically as our dashboard needs change. The updates are automatically picked up.
* If a screen needs to be refreshed, one can do so by accessing the web UI exposed port 8080 and hitting a few buttons.

### Using a Custom Receiver

If you wish to customize the behavior of the receiver from just displaying a single URL in an IFRAME, see the example custom receiver in `receiver/custom.html`.
Expand All @@ -177,6 +163,28 @@ Host your modified file via HTTPS on your hosting provider of choice. Then point

There is a property in the `config.yml` to override the receiver application.

### Case Study: Grafana Dashboards

The maintainer has used this to show statistics dashboards in a software engineering context.

- Buy a new Raspberry Pi and install the default Raspberry Pi OS (Raspbian).
- Configure and name your Chromecast(s).
- Install application via download or via Debian package (which will likely install Java runtime if it is not already installed).
- Create one Grafana playlist per device.
- Make your devices.yml file with each playlist url per device. Set the rotation and refresh parameters and make sure the kiosk mode is in the query string parameters.
- Figure out how to connect.
- Less secure: Use HTTPS with an IP address allowlist to your location (which must have a static IP) on whatever proxy you may have in front of your Grafana deployment.
- Medium Secure: Create a Grafana API token with viewer permission and run nginx or other proxy locally with a real SSL certificate, add Authorization header.
- More Secure: Create a Grafana API token with viewer permission and use this application's proxy feature.
- [Sign up as a Chromecast developer](https://developers.google.com/cast/docs/registration#RegisterApp) so you can use the local proxy over HTTP.
- Register your [devices](https://cast.google.com/publish) for development.
- Register a new custom reciever, but do not publish it (that would force it to use HTTPS). Configure this app with the "appId" alphanumeric string. Point the url to your _static_ private IP `http://192.168.1.XXX:8001/receiver.html`.
- Configure this app's proxy settings pointing at your Grafana root url.
- Add the Grafana token to the proxy "add header" settings
- Make your devices.yml have `proxyPath: /path/to/playlist?....`
- If you want to update the devices periodically, place the devices.yml file under version control (git) or store it someplace accessible (http/s3/gcs). Add a cron job to pull the devices.yml file from wherever you stored it (alternatively configure something to push the file to the Raspberry Pi). The updates are automatically picked up.
- If a screen needs to be refreshed, one can do so by accessing the web UI exposed port 8080 and hitting a few buttons.

### Troubleshooting

There may be some issues related to discovering the Chromecast devices on your network.
Expand All @@ -195,7 +203,6 @@ This is intended to be minimalist and easy to set up, so advanced features are n
### TODO

* Split screen layouts
* Framing proxy (may not be feasible or allowed under HTTPS)

## Related Projects

Expand Down
10 changes: 9 additions & 1 deletion src/dist/conf/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,12 @@
# retry-interval: 15s
# devices-path: conf/devices.yml
# preferred-interface: eth0
# advertise: true
# advertise: false
# server-log: true
# proxy:
# url: https://grafana.mysite.com/
# log: false
# add-request-headers:
# Authorization: Bearer my_grafana_token
# remove-response-headers:
# - My-Bad-Header
12 changes: 9 additions & 3 deletions src/dist/conf/devices.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
settings:
refreshSeconds: 180
# NOTE: these top level settings can be applied to all devices

#settings:
# refreshSeconds: 180

devices:
- name: device1
settings:
url: https://example.com/
- name: device2
settings:
url: https://example.com/
refreshSeconds: 1800
refreshSeconds: 1800
# - name: device3
# settings:
# proxyPath: /path/to/be/proxied
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,33 @@
*/
package com.jyuzawa.googolplex_theater;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.jyuzawa.googolplex_theater.DeviceConfig.DeviceInfo;
import io.netty.util.NetUtil;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.jmdns.impl.util.NamedThreadFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;

/**
* This class loads the device config at start and watches the files for subsequent changes. The
Expand All @@ -34,20 +42,24 @@
@Component
public final class DeviceConfigLoader implements Closeable {

private final ExecutorService executor;
private final Scheduler executor;
private final Path path;
private final Path directoryPath;
private WatchService watchService;
private final GoogolplexService service;
private final URI proxyUri;

@Autowired
public DeviceConfigLoader(
GoogolplexService service,
Path appHome,
@Value("${googolplex-theater.devices-path}") String deviceConfigPath)
@Value("${googolplex-theater.devices-path}") String deviceConfigPath,
ProxyProperties proxyProperties,
ServiceDiscovery serviceDiscovery)
throws IOException {
this.service = service;
this.executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("deviceConfigLoader"));
this.executor = Schedulers.newSingle("deviceConfigLoader");

this.path = appHome.resolve(deviceConfigPath).toAbsolutePath();
log.info("Using device config: {}", path);
if (!Files.isRegularFile(path)) {
Expand All @@ -57,14 +69,17 @@ public DeviceConfigLoader(
if (directoryPath == null) {
throw new IllegalArgumentException("Path has missing parent");
}
this.proxyUri = URI.create("http://"
+ NetUtil.toSocketAddressString(
new InetSocketAddress(serviceDiscovery.getInetAddress(), proxyProperties.port)));
}

@PostConstruct
public void start() throws IOException {
load();
this.watchService = path.getFileSystem().newWatchService();
directoryPath.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);
executor.submit(() -> {
executor.schedule(() -> {
try {
WatchKey key;
// this blocks until the system notifies us of any changes.
Expand Down Expand Up @@ -104,7 +119,23 @@ public void start() throws IOException {
private void load() throws IOException {
log.info("Reloading device config");
try (InputStream stream = Files.newInputStream(path)) {
DeviceConfig out = MapperUtil.YAML_MAPPER.readValue(stream, DeviceConfig.class);
DeviceConfig deviceConfig = MapperUtil.YAML_MAPPER.readValue(stream, DeviceConfig.class);
List<DeviceInfo> out = new ArrayList<>();
for (DeviceInfo deviceInfo : deviceConfig.getDevices()) {
ObjectNode settings = deviceInfo.getSettings();
JsonNode proxyPathNode = settings.get("proxyPath");
if (proxyPathNode != null) {
ObjectNode newSettings = new ObjectNode(MapperUtil.YAML_MAPPER.getNodeFactory());
newSettings.setAll(settings);
String url =
proxyUri.resolve(URI.create(proxyPathNode.asText())).toString();
newSettings.set("url", new TextNode(url));
newSettings.remove("proxyPath");
out.add(new DeviceInfo(deviceInfo.getName(), newSettings));
} else {
out.add(deviceInfo);
}
}
service.processDeviceConfig(out);
}
}
Expand All @@ -114,11 +145,6 @@ public void close() throws IOException {
if (watchService != null) {
watchService.close();
}
executor.shutdown();
try {
executor.awaitTermination(1, TimeUnit.MINUTES);
} catch (InterruptedException e) {
// pass
}
executor.disposeGracefully().block(Duration.ofSeconds(10));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.client.ReactorResourceFactory;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
Expand Down Expand Up @@ -80,7 +81,8 @@ public GoogolplexClient(
@Value("${googolplex-theater.app-id}") String appId,
@Value("${googolplex-theater.heartbeat-interval}") Duration heartbeatInterval,
@Value("${googolplex-theater.heartbeat-timeout}") Duration heartbeatTimeout,
@Value("${googolplex-theater.retry-interval}") Duration retryInterval)
@Value("${googolplex-theater.retry-interval}") Duration retryInterval,
ReactorResourceFactory reactorResourceFactory)
throws SSLException {
this.appId = appId;
if (!APP_ID_PATTERN.matcher(appId).find()) {
Expand All @@ -96,6 +98,7 @@ public GoogolplexClient(
log.info("Using cast application id: {}", appId);
// configure the socket client
this.bootstrap = TcpClient.create()
.runOn(reactorResourceFactory.getLoopResources())
.secure(spec -> spec.sslContext(sslContext))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ private record Channel(AtomicReference<Instant> birth, Disposable disposable) {}
/**
* Load the config and propagate the changes to the any currently connected devices.
*
* @param config the settings loaded from the file
* @param deviceInfos the device settings loaded from the file
*/
public Future<?> processDeviceConfig(DeviceConfig config) {
public Future<?> processDeviceConfig(List<DeviceInfo> deviceInfos) {
return executor.submit(() -> {
Set<String> namesToRemove = new HashSet<>(nameToDeviceInfo.keySet());
for (DeviceInfo deviceInfo : config.getDevices()) {
for (DeviceInfo deviceInfo : deviceInfos) {
String name = deviceInfo.getName();
// mark that we should not remove this device
namesToRemove.remove(name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.system.ApplicationHome;
import org.springframework.boot.web.embedded.netty.NettyServerCustomizer;
import org.springframework.context.annotation.Bean;

/**
Expand All @@ -26,6 +27,11 @@ public Path appHome(@Value("${googolplex-theater.app-home}") Path appHome) {
return appHome;
}

@Bean
public NettyServerCustomizer nettyServerCustomizer(@Value("${googolplex-theater.server-log}") boolean enabled) {
return s -> s.accessLog(enabled);
}

public static void main(String[] args) throws Exception {
Path appHome = Paths.get("src/dist").toAbsolutePath();
ApplicationHome home = new ApplicationHome(GoogolplexTheater.class);
Expand Down
Loading

0 comments on commit b88782f

Please sign in to comment.