diff --git a/core/pva/src/main/java/org/epics/pva/acf/AccessConfig.java b/core/pva/src/main/java/org/epics/pva/acf/AccessConfig.java new file mode 100644 index 0000000000..ec5501e054 --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/acf/AccessConfig.java @@ -0,0 +1,97 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.acf; + +import java.io.Reader; +import java.io.InputStreamReader; +import java.util.Collection; +import java.util.Map; + +/** Access security configuration + * + * @author Kay Kasemir + */ +public class AccessConfig +{ + private final Map user_groups; + private final Map host_groups; + private final Map access_groups; + + /** @return Default configuration where DEFAULT grants write access to anybody + * @throws Exception on error + */ + public static AccessConfig getDefault() throws Exception + { + final Reader reader = new InputStreamReader(AccessConfig.class.getResourceAsStream("default.acf")); + return new AccessConfigParser().parse("default.acf", reader); + } + + AccessConfig(Map user_groups, + Map host_groups, + Map access_groups) + { + this.user_groups = user_groups; + this.host_groups = host_groups; + this.access_groups = access_groups; + } + + /** @return Names of user groups */ + public Collection getUserGroupNames() + { + return user_groups.keySet(); + } + + /** @param name User group name + * @return {@link UserAccessGroup} or null + */ + public UserAccessGroup getUserGroup(final String name) + { + return user_groups.get(name); + } + + /** @return Names of host groups */ + public Collection getHostGroupNames() + { + return host_groups.keySet(); + } + + /** @param name Host group name + * @return {@link HostAccessGroup} or null + */ + public HostAccessGroup getHostGroup(final String name) + { + return host_groups.get(name); + } + + /** @return Names of access security groups */ + public Collection getAccessGroupNames() + { + return access_groups.keySet(); + } + + /** @param name Access security group name + * @return {@link AccessSecurityGroup} or null + */ + public AccessSecurityGroup getAccessGroup(final String name) + { + return access_groups.get(name); + } + + @Override + public String toString() + { + final StringBuilder buf = new StringBuilder(); + for (var uag : user_groups.values()) + buf.append(uag).append("\n"); + for (var hag : host_groups.values()) + buf.append(hag).append("\n"); + for (var asg : access_groups.values()) + buf.append(asg).append("\n"); + return buf.toString(); + } +} diff --git a/core/pva/src/main/java/org/epics/pva/acf/AccessConfigParser.java b/core/pva/src/main/java/org/epics/pva/acf/AccessConfigParser.java new file mode 100644 index 0000000000..762dfbe887 --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/acf/AccessConfigParser.java @@ -0,0 +1,217 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.acf; + +import java.io.FileReader; +import java.io.Reader; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; + +import org.epics.pva.acf.AccessConfigTokenizer.Token; + +import static org.epics.pva.PVASettings.logger; + +/** Access security configuration file parser + * + * @author Kay Kasemir + */ +public class AccessConfigParser +{ + private final Map user_groups = new HashMap<>(); + private final Map host_groups = new HashMap<>(); + private final Map access_groups = new HashMap<>(); + + /** @param filename '*.acf' file to parse + * @return {@link AccessConfig} + * @throws Exception on error + */ + public AccessConfig parse(final String filename) throws Exception + { + return parse(filename, new FileReader(filename)); + } + + /** @param filename '*.acf' file name + * @param file_reader {@link Reader} for filename + * @return {@link AccessConfig} + * @throws Exception on error + */ + public AccessConfig parse(final String filename, final Reader file_reader) throws Exception + { + try (AccessConfigTokenizer tokenizer = new AccessConfigTokenizer(filename, file_reader)) + { + while (! tokenizer.done()) + { + final Token token = tokenizer.nextToken(); + if (token == null) + break; + + // UAG(name) { item, "another item") + if ("UAG".equals(token.keyword())) + { + final String name = parseName(tokenizer); + final List names = parseNames(tokenizer, '{', '}'); + final UserAccessGroup uag = new UserAccessGroup(name, names); + user_groups.put(uag.name(), uag); + } + // HAG(name) { item, "another item") + else if ("HAG".equals(token.keyword())) + { + final String name = parseName(tokenizer); + final List names = parseNames(tokenizer, '{', '}'); + final List hosts = new ArrayList<>(); + for (String nm : names) + try + { + hosts.add(InetAddress.getByName(nm)); + } + catch (Exception ex) + { + logger.log(Level.WARNING, tokenizer + ": Cannot resolve host name '" + nm + "'", ex); + } + final HostAccessGroup hag = new HostAccessGroup(name, hosts); + host_groups.put(hag.name(), hag); + } + // ASG(name) { RULE... } + else if ("ASG".equals(token.keyword())) + { + final String name = parseName(tokenizer); + final AccessSecurityGroup asg = new AccessSecurityGroup(name); + tokenizer.checkSeparator('{'); + AccessRule last_rule = null; + while (!tokenizer.done()) + { // One or more RULEs, potentially followed by conditions + final Token rule_or_conditions = tokenizer.nextToken(); + if ("RULE".equals(rule_or_conditions.keyword())) + asg.add(last_rule = parseRule(tokenizer)); + else if (rule_or_conditions.separator() == '{') + { // Optional '{' UAG(...) '}' for the last(!) RULE + if (last_rule == null) + throw new Exception(tokenizer + " Missing RULE to which conditions could be added"); + while (! tokenizer.done()) + { + final Token condition = tokenizer.nextToken(); + if ("UAG".equals(condition.keyword())) + { + for (String nm : parseNames(tokenizer, '(', ')')) + { + final UserAccessGroup group = user_groups.get(nm); + if (group == null) + throw new Exception(tokenizer + " Unknown UAG " + nm); + last_rule.add(group); + } + } + else if ("HAG".equals(condition.keyword())) + { + for (String nm : parseNames(tokenizer, '(', ')')) + { + final HostAccessGroup group = host_groups.get(nm); + if (group == null) + throw new Exception(tokenizer + " Unknown HAG " + nm); + last_rule.add(group); + } + } + else if (condition.separator() == '}') // end of RULE(..) { ... } within ASG + break; + else + throw new Exception(tokenizer + " ASG condition expects UAG or HAG, got " + condition); + } + } + else if (rule_or_conditions.separator() == '}') // end of ASG(..) { .... } + break; + else + throw new Exception(tokenizer + " expected RULE, got " + rule_or_conditions); + } + access_groups.put(asg.getName(), asg); + } + else + throw new Exception(tokenizer + " Expected keyword UAG, HAG, ASG, got " + token); + } + } + + return new AccessConfig(user_groups, host_groups, access_groups); + } + + /** Parse "( NAME )" + * @return NAME + */ + private String parseName(final AccessConfigTokenizer tokenizer) throws Exception + { + tokenizer.checkSeparator('('); + final String name = tokenizer.nextName(); + tokenizer.checkSeparator(')'); + + return name; + } + + /** Parse "{ NAME, NAME, ... }" + * @return [ NAME, NAME, ... ] + */ + private List parseNames(final AccessConfigTokenizer tokenizer, final char open, final char close) throws Exception + { + final List items = new ArrayList<>(); + + // Locate opening delimiter + tokenizer.checkSeparator(open); + // Collect item, "another item" until closing delimiter + while (! tokenizer.done()) + { + final String item = tokenizer.nextName(); + items.add(item); + + final Token sep = tokenizer.nextToken(); + if (sep == null) + throw new Exception(tokenizer + " Expected ',' or '" + close + "'"); + + // Closing delimiter ends the list + if (sep.separator() == close) + break; + // Comma continues the list + if (sep.separator() != ',') + throw new Exception(tokenizer + " Expected ',' or '" + close + "', got " + sep); + } + return items; + } + + /** Parse "RULE(1, READ)" or ".. WRITE" with optional "{ ASG... }" + * @return {@link AccessRule} + */ + private AccessRule parseRule(final AccessConfigTokenizer tokenizer) throws Exception + { + // RULE(level, + tokenizer.checkSeparator('('); + String text = tokenizer.nextName(); + final int level = Integer.parseInt(text); + tokenizer.checkSeparator(','); + + // READ) or WRITE) + text = tokenizer.nextName(); + if (! AccessRule.MODES.contains(text.toUpperCase())) + throw new Exception(tokenizer + " Expect " + AccessRule.MODES + ", got '" + text + "'"); + final AccessRule.Mode mode = AccessRule.Mode.valueOf(text); + tokenizer.checkSeparator(')'); + + return new AccessRule(level, mode); + } + + @Override + public String toString() + { + final StringBuilder buf = new StringBuilder(); + for (var uag : user_groups.values()) + buf.append(uag).append("\n"); + for (var hag : host_groups.values()) + buf.append(hag).append("\n"); + for (var asg : access_groups.values()) + buf.append(asg).append("\n"); + return buf.toString(); + } +} diff --git a/core/pva/src/main/java/org/epics/pva/acf/AccessConfigTokenizer.java b/core/pva/src/main/java/org/epics/pva/acf/AccessConfigTokenizer.java new file mode 100644 index 0000000000..d3b4df4363 --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/acf/AccessConfigTokenizer.java @@ -0,0 +1,189 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.acf; + +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** Tokenizer for *.acf files + * @author Kay Kasemir + */ +class AccessConfigTokenizer implements Closeable +{ + final static List KEYWORDS = List.of("UAG", "HAG", "ASG", "RULE"); + + static enum Type { KEYWORD, SEPARATOR, NAME } + + static record Token(Type type, String value) + { + /** @return Separator or null-char */ + public char separator() + { + if (type == Type.SEPARATOR && value.length() == 1) + return value.charAt(0); + return '\0'; + } + + /** @return Keyword or null */ + public String keyword() + { + if (type == Type.KEYWORD) + return value; + return null; + } + + @Override + public final String toString() + { + return type + " " + value; + } + }; + + private final String filename; + private final BufferedReader reader; + private String line; + private int line_no = 0; + private int column = 0; + + /** @param filename File name (used for error messages) + * @param stream_reader {@link Reader} + * @throws Exception on error + */ + public AccessConfigTokenizer(final String filename, final Reader file_reader) throws Exception + { + this.filename = filename; + reader = new BufferedReader(file_reader); + nextLine(); + } + + /** @return Reached end of file? */ + public boolean done() + { + return line == null; + } + + /** @return Reached end of line? */ + private boolean eol() + { + return column >= line.length(); + } + + /** @return Char at current line and column */ + private char current() + { + return line.charAt(column); + } + + /** Read next line */ + private void nextLine() throws Exception + { + line = reader.readLine(); + ++line_no; + column = 0; + // System.out.println(this + ": " + line); + } + + /** @return Next {@link Token} or null at end of file */ + public Token nextToken() throws Exception + { + while (! done()) + { + if (eol()) + { // Move to next line + nextLine(); + continue; + } + + if (Character.isWhitespace(current())) + { // Skip spaces + do + ++column; + while (!eol() && Character.isWhitespace(current())); + continue; + } + + if (current() == '#') + { // Skip comments + nextLine(); + continue; + } + + if ("(){},".indexOf(current()) >= 0) + { // Found separator + final Token token = new Token(Type.SEPARATOR, Character.toString(current())); + ++column; + return token; + } + + if (current() == '"') + { // Collect chars of quoted name + ++column; + int end = line.indexOf('"', column); + if (end < 0) + throw new Exception(this + " Missing end of quoted string"); + final Token token = new Token(Type.NAME, line.substring(column, end)); + column = end + 1; + return token; + } + + // Check for keyword + final String rest = line.substring(column); + for (var keyword : KEYWORDS) + if (rest.startsWith(keyword)) + { + final Token token = new Token(Type.KEYWORD, keyword); + column += keyword.length(); + return token; + } + + // Assume it's a name, which includes "127.0.0.1" or "::1" + int start = column; + while (!eol() && (Character.isUpperCase(current()) || + Character.isLowerCase(current()) || + "_0123456789.:".indexOf(current()) >= 0)) + ++column; + if (start == column) + throw new Exception(this + " Stuck on invalid character"); + final String name = line.substring(start, column); + return new Token(Type.NAME, name); + } + return null; + } + + /** @param sep Expected separator */ + public void checkSeparator(final char sep) throws Exception + { + final Token token = nextToken(); + if (token == null || token.separator() != sep) + throw new Exception(this + " Expected '" + sep + "', got " + token); + } + + /** @return Next name */ + public String nextName() throws Exception + { + final Token token = nextToken(); + if (token == null || token.type() != Type.NAME || token.value().isBlank()) + throw new Exception(this + " Expected name, got " + token); + return token.value(); + } + + @Override + public void close() throws IOException + { + reader.close(); + } + + @Override + public String toString() + { + return filename + " " + line_no + "," + column; + } +} diff --git a/core/pva/src/main/java/org/epics/pva/acf/AccessRule.java b/core/pva/src/main/java/org/epics/pva/acf/AccessRule.java new file mode 100644 index 0000000000..b58213896b --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/acf/AccessRule.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.acf; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** Rule that describes READ or WRITE access + * + * @author Kay Kasemir + */ +class AccessRule +{ + enum Mode { READ, WRITE }; + + public static final List MODES = Stream.of(Mode.values()) + .map(m -> m.name()) + .toList(); + + final int level; + final Mode mode; + final List users = new ArrayList<>(); + final List hosts = new ArrayList<>(); + + AccessRule(final int level, final Mode mode) + { + this.level = level; + this.mode = mode; + } + + void add(final UserAccessGroup uag) + { + users.add(uag); + } + + void add(final HostAccessGroup hag) + { + hosts.add(hag); + } + + @Override + public String toString() + { + final StringBuilder buf = new StringBuilder(); + buf.append(" RULE(").append(level).append(", ").append(mode).append(")"); + if (!users.isEmpty() || !hosts.isEmpty()) + { + buf.append("\n {\n"); + for (var uag : users) + buf.append(" UAG(").append(uag.name()).append(")\n"); + for (var hag : hosts) + buf.append(" HAG(").append(hag.name()).append(")\n"); + buf.append(" }"); + } + return buf.toString(); + } +} diff --git a/core/pva/src/main/java/org/epics/pva/acf/AccessSecurityGroup.java b/core/pva/src/main/java/org/epics/pva/acf/AccessSecurityGroup.java new file mode 100644 index 0000000000..d2a8c12480 --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/acf/AccessSecurityGroup.java @@ -0,0 +1,98 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.acf; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + +import org.epics.pva.acf.AccessRule.Mode; + +/** Access security Group + * + * PVs are in a named group, + * or the 'DEFAULT' group if not specifically assigned. + * + * @author Kay Kasemir + */ +public class AccessSecurityGroup +{ + private final String name; + private final List rules = new ArrayList<>(); + + AccessSecurityGroup(final String name) + { + this.name = name; + } + + /** @return Name of the group */ + public String getName() + { + return name; + } + + void add(final AccessRule rule) + { + rules.add(rule); + } + + /** @param user Name of client that wants to write the PV + * @param host Host from which the client wants to write the PV + * @return May the client write the PV? + */ + public boolean mayWrite(final String user, final InetAddress host) + { + boolean user_may_write = false; + boolean host_may_write = false; + + for (AccessRule rule : rules) + { + if (rule.mode == Mode.WRITE) + { + // No uag listed -> Any user is accepted + if (rule.users.isEmpty()) + user_may_write = true; + else + for (UserAccessGroup uag : rule.users) + if (uag.users().contains(user)) + { + user_may_write = true; + break; + } + + // No hag listed -> Any host is accepted + if (rule.hosts.isEmpty()) + host_may_write = true; + else + for (HostAccessGroup hag : rule.hosts) + if (hag.hosts().contains(host)) + { + host_may_write = true; + break; + } + + if (user_may_write && host_may_write) + return true; + } + } + + return false; + } + + @Override + public String toString() + { + final StringBuilder buf = new StringBuilder(); + buf.append("ASG(").append(name).append(")"); + buf.append("\n{\n"); + for (var rule : rules) + buf.append(rule).append("\n"); + buf.append("}"); + return buf.toString(); + } +} diff --git a/core/pva/src/main/java/org/epics/pva/acf/HostAccessGroup.java b/core/pva/src/main/java/org/epics/pva/acf/HostAccessGroup.java new file mode 100644 index 0000000000..496ab634e6 --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/acf/HostAccessGroup.java @@ -0,0 +1,39 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.acf; + +import java.net.InetAddress; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.epics.pva.common.Network; + +/** Named list of hosts + * + * May for example list all hosts in the control room + * + * @author Kay Kasemir + */ +public record HostAccessGroup(String name, List hosts) +{ + public HostAccessGroup(final String name, final List hosts) + { + this.name = name; + this.hosts = Collections.unmodifiableList(hosts); + } + + @Override + public String toString() + { + return "HAG(" + name + ") { " + hosts.stream() + .map(Network::format) + .collect(Collectors.joining(", ")) + + " }"; + } +} diff --git a/core/pva/src/main/java/org/epics/pva/acf/UserAccessGroup.java b/core/pva/src/main/java/org/epics/pva/acf/UserAccessGroup.java new file mode 100644 index 0000000000..891f7e417c --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/acf/UserAccessGroup.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.acf; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** Named list of users + * + * May for example list all "expert" users + * + * @author Kay Kasemir + */ +public record UserAccessGroup(String name, List users) +{ + public UserAccessGroup(final String name, final List users) + { + this.name = name; + this.users = Collections.unmodifiableList(users); + } + + @Override + public String toString() + { + return "UAG(" + name + ") { " + users.stream() + .collect(Collectors.joining(", ")) + + " }"; + } +} diff --git a/core/pva/src/main/java/org/epics/pva/common/Network.java b/core/pva/src/main/java/org/epics/pva/common/Network.java index fafe0bf774..5698bcf07f 100644 --- a/core/pva/src/main/java/org/epics/pva/common/Network.java +++ b/core/pva/src/main/java/org/epics/pva/common/Network.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019-2025 Oak Ridge National Laboratory. + * Copyright (c) 2019-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -28,9 +28,28 @@ /** Network helpers * @author Kay Kasemir */ -@SuppressWarnings("nls") public class Network { + /** Format an {@link InetAddress} + * + * Avoids reverse DNS lookup. + * If a name was already resolved, use the name. + * Otherwise use the numeric notation + * + * @param address {@link InetAddress} + * @return "the.host.name", "12.34.56.78" or IPv6 notation + */ + public static String format(final InetAddress address) + { + // Avoid reverse lookup. toString() returns "name/12.34.56.100" + // where name may be empty if it has not been resolved. + String host = address.toString(); + int sep = host.indexOf('/'); + if (sep > 0) + return host.substring(0, sep); // use name + return host.substring(1); // use numeric notation + } + /** @param channel UDP channel * @return Local address or "UNBOUND" */ diff --git a/core/pva/src/main/java/org/epics/pva/common/SecureSockets.java b/core/pva/src/main/java/org/epics/pva/common/SecureSockets.java index 192f76df0b..d15d54957b 100644 --- a/core/pva/src/main/java/org/epics/pva/common/SecureSockets.java +++ b/core/pva/src/main/java/org/epics/pva/common/SecureSockets.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2023-2025 Oak Ridge National Laboratory. + * Copyright (c) 2023-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -10,6 +10,7 @@ import static org.epics.pva.PVASettings.logger; import java.io.FileInputStream; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; @@ -52,7 +53,7 @@ public class SecureSockets /** Initialize only once */ private static boolean initialized = false; - + /** Factory for secure server sockets */ private static SSLServerSocketFactory tls_server_sockets; @@ -316,23 +317,23 @@ public static class TLSHandshakeInfo public final String name; /** Host of the peer */ - public final String hostname; + public final InetAddress host; /** PV for client certificate status */ public final String status_pv_name; - TLSHandshakeInfo(final X509Certificate peer_cert, final String name, final String hostname, final String status_pv_name) + TLSHandshakeInfo(final X509Certificate peer_cert, final String name, final InetAddress host, final String status_pv_name) { this.peer_cert = peer_cert; this.name = name; - this.hostname = hostname; + this.host = host; this.status_pv_name = status_pv_name; } @Override public String toString() { - return "Name " + name + ", host " + hostname + ", cert status PV " + status_pv_name; + return "Name " + name + ", host " + host + ", cert status PV " + status_pv_name; } /** Get TLS/SSH info from socket @@ -398,7 +399,7 @@ public static TLSHandshakeInfo fromSocket(final SSLSocket socket) throws Excepti name = principal.getName(); } - final TLSHandshakeInfo info = new TLSHandshakeInfo(peer_cert, name, socket.getInetAddress().getHostName(), status_pv_name); + final TLSHandshakeInfo info = new TLSHandshakeInfo(peer_cert, name, socket.getInetAddress(), status_pv_name); return info; } diff --git a/core/pva/src/main/java/org/epics/pva/pvlist/PVListFile.java b/core/pva/src/main/java/org/epics/pva/pvlist/PVListFile.java new file mode 100644 index 0000000000..e3bc506350 --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/pvlist/PVListFile.java @@ -0,0 +1,108 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.pvlist; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + +import org.epics.pva.pvlist.PVListFileRule.AllowingRule; +import org.epics.pva.pvlist.PVListFileRule.DenyingRule; + +/** Handle a `*.pvlist` file with DENY and ALLOW rules */ +public class PVListFile +{ + private final List deny = new ArrayList<>(); + private final List allow = new ArrayList<>(); + + /** @return Built-in default pvlist that allows all access */ + public static PVListFile getDefault() throws Exception + { + final Reader reader = new InputStreamReader(PVListFile.class.getResourceAsStream("default.pvlist")); + return new PVListFile("default.pvlist", reader); + } + + /** @param filename `*.pvlist` file to parse + * @throws Exception on error + */ + public PVListFile(final String filename) throws Exception + { + this(filename, new FileReader(filename)); + } + + /** @param filename `*.pvlist` file to parse + * @param file_reader {@link Reader} for that file + * @throws Exception on error + */ + public PVListFile(final String filename, final Reader file_reader) throws Exception + { + try (BufferedReader reader = new BufferedReader(file_reader)) + { + String line; + int lineno = 0; + while ((line = reader.readLine()) != null) + { + ++lineno; + line = line.strip(); + if (line.isBlank() || line.startsWith("#")) + continue; + + final PVListFileRule rule; + try + { + rule = PVListFileRule.create(line); + } + catch (Exception ex) + { + throw new Exception(filename + " line " + lineno, ex); + } + if (rule instanceof AllowingRule r) + allow.add(r); + else if (rule instanceof DenyingRule r) + deny.add(r); + } + } + } + + /** Does a client have access? + * @param pv_name PV name to check + * @param host Address of the client + * @return Access security group of the client or null if no access + */ + public String getAccess(final String pv_name, final InetAddress host) + { + // Does any rule specifically deny access? + for (var rule : deny) + if (rule.isDenied(pv_name, host)) + return null; + // Does any rule specifically allow access? + for (var rule : allow) + { + final String asg = rule.getAccessSecurityGroup(pv_name); + if (asg != null) + return asg; + } + // No match: Deny + return null; + } + + @Override + public String toString() + { + final StringBuilder buf = new StringBuilder(); + for (var rule : deny) + buf.append(rule).append('\n'); + for (var rule : allow) + buf.append(rule).append('\n'); + return buf.toString(); + } +} diff --git a/core/pva/src/main/java/org/epics/pva/pvlist/PVListFileRule.java b/core/pva/src/main/java/org/epics/pva/pvlist/PVListFileRule.java new file mode 100644 index 0000000000..b98922b681 --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/pvlist/PVListFileRule.java @@ -0,0 +1,121 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.pvlist; + +import java.net.InetAddress; +import java.util.regex.Pattern; + +import org.epics.pva.common.Network; + +/** Rule that allows or denies access by PV name or client host */ +abstract class PVListFileRule +{ + /** Access group used when no specific one is provided */ + public static final String DEFAULT_ASG = "DEFAULT"; + + protected final Pattern pv_pattern; + + private PVListFileRule(final String pv_pattern) + { + this.pv_pattern = Pattern.compile(pv_pattern); + } + + /** Rule that allows access to name pattern, providing access security group for the PV */ + static class AllowingRule extends PVListFileRule + { + protected final String access_security_group; + + AllowingRule(final String pv_pattern, final String access_security_group) + { + super(pv_pattern); + this.access_security_group = access_security_group; + } + + /** Does this rule allow access? + * @param pv_name PV name to check + * @return Access security group or null + */ + public String getAccessSecurityGroup(final String pv_name) + { + if (pv_pattern.matcher(pv_name).matches()) + return access_security_group; + return null; + } + + @Override + public String toString() + { + return String.format("%-30s ALLOW %s", pv_pattern.pattern(), access_security_group); + } + } + + /** Rule that blocks access to name pattern and optionally client host address */ + static class DenyingRule extends PVListFileRule + { + protected final InetAddress address; + + DenyingRule(final String pv_pattern, final InetAddress address) + { + super(pv_pattern); + this.address = address; + } + + /** Does this rule deny access? + * @param pv_name PV name to check + * @param address Client's address + * @return Is this client denied access to the PV? + */ + public boolean isDenied(final String pv_name, final InetAddress address) + { + return pv_pattern.matcher(pv_name).matches() && + (this.address == null || this.address.equals(address)); + } + + @Override + public String toString() + { + String result = String.format("%-30s DENY", pv_pattern.pattern()); + if (address != null) + result += " FROM " + Network.format(address); + return result; + } + } + + /** Parse pvlist file line + * + * Will perform a DNS lookup when "DENY FROM ..." is + * used with host names; fast when used with "123.45.67.100". + * + * @param line "pattern ALLOW/DENY ..." + * @return {@link AllowingRule} or {@link DenyingRule} + * @throws Exception on error, including name lookup for "DENY FROM ..." + */ + static PVListFileRule create(final String line) throws Exception + { + final String[] parts = line.split("\\s+"); + if (parts.length < 2) + throw new Exception("Missing {pattern} DENY|ALLOW"); + + final String pv_pattern = parts[0]; + if ("ALLOW".equalsIgnoreCase(parts[1])) + { + if (parts.length >= 3) + return new AllowingRule(pv_pattern, parts[2]); + return new AllowingRule(pv_pattern, DEFAULT_ASG); + } + else if ("DENY".equalsIgnoreCase(parts[1])) + { + InetAddress address = null; + if (parts.length >= 4 && "FROM".equals(parts[2])) + address = InetAddress.getByName(parts[3]); + return new DenyingRule(pv_pattern, address); + } + else + throw new Exception("Expect ALLOW or DENY"); + } +} diff --git a/core/pva/src/main/java/org/epics/pva/server/ClientAuthentication.java b/core/pva/src/main/java/org/epics/pva/server/ClientAuthentication.java index 6151a3a3a7..104c13fef2 100644 --- a/core/pva/src/main/java/org/epics/pva/server/ClientAuthentication.java +++ b/core/pva/src/main/java/org/epics/pva/server/ClientAuthentication.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2025 Oak Ridge National Laboratory. + * Copyright (c) 2025-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -7,6 +7,7 @@ ******************************************************************************/ package org.epics.pva.server; +import java.net.InetAddress; import java.nio.ByteBuffer; import org.epics.pva.common.PVAAuth; @@ -16,17 +17,18 @@ import org.epics.pva.data.PVAStructure; import org.epics.pva.data.PVATypeRegistry; -/** Determine authentication of a client connected to this server +/** Describe authentication of a client connected to this server * @author Kay Kasemir */ public class ClientAuthentication { - public final static ClientAuthentication Anonymous = new ClientAuthentication(PVAAuth.anonymous, "nobody", "nohost"); + final static ClientAuthentication Anonymous = new ClientAuthentication(PVAAuth.anonymous, "nobody", InetAddress.getLoopbackAddress()); private final PVAAuth type; - private final String user, host; + private final String user; + private final InetAddress host; - ClientAuthentication(final PVAAuth type, final String user, final String host) + ClientAuthentication(final PVAAuth type, final String user, final InetAddress host) { this.type = type; this.user = user; @@ -46,8 +48,8 @@ public String getUser() } /** @return Client's host */ - public String getHost() - { // TODO Use numeric IP address? Host name? InetAddress? + public InetAddress getHost() + { return host; } @@ -64,7 +66,7 @@ public String toString() * @return {@link ClientAuthentication} * @throws Exception on error */ - public static ClientAuthentication decode(final ServerTCPHandler tcp, final ByteBuffer buffer, TLSHandshakeInfo tls_info) throws Exception + static ClientAuthentication decode(final ServerTCPHandler tcp, final ByteBuffer buffer, TLSHandshakeInfo tls_info) throws Exception { final String auth = PVAString.decodeString(buffer); @@ -89,7 +91,8 @@ public static ClientAuthentication decode(final ServerTCPHandler tcp, final Byte if (element == null) throw new Exception("Missing 'ca' authentication 'host', got " + info); final String host = element.get(); - return new ClientAuthentication(PVAAuth.ca, user, host); + final InetAddress addr = InetAddress.getByName(host); + return new ClientAuthentication(PVAAuth.ca, user, addr); } else // For other authentication methods, there should be no additional info structure if (info != null) @@ -97,8 +100,8 @@ public static ClientAuthentication decode(final ServerTCPHandler tcp, final Byte } if (PVAAuth.x509.name().equals(auth)) - return new ClientAuthentication(PVAAuth.x509, tls_info.name, tls_info.hostname); + return new ClientAuthentication(PVAAuth.x509, tls_info.name, tls_info.host); - return new ClientAuthentication(PVAAuth.anonymous, "nobody", tcp.getRemoteAddress().getHostString()); + return new ClientAuthentication(PVAAuth.anonymous, "nobody", tcp.getRemoteAddress().getAddress()); } } diff --git a/core/pva/src/main/java/org/epics/pva/server/FileBasedServerAuthorization.java b/core/pva/src/main/java/org/epics/pva/server/FileBasedServerAuthorization.java new file mode 100644 index 0000000000..5a1041bd67 --- /dev/null +++ b/core/pva/src/main/java/org/epics/pva/server/FileBasedServerAuthorization.java @@ -0,0 +1,83 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.server; + +import java.net.InetAddress; +import java.util.logging.Level; + +import org.epics.pva.acf.AccessConfig; +import org.epics.pva.acf.AccessConfigParser; +import org.epics.pva.acf.AccessSecurityGroup; +import org.epics.pva.common.PVAAuth; +import org.epics.pva.pvlist.PVListFile; + +import static org.epics.pva.PVASettings.logger; + +/** Determine authorization of a client connected to this server based on pvlist and acf files + * @author Kay Kasemir + */ +public class FileBasedServerAuthorization extends ServerAuthorization +{ + private final PVListFile pvlist; + private final AccessConfig access; + + /** @param pvlist_file *.pvlist file name + * @param acf_file *.acf file name + */ + public FileBasedServerAuthorization(final String pvlist_file, final String acf_file) throws Exception + { + this(new PVListFile(pvlist_file), + new AccessConfigParser().parse(acf_file)); + } + + /** @param pvlist {@link PVListFile} + * @param access {@link AccessConfig} + */ + public FileBasedServerAuthorization(final PVListFile pvlist, final AccessConfig access) + { + this.pvlist = pvlist; + this.access = access; + } + + @Override + public boolean allowSearch(final String pv_name, final InetAddress host) + { + final boolean allowed = pvlist.getAccess(pv_name, host) != null; + if (! allowed) + logger.log(Level.FINER, () -> "*.pvlist blocks client on " + host + " from accessing '" + pv_name + "'"); + return allowed; + } + + @Override + public boolean hasWriteAccess(final String pv_name, final ClientAuthentication client_auth) + { + if (client_auth.getType() == PVAAuth.anonymous) + { + logger.log(Level.FINER, () -> client_auth + " write access refused because anonymous"); + return false; + } + + final String asg_name = pvlist.getAccess(pv_name, client_auth.getHost()); + if (asg_name == null) + { // Should not get here because pvlist would already block the search reply... + logger.log(Level.FINER, () -> "Write access to '" + pv_name + "' refused by pvlist"); + return false; + } + + final AccessSecurityGroup asg = access.getAccessGroup(asg_name); + if (asg == null) + { + logger.log(Level.FINER, () -> "Write access to '" + pv_name + "' refused because of unknown ASG(" + asg_name + ")"); + return false; + } + + final boolean write = asg.mayWrite(client_auth.getUser(), client_auth.getHost()); + logger.log(Level.FINER, () -> client_auth + (write ? " has write access" : " has NO write access") + " for ASG(" + asg_name + ")"); + return write; + } +} diff --git a/core/pva/src/main/java/org/epics/pva/server/PVAServer.java b/core/pva/src/main/java/org/epics/pva/server/PVAServer.java index e545b06389..62382b219a 100644 --- a/core/pva/src/main/java/org/epics/pva/server/PVAServer.java +++ b/core/pva/src/main/java/org/epics/pva/server/PVAServer.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019-2025 Oak Ridge National Laboratory. + * Copyright (c) 2019-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -61,6 +61,9 @@ public class PVAServer implements AutoCloseable /** Handlers for the TCP connections clients established to this server */ private final KeySetView tcp_handlers = ConcurrentHashMap.newKeySet(); + /** Authorization handler, checks if a client may write a PV */ + private ServerAuthorization authorization = new ServerAuthorization(); + /** Create PVA Server * @throws Exception on error */ @@ -97,6 +100,28 @@ public InetSocketAddress getTCPAddress(final boolean tls) return tcp.getResponseAddress(tls); } + /** Configure server authorization + * + * By default, all authenticated clients may write + * + * @param authorization {@link ServerAuthorization} + */ + public void configureAuthorization(final ServerAuthorization authorization) + { + this.authorization = authorization; + } + + /** @param pv_name Channel for which to check write access + * @param client_auth Client authentication + * @return Does client have write access? + */ + boolean hasWriteAccess(final String pv_name, final ClientAuthentication client_auth) + { + boolean may_write = authorization.hasWriteAccess(pv_name, client_auth); + logger.log(Level.FINE, () -> client_auth + (may_write ? " can write '" : " CANNOT write '") + pv_name + "'"); + return may_write; + } + /** Create a read-only PV which serves data to clients * *

Creates a thread-safe copy of the initial value. @@ -192,7 +217,7 @@ public Collection getClientInfos() * @param client Client's UDP reply address * @param tls_requested Does client support tls? * @param tcp_connection Optional TCP connection for search received via TCP, else null - * @return + * @return Has the search been handled (sent reply, ...), or was it ignored? */ boolean handleSearchRequest(final int seq, final int cid, final String name, final InetSocketAddress client, @@ -202,7 +227,13 @@ boolean handleSearchRequest(final int seq, final int cid, final String name, // Both client and server must support TLS final boolean tls = tls_requested && !PVASettings.EPICS_PVAS_TLS_KEYCHAIN.isBlank(); if (tls_requested && !tls) - logger.log(Level.WARNING, "PVA Client " + client + " searches for '" + name + "' with TLS, but EPICS_PVAS_TLS_KEYCHAIN is not configured"); + logger.log(Level.WARNING, () -> "PVA Client " + client + " searches for '" + name + "' with TLS, but EPICS_PVAS_TLS_KEYCHAIN is not configured"); + + if (! authorization.allowSearch(name, client.getAddress())) + { + logger.log(Level.FINE, () -> "Ignoring PVA Client " + client + " search for '" + name + "'"); + return false; + } // Send search reply from either custom_search_handler or later in here final Consumer send_search_reply = server_address -> diff --git a/core/pva/src/main/java/org/epics/pva/server/ServerAuthorization.java b/core/pva/src/main/java/org/epics/pva/server/ServerAuthorization.java index 6ace6d2f45..4f80bc4efc 100644 --- a/core/pva/src/main/java/org/epics/pva/server/ServerAuthorization.java +++ b/core/pva/src/main/java/org/epics/pva/server/ServerAuthorization.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2025 Oak Ridge National Laboratory. + * Copyright (c) 2025-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -7,18 +7,49 @@ ******************************************************************************/ package org.epics.pva.server; -/** Determine authorization of a client connected to this server +import java.net.InetAddress; + +import org.epics.pva.common.PVAAuth; + +/** Determine authorization of a client to access a PV on this server + * + * Authorization is checked at two levels. + * + * First, a search for a PV name may be permitted or blocked + * based on for example name patterns and IP addresses configured + * in a *.pvlist file. + * + * If a search is permitted, server and client establish a TCP connection + * and exchange authentication details. Write access to a PV is then checked + * based on the client authentication. + * + * This implementation, the default, replies to any search + * and allows write access to any authenticated client, + * only blocking anonymous clients. + * + * @see {@link PVAServer.configureAuthorization} + * * @author Kay Kasemir */ public class ServerAuthorization { + /** @param pv_name Searched channel + * @param client Host from which the client issued the search + * @return Does the client have search access, should the server reply to the search? + */ + public boolean allowSearch(final String pv_name, final InetAddress client) + { + // Derived implementation can check name and client address + return true; + } + /** @param pv_name Channel for which to check write access * @param client_auth Client authentication * @return Does client have write access? */ public boolean hasWriteAccess(final String pv_name, final ClientAuthentication client_auth) { - // TODO Implement authorization based on for example an ACF file - return true; + // Derived implementation can check name and client authentication + return client_auth.getType() != PVAAuth.anonymous; } } diff --git a/core/pva/src/main/java/org/epics/pva/server/ServerPV.java b/core/pva/src/main/java/org/epics/pva/server/ServerPV.java index 23e84e155c..4267c8f451 100644 --- a/core/pva/src/main/java/org/epics/pva/server/ServerPV.java +++ b/core/pva/src/main/java/org/epics/pva/server/ServerPV.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019-2025 Oak Ridge National Laboratory. + * Copyright (c) 2019-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -17,7 +17,6 @@ import java.util.logging.Level; import org.epics.pva.common.AccessRightsChange; -import org.epics.pva.common.PVAAuth; import org.epics.pva.common.PVAHeader; import org.epics.pva.data.PVAString; import org.epics.pva.data.PVAStructure; @@ -261,10 +260,8 @@ PVAStructure getData() */ public boolean isWritable(final ClientAuthentication client_auth) { - // For now, as long as PV supports write access, - // any authenticated user (CA or X509) can write - // TODO Check user in ServerAuthorization - return writable.get() && client_auth.getType() != PVAAuth.anonymous; + // Is PV fundamentally writable, and is the client authorized? + return writable.get() && server.hasWriteAccess(getName(), client_auth); } /** Update write access diff --git a/core/pva/src/main/resources/org/epics/pva/acf/default.acf b/core/pva/src/main/resources/org/epics/pva/acf/default.acf new file mode 100644 index 0000000000..6d2dd16a05 --- /dev/null +++ b/core/pva/src/main/resources/org/epics/pva/acf/default.acf @@ -0,0 +1,9 @@ +# acf where DEFAULT grants write access to any user from anywhere +# +# This is equivalent to an EPICS IOC where +# no access security has been configured + +ASG(DEFAULT) +{ + RULE(1, WRITE) +} diff --git a/core/pva/src/main/resources/org/epics/pva/pvlist/default.pvlist b/core/pva/src/main/resources/org/epics/pva/pvlist/default.pvlist new file mode 100644 index 0000000000..0172c6764e --- /dev/null +++ b/core/pva/src/main/resources/org/epics/pva/pvlist/default.pvlist @@ -0,0 +1,4 @@ +# pvlist that allows access to any PV, +# placing all PVs in the 'DEFAULT' access group + +.* ALLOW DEFAULT diff --git a/core/pva/src/test/java/org/epics/pva/acf/AccessConfigTest.java b/core/pva/src/test/java/org/epics/pva/acf/AccessConfigTest.java new file mode 100644 index 0000000000..96504adf04 --- /dev/null +++ b/core/pva/src/test/java/org/epics/pva/acf/AccessConfigTest.java @@ -0,0 +1,121 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.acf; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.FileReader; +import java.net.InetAddress; + +import org.junit.jupiter.api.Test; + +/** JUNit test of the {@link AccessConfigParser} + * + * @author Kay Kasemir + */ +public class AccessConfigTest +{ + private static final String FILE = "src/test/resources/demo2.acf"; + + @Test + public void testTokenizer() throws Exception + { + try (AccessConfigTokenizer tokenizer = new AccessConfigTokenizer(FILE, new FileReader(FILE))) + { + while (! tokenizer.done()) + System.out.println(tokenizer.nextToken()); + } + } + + @Test + public void testParser() throws Exception + { + final AccessConfig acf = new AccessConfigParser().parse(FILE, new FileReader(FILE)); + System.out.println(acf); + + assertTrue(acf.getUserGroupNames().contains("expert")); + + assertTrue(acf.getUserGroup("expert").users().contains("Egon")); + assertFalse(acf.getUserGroup("expert").users().contains("Fred")); + + assertTrue(acf.getHostGroupNames().contains("local")); + + System.out.println("Resolved addresses in HAG(local):"); + HostAccessGroup hag = acf.getHostGroup("local"); + System.out.println(hag.hosts()); + + assertTrue(hag.hosts().contains(InetAddress.getByName("localhost"))); + assertTrue(hag.hosts().contains(InetAddress.getByName("127.0.0.1"))); + assertTrue(hag.hosts().contains(InetAddress.getByName("10.1.58.243"))); + + System.out.println("Resolved addresses in HAG(well_known):"); + hag = acf.getHostGroup("well_known"); + System.out.println(hag.hosts()); + + assertTrue(hag.hosts().contains(InetAddress.getByName("www.google.com"))); + + assertTrue(acf.getAccessGroupNames().contains("DEFAULT")); + } + + @Test + public void testBuiltinDefault() throws Exception + { + final AccessConfig acf = AccessConfig.getDefault(); + System.out.println(acf); + final AccessSecurityGroup asg = acf.getAccessGroup("DEFAULT"); + assertNotNull(asg); + assertTrue(asg.mayWrite("Egon", InetAddress.getByName("localhost"))); + assertTrue(asg.mayWrite("Fred", InetAddress.getByName("127.0.0.1"))); + assertTrue(asg.mayWrite("Anybody", InetAddress.getByName("10.23.93.200"))); + } + + @Test + public void testDefault() throws Exception + { + final AccessConfig acf = new AccessConfigParser().parse(FILE, new FileReader(FILE)); + + final AccessSecurityGroup asg = acf.getAccessGroup("DEFAULT"); + + // Egon, an 'expert', may write from localhost or 127.0.0.1 + assertTrue(asg.mayWrite("Egon", InetAddress.getByName("localhost"))); + assertTrue(asg.mayWrite("Egon", InetAddress.getByName("127.0.0.1"))); + + // Fred is not on the list of 'expert' users + assertFalse(asg.mayWrite("Fred", InetAddress.getByName("localhost"))); + + // Egon can only write from localhost, not other IPs + assertFalse(asg.mayWrite("Egon", InetAddress.getByName("127.0.0.99"))); + + // There's generally no write access to anybody from anywhere + assertFalse(asg.mayWrite("Anybody", InetAddress.getByName("1.2.3.4"))); + } + + @Test + public void testOpen() throws Exception + { + final AccessConfig acf = new AccessConfigParser().parse(FILE, new FileReader(FILE)); + + final AccessSecurityGroup asg = acf.getAccessGroup("OPEN"); + assertTrue(asg.mayWrite("Egon", InetAddress.getByName("localhost"))); + assertTrue(asg.mayWrite("Fred", InetAddress.getByName("localhost"))); + assertTrue(asg.mayWrite("Anybody", InetAddress.getByName("1.2.3.4"))); + } + + @Test + public void testVacuum() throws Exception + { + final AccessConfig acf = new AccessConfigParser().parse(FILE, new FileReader(FILE)); + + final AccessSecurityGroup asg = acf.getAccessGroup("VACUUM"); + System.out.println(asg); + assertTrue(asg.mayWrite("Mary Ann", InetAddress.getByName("1.2.3.4"))); + assertFalse(asg.mayWrite("Anybody", InetAddress.getByName("1.2.3.4"))); + } +} diff --git a/core/pva/src/test/java/org/epics/pva/common/NetworkTest.java b/core/pva/src/test/java/org/epics/pva/common/NetworkTest.java index 62fcabd11b..79fe977393 100644 --- a/core/pva/src/test/java/org/epics/pva/common/NetworkTest.java +++ b/core/pva/src/test/java/org/epics/pva/common/NetworkTest.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2021-2023 Oak Ridge National Laboratory. + * Copyright (c) 2021-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -25,7 +25,6 @@ /** Unit test of Network helper * @author Kay Kasemir */ -@SuppressWarnings("nls") public class NetworkTest { // If running on a host that does not support IPv6, @@ -33,6 +32,24 @@ public class NetworkTest private static final boolean ignore_local_ipv6 = Boolean.parseBoolean(System.getProperty("ignore_local_ipv6")) || !PVASettings.EPICS_PVA_ENABLE_IPV6; + @Test + public void testFormat() throws Exception + { + assertEquals("127.0.0.1", Network.format(InetAddress.getByName("127.0.0.1"))); + assertEquals("10.11.12.13", Network.format(InetAddress.getByName("10.11.12.13"))); + if (! ignore_local_ipv6) + assertEquals("0:0:0:0:0:0:0:1", Network.format(InetAddress.getByName("::1"))); + assertEquals("localhost", Network.format(InetAddress.getByName("localhost"))); + + // This will perform a name lookup + InetAddress addr = InetAddress.getByName("www.google.com"); + // Address now contains something like "www.google.com/142.250.189.132" + System.out.println(addr); + System.out.println(addr.getHostAddress()); + assertTrue(addr.getHostAddress().length() > 7); + assertEquals("www.google.com", Network.format(addr)); + } + @Test public void testBroadcastAddresses() { diff --git a/core/pva/src/test/java/org/epics/pva/pvlist/PVListFileTest.java b/core/pva/src/test/java/org/epics/pva/pvlist/PVListFileTest.java new file mode 100644 index 0000000000..3467a2dd01 --- /dev/null +++ b/core/pva/src/test/java/org/epics/pva/pvlist/PVListFileTest.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.pvlist; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.InetAddress; + +import org.junit.jupiter.api.Test; + +/** Unit test for {@link PVListFile} */ +class PVListFileTest +{ + @Test + void testFileLoading() throws Exception + { + final PVListFile pvlist = new PVListFile("src/test/resources/demo.pvlist"); + System.out.println(pvlist); + } + + @Test + void testDefaultFile() throws Exception + { + final PVListFile pvlist = PVListFile.getDefault(); + System.out.println(pvlist); + assertEquals("DEFAULT", pvlist.getAccess("Any", null)); + assertEquals("DEFAULT", pvlist.getAccess("PV", null)); + assertEquals("DEFAULT", pvlist.getAccess("Any", InetAddress.getByName("11.12.13.14"))); + assertEquals("DEFAULT", pvlist.getAccess("PV", InetAddress.getByName("11.12.13.14"))); + } + + @Test + void testDeny() throws Exception + { + final PVListFile pvlist = new PVListFile("src/test/resources/demo.pvlist"); + // Specifically denied + assertNull(pvlist.getAccess("Ignore:This", null)); + // OK, but caught by general deny rule for that IP address + assertNull(pvlist.getAccess("Basically:OK", InetAddress.getByName("11.12.13.14"))); + } + + @Test + void testAllow() throws Exception + { + final PVListFile pvlist = new PVListFile("src/test/resources/demo.pvlist"); + // PV allowed by final catch-all in ASG(DEFAULT) + assertEquals("DEFAULT", pvlist.getAccess("Basically:OK", null)); + // PV listed for ASG(RF) + assertEquals("RF", pvlist.getAccess("SomeRFSetting", null)); + } +} diff --git a/core/pva/src/test/java/org/epics/pva/server/AccessPermissionsDemo.java b/core/pva/src/test/java/org/epics/pva/server/AccessPermissionsDemo.java new file mode 100644 index 0000000000..57ea268d99 --- /dev/null +++ b/core/pva/src/test/java/org/epics/pva/server/AccessPermissionsDemo.java @@ -0,0 +1,94 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.epics.pva.server; + +import java.time.Instant; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +import org.epics.pva.PVASettings; +import org.epics.pva.data.PVADouble; +import org.epics.pva.data.PVAStructure; +import org.epics.pva.data.nt.PVATimeStamp; + +/** PVA server access permissions demo + * + * Creates a {@link PVAServer} with {@link FileBasedServerAuthorization} + * + * @author Kay Kasemir + */ +public class AccessPermissionsDemo +{ + private static final String PVLIST = "src/test/resources/demo.pvlist"; + private static final String ACF = "src/test/resources/demo2.acf"; + + public static void main(String[] args) throws Exception + { + LogManager.getLogManager().readConfiguration(PVASettings.class.getResourceAsStream("/pva_logging.properties")); + PVASettings.logger.setLevel(Level.FINE); + Logger.getLogger("jdk.event.security").setLevel(PVASettings.logger.getLevel()); + + try (final PVAServer server = new PVAServer()) + { + server.configureAuthorization(new FileBasedServerAuthorization(PVLIST, ACF)); + + // Create data structures to serve + final PVATimeStamp time1 = new PVATimeStamp(); + final PVATimeStamp time2 = new PVATimeStamp(); + + final PVADouble value1 = new PVADouble("value", 3.13); + final PVAStructure data1 = new PVAStructure("demo", "demo_t", + value1, + time1); + + final PVADouble value2 = new PVADouble("value", 10.0); + final PVAStructure data2 = new PVAStructure("demo", "demo_t", + value2, + time2); + + // Create PVs + final ServerPV pv1 = server.createPV("ramp", data1); + server.createPV("limit", data2, (tcp, pv, changes, written) -> + { + time2.set(Instant.now()); + PVADouble val = written.get("value"); + value2.set(val.get()); + pv.update(data2); + }); + + System.out.println("Check"); + System.out.println(); + System.out.println(" pvmonitor ramp"); + System.out.println(" pvput limit 5"); + System.out.println(); + System.out.println("'limit' is writable, for details see"); + System.out.println(PVLIST); + System.out.println("and"); + System.out.println(ACF); + + + // Update PVs + while (true) + { + TimeUnit.SECONDS.sleep(1); + + // Update the data, tell server that it changed. + // Server figures out what changed. + double next = value1.get() + 1; + if (next > value2.get()) + next = 0.0; + value1.set(next); + time1.set(Instant.now()); + + pv1.update(data1); + } + } + } +} diff --git a/core/pva/src/test/resources/AccessPermissionsDemo.bob b/core/pva/src/test/resources/AccessPermissionsDemo.bob new file mode 100644 index 0000000000..e05df31840 --- /dev/null +++ b/core/pva/src/test/resources/AccessPermissionsDemo.bob @@ -0,0 +1,28 @@ + + + Access Demo + 150 + 50 + + Label + Ramp: + + + Text Update + pva://ramp + 50 + + + Label_1 + Limit: + 30 + + + Text Update_1 + pva://limit + 50 + 30 + 0 + true + + diff --git a/core/pva/src/test/resources/demo.pvlist b/core/pva/src/test/resources/demo.pvlist new file mode 100644 index 0000000000..3c6e630ac4 --- /dev/null +++ b/core/pva/src/test/resources/demo.pvlist @@ -0,0 +1,46 @@ +# 'pvlist' example +# +# Syntax is similar to the CA or p4p Gateway: +# +# {regular expression for PV name} DENY +# {regular expression for PV name} DENY FROM {IP} +# {regular expression for PV name} ALLOW +# {regular expression for PV name} ALLOW {ASG} +# +# https://epics-base.github.io/p4p/gw.html#pvlist-file +# +# If no ASG listed for ALLOW, it defaults to 'DEFAULT' +# +# Compared to the CA gateway, the mode is always +# EVALUATION ORDER ALLOW, DENY +# +# This means that DENY has preference over ALLOW. +# DENY statements are checked first and matching PVs are blocked, +# even if an ALLOW statement would follow. + +# Ignore searches for PVs named 'Ignore:...' +Ignore:.* DENY + +# Ignore searches for PVs from a certain beam line +# BL10:.* DENY + +# Ignore searches for any PVs from specific host, +# useful to prevent search loops +.* DENY FROM 11.12.13.14 + +# .* DENY FROM 10.1.58.243 +# .* DENY FROM 10.159.201.93 + +.* DENY FROM www.google.com + +# Then list ALLOW statements, where matching PVs are accepted +# and considered to be in the access group ASG(DEFAULT) +SomePV ALLOW +Vac:.* ALLOW + +# PV where access is allowed to those in the ASG(RF) +SomeRFSetting ALLOW RF + +# If a PV does not match any ALLOW rule, it is DENYed, +# so add this if there are no specific ALLOW pattern +.* ALLOW diff --git a/core/pva/src/test/resources/demo2.acf b/core/pva/src/test/resources/demo2.acf new file mode 100644 index 0000000000..e19e159094 --- /dev/null +++ b/core/pva/src/test/resources/demo2.acf @@ -0,0 +1,39 @@ +# Demo of access security file + +# User groups +UAG (expert) +{ "Mary Ann", + Egon, + ky9 +} +UAG (vacuum) +{ "Mary Ann" +} + + +# Host groups +HAG (local) { localhost , 127.0.0.1, 10.1.58.243 } +HAG (well_known) { www.google.com } + +# By default, all can read but only experts can write +ASG(DEFAULT) +{ + RULE(1, READ) # Actually, with PVA you can always read + RULE(1, WRITE) + { # Conditions for write access + UAG(expert) + HAG(local) + } +} + +# OPEN allows anybody to write from anywhere +ASG(OPEN) +{ + RULE(1, WRITE) +} + +# VACUUM write access is limited to members of the vacuum group +ASG(VACUUM) +{ + RULE(1, WRITE) { UAG(vacuum) } +} \ No newline at end of file